From d459bfd81c8c38284c37b52cd224a18c81f9cd97 Mon Sep 17 00:00:00 2001 From: Kenia <19161242+kaxcode@users.noreply.github.com> Date: Fri, 29 May 2020 11:07:36 -0400 Subject: [PATCH] ui: Add blocking queries to gateways (#7967) * Remove gateway endpoint adapter, model, and serializer and tests * Update service tests to handle gateway-services-nodes * Upgrade consul-api-double to 2.15.2 * Add a fairly temporary shouldReconcile method Co-authored-by: John Cowen --- ui-v2/app/adapters/gateway.js | 17 ------- ui-v2/app/adapters/service.js | 25 +++++++--- ui-v2/app/models/gateway.js | 12 ----- ui-v2/app/models/service.js | 1 + ui-v2/app/routes/dc/services/show.js | 3 +- ui-v2/app/serializers/gateway.js | 17 ------- ui-v2/app/services/client/http.js | 5 ++ .../services/data-source/protocols/http.js | 2 +- ui-v2/app/services/repository.js | 3 ++ ui-v2/app/services/repository/gateway.js | 8 ---- ui-v2/app/services/repository/service.js | 18 +++++++ .../services/repository/type/event-source.js | 6 +-- .../templates/dc/services/show/services.hbs | 4 +- .../templates/dc/services/show/upstreams.hbs | 4 +- .../integration/adapters/gateway-test.js | 27 ----------- .../integration/adapters/service-test.js | 15 ++++++ .../integration/serializers/gateway-test.js | 47 ------------------- .../integration/serializers/service-test.js | 31 ++++++++++++ .../services/repository/gateway-test.js | 42 ----------------- .../services/repository/service-test.js | 42 +++++++++++++++++ ui-v2/tests/unit/adapters/gateway-test.js | 12 ----- ui-v2/tests/unit/models/gateway-test.js | 13 ----- ui-v2/tests/unit/serializers/gateway-test.js | 23 --------- .../unit/services/repository/gateway-test.js | 12 ----- ui-v2/yarn.lock | 6 +-- 25 files changed, 144 insertions(+), 251 deletions(-) delete mode 100644 ui-v2/app/adapters/gateway.js delete mode 100644 ui-v2/app/models/gateway.js delete mode 100644 ui-v2/app/serializers/gateway.js delete mode 100644 ui-v2/app/services/repository/gateway.js delete mode 100644 ui-v2/tests/integration/adapters/gateway-test.js delete mode 100644 ui-v2/tests/integration/serializers/gateway-test.js delete mode 100644 ui-v2/tests/integration/services/repository/gateway-test.js delete mode 100644 ui-v2/tests/unit/adapters/gateway-test.js delete mode 100644 ui-v2/tests/unit/models/gateway-test.js delete mode 100644 ui-v2/tests/unit/serializers/gateway-test.js delete mode 100644 ui-v2/tests/unit/services/repository/gateway-test.js diff --git a/ui-v2/app/adapters/gateway.js b/ui-v2/app/adapters/gateway.js deleted file mode 100644 index 96d1e4cf1e..0000000000 --- a/ui-v2/app/adapters/gateway.js +++ /dev/null @@ -1,17 +0,0 @@ -import Adapter from './application'; - -export default Adapter.extend({ - requestForQueryRecord: function(request, { dc, ns, index, id }) { - if (typeof id === 'undefined') { - throw new Error('You must specify an id'); - } - return request` - GET /v1/internal/ui/gateway-services-nodes/${id}?${{ dc }} - - ${{ - ...this.formatNspace(ns), - index, - }} - `; - }, -}); diff --git a/ui-v2/app/adapters/service.js b/ui-v2/app/adapters/service.js index 27276fecb1..dd079aaaa5 100644 --- a/ui-v2/app/adapters/service.js +++ b/ui-v2/app/adapters/service.js @@ -1,15 +1,26 @@ import Adapter from './application'; // TODO: Update to use this.formatDatacenter() export default Adapter.extend({ - requestForQuery: function(request, { dc, ns, index }) { - return request` - GET /v1/internal/ui/services?${{ dc }} + requestForQuery: function(request, { dc, ns, index, gateway }) { + if (typeof gateway !== 'undefined') { + return request` + GET /v1/internal/ui/gateway-services-nodes/${gateway}?${{ dc }} - ${{ - ...this.formatNspace(ns), - index, - }} + ${{ + ...this.formatNspace(ns), + index, + }} + `; + } else { + return request` + GET /v1/internal/ui/services?${{ dc }} + + ${{ + ...this.formatNspace(ns), + index, + }} `; + } }, requestForQueryRecord: function(request, { dc, ns, index, id }) { if (typeof id === 'undefined') { diff --git a/ui-v2/app/models/gateway.js b/ui-v2/app/models/gateway.js deleted file mode 100644 index 85b081a0ff..0000000000 --- a/ui-v2/app/models/gateway.js +++ /dev/null @@ -1,12 +0,0 @@ -import Model from 'ember-data/model'; -import attr from 'ember-data/attr'; - -export const PRIMARY_KEY = 'uid'; -export const SLUG_KEY = 'Name'; -export default Model.extend({ - [PRIMARY_KEY]: attr('string'), - [SLUG_KEY]: attr('string'), - Datacenter: attr('string'), - Namespace: attr('string'), - Services: attr(), -}); diff --git a/ui-v2/app/models/service.js b/ui-v2/app/models/service.js index 611031f04d..18a739fa1d 100644 --- a/ui-v2/app/models/service.js +++ b/ui-v2/app/models/service.js @@ -17,6 +17,7 @@ export default Model.extend({ ProxyFor: attr(), Kind: attr('string'), ExternalSources: attr(), + GatewayConfig: attr(), Meta: attr(), Address: attr('string'), TaggedAddresses: attr(), diff --git a/ui-v2/app/routes/dc/services/show.js b/ui-v2/app/routes/dc/services/show.js index e5b699de58..2af752b489 100644 --- a/ui-v2/app/routes/dc/services/show.js +++ b/ui-v2/app/routes/dc/services/show.js @@ -8,7 +8,6 @@ export default Route.extend({ intentionRepo: service('repository/intention'), chainRepo: service('repository/discovery-chain'), proxyRepo: service('repository/proxy'), - gatewayRepo: service('repository/gateway'), settings: service('settings'), model: function(params, transition = {}) { const dc = this.modelFor('dc').dc.Name; @@ -55,7 +54,7 @@ export default Route.extend({ .then(model => { return ['ingress-gateway', 'terminating-gateway'].includes(get(model, 'item.Service.Kind')) ? hash({ - gateway: this.gatewayRepo.findBySlug(params.name, dc, nspace), + gatewayServices: this.repo.findGatewayBySlug(params.name, dc, nspace), ...model, }) : model; diff --git a/ui-v2/app/serializers/gateway.js b/ui-v2/app/serializers/gateway.js deleted file mode 100644 index 069c7a695f..0000000000 --- a/ui-v2/app/serializers/gateway.js +++ /dev/null @@ -1,17 +0,0 @@ -import Serializer from './application'; -import { PRIMARY_KEY, SLUG_KEY } from 'consul-ui/models/gateway'; - -export default Serializer.extend({ - primaryKey: PRIMARY_KEY, - slugKey: SLUG_KEY, - respondForQueryRecord: function(respond, query) { - return this._super(function(cb) { - return respond(function(headers, body) { - return cb(headers, { - Name: query.id, - Services: body, - }); - }); - }, query); - }, -}); diff --git a/ui-v2/app/services/client/http.js b/ui-v2/app/services/client/http.js index b5f75acd62..71e081267f 100644 --- a/ui-v2/app/services/client/http.js +++ b/ui-v2/app/services/client/http.js @@ -96,6 +96,11 @@ export default Service.extend({ body: function(strs, ...values) { let body = {}; const doubleBreak = strs.reduce(function(prev, item, i) { + // Ensure each line has no whitespace either end, including empty lines + item = item + .split('\n') + .map(item => item.trim()) + .join('\n'); if (item.indexOf('\n\n') !== -1) { return i; } diff --git a/ui-v2/app/services/data-source/protocols/http.js b/ui-v2/app/services/data-source/protocols/http.js index a0bbabca6b..4cda93be88 100644 --- a/ui-v2/app/services/data-source/protocols/http.js +++ b/ui-v2/app/services/data-source/protocols/http.js @@ -20,7 +20,7 @@ export default Service.extend({ // always be complete, they should never have things like '///model' let find; const repo = this[model]; - if (typeof repo.reconcile === 'function') { + if (repo.shouldReconcile(src)) { configuration.createEvent = function(result = {}, configuration) { const event = { type: 'message', diff --git a/ui-v2/app/services/repository.js b/ui-v2/app/services/repository.js index 8116914baf..c5088d66f6 100644 --- a/ui-v2/app/services/repository.js +++ b/ui-v2/app/services/repository.js @@ -15,6 +15,9 @@ export default Service.extend({ }, // store: service('store'), + shouldReconcile: function(method) { + return true; + }, reconcile: function(meta = {}) { // unload anything older than our current sync date/time if (typeof meta.date !== 'undefined') { diff --git a/ui-v2/app/services/repository/gateway.js b/ui-v2/app/services/repository/gateway.js deleted file mode 100644 index 647cf86629..0000000000 --- a/ui-v2/app/services/repository/gateway.js +++ /dev/null @@ -1,8 +0,0 @@ -import RepositoryService from 'consul-ui/services/repository'; - -const modelName = 'gateway'; -export default RepositoryService.extend({ - getModelName: function() { - return modelName; - }, -}); diff --git a/ui-v2/app/services/repository/service.js b/ui-v2/app/services/repository/service.js index ca9a5bc657..ae34a6dd2e 100644 --- a/ui-v2/app/services/repository/service.js +++ b/ui-v2/app/services/repository/service.js @@ -5,6 +5,13 @@ export default RepositoryService.extend({ getModelName: function() { return modelName; }, + shouldReconcile: function(method) { + switch (method) { + case 'findGatewayBySlug': + return false; + } + return this._super(...arguments); + }, findBySlug: function(slug, dc) { return this._super(...arguments).then(function(item) { // TODO: Move this to the Serializer @@ -69,4 +76,15 @@ export default RepositoryService.extend({ throw e; }); }, + findGatewayBySlug: function(slug, dc, nspace, configuration) { + const query = { + dc: dc, + ns: nspace, + gateway: slug, + }; + if (typeof configuration.cursor !== 'undefined') { + query.index = configuration.cursor; + } + return this.store.query(this.getModelName(), query); + }, }); diff --git a/ui-v2/app/services/repository/type/event-source.js b/ui-v2/app/services/repository/type/event-source.js index 65bd874f84..9ddc23e610 100644 --- a/ui-v2/app/services/repository/type/event-source.js +++ b/ui-v2/app/services/repository/type/event-source.js @@ -10,15 +10,13 @@ const createProxy = function(repo, find, settings, cache, serialize = JSON.strin const client = this.client; // custom createEvent, here used to reconcile the ember-data store for each tick let createEvent; - if (typeof repo.reconcile === 'function') { + if (repo.shouldReconcile(find)) { createEvent = function(result = {}, configuration) { const event = { type: 'message', data: result, }; - if (repo.reconcile === 'function') { - repo.reconcile(get(event, 'data.meta')); - } + repo.reconcile(get(event, 'data.meta')); return event; }; } diff --git a/ui-v2/app/templates/dc/services/show/services.hbs b/ui-v2/app/templates/dc/services/show/services.hbs index e93ef13e9f..b29e1dc91a 100644 --- a/ui-v2/app/templates/dc/services/show/services.hbs +++ b/ui-v2/app/templates/dc/services/show/services.hbs @@ -1,12 +1,12 @@
- {{#if (gt gateway.Services.length 0)}} + {{#if (gt gatewayServices.length 0)}}

The following services may receive traffic from external services through this gateway. Learn more about configuring gateways in our step-by-step guide.

- +
{{else}}

diff --git a/ui-v2/app/templates/dc/services/show/upstreams.hbs b/ui-v2/app/templates/dc/services/show/upstreams.hbs index 372b868685..f329541e8a 100644 --- a/ui-v2/app/templates/dc/services/show/upstreams.hbs +++ b/ui-v2/app/templates/dc/services/show/upstreams.hbs @@ -1,12 +1,12 @@

- {{#if (gt gateway.Services.length 0)}} + {{#if (gt gatewayServices.length 0)}}

Upstreams are services that may receive traffic from this gateway. Learn more about configuring gateways in our documentation.

{{#let item.Service.Namespace as |nspace|}} - + {{#if (service/exists item)}} {{item.Name}} diff --git a/ui-v2/tests/integration/adapters/gateway-test.js b/ui-v2/tests/integration/adapters/gateway-test.js deleted file mode 100644 index f2138a61b8..0000000000 --- a/ui-v2/tests/integration/adapters/gateway-test.js +++ /dev/null @@ -1,27 +0,0 @@ -import { module, test } from 'qunit'; -import { setupTest } from 'ember-qunit'; - -module('Integration | Adapter | gateway', function(hooks) { - setupTest(hooks); - const dc = 'dc-1'; - const id = 'slug'; - test('requestForQueryRecord returns the correct url/method', function(assert) { - const adapter = this.owner.lookup('adapter:gateway'); - const client = this.owner.lookup('service:client/http'); - const expected = `GET /v1/internal/ui/gateway-services-nodes/${id}?dc=${dc}`; - const actual = adapter.requestForQueryRecord(client.url, { - dc: dc, - id: id, - }); - assert.equal(actual, expected); - }); - test("requestForQueryRecord throws if you don't specify an id", function(assert) { - const adapter = this.owner.lookup('adapter:gateway'); - const client = this.owner.lookup('service:client/http'); - assert.throws(function() { - adapter.requestForQueryRecord(client.url, { - dc: dc, - }); - }); - }); -}); diff --git a/ui-v2/tests/integration/adapters/service-test.js b/ui-v2/tests/integration/adapters/service-test.js index face132991..7588fdfc57 100644 --- a/ui-v2/tests/integration/adapters/service-test.js +++ b/ui-v2/tests/integration/adapters/service-test.js @@ -23,6 +23,21 @@ module('Integration | Adapter | service', function(hooks) { actual = actual.join('\n').trim(); assert.equal(actual, `${shouldHaveNspace(nspace) ? `ns=${nspace}` : ``}`); }); + test(`requestForQuery returns the correct url/method when called with gateway when nspace is ${nspace}`, function(assert) { + const adapter = this.owner.lookup('adapter:service'); + const client = this.owner.lookup('service:client/http'); + const gateway = 'gateway'; + const expected = `GET /v1/internal/ui/gateway-services-nodes/${gateway}?dc=${dc}`; + let actual = adapter.requestForQuery(client.url, { + dc: dc, + ns: nspace, + gateway: gateway, + }); + actual = actual.split('\n'); + assert.equal(actual.shift().trim(), expected); + actual = actual.join('\n').trim(); + assert.equal(actual, `${shouldHaveNspace(nspace) ? `ns=${nspace}` : ``}`); + }); test(`requestForQueryRecord returns the correct url/method when nspace is ${nspace}`, function(assert) { const adapter = this.owner.lookup('adapter:service'); const client = this.owner.lookup('service:client/http'); diff --git a/ui-v2/tests/integration/serializers/gateway-test.js b/ui-v2/tests/integration/serializers/gateway-test.js deleted file mode 100644 index 837c09aa77..0000000000 --- a/ui-v2/tests/integration/serializers/gateway-test.js +++ /dev/null @@ -1,47 +0,0 @@ -import { module, test } from 'qunit'; -import { setupTest } from 'ember-qunit'; - -import { get } from 'consul-ui/tests/helpers/api'; -import { - HEADERS_SYMBOL as META, - HEADERS_DATACENTER as DC, - HEADERS_NAMESPACE as NSPACE, -} from 'consul-ui/utils/http/consul'; - -module('Integration | Serializer | gateway', function(hooks) { - setupTest(hooks); - test('respondForQueryRecord returns the correct data for item endpoint', function(assert) { - const serializer = this.owner.lookup('serializer:gateway'); - const dc = 'dc-1'; - const id = 'slug'; - const nspace = 'default'; - const request = { - url: `/v1/internal/ui/gateway-services-nodes/${id}?dc=${dc}`, - }; - return get(request.url).then(function(payload) { - const expected = { - Datacenter: dc, - [META]: { - [DC.toLowerCase()]: dc, - [NSPACE.toLowerCase()]: nspace, - }, - uid: `["${nspace}","${dc}","${id}"]`, - Name: id, - Namespace: nspace, - Services: payload, - }; - const actual = serializer.respondForQueryRecord( - function(cb) { - const headers = {}; - const body = payload; - return cb(headers, body); - }, - { - dc: dc, - id: id, - } - ); - assert.deepEqual(actual, expected); - }); - }); -}); diff --git a/ui-v2/tests/integration/serializers/service-test.js b/ui-v2/tests/integration/serializers/service-test.js index f970562dc1..1d50230d7d 100644 --- a/ui-v2/tests/integration/serializers/service-test.js +++ b/ui-v2/tests/integration/serializers/service-test.js @@ -40,6 +40,37 @@ module('Integration | Serializer | service', function(hooks) { assert.deepEqual(actual, expected); }); }); + test(`respondForQuery returns the correct data for list endpoint when gateway is set when nspace is ${nspace}`, function(assert) { + const serializer = this.owner.lookup('serializer:service'); + const gateway = 'gateway'; + const request = { + url: `/v1/internal/ui/gateway-services-nodes/${gateway}?dc=${dc}${ + typeof nspace !== 'undefined' ? `&ns=${nspace}` : `` + }`, + }; + return get(request.url).then(function(payload) { + const expected = payload.map(item => + Object.assign({}, item, { + Namespace: item.Namespace || undefinedNspace, + Datacenter: dc, + uid: `["${item.Namespace || undefinedNspace}","${dc}","${item.Name}"]`, + }) + ); + const actual = serializer.respondForQuery( + function(cb) { + const headers = {}; + const body = payload; + return cb(headers, body); + }, + { + dc: dc, + ns: nspace, + gateway: gateway, + } + ); + assert.deepEqual(actual, expected); + }); + }); test(`respondForQueryRecord returns the correct data for item endpoint when nspace is ${nspace}`, function(assert) { const serializer = this.owner.lookup('serializer:service'); const id = 'service-name'; diff --git a/ui-v2/tests/integration/services/repository/gateway-test.js b/ui-v2/tests/integration/services/repository/gateway-test.js deleted file mode 100644 index c43b2f7e46..0000000000 --- a/ui-v2/tests/integration/services/repository/gateway-test.js +++ /dev/null @@ -1,42 +0,0 @@ -import { moduleFor, test } from 'ember-qunit'; -import repo from 'consul-ui/tests/helpers/repo'; - -moduleFor('service:repository/gateway', 'Integration | Repository | gateway', { - // Specify the other units that are required for this test. - integration: true, -}); -const dc = 'dc-1'; -const id = 'slug'; -const nspace = 'default'; -test('findBySlug returns the correct data for item endpoint', function(assert) { - return repo( - 'Gateway', - 'findBySlug', - this.subject(), - function retrieveStub(stub) { - return stub(`/v1/internal/ui/gateway-services-nodes/${id}`); - }, - function performTest(service) { - return service.findBySlug(id, dc); - }, - function performAssertion(actual, expected) { - assert.deepEqual( - actual, - expected(function(payload) { - return Object.assign( - {}, - { - Datacenter: dc, - Name: id, - Namespace: nspace, - uid: `["${nspace}","${dc}","${id}"]`, - }, - { - Services: payload, - } - ); - }) - ); - } - ); -}); diff --git a/ui-v2/tests/integration/services/repository/service-test.js b/ui-v2/tests/integration/services/repository/service-test.js index be2745912e..ace26d0bd6 100644 --- a/ui-v2/tests/integration/services/repository/service-test.js +++ b/ui-v2/tests/integration/services/repository/service-test.js @@ -130,4 +130,46 @@ const undefinedNspace = 'default'; } ); }); + test(`findGatewayBySlug returns the correct data for list endpoint when nspace is ${nspace}`, function(assert) { + get(this.subject(), 'store').serializerFor(NAME).timestamp = function() { + return now; + }; + const gateway = 'gateway'; + const conf = { + cursor: 1, + }; + return repo( + 'Service', + 'findGatewayBySlug', + this.subject(), + function retrieveStub(stub) { + return stub( + `/v1/internal/ui/gateway-services-nodes/${gateway}?dc=${dc}${ + typeof nspace !== 'undefined' ? `&ns=${nspace}` : `` + }`, + { + CONSUL_SERVICE_COUNT: '100', + } + ); + }, + function performTest(service) { + return service.findGatewayBySlug(gateway, dc, nspace || undefinedNspace, conf); + }, + function performAssertion(actual, expected) { + assert.deepEqual( + actual, + expected(function(payload) { + return payload.map(item => + Object.assign({}, item, { + SyncTime: now, + Datacenter: dc, + Namespace: item.Namespace || undefinedNspace, + uid: `["${item.Namespace || undefinedNspace}","${dc}","${item.Name}"]`, + }) + ); + }) + ); + } + ); + }); }); diff --git a/ui-v2/tests/unit/adapters/gateway-test.js b/ui-v2/tests/unit/adapters/gateway-test.js deleted file mode 100644 index e131d648bc..0000000000 --- a/ui-v2/tests/unit/adapters/gateway-test.js +++ /dev/null @@ -1,12 +0,0 @@ -import { module, test } from 'qunit'; -import { setupTest } from 'ember-qunit'; - -module('Unit | Adapter | gateway', function(hooks) { - setupTest(hooks); - - // Replace this with your real tests. - test('it exists', function(assert) { - let adapter = this.owner.lookup('adapter:gateway'); - assert.ok(adapter); - }); -}); diff --git a/ui-v2/tests/unit/models/gateway-test.js b/ui-v2/tests/unit/models/gateway-test.js deleted file mode 100644 index d86be7ec1f..0000000000 --- a/ui-v2/tests/unit/models/gateway-test.js +++ /dev/null @@ -1,13 +0,0 @@ -import { module, test } from 'qunit'; -import { setupTest } from 'ember-qunit'; - -module('Unit | Model | gateway', function(hooks) { - setupTest(hooks); - - // Replace this with your real tests. - test('it exists', function(assert) { - let store = this.owner.lookup('service:store'); - let model = store.createRecord('gateway', {}); - assert.ok(model); - }); -}); diff --git a/ui-v2/tests/unit/serializers/gateway-test.js b/ui-v2/tests/unit/serializers/gateway-test.js deleted file mode 100644 index fc0a1c80e8..0000000000 --- a/ui-v2/tests/unit/serializers/gateway-test.js +++ /dev/null @@ -1,23 +0,0 @@ -import { module, test } from 'qunit'; -import { setupTest } from 'ember-qunit'; - -module('Unit | Serializer | gateway', function(hooks) { - setupTest(hooks); - - // Replace this with your real tests. - test('it exists', function(assert) { - let store = this.owner.lookup('service:store'); - let serializer = store.serializerFor('gateway'); - - assert.ok(serializer); - }); - - test('it serializes records', function(assert) { - let store = this.owner.lookup('service:store'); - let record = store.createRecord('gateway', {}); - - let serializedRecord = record.serialize(); - - assert.ok(serializedRecord); - }); -}); diff --git a/ui-v2/tests/unit/services/repository/gateway-test.js b/ui-v2/tests/unit/services/repository/gateway-test.js deleted file mode 100644 index ca2989eb3e..0000000000 --- a/ui-v2/tests/unit/services/repository/gateway-test.js +++ /dev/null @@ -1,12 +0,0 @@ -import { module, test } from 'qunit'; -import { setupTest } from 'ember-qunit'; - -module('Unit | Repository | gateway', function(hooks) { - setupTest(hooks); - - // Replace this with your real tests. - test('it exists', function(assert) { - const repo = this.owner.lookup('service:repository/gateway'); - assert.ok(repo); - }); -}); diff --git a/ui-v2/yarn.lock b/ui-v2/yarn.lock index 35170d6efc..a79b75e863 100644 --- a/ui-v2/yarn.lock +++ b/ui-v2/yarn.lock @@ -1211,9 +1211,9 @@ js-yaml "^3.13.1" "@hashicorp/consul-api-double@^2.6.2": - version "2.15.1" - resolved "https://registry.yarnpkg.com/@hashicorp/consul-api-double/-/consul-api-double-2.15.1.tgz#1b41c92ee7930e0bcead8283eea019b5f1238819" - integrity sha512-0q0h2krXFR5uj/A+x5WtsKVF1ltPPDrrxmX9g+SjUmeWHIcffH7qz/PCo4fdqWOPjcTXkPfBxSZwGd2uDishaQ== + version "2.15.2" + resolved "https://registry.yarnpkg.com/@hashicorp/consul-api-double/-/consul-api-double-2.15.2.tgz#e2c34a348b9959fcc95ffad797c1fed9644a41bd" + integrity sha512-VNdwsL3ut4SubCtwWfqX4prD9R/RczKtWUID6s6K9h1TCdzTgpZQhbb+gdzaYGqzCE3Mrw416JzclxVTIFIUFw== "@hashicorp/ember-cli-api-double@^3.0.2": version "3.0.2"