Merge pull request #135 from hashicorp/ICU-547-hostsets-in-target
View Host Sets in a Target
This commit is contained in:
25 files changed
+357
-66
No files matched your search
@@ -30,16 +30,42 @@ export default class TargetModel extends GeneratedTargetModel {
|
||||
// =methods
|
||||
|
||||
/**
|
||||
* Saves the `host_set_ids` array on the via the `set-hosts` method.
|
||||
* Adds host sets via the `add-host-sets` method.
|
||||
* See serializer and adapter for more information.
|
||||
* @param {[string]} hostSetIDs
|
||||
* @param {object} options
|
||||
* @param {object} options.adapterOptions
|
||||
* @return {Promise}
|
||||
*/
|
||||
saveHostSets(options={ adapterOptions: {} }) {
|
||||
addHostSets(hostSetIDs, options={ adapterOptions: {} }) {
|
||||
const defaultAdapterOptions = {
|
||||
method: 'set-host-sets',
|
||||
serializeHostSets: true
|
||||
method: 'add-host-sets',
|
||||
hostSetIDs
|
||||
};
|
||||
// There is no "deep merge" in ES.
|
||||
// All of this nonsense is here to ensure we get
|
||||
// a decent merge of `adapterOptions`.
|
||||
return this.save({
|
||||
...options,
|
||||
adapterOptions: {
|
||||
...defaultAdapterOptions,
|
||||
...options.adapterOptions
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete host sets via the `delete-host-sets` method.
|
||||
* See serializer and adapter for more information.
|
||||
* @param {[string]} hostSetIDs
|
||||
* @param {object} options
|
||||
* @param {object} options.adapterOptions
|
||||
* @return {Promise}
|
||||
*/
|
||||
deleteHostSets(hostSetIDs, options={ adapterOptions: {} }) {
|
||||
const defaultAdapterOptions = {
|
||||
method: 'delete-host-sets',
|
||||
hostSetIDs
|
||||
};
|
||||
// There is no "deep merge" in ES.
|
||||
// All of this nonsense is here to ensure we get
|
||||
|
||||
@@ -5,30 +5,32 @@ export default class TargetSerializer extends ApplicationSerializer {
|
||||
// =methods
|
||||
|
||||
/**
|
||||
* If `adapterOptions.serializeHostSets` is true, the serialization should
|
||||
* include **only host sets** and the version. Normally, host sets are not
|
||||
* serialized.
|
||||
* If `adapterOptions.hostSets` is set to an array of host set models,
|
||||
* the resulting target serialization should include **only host sets**
|
||||
* and the version. Normally, host sets are not serialized.
|
||||
* @override
|
||||
* @param {Snapshot} snapshot
|
||||
* @return {object}
|
||||
*/
|
||||
serialize(snapshot) {
|
||||
const serializeHostSets = snapshot?.adapterOptions?.serializeHostSets;
|
||||
let serialized = super.serialize(...arguments);
|
||||
if (serializeHostSets) serialized = this.serializeWithHostSets(snapshot);
|
||||
const hostSetIDs = snapshot?.adapterOptions?.hostSetIDs;
|
||||
if (hostSetIDs) serialized =
|
||||
this.serializeWithHostSets(snapshot, hostSetIDs);
|
||||
return serialized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a payload containing only the host_set_ids array.
|
||||
* Returns a payload containing only the host_set_ids array using IDs
|
||||
* passed into the function (rather than existing host sets on the model).
|
||||
* @param {Snapshot} snapshot
|
||||
* @param {[string]} hostSetIDs
|
||||
* @return {object}
|
||||
*/
|
||||
serializeWithHostSets(snapshot) {
|
||||
serializeWithHostSets(snapshot, hostSetIDs) {
|
||||
return {
|
||||
version: snapshot.attr('version'),
|
||||
host_set_ids:
|
||||
snapshot.attr('host_sets').map(hostSet => hostSet.attr('host_set_id'))
|
||||
host_set_ids: hostSetIDs
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -57,9 +57,42 @@ module('Unit | Model | target', function (hooks) {
|
||||
assert.ok(target.hostSets[0].hostCatalog, 'Host catalog is resolved');
|
||||
});
|
||||
|
||||
test('it has a `saveHostSets` method that targets a specific POST API endpoint and serialization', async function (assert) {
|
||||
test('it has an `addHostSets` method that targets a specific POST API endpoint and serialization', async function (assert) {
|
||||
assert.expect(1);
|
||||
this.server.post('/v1/scopes/o_1/targets/123abc:set-host-sets', (schema, request) => {
|
||||
this.server.post('/v1/scopes/o_1/targets/123abc:add-host-sets', (schema, request) => {
|
||||
const body = JSON.parse(request.requestBody);
|
||||
assert.deepEqual(body, {
|
||||
host_set_ids: ['123_abc', 'foobar'],
|
||||
version: 1
|
||||
});
|
||||
});
|
||||
const store = this.owner.lookup('service:store');
|
||||
store.push({
|
||||
data: {
|
||||
id: '123abc',
|
||||
type: 'target',
|
||||
attributes: {
|
||||
name: 'Target',
|
||||
description: 'Description',
|
||||
host_sets: [
|
||||
{ host_set_id: '1', host_catalog_id: '2' },
|
||||
{ host_set_id: '3', host_catalog_id: '4' }
|
||||
],
|
||||
version: 1,
|
||||
scope: {
|
||||
scope_id: 'o_1',
|
||||
type: 'scope'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
const model = store.peekRecord('target', '123abc');
|
||||
await model.addHostSets(['123_abc', 'foobar']);
|
||||
});
|
||||
|
||||
test('it has a `deleteHostSets` method that targets a specific POST API endpoint and serialization', async function (assert) {
|
||||
assert.expect(1);
|
||||
this.server.post('/v1/scopes/o_1/targets/123abc:delete-host-sets', (schema, request) => {
|
||||
const body = JSON.parse(request.requestBody);
|
||||
assert.deepEqual(body, {
|
||||
host_set_ids: ['1', '3'],
|
||||
@@ -87,6 +120,6 @@ module('Unit | Model | target', function (hooks) {
|
||||
}
|
||||
});
|
||||
const model = store.peekRecord('target', '123abc');
|
||||
await model.saveHostSets();
|
||||
await model.deleteHostSets(['1', '3']);
|
||||
});
|
||||
});
|
||||
@@ -29,7 +29,7 @@ module('Unit | Serializer | target', function(hooks) {
|
||||
|
||||
});
|
||||
|
||||
test('it serializes only host sets when `adapterOptions.serializeHostSets` is true', function (assert) {
|
||||
test('it serializes only host sets and version when an `adapterOptions.hostSetIDs` array is passed', function (assert) {
|
||||
assert.expect(1);
|
||||
const store = this.owner.lookup('service:store');
|
||||
const serializer = store.serializerFor('target');
|
||||
@@ -44,11 +44,11 @@ module('Unit | Serializer | target', function(hooks) {
|
||||
});
|
||||
const snapshot = record._createSnapshot();
|
||||
snapshot.adapterOptions = {
|
||||
serializeHostSets: true
|
||||
hostSetIDs: ['4', '5']
|
||||
};
|
||||
const serializedRecord = serializer.serialize(snapshot);
|
||||
assert.deepEqual(serializedRecord, {
|
||||
host_set_ids: ['1', '3'],
|
||||
host_set_ids: ['4', '5'],
|
||||
version: 1
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import Controller from '@ember/controller';
|
||||
import { inject as service } from '@ember/service';
|
||||
|
||||
export default class ScopesScopeProjectsProjectTargetsTargetHostSetsController extends Controller {
|
||||
|
||||
// =services
|
||||
|
||||
@service intl;
|
||||
|
||||
// =attributes
|
||||
|
||||
/**
|
||||
* Host sets breadcrumb
|
||||
* @type {string}
|
||||
*/
|
||||
get breadCrumb() {
|
||||
return this.intl.t('resources.host-sets');
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,11 @@ Router.map(function () {
|
||||
});
|
||||
});
|
||||
this.route('targets', function() {
|
||||
this.route('target', { path: ':target_id' }, function() {});
|
||||
this.route('target', { path: ':target_id' }, function() {
|
||||
this.route('host-sets', function() {
|
||||
this.route('host-set', { path: ':host_set_id'}, function() {});
|
||||
});
|
||||
});
|
||||
this.route('new');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import Route from '@ember/routing/route';
|
||||
import { all, hash } from 'rsvp';
|
||||
|
||||
export default class ScopesScopeProjectsProjectTargetsTargetHostSetsRoute extends Route {
|
||||
// =methods
|
||||
|
||||
/**
|
||||
* Loads all host-sets and associated host-catalogs under the current target
|
||||
* and it's parent scope.
|
||||
* @return {Promise{[HostSetModel, HostCatalogModel]}}
|
||||
*/
|
||||
beforeModel() {
|
||||
const { scopeID, host_sets } =
|
||||
this.modelFor('scopes.scope.projects.project.targets.target');
|
||||
const promises = host_sets
|
||||
.map(({ host_set_id, host_catalog_id: hostCatalogID }) =>
|
||||
hash({
|
||||
// TODO: multiple host sets may belong to the same catalog,
|
||||
// resulting in the catalog being loaded multiple times.
|
||||
// An improvement would be to find the unique set of catalogs first.
|
||||
hostCatalog: this.store.findRecord(
|
||||
'host-catalog',
|
||||
hostCatalogID,
|
||||
{ adapterOptions: { scopeID } }
|
||||
),
|
||||
hostSet: this.store.findRecord(
|
||||
'host-set',
|
||||
host_set_id,
|
||||
{ adapterOptions: { scopeID, hostCatalogID } }
|
||||
)
|
||||
})
|
||||
);
|
||||
return all(promises);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the previously loaded target instance.
|
||||
* @return {TargetModel}
|
||||
*/
|
||||
model() {
|
||||
return this.modelFor('scopes.scope.projects.project.targets.target');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import Route from '@ember/routing/route';
|
||||
|
||||
export default class ScopesScopeProjectsProjectTargetsTargetHostSetsHostSetRoute extends Route {
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import Route from '@ember/routing/route';
|
||||
|
||||
export default class ScopesScopeProjectsProjectTargetsTargetHostSetsIndexRoute extends Route {
|
||||
}
|
||||
@@ -1 +1,38 @@
|
||||
{{outlet}}
|
||||
<Rose::Layout::Page as |page|>
|
||||
|
||||
<page.breadcrumbs>
|
||||
<BreadCrumbs />
|
||||
</page.breadcrumbs>
|
||||
|
||||
<page.header>
|
||||
<h2>{{t "resources.target"}}</h2>
|
||||
</page.header>
|
||||
|
||||
<page.navigation>
|
||||
<Rose::Nav::Tabs as |nav|>
|
||||
<nav.link @route='scopes.scope.projects.project.targets.target.index'>
|
||||
{{t 'titles.details'}}
|
||||
</nav.link>
|
||||
<nav.link @route='scopes.scope.projects.project.targets.target.host-sets'>
|
||||
{{t 'titles.host-sets'}}
|
||||
</nav.link>
|
||||
</Rose::Nav::Tabs>
|
||||
</page.navigation>
|
||||
|
||||
<page.actions>
|
||||
<Rose::Dropdown @text="{{t 'form.label.manage'}}" @dropdownRight={{true}} as |dropdown|>
|
||||
<dropdown.button
|
||||
@style="danger"
|
||||
@disabled={{@model.canSave}}
|
||||
{{on "click" (route-action "delete" @model)}}
|
||||
>
|
||||
{{t 'actions.delete-target'}}
|
||||
</dropdown.button>
|
||||
</Rose::Dropdown>
|
||||
</page.actions>
|
||||
|
||||
<page.body>
|
||||
{{outlet}}
|
||||
</page.body>
|
||||
|
||||
</Rose::Layout::Page>
|
||||
@@ -0,0 +1,41 @@
|
||||
{{title (t "resources.host-sets")}}
|
||||
|
||||
{{#if @model.hostSets}}
|
||||
|
||||
<Rose::Table as |table|>
|
||||
<table.header as |header|>
|
||||
<header.row as |row|>
|
||||
<row.headerCell>{{t "form.label.name"}}</row.headerCell>
|
||||
<row.headerCell>{{t "form.label.type"}}</row.headerCell>
|
||||
<row.headerCell>{{t "form.label.id"}}</row.headerCell>
|
||||
</header.row>
|
||||
</table.header>
|
||||
<table.body as |body|>
|
||||
{{#each @model.hostSets as |hostSet|}}
|
||||
<body.row as |row|>
|
||||
<row.headerCell>
|
||||
{{hostSet.model.displayName}}
|
||||
<p>{{hostSet.model.description}}</p>
|
||||
</row.headerCell>
|
||||
<row.cell>
|
||||
<Rose::Badge @style="outline">{{hostSet.model.type}}</Rose::Badge>
|
||||
</row.cell>
|
||||
<row.cell>
|
||||
<code>{{hostSet.model.id}}</code>
|
||||
</row.cell>
|
||||
</body.row>
|
||||
{{/each}}
|
||||
</table.body>
|
||||
</Rose::Table>
|
||||
|
||||
{{else}}
|
||||
|
||||
<Rose::Layout::Centered>
|
||||
<Rose::Message @title={{t "titles.target-host-sets-welcome"}} as |message|>
|
||||
<message.description>
|
||||
{{t "descriptions.target-host-sets-welcome"}}
|
||||
</message.description>
|
||||
</Rose::Message>
|
||||
</Rose::Layout::Centered>
|
||||
|
||||
{{/if}}
|
||||
+1
@@ -0,0 +1 @@
|
||||
{{outlet}}
|
||||
@@ -1,39 +1,5 @@
|
||||
<Rose::Layout::Page as |page|>
|
||||
|
||||
<page.breadcrumbs>
|
||||
<BreadCrumbs />
|
||||
</page.breadcrumbs>
|
||||
|
||||
<page.header>
|
||||
<h2>{{t "resources.target"}}</h2>
|
||||
</page.header>
|
||||
|
||||
<page.navigation>
|
||||
<Rose::Nav::Tabs as |nav|>
|
||||
<nav.link @route='scopes.scope.projects.project.targets.target'>
|
||||
{{t 'titles.details'}}
|
||||
</nav.link>
|
||||
</Rose::Nav::Tabs>
|
||||
</page.navigation>
|
||||
|
||||
<page.actions>
|
||||
<Rose::Dropdown @text="{{t 'form.label.manage'}}" @dropdownRight={{true}} as |dropdown|>
|
||||
<dropdown.button
|
||||
@style="danger"
|
||||
@disabled={{@model.canSave}}
|
||||
{{on "click" (route-action "delete" @model)}}
|
||||
>
|
||||
{{t 'actions.delete-target'}}
|
||||
</dropdown.button>
|
||||
</Rose::Dropdown>
|
||||
</page.actions>
|
||||
|
||||
<page.body>
|
||||
<Form::Target
|
||||
@model={{@model}}
|
||||
@submit={{route-action "save" @model}}
|
||||
@cancel={{route-action "cancel" @model}}
|
||||
/>
|
||||
</page.body>
|
||||
|
||||
</Rose::Layout::Page>
|
||||
<Form::Target
|
||||
@model={{@model}}
|
||||
@submit={{route-action "save" @model}}
|
||||
@cancel={{route-action "cancel" @model}}
|
||||
/>
|
||||
@@ -130,6 +130,31 @@ export default function() {
|
||||
this.get('/scopes/:scope_id/targets/:id');
|
||||
this.patch('/scopes/:scope_id/targets/:id');
|
||||
this.del('/scopes/:scope_id/targets/:id');
|
||||
this.post('/scopes/:scope_id/targets/:idMethod', function ({ targets }, { params: { idMethod } }) {
|
||||
const attrs = this.normalizedRequestAttrs();
|
||||
const id = idMethod.split(':')[0];
|
||||
const method = idMethod.split(':')[1];
|
||||
const target = targets.find(id);
|
||||
const updatedAttrs = {
|
||||
version: attrs.version,
|
||||
hostSetIds: target.hostSetIds
|
||||
};
|
||||
// If adding host sets, push them into the array
|
||||
if (method === 'add-host-sets') {
|
||||
attrs.hostSetIds.forEach(id => {
|
||||
if (!updatedAttrs.hostSetIds.includes(id)) {
|
||||
updatedAttrs.hostSetIds.push(id);
|
||||
}
|
||||
});
|
||||
}
|
||||
// If deleting host sets, filter them out of the array
|
||||
if (method === 'delete-host-sets') {
|
||||
updatedAttrs.hostSetIds = updatedAttrs.hostSetIds.filter(id => {
|
||||
return !attrs.hostSetIds.includes(id);
|
||||
});
|
||||
}
|
||||
return target.update(updatedAttrs);
|
||||
});
|
||||
|
||||
// Uncomment the following line and the Response import above
|
||||
// Then change the response code to simulate error responses.
|
||||
|
||||
@@ -1,3 +1,22 @@
|
||||
import factory from '../generated/factories/target';
|
||||
import { trait } from 'ember-cli-mirage';
|
||||
|
||||
export default factory.extend();
|
||||
const randomBoolean = (chance = 0.5) => Math.random() < chance;
|
||||
const hostSetChance = 0.3;
|
||||
|
||||
export default factory.extend({
|
||||
|
||||
/**
|
||||
* Randomly selects existing host sets to assign to target.
|
||||
*/
|
||||
withRandomHostSets: trait({
|
||||
afterCreate(target, server) {
|
||||
let randomlySelectedHostSets;
|
||||
randomlySelectedHostSets = server.schema.hostSets
|
||||
.where(item => item.scope.id === target.scope.id).models
|
||||
.filter(() => randomBoolean(hostSetChance));
|
||||
target.update({hostSets: randomlySelectedHostSets});
|
||||
}
|
||||
})
|
||||
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import { random, date } from 'faker';
|
||||
*/
|
||||
export default Factory.extend({
|
||||
|
||||
protocol: 'SSH',
|
||||
protocol: 'tcp',
|
||||
name: () => random.words(),
|
||||
description: () => random.words(),
|
||||
created_time: () => date.recent(),
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Model, belongsTo, hasMany } from 'ember-cli-mirage';
|
||||
|
||||
export default Model.extend({
|
||||
|
||||
target: belongsTo(),
|
||||
hostCatalog: belongsTo(),
|
||||
hosts: hasMany()
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Model, hasMany } from 'ember-cli-mirage';
|
||||
|
||||
export default Model.extend({
|
||||
|
||||
hostSets: hasMany()
|
||||
|
||||
});
|
||||
@@ -33,7 +33,7 @@ export default function(server) {
|
||||
server.db.scopes.where({type: 'project'}).forEach(({ id, type }) => {
|
||||
const scope = { id, type };
|
||||
server.createList('host-catalog', 2, { scope }, 'withChildren');
|
||||
server.createList('target', 2, { scope });
|
||||
server.createList('target', 2, { scope }, 'withRandomHostSets');
|
||||
});
|
||||
|
||||
|
||||
}
|
||||
@@ -1,5 +1,24 @@
|
||||
import ApplicationSerializer from './application';
|
||||
import { underscore } from '@ember/string';
|
||||
|
||||
export default ApplicationSerializer.extend({
|
||||
modelName: 'target'
|
||||
modelName: 'target',
|
||||
|
||||
keyForRelationshipIds(relationshipName) {
|
||||
return `${this._container.inflector.singularize(
|
||||
underscore(relationshipName)
|
||||
)}_ids`;
|
||||
},
|
||||
|
||||
_hashForModel(model) {
|
||||
const json = ApplicationSerializer.prototype._hashForModel.apply(this, arguments);
|
||||
const { hostSets } = this.schema;
|
||||
json.host_sets = model.hostSetIds.map(host_set_id => {
|
||||
const hostSet = hostSets.find(host_set_id);
|
||||
const host_catalog_id = hostSet?.hostCatalog?.id;
|
||||
return { host_set_id, host_catalog_id };
|
||||
});
|
||||
return json;
|
||||
}
|
||||
|
||||
});
|
||||
@@ -58,4 +58,8 @@ module('Acceptance | targets', function (hooks) {
|
||||
test('saving an existing target with invalid fields displays error messages', async function (assert) {
|
||||
assert.expect(0);
|
||||
});
|
||||
|
||||
test('viewing target host sets', async function (assert) {
|
||||
assert.expect(0);
|
||||
});
|
||||
});
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { setupTest } from 'ember-qunit';
|
||||
|
||||
module('Unit | Controller | scopes/scope/projects/project/targets/target/host-sets', function(hooks) {
|
||||
setupTest(hooks);
|
||||
|
||||
// Replace this with your real tests.
|
||||
test('it exists', function(assert) {
|
||||
let controller = this.owner.lookup('controller:scopes/scope/projects/project/targets/target/host-sets');
|
||||
assert.ok(controller);
|
||||
});
|
||||
});
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { setupTest } from 'ember-qunit';
|
||||
|
||||
module('Unit | Route | scopes/scope/projects/project/targets/target/host-sets', function(hooks) {
|
||||
setupTest(hooks);
|
||||
|
||||
test('it exists', function(assert) {
|
||||
let route = this.owner.lookup('route:scopes/scope/projects/project/targets/target/host-sets');
|
||||
assert.ok(route);
|
||||
});
|
||||
});
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { setupTest } from 'ember-qunit';
|
||||
|
||||
module('Unit | Route | scopes/scope/projects/project/targets/target/host-sets/host-set', function(hooks) {
|
||||
setupTest(hooks);
|
||||
|
||||
test('it exists', function(assert) {
|
||||
let route = this.owner.lookup('route:scopes/scope/projects/project/targets/target/host-sets/host-set');
|
||||
assert.ok(route);
|
||||
});
|
||||
});
|
||||
@@ -36,7 +36,7 @@ host-set:
|
||||
static: Static
|
||||
target:
|
||||
protocols:
|
||||
SSH: SSH
|
||||
tcp: TCP
|
||||
host:
|
||||
types:
|
||||
static: Static
|
||||
@@ -79,6 +79,7 @@ titles:
|
||||
groups-welcome: Welcome to Groups
|
||||
auth-methods-welcome: Welcome to Auth Methods
|
||||
targets-welcome: Create your first Target
|
||||
target-host-sets-welcome: No Host Sets Associated
|
||||
grants: Permissions
|
||||
new-grant: New Grant
|
||||
principals: Principals
|
||||
@@ -99,6 +100,7 @@ descriptions:
|
||||
groups-welcome: Groups are collections of users.
|
||||
auth-methods-welcome: Auth methods are configurations to authenticate users.
|
||||
targets-welcome: Targets provide Users the means to connect to and interact networked services. A Target contains a collection of Hosts and a collection of Credentials.
|
||||
target-host-sets-welcome: Add Host Sets to this target to provide access.
|
||||
no-host-set-hosts: Before associating hosts with this host set, create hosts within the host catalog.
|
||||
states:
|
||||
info: Info
|
||||
|
||||
Reference in new issue
Block a user