feat: 🎸 Add create worker page (#1244)

* feat: 🎸 Add create worker page

✅ Closes: ICU-5246
This commit is contained in:
Zhihe Li authored and GitHub committed 2022-08-26 11:50:53 -04:00
1 parent dd0d37241c
commit 3130831ae0
20 files changed
+507 -66

No files matched your search

+3 -2
View File
@@ -37,12 +37,13 @@ export default class WorkerModel extends GeneratedWorkerModel {
config_tags;
/**
* Method to modify the adpater to handle custom POST route for creating worker.
* Method to modify the adapter to handle custom POST route for creating worker.
* @param {string} workerGeneratedAuthToken
* @param {object} options
* @param {object} options.adapterOptions
* @return {Promise}
*/
addWorkerLed(workerGeneratedAuthToken, options = { adapterOptions: {} }) {
createWorkerLed(workerGeneratedAuthToken, options = { adapterOptions: {} }) {
const defaultAdapterOptions = {
method: 'create:worker-led',
workerGeneratedAuthToken,
+1 -1
View File
@@ -30,7 +30,7 @@ export default class WorkerSerializer extends ApplicationSerializer {
*/
serializeWithGeneratedToken(snapshot, workerGeneratedAuthToken) {
return {
version: snapshot.attr('version'),
scope_id: snapshot.attr('scope').scope_id,
worker_generated_auth_token: workerGeneratedAuthToken,
};
}
+10
View File
@@ -645,6 +645,16 @@ export default function () {
this.get('/workers/:id');
this.del('/workers/:id');
this.patch('/workers/:id');
this.post('/workers:create:worker-led', ({ workers, scopes }) => {
const globalScope = scopes.find('global');
// This POST only takes in a token so we need to generate a random worker to return
const newWorker = this.create('worker', {
type: 'pki',
scope: globalScope,
});
return workers.create(newWorker.attrs);
});
/* Uncomment the following line and the Response import above
* Then change the response code to simulate error responses.
+14 -4
View File
@@ -6,19 +6,29 @@ module('Unit | Model | worker', function (hooks) {
setupTest(hooks);
setupMirage(hooks);
test('it has an `addWorkerLed` method that targets a specific POST API', async function (assert) {
test('it has a `createWorkerLed` method that targets a specific POST API', async function (assert) {
assert.expect(2);
const scopeId = 'global';
const workerGeneratedAuthToken = 'token';
this.server.post('/v1/workers:create:worker-led', (schema, request) => {
const body = JSON.parse(request.requestBody);
assert.deepEqual(body, {
worker_generated_auth_token: 'token',
scope_id: scopeId,
worker_generated_auth_token: workerGeneratedAuthToken,
});
return { id: '123abc' };
});
const store = this.owner.lookup('service:store');
const model = store.createRecord('worker', { type: 'pki' });
await model.addWorkerLed('token');
const model = store.createRecord('worker', {
type: 'pki',
scope: {
scope_id: scopeId,
type: 'global',
},
});
await model.createWorkerLed(workerGeneratedAuthToken);
const worker = store.peekRecord('worker', '123abc');
assert.ok(worker);
});
@@ -23,12 +23,17 @@ module('Unit | Serializer | worker', function (hooks) {
});
test('it serializes using `adapterOptions.workerGeneratedAuthToken`', function (assert) {
const scopeId = 'global';
const store = this.owner.lookup('service:store');
const serializer = store.serializerFor('worker');
const record = store.createRecord('worker', {
name: 'worker',
description: 'Description',
version: 1,
scope: {
scope_id: scopeId,
type: 'global',
},
});
const snapshot = record._createSnapshot();
snapshot.adapterOptions = {
@@ -36,7 +41,7 @@ module('Unit | Serializer | worker', function (hooks) {
};
const serializedRecord = serializer.serialize(snapshot);
assert.deepEqual(serializedRecord, {
version: 1,
scope_id: scopeId,
worker_generated_auth_token: '123-abc',
});
});
+33 -2
View File
@@ -26,7 +26,7 @@ auth-method:
title: No Auth Methods
description: No auth methods were found for this scope.
no-primary-auth-method:
title: "Recommendation: Select a Primary Auth Method"
title: 'Recommendation: Select a Primary Auth Method'
description: A primary auth method auto-creates users when they authenticate for the first time. Scopes without primary auth methods may experience authentication failures.
pending:
title: Authentication Pending
@@ -52,7 +52,7 @@ auth-method:
questions:
make-primary: Make primary auth method?
remove-as-primary: Remove primary auth method?
make-primary-confirm: "A primary auth method auto-creates users on their first authentication attempt if needed. Note: if you change the primary auth method, the previous primary will no longer auto-create users."
make-primary-confirm: 'A primary auth method auto-creates users on their first authentication attempt if needed. Note: if you change the primary auth method, the previous primary will no longer auto-create users.'
remove-as-primary-confirm: Removing the primary auth method may lead to authentication failures for this scope.
no-see-window: Don't see the new window?
group:
@@ -436,9 +436,40 @@ worker:
title: Worker
title_plural: Workers
description: Workers are the server components that perform the session handling.
actions:
register: Register Worker
messages:
welcome:
title: Welcome to Workers
table:
session_count: Session Count
ip_address: IP Address
form:
cluster_id:
label: Boundary Cluster ID
help: The cluster ID is the UUID in the HCP Boundary Controller URL
ip_address:
label: IP Address
help: The public IP address of the server
config_file_path:
label: Config file path
help: The path on your server where you will store your configuration file
worker_tags:
label: Worker Tags
help: Boundary can use tags to define key-value pairs which targets can use to determine where they should route connections
steps:
1:
title: 1. Create a config file
create_directory: Create a configuration file in a new directory on your server
create_config: 'Open the file with a text editor, such as Vi. Paste the following configuration into the worker config file:'
save_config: Save the configuration in your `pki-worker.hcl` file.
2:
title: 2. Install & start Boundary on your server
run_command: 'Run this command on your server to install Boundary:'
copy_registration_request: When the server starts, it will print details related to this worker. Copy the value for
3:
title: 3. Confirm the worker when they connect
worker_auth_registration_request:
label: Worker Auth Registration Request
help: The registration key provided by this worker when it started
registered: Registered
@@ -31,8 +31,6 @@
display: flex;
justify-content: space-between;
align-items: center;
background-color: var(--ui-gray-subtler-6);
border-top: 1px solid var(--ui-gray-subtler-3);
border-bottom: 1px solid var(--ui-gray-subtler-3);
height: 2.75rem;
@@ -72,6 +70,7 @@
margin-right: sizing.rems(xs);
margin-left: sizing.rems(s);
height: sizing.rems(xl);
white-space: nowrap;
&-icon {
margin-right: sizing.rems(xs);
@@ -32,6 +32,7 @@
.rose-layout-page-body {
grid-area: body;
width: 100%;
}
}
@@ -0,0 +1,127 @@
<Rose::Form
@onSubmit={{fn @submit this.generatedWorkerAuthToken}}
@cancel={{@cancel}}
@disabled={{@model.isSaving}}
@showEditToggle={{false}}
as |form|
>
<form.input
@name='cluster_id'
@value={{this.clusterId}}
@label={{t 'resources.worker.form.cluster_id.label'}}
@helperText={{t 'resources.worker.form.cluster_id.help'}}
@linkText={{t 'actions.learn-more'}}
@link={{doc-url 'worker.manage-workers'}}
placeholder='69b6ddb3-ffec-42ab-a994-f43a6519470a'
/>
<form.input
@name='ip_address'
@value={{this.ipAddress}}
@label={{t 'resources.worker.form.ip_address.label'}}
@helperText={{t 'resources.worker.form.ip_address.help'}}
placeholder='127.0.0.1:9202'
/>
<form.input
@name='config_file_path'
@value={{this.configFilePath}}
@label={{t 'resources.worker.form.config_file_path.label'}}
@helperText={{t 'resources.worker.form.config_file_path.help'}}
placeholder='/home/ubuntu/boundary'
/>
<form.input
@name='worker_tags'
@value={{this.workerTags}}
@label={{t 'resources.worker.form.worker_tags.label'}}
@helperText={{t 'resources.worker.form.worker_tags.help'}}
placeholder='dev-worker, ubuntu'
/>
<h3>{{t 'resources.worker.form.steps.1.title'}}</h3>
<div class='worker-create-section'>
<Rose::CodeEditor @codeValue={{this.createConfigText}} as |c|>
<c.toolbar>
<:action>
<span>{{t 'resources.worker.form.steps.1.create_directory'}}</span>
</:action>
</c.toolbar>
<c.fieldEditor class='rose-autosize' @options={{this.shellCodeEditor}} />
</Rose::CodeEditor>
<Rose::CodeEditor @codeValue={{this.workerConfigText}} as |c|>
<c.toolbar>
<:action>
<span>{{t 'resources.worker.form.steps.1.create_config'}}</span>
</:action>
</c.toolbar>
<c.fieldEditor class='rose-autosize' @options={{this.hclCodeEditor}} />
</Rose::CodeEditor>
<p>{{t 'resources.worker.form.steps.1.save_config'}}</p>
</div>
<h3>{{t 'resources.worker.form.steps.2.title'}}</h3>
<div class='worker-create-section'>
<Rose::CodeEditor @codeValue={{this.installBoundaryText}} as |c|>
<c.toolbar>
<:action>
<span>{{t 'resources.worker.form.steps.2.run_command'}}</span>
</:action>
</c.toolbar>
<c.fieldEditor class='rose-autosize' @options={{this.shellCodeEditor}} />
</Rose::CodeEditor>
<span>{{t 'resources.worker.form.steps.2.copy_registration_request'}}</span>
<code>{{t
'resources.worker.form.steps.3.worker_auth_registration_request.label'
}}
</code>
</div>
<h3>{{t 'resources.worker.form.steps.3.title'}}</h3>
<div class='worker-create-section'>
<div class='worker-auth-token'>
<form.input
@name='worker_auth_registration_request'
@value={{this.generatedWorkerAuthToken}}
@disabled={{@model.cannotSave}}
@label={{t
'resources.worker.form.steps.3.worker_auth_registration_request.label'
}}
@helperText={{t
'resources.worker.form.steps.3.worker_auth_registration_request.help'
}}
/>
{{#if (can 'save worker' @model)}}
<Rose::Button
@submit={{true}}
@style='primary'
class='nowrap'
@disabled={{@model.cannotSave}}
>
{{t 'resources.worker.actions.register'}}
</Rose::Button>
{{/if}}
</div>
{{#if @model.cannotSave}}
<div class='worker-success'>
<div>
<Rose::Icon @name='flight-icons/svg/cpu-16' @size='medium' />
<p>{{this.generatedWorkerAuthToken}}</p>
</div>
<div>
<Rose::Icon
@name='flight-icons/svg/check-circle-fill-16'
@size='medium'
/>
<p>{{t 'resources.worker.form.steps.3.registered'}}</p>
</div>
</div>
{{/if}}
</div>
<Rose::LinkButton @route='scopes.scope.workers' @style='secondary'>
{{t 'actions.done'}}
</Rose::LinkButton>
</Rose::Form>
@@ -0,0 +1,68 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
export default class FormWorkerCreateWorkerLedComponent extends Component {
// =attributes
@tracked generatedWorkerAuthToken;
@tracked clusterId;
@tracked ipAddress;
@tracked configFilePath;
@tracked workerTags;
get createConfigText() {
return `mkdir ${this.configFilePath || '<path>'}/ ;\\
touch ${this.configFilePath || '<path>'}/pki-worker.hcl`;
}
get workerConfigText() {
return `hcp_boundary_cluster_id = "${this.clusterId || '<config_id>'}"
listener "tcp" {
address = "0.0.0.0:9202"
purpose = "proxy"
}
worker {
public_addr = "${this.ipAddress || '<public_ip_address>'}"
auth_storage_path = "${this.configFilePath || '<path>'}/worker1"
tags {
type = [${
this.workerTags
? this.workerTags
.split(',')
.filter((tag) => tag.trim())
.map((tag) => `"${tag?.trim()}"`)
.join(', ')
: '"<tag1>", "<tag2>"'
}]
}
}`;
}
get installBoundaryText() {
return `curl -fsSL https://apt.releases.hashicorp.com/gpg | sudo apt-key add - ;\\
sudo apt-add-repository "deb [arch=amd64] https://apt.releases.hashicorp.com $(lsb_release -cs) main" ;\\
sudo apt-get update && sudo apt-get install boundary ;\\
boundary server -config="${this.configFilePath || '<path>'}/pki-worker.hcl"`;
}
get shellCodeEditor() {
return {
mode: 'shell',
readOnly: true,
lineNumbers: false,
cursorBlinkRate: -1,
styleActiveLine: false,
};
}
get hclCodeEditor() {
return {
mode: 'hcl',
readOnly: true,
cursorBlinkRate: -1,
styleActiveLine: false,
};
}
}
+1 -14
View File
@@ -5,7 +5,6 @@ import { action } from '@ember/object';
export default class ScopesScopeWorkersRoute extends Route {
// =services
@service can;
@service session;
@service router;
@@ -18,23 +17,11 @@ export default class ScopesScopeWorkersRoute extends Route {
if (!this.session.isAuthenticated) this.router.transitionTo('index');
}
/**
* Load all workers.
* @return {WorkerModel}
*/
model() {
const scope = this.modelFor('scopes.scope');
const { id: scope_id } = scope;
if (this.can.can('list worker', scope, { collection: 'workers' })) {
return this.store.query('worker', { scope_id });
}
}
/**
* Refreshes worker data.
*/
@action
refreshWorkers() {
refresh() {
return super.refresh(...arguments);
}
}
@@ -1,16 +1,19 @@
import Route from '@ember/routing/route';
import { action } from '@ember/object';
import { resourceFilter } from 'core/decorators/resource-filter';
import { inject as service } from '@ember/service';
export default class ScopesScopeWorkersIndexRoute extends Route {
// =attributes
@service can;
@resourceFilter({
allowed: (route) => {
const configTags = route
.modelFor('scopes.scope.workers')
.modelFor('scopes.scope.workers.index')
.toArray()
.flatMap((worker) => worker.config_tags.type);
.flatMap((worker) => worker.config_tags?.type)
.filter(Boolean);
// Filter out duplicate tags
return [...new Set(configTags)];
@@ -21,18 +24,24 @@ export default class ScopesScopeWorkersIndexRoute extends Route {
// =methods
model() {
const workers = this.modelFor('scopes.scope.workers');
const scope = this.modelFor('scopes.scope');
const { id: scope_id } = scope;
if (this.can.can('list worker', scope, { collection: 'workers' })) {
const workers = this.store.query('worker', { scope_id });
if (this.tags?.length) {
// Return workers that have config tags that have at
// least one intersection with the filter tags
return workers.filter(
(worker) =>
worker.config_tags.type.filter(Set.prototype.has, new Set(this.tags))
.length > 0
);
if (this.tags?.length) {
// Return workers that have config tags that have at
// least one intersection with the filter tags
return workers.filter(
(worker) =>
worker.config_tags?.type.filter(
Set.prototype.has,
new Set(this.tags)
).length > 0
);
}
return workers;
}
return workers;
}
/**
@@ -1,3 +1,30 @@
import Route from '@ember/routing/route';
import { action } from '@ember/object';
import { loading } from 'ember-loading';
import { notifyError, notifySuccess } from 'core/decorators/notify';
export default class ScopesScopeWorkersNewRoute extends Route {}
export default class ScopesScopeWorkersNewRoute extends Route {
// =methods
/**
* Creates a new unsaved worker.
* @return {WorkerModel}
*/
model() {
const scopeModel = this.modelFor('scopes.scope');
return this.store.createRecord('worker', { scopeModel });
}
/**
* Created the worker and notifies the user of success or error.
* @param {WorkerModel} worker
* @param {string} workerGeneratedAuthToken
*/
@action
@loading
@notifyError(({ message }) => message, { catch: true })
@notifySuccess('notifications.add-success')
async createWorkerLed(worker, workerGeneratedAuthToken) {
await worker.createWorkerLed(workerGeneratedAuthToken);
}
}
+57
View File
@@ -394,3 +394,60 @@
margin-top: sizing.rems(xs);
}
}
.worker-create-section {
border: sizing.rems(xxxxs) solid var(--ui-gray-subtler-3);
border-radius: sizing.rems(xxs);
padding: sizing.rems(l);
margin-bottom: sizing.rems(xl);
> div {
margin-bottom: sizing.rems(l);
}
> code {
color: var(--failure);
background-color: var(--ui-gray-subtler-5);
border: sizing.rems(xxxxs) solid var(--ui-gray-subtler-2);
}
.worker-auth-token {
display: flex;
gap: sizing.rems(m);
align-items: flex-end;
}
.worker-success {
display: flex;
align-items: center;
justify-content: space-between;
> div {
display: flex;
gap: sizing.rems(xs);
align-items: center;
p {
margin-bottom: sizing.rems(xxs);
}
}
> div:first-child {
width: 40%;
p {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
> div:last-child {
p {
color: var(--success);
}
}
.rose-icon {
color: var(--success);
}
}
}
@@ -30,29 +30,30 @@
(resource-filter 'scopes.scope.workers.index' 'tags')
as |filter|
}}
<Rose::Dropdown
@text={{t (concat 'form.' filter.name '.label')}}
@count={{filter.selectedValue.length}}
@ignoreClickInside={{true}}
as |dropdown|
>
<form.checkboxGroup
@name={{filter.name}}
@items={{filter.allowedValues}}
@selectedItems={{filter.selectedValue}}
@onChange={{route-action 'filterBy' filter.name}}
as |group|
{{#if filter.allowedValues}}
<Rose::Dropdown
@text={{t (concat 'form.' filter.name '.label')}}
@count={{filter.selectedValue.length}}
@ignoreClickInside={{true}}
as |dropdown|
>
<dropdown.item>
<group.checkbox
@label={{group.item}}
value={{group.item}}
/>
</dropdown.item>
</form.checkboxGroup>
</Rose::Dropdown>
<form.checkboxGroup
@name={{filter.name}}
@items={{filter.allowedValues}}
@selectedItems={{filter.selectedValue}}
@onChange={{route-action 'filterBy' filter.name}}
as |group|
>
<dropdown.item>
<group.checkbox
@label={{group.item}}
value={{group.item}}
/>
</dropdown.item>
</form.checkboxGroup>
</Rose::Dropdown>
{{/if}}
{{/with}}
{{#if
(has-resource-filter-selections
'scopes.scope.workers.index' 'tags'
@@ -69,7 +70,7 @@
</toolbar.primary>
<toolbar.secondary>
<ToolbarRefresher @onClick={{route-action 'refreshWorkers'}} />
<ToolbarRefresher @onClick={{route-action 'refresh'}} />
</toolbar.secondary>
</Rose::Toolbar>
@@ -1,2 +1,23 @@
{{page-title (t 'titles.new')}}
{{outlet}}
<Rose::Layout::Page as |page|>
<page.breadcrumbs>
<BreadCrumbs />
</page.breadcrumbs>
<page.header>
<h2>
{{t 'titles.new'}}
<DocLink @doc='worker' @iconSize='large' />
</h2>
</page.header>
<page.body>
<Form::Worker::CreateWorkerLed
@model={{@model}}
@submit={{route-action 'createWorkerLed' @model}}
/>
</page.body>
</Rose::Layout::Page>
+1 -1
View File
@@ -93,7 +93,7 @@ module.exports = function (environment) {
'api-client.cli': '/api-client/cli',
'api-client.api': '/api-client/api',
worker: '/workers',
'worker.new': '/workers/new',
'worker.manage-workers': '/workers/manage-workers-on-hcp',
},
},
+3
View File
@@ -34,6 +34,9 @@ module.exports = function (defaults) {
app.import('node_modules/clipboard/dist/clipboard.min.js');
}
app.import('node_modules/codemirror/lib/codemirror.css');
app.import('node_modules/codemirror/theme/monokai.css');
// Use `app.import` to add additional libraries to the generated
// output files.
//
@@ -0,0 +1,86 @@
import { module, test } from 'qunit';
import { visit, fillIn, click, find } from '@ember/test-helpers';
import { setupApplicationTest } from 'ember-qunit';
import setupMirage from 'ember-cli-mirage/test-support/setup-mirage';
import { Response } from 'miragejs';
import {
authenticateSession,
// These are left here intentionally for future reference.
//currentSession,
//invalidateSession,
} from 'ember-simple-auth/test-support';
module('Acceptance | workers | create', function (hooks) {
setupApplicationTest(hooks);
setupMirage(hooks);
let globalScope;
let workersURL;
let newWorkerURL;
let getWorkersCount;
hooks.beforeEach(function () {
globalScope = this.server.create('scope', { id: 'global' });
workersURL = `/scopes/global/workers`;
newWorkerURL = `${workersURL}/new`;
getWorkersCount = () => this.server.schema.workers.all().length;
authenticateSession({});
});
test('can create new workers', async function (assert) {
assert.expect(1);
const workersCount = getWorkersCount();
await visit(newWorkerURL);
await fillIn('[name="worker_auth_registration_request"]', 'token');
await click('[type="submit"]');
assert.strictEqual(getWorkersCount(), workersCount + 1);
});
test('Users can navigate to new workers route with proper authorization', async function (assert) {
assert.expect(2);
await visit(newWorkerURL);
assert.ok(
globalScope.authorized_collection_actions.workers.includes(
'create:worker-led'
)
);
assert.dom(`[href="${newWorkerURL}"]`).isVisible();
});
test('Users cannot navigate to new workers route without proper authorization', async function (assert) {
assert.expect(2);
globalScope.authorized_collection_actions.workers = [];
await visit(workersURL);
assert.notOk(
globalScope.authorized_collection_actions.users.includes(
'create:worker-led'
)
);
assert.dom(`[href="${newWorkerURL}"]`).isNotVisible();
});
test('saving a new user with invalid fields displays error messages', async function (assert) {
assert.expect(1);
this.server.post('/workers:create:worker-led', () => {
return new Response(
500,
{},
{
status: 500,
code: 'api_error',
message: 'rpc error: code = Unknown',
}
);
});
await visit(newWorkerURL);
await fillIn('[name="worker_auth_registration_request"]', 'token');
await click('[type="submit"]');
assert.strictEqual(
find('.rose-notification-body').textContent.trim(),
'rpc error: code = Unknown',
'Displays primary error message.'
);
});
});
@@ -26,9 +26,7 @@ module('Acceptance | workers | list', function (hooks) {
hooks.beforeEach(function () {
instances.scopes.global = this.server.create('scope', { id: 'global' });
instances.user = this.server.create('user', {
scope: instances.scopes.global,
});
urls.globalScope = `/scopes/global/scopes`;
urls.workers = `/scopes/global/workers`;
authenticateSession({});