Merge pull request #135 from hashicorp/ICU-547-hostsets-in-target

View Host Sets in a Target
This commit is contained in:
Randall Morey authored and GitHub committed 2020-08-27 11:11:44 -04:00
commit 290c38738e
25 files changed
+357 -66

No files matched your search

+30 -4
View File
@@ -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
+11 -9
View File
@@ -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
};
}
+36 -3
View File
@@ -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');
}
}
+5 -1
View File
@@ -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,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}}
/>
+25
View File
@@ -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.
+20 -1
View File
@@ -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});
}
})
});
+1 -1
View File
@@ -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(),
+1
View File
@@ -2,6 +2,7 @@ import { Model, belongsTo, hasMany } from 'ember-cli-mirage';
export default Model.extend({
target: belongsTo(),
hostCatalog: belongsTo(),
hosts: hasMany()
+7
View File
@@ -0,0 +1,7 @@
import { Model, hasMany } from 'ember-cli-mirage';
export default Model.extend({
hostSets: hasMany()
});
+2 -2
View File
@@ -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');
});
}
+20 -1
View File
@@ -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;
}
});
+4
View File
@@ -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);
});
});
@@ -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);
});
});
@@ -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);
});
});
@@ -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);
});
});
+3 -1
View File
@@ -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