Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[BUG] Cluster endpoint test connection passed unexpectly #5707

Closed
xinruiba opened this issue Jan 17, 2024 · 15 comments
Closed

[BUG] Cluster endpoint test connection passed unexpectly #5707

xinruiba opened this issue Jan 17, 2024 · 15 comments
Labels
bug Something isn't working v2.12.0

Comments

@xinruiba
Copy link
Member

xinruiba commented Jan 17, 2024

Describe the bug
Context:
I create a opensearch domain in my own aws account, and when I try to create a datasource with some different endpoints with my user name and password, I notice the test connection still pass.

Example:

  1. Endpoint: "https://google.com"
  2. User Name: Some user name I created
  3. Password: Some real password I created

To Reproduce
Steps to reproduce the behavior:

  1. Go to "Dashboards Management" ---> "Data Sources"
  2. Click on "Create data source connection"
  3. Fill in the form with DataSource Title. eg: "test"
  4. Fill in the form with DataSource Endpoint using "https://google.com"
  5. Fill in my username and password
  6. Error: Click on "Test Connection" the test will pass

Expected behavior
Test Connection should fail since "https://google.com" since is not a cluster endpoint

In scope
Using this task to:

  1. Check test connection handshake process
  2. Get the test connection response format
  3. Compare the difference between connection positive path and negative path
  4. Code change to resolve this ticket

Out of scope
Since this ticket is to do bug fix, so following staff are not in scope:

  1. Any new UX change
  2. Error Handling improvement
@xinruiba
Copy link
Member Author

xinruiba commented Jan 18, 2024

Get following response by calling "client.info()" with endpoint "https://google.com." (Client is imported from @opensearch-project/opensearch). The response code is 301.

But we not directly call opensearch-project/opensearch's client to do connection testing, instead we create wrappers on top of it.

In the progress of checking our code base to root causing.

Screenshot 2024-01-18 at 10 07 04 AM
{
  body: '<HTML><HEAD><meta http-equiv="content-type" content="text/html;charset=utf-8">\n' +
    '<TITLE>301 Moved</TITLE></HEAD><BODY>\n' +
    '<H1>301 Moved</H1>\n' +
    'The document has moved\n' +
    '<A HREF="https://www.google.com/">here</A>.\r\n' +
    '</BODY></HTML>\r\n',
  statusCode: 301,
  headers: {
    location: 'https://www.google.com/',
    'content-type': 'text/html; charset=UTF-8',
    'content-security-policy-report-only': "object-src 'none';base-uri 'self';script-src 'nonce-_k99aR7O4_jEKmrx6JBVRg' 'strict-dynamic' 'report-sample' 'unsafe-eval' 'unsafe-inline' https: http:;report-uri https://csp.withgoogle.com/csp/gws/other-hp",
    date: 'Wed, 17 Jan 2024 21:40:22 GMT',
    expires: 'Fri, 16 Feb 2024 21:40:22 GMT',
    'cache-control': 'public, max-age=2592000',
    server: 'gws',
    'content-length': '220',
    'x-xss-protection': '0',
    'x-frame-options': 'SAMEORIGIN',
    'alt-svc': 'h3=":443"; ma=2592000,h3-29=":443"; ma=2592000'
  },
  meta: {
    context: null,
    request: { params: [Object], options: {}, id: 1 },
    name: 'opensearch-js',
    connection: {
      url: 'https://google.com/',
      id: 'https://google.com/',
      headers: {},
      deadCount: 0,
      resurrectTimeout: 0,
      _openRequests: 0,
      status: 'alive',
      roles: [Object]
    },
    attempts: 0,
    aborted: false
  }
}

@xinruiba
Copy link
Member Author

Because of this code:
https://github.com/opensearch-project/OpenSearch-Dashboards/blob/main/src/plugins/data_source/server/routes/test_connection.ts#L74
Our test connection depends on open-search client's own error handling.
If the response is not an error (which in "https://google.com"'s case, the response is "301" and not an error), the test connection will pass.

@xinruiba
Copy link
Member Author

xinruiba commented Jan 18, 2024

This is how a real success connection response looks like for "client.info()" call:

{
  body: {
    name: 'name',
    cluster_name: 'name',
    cluster_uuid: 'id',
    version: {
      distribution: 'opensearch',
      number: '2.11.0',
      build_type: 'tar',
      build_hash: 'unknown',
      build_date: '2023-11-14T10:02:57.403854203Z',
      build_snapshot: false,
      lucene_version: '9.7.0',
      minimum_wire_compatibility_version: '7.10.0',
      minimum_index_compatibility_version: '7.0.0'
    },
    tagline: 'The OpenSearch Project: https://opensearch.org/'
  },
  statusCode: 200,
  headers: {
    date: 'Wed, 17 Jan 2024 21:39:38 GMT',
    'content-type': 'application/json; charset=UTF-8',
    'content-length': '566',
    connection: 'keep-alive',
    'access-control-allow-origin': '*'
  },
  meta: {
    context: null,
    request: { params: [Object], options: {}, id: 1 },
    name: 'opensearch-js',
    connection: {
      url: 'https://url/',
      id: 'https://url/',
      headers: {},
      deadCount: 0,
      resurrectTimeout: 0,
      _openRequests: 0,
      status: 'alive',
      roles: [Object]
    },
    attempts: 0,
    aborted: false
  }
}

@xinruiba
Copy link
Member Author

This is the real error looks like when there is a failure from "client.info()", and currently our test connection will fail only in this case.

  meta: {
    body: { Message: "Your request: '/_dashboard/' is not allowed." },
    statusCode: 401,
    headers: {
      date: 'Wed, 17 Jan 2024 21:38:40 GMT',
      'content-type': 'application/json',
      'content-length': '58',
      connection: 'keep-alive',
      'x-amzn-requestid': '34d608b1-ebe5-4a32-8ab0-a6aa13f152d0',
      'access-control-allow-origin': '*'
    },
    meta: {
      context: null,
      request: {
        params: {
          method: 'GET',
          path: '/',
          body: null,
          querystring: '',
          headers: {
            'user-agent': 'opensearch-js/2.3.1 (linux 5.15.0-1039-aws-x64; Node.js v18.16.0)'
          },
          timeout: 30000
        },
        options: {},
        id: 1
      },
      name: 'opensearch-js',
      connection: AwsSigv4SignerConnection {
        url: <ref *1> URL {
          [Symbol(context)]: URLContext {
            href: 'https://search-xinruitesting-kmyg7nuryssiicvd4cvvzflp7q.us-east-2.es.amazonaws.com/_dashboard',
            origin: 'https://search-xinruitesting-kmyg7nuryssiicvd4cvvzflp7q.us-east-2.es.amazonaws.com',
            protocol: 'https:',
            hostname: 'search-xinruitesting-kmyg7nuryssiicvd4cvvzflp7q.us-east-2.es.amazonaws.com',
            pathname: '/_dashboard',
            search: '',
            username: '',
            password: '',
            port: '',
            hash: ''
          },
          [Symbol(query)]: URLSearchParams {
            [Symbol(query)]: [],
            [Symbol(context)]: [Circular *1]
          }
        },
        ssl: null,
        id: 'https://search-xinruitesting-kmyg7nuryssiicvd4cvvzflp7q.us-east-2.es.amazonaws.com/_dashboard',
        headers: {},
        deadCount: 0,
        resurrectTimeout: 0,
        _openRequests: 0,
        _status: 'alive',
        roles: { data: true, ingest: true },
        agent: Agent {
          _events: [Object: null prototype] {
            free: [Function (anonymous)],
            newListener: [Function: maybeEnableKeylog]
          },
          _eventsCount: 2,
          _maxListeners: undefined,
          defaultPort: 443,
          protocol: 'https:',
          options: [Object: null prototype] {
            keepAlive: true,
            keepAliveMsecs: 1000,
            maxSockets: 256,
            maxFreeSockets: 256,
            scheduling: 'lifo',
            noDelay: true,
            path: null
          },
          requests: [Object: null prototype] {},
          sockets: [Object: null prototype] {},
          freeSockets: [Object: null prototype] {
            'search-xinruitesting-kmyg7nuryssiicvd4cvvzflp7q.us-east-2.es.amazonaws.com:443:::::::::::::::::::::': [Array]
          },
          keepAliveMsecs: 1000,
          keepAlive: true,
          maxSockets: 256,
          maxFreeSockets: 256,
          scheduling: 'lifo',
          maxTotalSockets: Infinity,
          totalSocketCount: 1,
          maxCachedSessions: 100,
          _sessionCache: { map: [Object], list: [Array] },
          [Symbol(kCapture)]: false
        },
        makeRequest: [Function: request]
      },
      attempts: 0,
      aborted: false
    }
  }
}

@xinruiba
Copy link
Member Author

Also we call "client.cat.indices()" in serverless scenario: https://github.com/opensearch-project/OpenSearch-Dashboards/blob/main/src/plugins/data_source/server/routes/data_source_connection_validator.ts#L18

Also need to take the "client.cat.indices()" response formate into consideration

@xinruiba
Copy link
Member Author

xinruiba commented Jan 18, 2024

This is a successful "client.cat.indices()" response looks like:
Screenshot 2024-01-22 at 11 03 52 AM

{
  body: 'green  open .opensearch-observability              64N56cywSCaxFJ8ZAs4TtQ 1 0     0 0    208b    208b\n' +
    'green  open .plugins-ml-config                     HvaR-yftSseamDaR-HIYVA 5 0     1 0   4.7kb   4.7kb\n' +
    'green  open .ql-datasources                        XI2kEm5cSuSQ-sFiaGJ9mw 1 0     0 0    208b    208b\n' +
    'yellow open opensearch_dashboards_sample_data_logs tzWgooqoTamoDXmvvZLsxg 1 1 14074 0   8.9mb   8.9mb\n' +
    'green  open .kibana_1                              kNCYR3ByS_CyuVK66ePA0w 1 0   258 1 136.9kb 136.9kb\n' +
    'green  open .opendistro_security                   PS2fVSqNTC681J084gotSg 1 0    10 2    66kb    66kb\n',
  statusCode: 200,
  headers: {
    date: 'Thu, 18 Jan 2024 23:13:50 GMT',
    'content-type': 'text/plain;charset=UTF-8',
    'content-length': '612',
    connection: 'keep-alive',
    'access-control-allow-origin': '*'
  },
  meta: {
    context: null,
    request: { params: [Object], options: {}, id: 2 },
    name: 'opensearch-js',
    connection: {
      url: 'https://url/',
      id: 'https://id/',
      headers: {},
      deadCount: 0,
      resurrectTimeout: 0,
      _openRequests: 0,
      status: 'alive',
      roles: [Object]
    },
    attempts: 0,
    aborted: false
  }
}

@xinruiba
Copy link
Member Author

xinruiba commented Jan 18, 2024

Failed "client.cat.indices()"response by appending path name after the end point:

  meta: {
    body: {
      Message: "Your request: '/_dashboard/_cat/indices' is not allowed."
    },
    statusCode: 401,
    headers: {
      date: 'Thu, 18 Jan 2024 23:26:04 GMT',
      'content-type': 'application/json',
      'content-length': '70',
      connection: 'keep-alive',
      'x-amzn-requestid': 'e577fa85-8e6f-4585-b15a-1a9d808338a9',
      'access-control-allow-origin': '*'
    },
    meta: {
      context: null,
      request: {
        params: {
          method: 'GET',
          path: '/_cat/indices',
          body: null,
          querystring: '',
          headers: {
            'user-agent': 'opensearch-js/2.3.1 (linux 5.15.0-1039-aws-x64; Node.js v18.16.0)'
          },
          timeout: 30000
        },
        options: {},
        id: 1
      },
      name: 'opensearch-js',
      connection: AwsSigv4SignerConnection {
        url: <ref *1> URL {
          [Symbol(context)]: URLContext {
            href: 'https://url/_dashboard',
            origin: 'https://url',
            protocol: 'https:',
            hostname: 'hostname',
            pathname: '/_dashboard',
            search: '',
            username: '',
            password: '',
            port: '',
            hash: ''
          },
          [Symbol(query)]: URLSearchParams {
            [Symbol(query)]: [],
            [Symbol(context)]: [Circular *1]
          }
        },
        ssl: null,
        id: 'https://id,
        headers: {},
        deadCount: 0,
        resurrectTimeout: 0,
        _openRequests: 0,
        _status: 'alive',
        roles: { data: true, ingest: true },
        agent: Agent {
          _events: [Object: null prototype] {
            free: [Function (anonymous)],
            newListener: [Function: maybeEnableKeylog]
          },
          _eventsCount: 2,
          _maxListeners: undefined,
          defaultPort: 443,
          protocol: 'https:',
          options: [Object: null prototype] {
            keepAlive: true,
            keepAliveMsecs: 1000,
            maxSockets: 256,
            maxFreeSockets: 256,
            scheduling: 'lifo',
            noDelay: true,
            path: null
          },
          requests: [Object: null prototype] {},
          sockets: [Object: null prototype] {},
          freeSockets: [Object: null prototype] {
            'search-xinruitesting-kmyg7nuryssiicvd4cvvzflp7q.us-east-2.es.amazonaws.com:443:::::::::::::::::::::': [Array]
          },
          keepAliveMsecs: 1000,
          keepAlive: true,
          maxSockets: 256,
          maxFreeSockets: 256,
          scheduling: 'lifo',
          maxTotalSockets: Infinity,
          totalSocketCount: 1,
          maxCachedSessions: 100,
          _sessionCache: { map: [Object], list: [Array] },
          [Symbol(kCapture)]: false
        },
        makeRequest: [Function: request]
      },
      attempts: 0,
      aborted: false
    }
  }
}

@xinruiba
Copy link
Member Author

xinruiba commented Jan 18, 2024

Failed "client.cat.indices()" response by using "https://google.com" endpoint:

  meta: {
    body: '<!DOCTYPE html>\n' +
      '<html lang=en>\n' +
      '  <meta charset=utf-8>\n' +
      '  <meta name=viewport content="initial-scale=1, minimum-scale=1, width=device-width">\n' +
      '  <title>Error 404 (Not Found)!!1</title>\n' +
      '  <style>\n' +
      '    *{margin:0;padding:0}html,code{font:15px/22px arial,sans-serif}html{background:#fff;color:#222;padding:15px}body{margin:7% auto 0;max-width:390px;min-height:180px;padding:30px 0 15px}* > body{background:url(//www.google.com/images/errors/robot.png) 100% 5px no-repeat;padding-right:205px}p{margin:11px 0 22px;overflow:hidden}ins{color:#777;text-decoration:none}a img{border:0}@media screen and (max-width:772px){body{background:none;margin-top:0;max-width:none;padding-right:0}}#logo{background:url(//www.google.com/images/branding/googlelogo/1x/googlelogo_color_150x54dp.png) no-repeat;margin-left:-5px}@media only screen and (min-resolution:192dpi){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat 0% 0%/100% 100%;-moz-border-image:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) 0}}@media only screen and (-webkit-min-device-pixel-ratio:2){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat;-webkit-background-size:100% 100%}}#logo{display:inline-block;height:54px;width:150px}\n' +
      '  </style>\n' +
      '  <a href=//www.google.com/><span id=logo aria-label=Google></span></a>\n' +
      '  <p><b>404.</b> <ins>That’s an error.</ins>\n' +
      '  <p>The requested URL <code>/_cat/indices</code> was not found on this server.  <ins>That’s all we know.</ins>\n',
    statusCode: 404,
    headers: {
      'content-type': 'text/html; charset=UTF-8',
      'referrer-policy': 'no-referrer',
      'content-length': '1573',
      date: 'Thu, 18 Jan 2024 23:27:47 GMT',
      'alt-svc': 'h3=":443"; ma=2592000,h3-29=":443"; ma=2592000'
    },
    meta: {
      context: null,
      request: {
        params: {
          method: 'GET',
          path: '/_cat/indices',
          body: null,
          querystring: '',
          headers: {
            'user-agent': 'opensearch-js/2.3.1 (linux 5.15.0-1039-aws-x64; Node.js v18.16.0)'
          },
          timeout: 30000
        },
        options: {},
        id: 1
      },
      name: 'opensearch-js',
      connection: AwsSigv4SignerConnection {
        url: <ref *1> URL {
          [Symbol(context)]: URLContext {
            href: 'https://google.com/',
            origin: 'https://google.com',
            protocol: 'https:',
            hostname: 'google.com',
            pathname: '/',
            search: '',
            username: '',
            password: '',
            port: '',
            hash: ''
          },
          [Symbol(query)]: URLSearchParams {
            [Symbol(query)]: [],
            [Symbol(context)]: [Circular *1]
          }
        },
        ssl: null,
        id: 'https://google.com/',
        headers: {},
        deadCount: 0,
        resurrectTimeout: 0,
        _openRequests: 0,
        _status: 'alive',
        roles: { data: true, ingest: true },
        agent: Agent {
          _events: [Object: null prototype] {
            free: [Function (anonymous)],
            newListener: [Function: maybeEnableKeylog]
          },
          _eventsCount: 2,
          _maxListeners: undefined,
          defaultPort: 443,
          protocol: 'https:',
          options: [Object: null prototype] {
            keepAlive: true,
            keepAliveMsecs: 1000,
            maxSockets: 256,
            maxFreeSockets: 256,
            scheduling: 'lifo',
            noDelay: true,
            path: null
          },
          requests: [Object: null prototype] {},
          sockets: [Object: null prototype] {},
          freeSockets: [Object: null prototype] {
            'google.com:443:::::::::::::::::::::': [Array]
          },
          keepAliveMsecs: 1000,
          keepAlive: true,
          maxSockets: 256,
          maxFreeSockets: 256,
          scheduling: 'lifo',
          maxTotalSockets: Infinity,
          totalSocketCount: 1,
          maxCachedSessions: 100,
          _sessionCache: { map: [Object], list: [Array] },
          [Symbol(kCapture)]: false
        },
        makeRequest: [Function: request]
      },
      attempts: 0,
      aborted: false
    }
  }
}

@xinruiba
Copy link
Member Author

xinruiba commented Jan 19, 2024

Proposal to resolve this problem by:

  1. Checking the response status code, if it's 200, then test connection pass. Otherwise fail the test connection
  2. Also inherit response body from original "client.info()" or "client.cat.indices()" call and put the body as a part of test connection response. We are able to provide more information in this way.

Since "client.info()" and "client.cat.indices()" response body are in different formats, thus response body don't have a happy path which able to apply both APIs, so propose to only depend on response status code (200) as the single source of truth that refers to "connection testing success".

Please feel free to leave comments if there are any concerns regarding this proposal~
@bandinib-amzn @Flyingliuhub

Thanks~

@bandinib-amzn
Copy link
Member

Proposal to resolve this problem by:

  1. Checking the response status code, if it's 200, then test connection pass. Otherwise fail the test connection
  2. Also inherit response body from original "client.info()" or "client.cat.indices()" call and put the body as a part of test connection response. We are able to provide more information in this way.

Since "client.info()" and "client.cat.indices()" response body are in different formats, thus response body don't have a happy path which able to apply both APIs, so propose to only depend on response status code (200) as the single source of truth that refers to "connection testing success".

Please feel free to leave comments if there are any concerns regarding this proposal~ @bandinib-amzn @Flyingliuhub

Thanks~

Thanks @xinruiba for investigating this. I agree with you. Let's only depend upon status code.

Question: In case of serverless, what client.info() returns? why we have to use cat indices api for serverless?

@xinruiba
Copy link
Member Author

xinruiba commented Jan 19, 2024

Question: In case of serverless, what client.info() returns? why we have to use cat indices api for serverless?

Thanks @bandinib-amzn for the comment and response.

Based on this comment:
https://github.com/opensearch-project/OpenSearch-Dashboards/blob/main/src/plugins/data_source/server/routes/data_source_connection_validator.ts#L17
client.info() API is not supported for serverless.

Let me take a further investigation, to see what will happen if we call client.info() on serverless cluster.

@xinruiba
Copy link
Member Author

xinruiba commented Jan 19, 2024

I created a serverless cluster and do following validation:

  1. Verified 'client.info()' not supported for cluster:
{
  meta: {
    body: '',
    statusCode: 404,
    headers: {
      'x-request-id': 'eefa9cf0-5674-9840-9b9b-3cf73104dc37',
      date: 'Fri, 19 Jan 2024 21:39:43 GMT',
      server: 'aoss-amazon',
      'content-length': '0'
    },
    meta: {
      context: null,
      request: {
        params: {
          method: 'GET',
          path: '/',
          body: null,
          querystring: '',
          headers: {
            'user-agent': 'opensearch-js/2.3.1 (linux 5.15.0-1039-aws-x64; Node.js v18.16.0)'
          },
          timeout: 30000
        },
        options: {},
        id: 1
      },
      name: 'opensearch-js',
      connection: AwsSigv4SignerConnection {
        url: <ref *1> URL {
          [Symbol(context)]: URLContext {
            href: 'https://url/',
            origin: 'https://url',
            protocol: 'https:',
            hostname: 'hostname',
            pathname: '/',
            search: '',
            username: '',
            password: '',
            port: '',
            hash: ''
          },
          [Symbol(query)]: URLSearchParams {
            [Symbol(query)]: [],
            [Symbol(context)]: [Circular *1]
          }
        },
        ssl: null,
        id: 'some_id',
        headers: {},
        deadCount: 0,
        resurrectTimeout: 0,
        _openRequests: 0,
        _status: 'alive',
        roles: { data: true, ingest: true },
        agent: Agent {
          _events: [Object: null prototype] {
            free: [Function (anonymous)],
            newListener: [Function: maybeEnableKeylog]
          },
          _eventsCount: 2,
          _maxListeners: undefined,
          defaultPort: 443,
          protocol: 'https:',
          options: [Object: null prototype] {
            keepAlive: true,
            keepAliveMsecs: 1000,
            maxSockets: 256,
            maxFreeSockets: 256,
            scheduling: 'lifo',
            noDelay: true,
            path: null
          },
          requests: [Object: null prototype] {},
          sockets: [Object: null prototype] {},
          freeSockets: [Object: null prototype] {
            'i8vekkrb30e4mqa2fpi5.us-east-2.aoss.amazonaws.com:443:::::::::::::::::::::': [Array]
          },
          keepAliveMsecs: 1000,
          keepAlive: true,
          maxSockets: 256,
          maxFreeSockets: 256,
          scheduling: 'lifo',
          maxTotalSockets: Infinity,
          totalSocketCount: 1,
          maxCachedSessions: 100,
          _sessionCache: { map: [Object], list: [Array] },
          [Symbol(kCapture)]: false
        },
        makeRequest: [Function: request]
      },
      attempts: 0,
      aborted: false
    }
  }
}
  1. Verified client.cat.indices() works for serverless cluster.
{
  body: '',
  statusCode: 200,
  headers: {
    'content-type': 'text/plain; charset=UTF-8',
    'content-length': '0',
    'x-envoy-upstream-service-time': '43',
    date: 'Fri, 19 Jan 2024 21:39:06 GMT',
    server: 'aoss-amazon-m',
    'x-request-id': 'b9d825b2-c4b8-97fd-b43b-094480eef3ed'
  },
  meta: {
    context: null,
    request: { params: [Object], options: {}, id: 1 },
    name: 'opensearch-js',
    connection: {
      url: 'https://url/',
      id: 'https://id/',
      headers: {},
      deadCount: 0,
      resurrectTimeout: 0,
      _openRequests: 0,
      status: 'alive',
      roles: [Object]
    },
    attempts: 0,
    aborted: false
  }
}

In summary, for serverless cluster, client.cat.indices() works fine and client.info() not works.

CC @bandinib-amzn

@bandinib-amzn
Copy link
Member

bandinib-amzn commented Jan 19, 2024

Got it. Thanks @xinruiba for checking. Looking forward to PR fixing this issue.

@xinruiba
Copy link
Member Author

Get a comment from @Flyingliuhub to add more validations based on response body, which make sense to me.
Instead of only depend on response status code, I will update the validation condition into:

  1. Server cluster:
    (response.statuscode == 200 && response.body.cluster_name.isNotEmpty())

  2. Serverless collection:
    (response.statuscode == 200 && response.body.isNotEmpty())

cc @Flyingliuhub @bandinib-amzn

@xinruiba
Copy link
Member Author

This issue get fixed here:#5663
Closing

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug Something isn't working v2.12.0
Projects
None yet
Development

No branches or pull requests

2 participants