diff --git a/addons/api/addon/generated/models/host-set.js b/addons/api/addon/generated/models/host-set.js index df5ed719a..31a126d45 100644 --- a/addons/api/addon/generated/models/host-set.js +++ b/addons/api/addon/generated/models/host-set.js @@ -63,6 +63,10 @@ export default class GeneratedHostSetModel extends BaseModel { plugin; @attr('number', { + for: { + type: 'plugin', + name: ['aws', 'azure', 'gcp'], + }, description: "The number of seconds between the time boundary syncs the hosts in this set using this host set's plugin. If not provided a system determined default is used", }) @@ -79,12 +83,20 @@ export default class GeneratedHostSetModel extends BaseModel { host_ids; @attr('string-array', { + for: { + type: 'plugin', + name: ['aws', 'azure', 'gcp'], + }, emptyArrayIfMissing: true, }) preferred_endpoints; // AWS & GCP specific @attr('string-array', { + for: { + type: 'plugin', + name: ['aws', 'gcp'], + }, isNestedAttribute: true, emptyArrayIfMissing: true, }) @@ -92,6 +104,10 @@ export default class GeneratedHostSetModel extends BaseModel { // Azure specific, comes in from API as "filter". This is to avoid collisions with filter @attr('string', { + for: { + type: 'plugin', + name: 'azure', + }, description: '', isNestedAttribute: true, }) diff --git a/addons/api/addon/serializers/host-set.js b/addons/api/addon/serializers/host-set.js index c2ef41c52..6e303ce14 100644 --- a/addons/api/addon/serializers/host-set.js +++ b/addons/api/addon/serializers/host-set.js @@ -5,10 +5,6 @@ import ApplicationSerializer from './application'; -const fieldByType = { - aws: ['preferred_endpoints', 'filters', 'sync_interval_seconds'], - azure: ['preferred_endpoints', 'filter_string', 'sync_interval_seconds'], -}; export default class HostSetSerializer extends ApplicationSerializer { // =properties @@ -63,16 +59,8 @@ export default class HostSetSerializer extends ApplicationSerializer { return serialized; } - serializeAttribute(snapshot, json, key, attribute) { + serializeAttribute(snapshot, json, key) { const value = super.serializeAttribute(...arguments); - const { isPlugin, compositeType } = snapshot.record; - const { options } = attribute; - - if (isPlugin && options.isNestedAttribute) { - if (!fieldByType[compositeType].includes(key)) { - delete json.attributes[key]; - } - } if (key === 'filter_string') { const { filter_string } = json.attributes; diff --git a/addons/api/mirage/factories/host-catalog.js b/addons/api/mirage/factories/host-catalog.js index 90a887d5d..910e8bb3c 100644 --- a/addons/api/mirage/factories/host-catalog.js +++ b/addons/api/mirage/factories/host-catalog.js @@ -53,8 +53,8 @@ export default factory.extend({ worker_filter: function () { if ( this.type === TYPE_HOST_CATALOG_DYNAMIC && - (this.plugin?.name === TYPE_HOST_CATALOG_PLUGIN_AWS || - this.plugin?.name === TYPE_HOST_CATALOG_PLUGIN_GCP) + (this.plugin?.name === TYPE_HOST_CATALOG_PLUGIN_AWS || + this.plugin?.name === TYPE_HOST_CATALOG_PLUGIN_GCP) ) { return `"${faker.word.noun()}" in "${faker.system.directoryPath()}"`; } diff --git a/addons/api/mirage/factories/host-set.js b/addons/api/mirage/factories/host-set.js index eb5d8b30b..8442d6dd5 100644 --- a/addons/api/mirage/factories/host-set.js +++ b/addons/api/mirage/factories/host-set.js @@ -7,6 +7,22 @@ import factory from '../generated/factories/host-set'; import permissions from '../helpers/permissions'; import { faker } from '@faker-js/faker'; import generateId from '../helpers/id'; +import { + TYPE_HOST_CATALOG_PLUGIN_AWS, + TYPE_HOST_CATALOG_PLUGIN_AZURE, + TYPE_HOST_CATALOG_PLUGIN_GCP, +} from 'api/models/host-catalog'; + +function generateFilters(prefix) { + const filtersAmount = faker.number.int({ min: 1, max: 5 }); + let filters = []; + for (let i = 0; i < filtersAmount; ++i) { + filters.push(`${prefix}${faker.word.words(1)}=${faker.word.words(1)}`); + } + return { + filters, + }; +} export default factory.extend({ id: () => generateId('hs_'), @@ -42,22 +58,15 @@ export default factory.extend({ }, attributes() { - // AWS specific - if (this.plugin?.name === 'aws') { - const filtersAmount = faker.number.int({ min: 1, max: 5 }); - let filters = []; - for (let i = 0; i < filtersAmount; ++i) { - filters.push(`${faker.word.words(1)}=${faker.word.words(1)}`); - } - return { - filters, - }; - } - // Azure specific - if (this.plugin?.name === 'azure') { - return { - filter: `${faker.database.column()}=${faker.database.collation()}`, - }; + switch (this.plugin?.name) { + case TYPE_HOST_CATALOG_PLUGIN_AWS: + return generateFilters('tag:'); + case TYPE_HOST_CATALOG_PLUGIN_AZURE: + return { + filter: `${faker.database.column()}=${faker.database.collation()}`, + }; + case TYPE_HOST_CATALOG_PLUGIN_GCP: + return generateFilters('labels.'); } }, }); diff --git a/addons/api/mirage/factories/host.js b/addons/api/mirage/factories/host.js index 515f55f51..64d86ac60 100644 --- a/addons/api/mirage/factories/host.js +++ b/addons/api/mirage/factories/host.js @@ -7,24 +7,28 @@ import factory from '../generated/factories/host'; import permissions from '../helpers/permissions'; import { faker } from '@faker-js/faker'; import generateId from '../helpers/id'; +import { + TYPE_HOST_CATALOG_STATIC, + TYPE_HOST_CATALOG_DYNAMIC, +} from 'api/models/host-catalog'; export default factory.extend({ id: () => generateId('h_'), name() { - if (this.type === 'static') return faker.word.words(); + if (this.type === TYPE_HOST_CATALOG_STATIC) return faker.word.words(); }, type() { return this.hostCatalog?.type || factory.attrs.type; }, authorized_actions: function () { - const isStatic = this.type === 'static'; + const isStatic = this.type === TYPE_HOST_CATALOG_STATIC; const defaults = ['no-op', 'read']; // Only static allows update/delete at this time. if (isStatic) defaults.push('update', 'delete'); return permissions.authorizedActionsFor('host') || defaults; }, plugin() { - if (this.type === 'plugin') { + if (this.type === TYPE_HOST_CATALOG_DYNAMIC) { return this.hostCatalog.plugin; } }, @@ -38,12 +42,12 @@ export default factory.extend({ }, // Return external fields only for plugins external_id() { - if (this.type === 'plugin') { + if (this.type === TYPE_HOST_CATALOG_DYNAMIC) { return faker.string.uuid(); } }, external_name(i) { - if (this.type === 'plugin') { + if (this.type === TYPE_HOST_CATALOG_DYNAMIC) { return `${this.plugin.name} provided name ${i}`; } }, diff --git a/addons/core/addon/styles/addon.scss b/addons/core/addon/styles/addon.scss index 891f49b8e..2a9218e05 100644 --- a/addons/core/addon/styles/addon.scss +++ b/addons/core/addon/styles/addon.scss @@ -65,3 +65,10 @@ overflow: hidden; } } + +// Utility classes +.container-inputs__margin-bottom { + input:not(:last-child) { + margin-bottom: 0.5rem; + } +} diff --git a/addons/core/translations/resources/en-us.yaml b/addons/core/translations/resources/en-us.yaml index 82ac6af5b..ce8b0ed52 100644 --- a/addons/core/translations/resources/en-us.yaml +++ b/addons/core/translations/resources/en-us.yaml @@ -275,9 +275,11 @@ host-set: filter: label: Filter aws: - help: Create a filter to select resources using values such as tag name or tag value. The AWS filter format is tag:=. For example:tag:application=production + help: 'Create a filter to select resources using values such as tag name or tag value. The AWS filter format is tag:=. For example: tag:application=production' azure: help: Create a filter to select resources using values such as tag name or tag value. The Azure filter format is:tagName eq ‘’ and tagValue eq ‘'. For example:tagName eq 'application' and tagValue eq 'production' + gcp: + help: 'Create a filter to select resources using values such as label name or label value. The GCP filter format is labels.=. For example: labels.application=production' preferred_endpoints: label: Preferred Endpoints help: Preferred address at which the host should be accessed when multiple options are present, in cidr: or dns: format. diff --git a/ui/admin/app/components/form/host-set/aws/index.hbs b/ui/admin/app/components/form/host-set/aws/index.hbs index 3ead45061..8eb10dfb2 100644 --- a/ui/admin/app/components/form/host-set/aws/index.hbs +++ b/ui/admin/app/components/form/host-set/aws/index.hbs @@ -51,16 +51,11 @@ {{/if}} - + {{t 'form.type.label'}} - + {{t 'titles.provider'}} {{t 'resources.host-catalog.types.aws'}} diff --git a/ui/admin/app/components/form/host-set/azure/index.hbs b/ui/admin/app/components/form/host-set/azure/index.hbs index 1873a3235..24f9338d7 100644 --- a/ui/admin/app/components/form/host-set/azure/index.hbs +++ b/ui/admin/app/components/form/host-set/azure/index.hbs @@ -51,16 +51,11 @@ {{/if}} - + {{t 'form.type.label'}} - + {{t 'titles.provider'}} {{t 'resources.host-catalog.types.azure'}} diff --git a/ui/admin/app/components/form/host-set/gcp/index.hbs b/ui/admin/app/components/form/host-set/gcp/index.hbs new file mode 100644 index 000000000..23a0e5bf2 --- /dev/null +++ b/ui/admin/app/components/form/host-set/gcp/index.hbs @@ -0,0 +1,156 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +}} + + + + {{t 'form.name.label'}} + {{t 'form.name.help'}} + {{#if @model.errors.name}} + + {{#each @model.errors.name as |error|}} + {{error.message}} + {{/each}} + + {{/if}} + + + + {{t 'form.description.label'}} + {{t 'form.description.help'}} + {{#if @model.errors.description}} + + {{#each @model.errors.description as |error|}} + {{error.message}} + {{/each}} + + {{/if}} + + + + {{t 'form.type.label'}} + + + + {{t 'titles.provider'}} + + {{t 'resources.host-catalog.types.gcp'}} + + + + + <:fieldset as |F|> + + {{t 'resources.host-set.form.preferred_endpoints.label'}} + + + {{t 'resources.host-set.form.preferred_endpoints.help'}} + + + {{#if @model.errors.preferred_endpoints}} + + {{#each @model.errors.preferred_endpoints as |error|}} + {{error.message}} + {{/each}} + + {{/if}} + + <:field as |F|> + + + + + + <:fieldset as |F|> + + {{t 'resources.host-set.form.filter.label'}} + + + {{t 'resources.host-set.form.filter.gcp.help'}} + + + {{#if @model.errors.filters}} + + {{#each @model.errors.filters as |error|}} + {{error.message}} + {{/each}} + + {{/if}} + + <:field as |F|> + + + + + + {{t 'form.sync-interval.label'}} + + {{t 'form.sync-interval.help'}} + + {{t 'actions.learn-more'}} + + + {{#if @model.errors.sync_interval_seconds}} + + {{#each @model.errors.sync_interval_seconds as |error|}} + {{error.message}} + {{/each}} + + {{/if}} + + + {{#if (can 'save model' @model)}} + + {{/if}} + \ No newline at end of file diff --git a/ui/admin/app/components/form/host/aws/index.hbs b/ui/admin/app/components/form/host/aws/index.hbs index d89b87a61..810d43624 100644 --- a/ui/admin/app/components/form/host/aws/index.hbs +++ b/ui/admin/app/components/form/host/aws/index.hbs @@ -30,13 +30,6 @@ > {{t 'form.external_name.label'}} {{t 'form.external_name.help'}} - {{#if @model.errors.external_name}} - - {{#each @model.errors.external_name as |error|}} - {{error.message}} - {{/each}} - - {{/if}} {{/if}} @@ -49,13 +42,6 @@ > {{t 'form.external_id.label'}} {{t 'form.external_id.help'}} - {{#if @model.errors.external_id}} - - {{#each @model.errors.external_id as |error|}} - {{error.message}} - {{/each}} - - {{/if}} {{t 'form.description.label'}} {{t 'form.description.help'}} - {{#if @model.errors.description}} - - {{#each @model.errors.description as |error|}} - {{error.message}} - {{/each}} - - {{/if}} - + {{t 'form.ip_addresses.label'}} {{t 'form.ip_addresses.help'}} {{#each @model.ip_addresses as |ip_address|}} @@ -102,7 +81,7 @@ {{/each}} - + {{t 'form.dns_names.label'}} {{t 'form.dns_names.help'}} {{#each @model.dns_names as |dns_name|}} diff --git a/ui/admin/app/components/form/host/azure/index.hbs b/ui/admin/app/components/form/host/azure/index.hbs index 5133350fc..9ec27094f 100644 --- a/ui/admin/app/components/form/host/azure/index.hbs +++ b/ui/admin/app/components/form/host/azure/index.hbs @@ -30,13 +30,6 @@ > {{t 'form.external_name.label'}} {{t 'form.external_name.help'}} - {{#if @model.errors.external_name}} - - {{#each @model.errors.external_name as |error|}} - {{error.message}} - {{/each}} - - {{/if}} {{/if}} @@ -49,13 +42,6 @@ > {{t 'form.external_id.label'}} {{t 'form.external_id.help'}} - {{#if @model.errors.external_id}} - - {{#each @model.errors.external_id as |error|}} - {{error.message}} - {{/each}} - - {{/if}} {{t 'form.description.label'}} {{t 'form.description.help'}} - {{#if @model.errors.description}} - - {{#each @model.errors.description as |error|}} - {{error.message}} - {{/each}} - - {{/if}} - + {{t 'form.ip_addresses.label'}} {{t 'form.ip_addresses.help'}} {{#each @model.ip_addresses as |ip_address|}} diff --git a/ui/admin/app/components/form/host/gcp/index.hbs b/ui/admin/app/components/form/host/gcp/index.hbs new file mode 100644 index 000000000..59facc4c9 --- /dev/null +++ b/ui/admin/app/components/form/host/gcp/index.hbs @@ -0,0 +1,91 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +}} + + + + + {{t 'form.type.label'}} + + + {{#if @model.external_name}} + + {{t 'form.external_name.label'}} + {{t 'form.external_name.help'}} + + {{/if}} + + + {{t 'form.external_id.label'}} + {{t 'form.external_id.help'}} + + + + {{t 'form.description.label'}} + {{t 'form.description.help'}} + + + + {{t 'titles.provider'}} + + {{t 'resources.host-catalog.types.gcp'}} + + + + + {{t 'form.ip_addresses.label'}} + {{t 'form.ip_addresses.help'}} + {{#each @model.ip_addresses as |ip_address|}} + + + + {{/each}} + + + {{#if (can 'save model' @model)}} + + {{/if}} + \ No newline at end of file diff --git a/ui/admin/app/controllers/scopes/scope/host-catalogs/host-catalog/host-sets/index.js b/ui/admin/app/controllers/scopes/scope/host-catalogs/host-catalog/host-sets/index.js index 862d5299e..91fff6fe4 100644 --- a/ui/admin/app/controllers/scopes/scope/host-catalogs/host-catalog/host-sets/index.js +++ b/ui/admin/app/controllers/scopes/scope/host-catalogs/host-catalog/host-sets/index.js @@ -18,6 +18,7 @@ export default class ScopesScopeHostCatalogsHostCatalogHostSetsIndexController e @service can; @service intl; @service router; + @service store; // =attributes @@ -74,8 +75,24 @@ export default class ScopesScopeHostCatalogsHostCatalogHostSetsIndexController e isNew ? 'notifications.create-success' : 'notifications.save-success', ) async save(hostSet) { + // Fetch newest host set as updates to host set attributes cause an async db update which + // updates the version again and can cause a version mismatch if the host set is updated + // again and we haven't fetched the newest version. + if (this.can.can('read host-set', hostSet)) { + const newestHostSet = await this.store.findRecord( + 'host-set', + hostSet.id, + { + reload: true, + }, + ); + + hostSet.version = newestHostSet.version; + } + await hostSet.save(); - if (this.can.can('read model', hostSet)) { + + if (this.can.can('read host-set', hostSet)) { await this.router.transitionTo( 'scopes.scope.host-catalogs.host-catalog.host-sets.host-set', hostSet, diff --git a/ui/admin/tests/acceptance/host-catalogs/host-sets/create-test.js b/ui/admin/tests/acceptance/host-catalogs/host-sets/create-test.js index a9906a445..1d3b9553a 100644 --- a/ui/admin/tests/acceptance/host-catalogs/host-sets/create-test.js +++ b/ui/admin/tests/acceptance/host-catalogs/host-sets/create-test.js @@ -150,6 +150,37 @@ module('Acceptance | host-catalogs | host sets | create', function (hooks) { assert.deepEqual(hostSet.syncIntervalSeconds, 10); }); + test('can create new gcp host set', async function (assert) { + instances.hostCatalog = this.server.create('host-catalog', { + scope: instances.scopes.project, + type: 'plugin', + plugin: { + id: `plugin-id-1`, + name: 'gcp', + }, + }); + + const count = getHostSetCount(); + await visit( + `${urls.hostCatalogs}/${instances.hostCatalog.id}/host-sets/new`, + ); + const name = 'gcp host set'; + await fillIn(NAME_SELECTOR, name); + await fillIn(PREFERRED_ENDPOINT_TEXT_INPUT_SELECTOR, 'endpoint'); + await click(PREFERRED_ENDPOINT_BUTTON_SELECTOR); + await fillIn(FILTER_TEXT_INPUT_SELECTOR, 'filter_test'); + await click(FILTER_BUTTON_SELECTOR); + + await fillIn(SYNC_INTERVAL_SELECTOR, 10); + await click(SUBMIT_BTN_SELECTOR); + + assert.strictEqual(getHostSetCount(), count + 1); + const hostSet = this.server.schema.hostSets.findBy({ name }); + assert.deepEqual(hostSet.preferredEndpoints, ['endpoint']); + assert.deepEqual(hostSet.attributes.filters, ['filter_test']); + assert.deepEqual(hostSet.syncIntervalSeconds, 10); + }); + test('Users cannot create a new host set without proper authorization', async function (assert) { instances.hostCatalog.authorized_collection_actions['host-sets'] = []; await visit(urls.hostCatalog); diff --git a/ui/admin/tests/acceptance/host-catalogs/host-sets/update-test.js b/ui/admin/tests/acceptance/host-catalogs/host-sets/update-test.js index 5f17676db..4ea296082 100644 --- a/ui/admin/tests/acceptance/host-catalogs/host-sets/update-test.js +++ b/ui/admin/tests/acceptance/host-catalogs/host-sets/update-test.js @@ -247,6 +247,52 @@ module('Acceptance | host-catalogs | host sets | update', function (hooks) { assert.deepEqual(hostSet.syncIntervalSeconds, 10); }); + test('can save changes to an existing gcp host-set', async function (assert) { + await visit(urls.awshostSet); + + await click(EDIT_BUTTON_SELECTOR, 'Activate edit mode'); + + const name = 'gcp host set'; + await fillIn('[name="name"]', name); + + const endpointList = findAll(PREFERRED_ENDPOINT_REMOVE_BUTTON_SELECTOR); + + for (const element of endpointList) { + await click(element); + } + + assert.strictEqual( + findAll(PREFERRED_ENDPOINT_REMOVE_BUTTON_SELECTOR).length, + 0, + ); + + await fillIn(PREFERRED_ENDPOINT_TEXT_INPUT_SELECTOR, 'sample endpoint'); + await click(PREFERRED_ENDPOINT_BUTTON_SELECTOR); + + // Remove all the filters + const filterList = await Promise.all( + findAll(FILTER_REMOVE_BUTTON_SELECTOR), + ); + for (const element of filterList) { + await click(element); + } + + assert.strictEqual(findAll(FILTER_REMOVE_BUTTON_SELECTOR).length, 0); + await fillIn(FILTER_TEXT_INPUT_SELECTOR, 'sample filters'); + await click(FILTER_BUTTON_SELECTOR); + + await fillIn(SYNC_INTERVAL_SELECTOR, 10); + + await click(SUBMIT_BTN_SELECTOR); + + assert.strictEqual(currentURL(), urls.awshostSet); + const hostSet = this.server.schema.hostSets.findBy({ name }); + assert.strictEqual(hostSet.name, name); + assert.deepEqual(hostSet.preferredEndpoints, ['sample endpoint']); + assert.deepEqual(hostSet.attributes.filters, ['sample filters']); + assert.deepEqual(hostSet.syncIntervalSeconds, 10); + }); + test('cannot make changes to an existing host without proper authorization', async function (assert) { instances.hostSet.authorized_actions = instances.hostSet.authorized_actions.filter((item) => item !== 'update');