UI: Support for CSI (#7446)

Closes #7197 #7199

Note: Test coverage is limited to adapter and serializer unit tests. All
acceptance tests have been stubbed and all features have been manually
tested end-to-end.

This represents Phase 1 of #6993 which is the core workflow of CSI in
the UI. It includes a couple new pages for viewing all external volumes
as well as the allocations associated with each. It also updates
existing volume related views on job and allocation pages to handle both
Host Volumes and CSI Volumes.
This commit is contained in:
Michael Lange 2020-03-25 05:51:26 -07:00 committed by GitHub
parent 0c1dd8a204
commit 1bd6a69067
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
50 changed files with 1714 additions and 78 deletions

View File

@ -1,51 +1,8 @@
import { inject as service } from '@ember/service';
import Watchable from './watchable';
import addToPath from 'nomad-ui/utils/add-to-path';
import WithNamespaceIDs from 'nomad-ui/mixins/with-namespace-ids';
export default Watchable.extend({
system: service(),
findAll() {
const namespace = this.get('system.activeNamespace');
return this._super(...arguments).then(data => {
data.forEach(job => {
job.Namespace = namespace ? namespace.get('id') : 'default';
});
return data;
});
},
findRecord(store, type, id, snapshot) {
const [, namespace] = JSON.parse(id);
const namespaceQuery = namespace && namespace !== 'default' ? { namespace } : {};
return this._super(store, type, id, snapshot, namespaceQuery);
},
urlForFindAll() {
const url = this._super(...arguments);
const namespace = this.get('system.activeNamespace.id');
return associateNamespace(url, namespace);
},
urlForFindRecord(id, type, hash) {
const [name, namespace] = JSON.parse(id);
let url = this._super(name, type, hash);
return associateNamespace(url, namespace);
},
urlForUpdateRecord(id, type, hash) {
const [name, namespace] = JSON.parse(id);
let url = this._super(name, type, hash);
return associateNamespace(url, namespace);
},
xhrKey(url, method, options = {}) {
const plainKey = this._super(...arguments);
const namespace = options.data && options.data.namespace;
return associateNamespace(plainKey, namespace);
},
export default Watchable.extend(WithNamespaceIDs, {
relationshipFallbackLinks: {
summary: '/summary',
},
@ -113,10 +70,3 @@ export default Watchable.extend({
});
},
});
function associateNamespace(url, namespace) {
if (namespace && namespace !== 'default') {
url += `?namespace=${namespace}`;
}
return url;
}

View File

@ -0,0 +1,9 @@
import Watchable from './watchable';
import WithNamespaceIDs from 'nomad-ui/mixins/with-namespace-ids';
export default Watchable.extend(WithNamespaceIDs, {
queryParamsToAttrs: {
type: 'type',
plugin_id: 'plugin.id',
},
});

View File

@ -1,16 +1,28 @@
import { get } from '@ember/object';
import { assign } from '@ember/polyfills';
import { inject as service } from '@ember/service';
import { AbortError } from '@ember-data/adapter/error';
import queryString from 'query-string';
import ApplicationAdapter from './application';
import { AbortError } from '@ember-data/adapter/error';
import removeRecord from '../utils/remove-record';
export default ApplicationAdapter.extend({
watchList: service(),
store: service(),
ajaxOptions(url, type, options) {
const ajaxOptions = this._super(...arguments);
const ajaxOptions = this._super(url, type, options);
// Since ajax has been changed to include query params in the URL,
// we have to remove query params that are in the URL from the data
// object so they don't get passed along twice.
const [newUrl, params] = ajaxOptions.url.split('?');
const queryParams = queryString.parse(params);
ajaxOptions.url = !params ? newUrl : `${newUrl}?${queryString.stringify(queryParams)}`;
Object.keys(queryParams).forEach(key => {
delete ajaxOptions.data[key];
});
const abortToken = (options || {}).abortToken;
if (abortToken) {
delete options.abortToken;
@ -27,6 +39,23 @@ export default ApplicationAdapter.extend({
return ajaxOptions;
},
// Overriding ajax is not advised, but this is a minimal modification
// that sets off a series of events that results in query params being
// available in handleResponse below. Unfortunately, this is the only
// place where what becomes requestData can be modified.
//
// It's either this weird side-effecting thing that also requires a change
// to ajaxOptions or overriding ajax completely.
ajax(url, type, options) {
const hasParams = hasNonBlockingQueryParams(options);
if (!hasParams || type !== 'GET') return this._super(url, type, options);
const params = { ...options.data };
delete params.index;
return this._super(`${url}?${queryString.stringify(params)}`, type, options);
},
findAll(store, type, sinceToken, snapshotRecordArray, additionalParams = {}) {
const params = assign(this.buildQuery(), additionalParams);
const url = this.urlForFindAll(type.modelName);
@ -62,6 +91,48 @@ export default ApplicationAdapter.extend({
});
},
query(store, type, query, snapshotRecordArray, options, additionalParams = {}) {
const url = this.buildURL(type.modelName, null, null, 'query', query);
let [, params] = url.split('?');
params = assign(queryString.parse(params) || {}, this.buildQuery(), additionalParams, query);
if (get(options, 'adapterOptions.watch')) {
// The intended query without additional blocking query params is used
// to track the appropriate query index.
params.index = this.watchList.getIndexFor(`${url}?${queryString.stringify(query)}`);
}
const abortToken = get(options, 'adapterOptions.abortToken');
return this.ajax(url, 'GET', {
abortToken,
data: params,
}).then(payload => {
const adapter = store.adapterFor(type.modelName);
// Query params may not necessarily map one-to-one to attribute names.
// Adapters are responsible for declaring param mappings.
const queryParamsToAttrs = Object.keys(adapter.queryParamsToAttrs || {}).map(key => ({
queryParam: key,
attr: adapter.queryParamsToAttrs[key],
}));
// Remove existing records that match this query. This way if server-side
// deletes have occurred, the store won't have stale records.
store
.peekAll(type.modelName)
.filter(record =>
queryParamsToAttrs.some(
mapping => get(record, mapping.attr) === query[mapping.queryParam]
)
)
.forEach(record => {
removeRecord(store, record);
});
return payload;
});
},
reloadRelationship(model, relationshipName, options = { watch: false, abortToken: null }) {
const { watch, abortToken } = options;
const relationship = model.relationshipFor(relationshipName);
@ -122,3 +193,12 @@ export default ApplicationAdapter.extend({
return this._super(...arguments);
},
});
function hasNonBlockingQueryParams(options) {
if (!options || !options.data) return false;
const keys = Object.keys(options.data);
if (!keys.length) return false;
if (keys.length === 1 && keys[0] === 'index') return false;
return true;
}

11
ui/app/controllers/csi.js Normal file
View File

@ -0,0 +1,11 @@
import Controller from '@ember/controller';
export default Controller.extend({
queryParams: {
volumeNamespace: 'namespace',
},
isForbidden: false,
volumeNamespace: 'default',
});

View File

@ -0,0 +1,35 @@
import { inject as service } from '@ember/service';
import { alias } from '@ember/object/computed';
import Controller, { inject as controller } from '@ember/controller';
import SortableFactory from 'nomad-ui/mixins/sortable-factory';
export default Controller.extend(
SortableFactory([
'id',
'schedulable',
'controllersHealthyProportion',
'nodesHealthyProportion',
'provider',
]),
{
system: service(),
csiController: controller('csi'),
isForbidden: alias('csiController.isForbidden'),
queryParams: {
currentPage: 'page',
sortProperty: 'sort',
sortDescending: 'desc',
},
currentPage: 1,
pageSize: 10,
sortProperty: 'id',
sortDescending: true,
listToSort: alias('model'),
sortedVolumes: alias('listSorted'),
}
);

View File

@ -0,0 +1,9 @@
import Controller from '@ember/controller';
export default Controller.extend({
actions: {
gotoAllocation(allocation) {
this.transitionToRoute('allocations.allocation', allocation);
},
},
});

View File

@ -0,0 +1,60 @@
import { inject as service } from '@ember/service';
import Mixin from '@ember/object/mixin';
export default Mixin.create({
system: service(),
findAll() {
const namespace = this.get('system.activeNamespace');
return this._super(...arguments).then(data => {
data.forEach(record => {
record.Namespace = namespace ? namespace.get('id') : 'default';
});
return data;
});
},
findRecord(store, type, id, snapshot) {
const [, namespace] = JSON.parse(id);
const namespaceQuery = namespace && namespace !== 'default' ? { namespace } : {};
return this._super(store, type, id, snapshot, namespaceQuery);
},
urlForFindAll() {
const url = this._super(...arguments);
const namespace = this.get('system.activeNamespace.id');
return associateNamespace(url, namespace);
},
urlForQuery() {
const url = this._super(...arguments);
const namespace = this.get('system.activeNamespace.id');
return associateNamespace(url, namespace);
},
urlForFindRecord(id, type, hash) {
const [name, namespace] = JSON.parse(id);
let url = this._super(name, type, hash);
return associateNamespace(url, namespace);
},
urlForUpdateRecord(id, type, hash) {
const [name, namespace] = JSON.parse(id);
let url = this._super(name, type, hash);
return associateNamespace(url, namespace);
},
xhrKey(url, method, options = {}) {
const plainKey = this._super(...arguments);
const namespace = options.data && options.data.namespace;
return associateNamespace(plainKey, namespace);
},
});
function associateNamespace(url, namespace) {
if (namespace && namespace !== 'default') {
url += `?namespace=${namespace}`;
}
return url;
}

13
ui/app/models/plugin.js Normal file
View File

@ -0,0 +1,13 @@
import Model from 'ember-data/model';
import attr from 'ember-data/attr';
// import { fragmentArray } from 'ember-data-model-fragments/attributes';
export default Model.extend({
topologies: attr(),
provider: attr('string'),
version: attr('string'),
controllerRequired: attr('boolean'),
// controllers: fragmentArray('storage-controller', { defaultValue: () => [] }),
// nodes: fragmentArray('storage-node', { defaultValue: () => [] }),
});

View File

@ -0,0 +1,21 @@
import attr from 'ember-data/attr';
import { belongsTo } from 'ember-data/relationships';
import Fragment from 'ember-data-model-fragments/fragment';
import { fragmentOwner } from 'ember-data-model-fragments/attributes';
export default Fragment.extend({
plugin: fragmentOwner(),
node: belongsTo('node'),
allocation: belongsTo('allocation'),
provider: attr('string'),
version: attr('string'),
healthy: attr('boolean'),
healthDescription: attr('string'),
updateTime: attr('date'),
requiresControllerPlugin: attr('boolean'),
requiresTopologies: attr('boolean'),
controllerInfo: attr(),
});

View File

@ -0,0 +1,21 @@
import attr from 'ember-data/attr';
import { belongsTo } from 'ember-data/relationships';
import Fragment from 'ember-data-model-fragments/fragment';
import { fragmentOwner } from 'ember-data-model-fragments/attributes';
export default Fragment.extend({
plugin: fragmentOwner(),
node: belongsTo('node'),
allocation: belongsTo('allocation'),
provider: attr('string'),
version: attr('string'),
healthy: attr('boolean'),
healthDescription: attr('string'),
updateTime: attr('date'),
requiresControllerPlugin: attr('boolean'),
requiresTopologies: attr('boolean'),
nodeInfo: attr(),
});

View File

@ -16,7 +16,7 @@ export default Fragment.extend({
services: fragmentArray('service'),
volumes: fragmentArray('volume'),
volumes: fragmentArray('volume-definition'),
drivers: computed('tasks.@each.driver', function() {
return this.tasks.mapBy('driver').uniq();

View File

@ -0,0 +1,17 @@
import { alias, equal } from '@ember/object/computed';
import attr from 'ember-data/attr';
import Fragment from 'ember-data-model-fragments/fragment';
import { fragmentOwner } from 'ember-data-model-fragments/attributes';
export default Fragment.extend({
taskGroup: fragmentOwner(),
name: attr('string'),
source: attr('string'),
type: attr('string'),
readOnly: attr('boolean'),
isCSI: equal('type', 'csi'),
namespace: alias('taskGroup.job.namespace'),
});

View File

@ -1,4 +1,5 @@
import { computed } from '@ember/object';
import { alias, equal } from '@ember/object/computed';
import attr from 'ember-data/attr';
import Fragment from 'ember-data-model-fragments/fragment';
import { fragmentOwner } from 'ember-data-model-fragments/attributes';
@ -7,10 +8,18 @@ export default Fragment.extend({
task: fragmentOwner(),
volume: attr('string'),
source: computed('volume', 'task.taskGroup.volumes.@each.{name,source}', function() {
return this.task.taskGroup.volumes.findBy('name', this.volume).source;
volumeDeclaration: computed('task.taskGroup.volumes.@each.name', function() {
return this.task.taskGroup.volumes.findBy('name', this.volume);
}),
isCSI: equal('volumeDeclaration.type', 'csi'),
source: alias('volumeDeclaration.source'),
// Since CSI volumes are namespaced, the link intent of a volume mount will
// be to the CSI volume with a namespace that matches this task's job's namespace.
namespace: alias('task.taskGroup.job.namespace'),
destination: attr('string'),
propagationMode: attr('string'),
readOnly: attr('boolean'),

View File

@ -1,10 +1,46 @@
import { computed } from '@ember/object';
import Model from 'ember-data/model';
import attr from 'ember-data/attr';
import Fragment from 'ember-data-model-fragments/fragment';
import { belongsTo, hasMany } from 'ember-data/relationships';
export default Fragment.extend({
export default Model.extend({
plainId: attr('string'),
name: attr('string'),
source: attr('string'),
type: attr('string'),
readOnly: attr('boolean'),
namespace: belongsTo('namespace'),
plugin: belongsTo('plugin'),
writeAllocations: hasMany('allocation'),
readAllocations: hasMany('allocation'),
allocations: computed('writeAllocations.[]', 'readAllocations.[]', function() {
return [...this.writeAllocations.toArray(), ...this.readAllocations.toArray()];
}),
externalId: attr('string'),
topologies: attr(),
accessMode: attr('string'),
attachmentMode: attr('string'),
schedulable: attr('boolean'),
provider: attr('string'),
version: attr('string'),
controllerRequired: attr('boolean'),
controllersHealthy: attr('number'),
controllersExpected: attr('number'),
controllersHealthyProportion: computed('controllersHealthy', 'controllersExpected', function() {
return this.controllersHealthy / this.controllersExpected;
}),
nodesHealthy: attr('number'),
nodesExpected: attr('number'),
nodesHealthyProportion: computed('nodesHealthy', 'nodesExpected', function() {
return this.nodesHealthy / this.nodesExpected;
}),
resourceExhausted: attr('number'),
createIndex: attr('number'),
modifyIndex: attr('number'),
});

View File

@ -33,6 +33,12 @@ Router.map(function() {
this.route('server', { path: '/:agent_id' });
});
this.route('csi', function() {
this.route('volumes', function() {
this.route('volume', { path: '/:volume_name' });
});
});
this.route('allocations', function() {
this.route('allocation', { path: '/:allocation_id' }, function() {
this.route('task', { path: '/:name' }, function() {

View File

@ -0,0 +1,7 @@
import Route from '@ember/routing/route';
export default Route.extend({
redirect() {
this.transitionTo('csi.volumes');
},
});

View File

@ -0,0 +1,41 @@
import { inject as service } from '@ember/service';
import Route from '@ember/routing/route';
import WithForbiddenState from 'nomad-ui/mixins/with-forbidden-state';
import notifyForbidden from 'nomad-ui/utils/notify-forbidden';
export default Route.extend(WithForbiddenState, {
system: service(),
store: service(),
breadcrumbs: [
{
label: 'CSI',
args: ['csi.volumes.index'],
},
],
queryParams: {
volumeNamespace: {
refreshModel: true,
},
},
beforeModel(transition) {
return this.get('system.namespaces').then(namespaces => {
const queryParam = transition.to.queryParams.namespace;
this.set('system.activeNamespace', queryParam || 'default');
return namespaces;
});
},
model() {
return this.store
.query('volume', { type: 'csi' })
.then(volumes => {
volumes.forEach(volume => volume.plugin);
return volumes;
})
.catch(notifyForbidden(this));
},
});

View File

@ -0,0 +1,13 @@
import Route from '@ember/routing/route';
import { collect } from '@ember/object/computed';
import { watchQuery } from 'nomad-ui/utils/properties/watch';
import WithWatchers from 'nomad-ui/mixins/with-watchers';
export default Route.extend(WithWatchers, {
startWatchers(controller) {
controller.set('modelWatch', this.watch.perform({ type: 'csi' }));
},
watch: watchQuery('volume'),
watchers: collect('watch'),
});

View File

@ -0,0 +1,55 @@
import { inject as service } from '@ember/service';
import Route from '@ember/routing/route';
import { collect } from '@ember/object/computed';
import notifyError from 'nomad-ui/utils/notify-error';
import { qpBuilder } from 'nomad-ui/utils/classes/query-params';
import { watchRecord } from 'nomad-ui/utils/properties/watch';
import WithWatchers from 'nomad-ui/mixins/with-watchers';
export default Route.extend(WithWatchers, {
store: service(),
system: service(),
breadcrumbs: volume => [
{
label: 'Volumes',
args: [
'csi.volumes',
qpBuilder({ volumeNamespace: volume.get('namespace.name') || 'default' }),
],
},
{
label: volume.name,
args: [
'csi.volumes.volume',
volume.plainId,
qpBuilder({ volumeNamespace: volume.get('namespace.name') || 'default' }),
],
},
],
startWatchers(controller, model) {
if (!model) return;
controller.set('watchers', {
model: this.watch.perform(model),
});
},
serialize(model) {
return { volume_name: model.get('plainId') };
},
model(params, transition) {
const namespace = transition.to.queryParams.namespace || this.get('system.activeNamespace.id');
const name = params.volume_name;
const fullId = JSON.stringify([`csi/${name}`, namespace || 'default']);
return this.store.findRecord('volume', fullId, { reload: true }).catch(notifyError(this));
},
// Since volume includes embedded records for allocations,
// it's possible that allocations that are server-side deleted may
// not be removed from the UI while sitting on the volume detail page.
watch: watchRecord('volume'),
watchers: collect('watch'),
});

View File

@ -0,0 +1,18 @@
import ApplicationSerializer from './application';
export default ApplicationSerializer.extend({
normalize(typeHash, hash) {
hash.PlainID = hash.ID;
// TODO This shouldn't hardcode `csi/` as part of the ID,
// but it is necessary to make the correct find request and the
// payload does not contain the required information to derive
// this identifier.
hash.ID = `csi/${hash.ID}`;
hash.Nodes = hash.Nodes || [];
hash.Controllers = hash.Controllers || [];
return this._super(typeHash, hash);
},
});

View File

@ -0,0 +1,96 @@
import { set, get } from '@ember/object';
import ApplicationSerializer from './application';
export default ApplicationSerializer.extend({
attrs: {
externalId: 'ExternalID',
},
embeddedRelationships: ['writeAllocations', 'readAllocations'],
// Volumes treat Allocations as embedded records. Ember has an
// EmbeddedRecords mixin, but it assumes an application is using
// the REST serializer and Nomad does not.
normalize(typeHash, hash) {
hash.NamespaceID = hash.Namespace;
hash.PlainId = hash.ID;
// TODO These shouldn't hardcode `csi/` as part of the IDs,
// but it is necessary to make the correct find requests and the
// payload does not contain the required information to derive
// this identifier.
hash.ID = JSON.stringify([`csi/${hash.ID}`, hash.NamespaceID || 'default']);
hash.PluginID = `csi/${hash.PluginID}`;
// Convert hash-based allocation embeds to lists
const readAllocs = hash.ReadAllocs || {};
const writeAllocs = hash.WriteAllocs || {};
const bindIDToAlloc = hash => id => {
const alloc = hash[id];
alloc.ID = id;
return alloc;
};
hash.ReadAllocations = Object.keys(readAllocs).map(bindIDToAlloc(readAllocs));
hash.WriteAllocations = Object.keys(writeAllocs).map(bindIDToAlloc(writeAllocs));
const normalizedHash = this._super(typeHash, hash);
return this.extractEmbeddedRecords(this, this.store, typeHash, normalizedHash);
},
keyForRelationship(attr, relationshipType) {
//Embedded relationship attributes don't end in IDs
if (this.embeddedRelationships.includes(attr)) return attr.capitalize();
return this._super(attr, relationshipType);
},
// Convert the embedded relationship arrays into JSONAPI included records
extractEmbeddedRecords(serializer, store, typeHash, partial) {
partial.included = partial.included || [];
this.embeddedRelationships.forEach(embed => {
const relationshipMeta = typeHash.relationshipsByName.get(embed);
const relationship = get(partial, `data.relationships.${embed}.data`);
if (!relationship) return;
// Create a sidecar relationships array
const hasMany = new Array(relationship.length);
// For each embedded allocation, normalize the allocation JSON according
// to the allocation serializer.
relationship.forEach((alloc, idx) => {
const { data, included } = this.normalizeEmbeddedRelationship(
store,
relationshipMeta,
alloc
);
// In JSONAPI, embedded records go in the included array.
partial.included.push(data);
if (included) {
partial.included.push(...included);
}
// In JSONAPI, the main payload value is an array of IDs that
// map onto the objects in the included array.
hasMany[idx] = { id: data.id, type: data.type };
});
// Set the JSONAPI relationship value to the sidecar.
const relationshipJson = { data: hasMany };
set(partial, `data.relationships.${embed}`, relationshipJson);
});
return partial;
},
normalizeEmbeddedRelationship(store, relationshipMeta, relationshipHash) {
const modelName = relationshipMeta.type;
const modelClass = store.modelFor(modelName);
const serializer = store.serializerFor(modelName);
return serializer.normalize(modelClass, relationshipHash, null);
},
});

View File

@ -1,3 +1,4 @@
import { computed } from '@ember/object';
import { readOnly } from '@ember/object/computed';
import { copy } from 'ember-copy';
import Service from '@ember/service';
@ -5,9 +6,9 @@ import Service from '@ember/service';
let list = {};
export default Service.extend({
list: readOnly(function() {
return copy(list, true);
}),
_list: computed(() => copy(list, true)),
list: readOnly('_list'),
init() {
this._super(...arguments);

View File

@ -1,5 +1,7 @@
.menu {
.menu-list {
margin-top: 1rem;
a {
font-weight: $weight-semibold;
padding: 0.5rem 1.5rem;
@ -48,6 +50,15 @@
&:not(:first-child) {
border-top: 1px solid $grey-blue;
}
&.is-minor {
border-top: none;
margin-top: 0;
}
+ .menu-list {
margin-top: 0.5rem;
}
}
.collapsed-only + .menu-label {

View File

@ -131,7 +131,15 @@
{{/t.head}}
{{#t.body as |row|}}
<tr data-test-volume>
<td data-test-volume-name>{{row.model.volume}}</td>
<td data-test-volume-name>
{{#if row.model.isCSI}}
{{#link-to "csi.volumes.volume" row.model.volume (query-params volumeNamespace=row.model.namespace.id)}}
{{row.model.volume}}
{{/link-to}}
{{else}}
{{row.model.volume}}
{{/if}}
</td>
<td data-test-volume-destination><code>{{row.model.destination}}</code></td>
<td data-test-volume-permissions>{{if row.model.readOnly "Read" "Read/Write"}}</td>
<td data-test-volume-client-source>{{row.model.source}}</td>

View File

@ -36,10 +36,13 @@
<td data-test-client-status class="is-one-line">
<span class="color-swatch {{allocation.clientStatus}}" /> {{allocation.clientStatus}}
</td>
{{#if (eq context "volume")}}
<td data-test-client>{{#link-to "clients.client" allocation.node}}{{allocation.node.shortId}}{{/link-to}}</td>
{{/if}}
{{#if (or (eq context "taskGroup") (eq context "job"))}}
<td data-test-job-version>{{allocation.jobVersion}}</td>
<td data-test-client>{{#link-to "clients.client" allocation.node}}{{allocation.node.shortId}}{{/link-to}}</td>
{{else if (eq context "node")}}
{{else if (or (eq context "node") (eq context "volume"))}}
<td>
{{#if (or allocation.job.isPending allocation.job.isReloading)}}
...
@ -50,7 +53,9 @@
</td>
<td data-test-job-version class="is-1">{{allocation.jobVersion}}</td>
{{/if}}
<td data-test-volume>{{if allocation.taskGroup.volumes.length "Yes"}}</td>
{{#if (not (eq context "volume"))}}
<td data-test-volume>{{if allocation.taskGroup.volumes.length "Yes"}}</td>
{{/if}}
<td data-test-cpu class="is-1 has-text-centered">
{{#if allocation.isRunning}}
{{#if (and (not cpu) fetchStats.isRunning)}}

View File

@ -12,7 +12,7 @@
{{#if system.shouldShowRegions}}
<div class="collapsed-only">
<p class="menu-label">
Region
Region {{if system.shouldShowNamespaces "& Namespace"}}
</p>
<ul class="menu-list">
<li>
@ -22,12 +22,9 @@
</li>
</ul>
</div>
{{/if}}
<p class="menu-label">
Workload
</p>
<ul class="menu-list">
{{#if system.shouldShowNamespaces}}
{{/if}}
{{#if system.shouldShowNamespaces}}
<ul class="menu-list">
<li>
<div class="menu-item is-wide">
{{#power-select
@ -48,9 +45,20 @@
{{/power-select}}
</div>
</li>
{{/if}}
</ul>
{{/if}}
<p class="menu-label">
Workload
</p>
<ul class="menu-list">
<li>{{#link-to "jobs" activeClass="is-active" data-test-gutter-link="jobs"}}Jobs{{/link-to}}</li>
</ul>
<p class="menu-label is-minor">
Integrations
</p>
<ul class="menu-list">
<li>{{#link-to "csi" activeClass="is-active" data-test-gutter-link="csi"}}CSI{{/link-to}}</li>
</ul>
<p class="menu-label">
Cluster
</p>

View File

@ -27,7 +27,13 @@
{{#each task.task.volumeMounts as |volume|}}
<li data-test-volume>
<strong>{{volume.volume}}:</strong>
{{volume.source}}
{{#if volume.isCSI}}
{{#link-to "csi.volumes.volume" volume.volume (query-params volumeNamespace=volume.namespace.id)}}
{{volume.source}}
{{/link-to}}
{{else}}
{{volume.source}}
{{/if}}
</li>
{{/each}}
</ul>

3
ui/app/templates/csi.hbs Normal file
View File

@ -0,0 +1,3 @@
{{#page-layout}}
{{outlet}}
{{/page-layout}}

View File

@ -0,0 +1 @@
{{outlet}}

View File

@ -0,0 +1,65 @@
{{title "CSI Volumes"}}
<section class="section">
{{#if isForbidden}}
{{partial "partials/forbidden-message"}}
{{else}}
{{#if sortedVolumes}}
{{#list-pagination
source=sortedVolumes
size=pageSize
page=currentPage as |p|}}
{{#list-table
source=p.list
sortProperty=sortProperty
sortDescending=sortDescending
class="with-foot" as |t|}}
{{#t.head}}
{{#t.sort-by prop="name"}}Name{{/t.sort-by}}
{{#t.sort-by prop="schedulable"}}Volume Health{{/t.sort-by}}
{{#t.sort-by prop="controllersHealthyProportion"}}Controller Health{{/t.sort-by}}
{{#t.sort-by prop="nodesHealthyProportion"}}Node Health{{/t.sort-by}}
{{#t.sort-by prop="provider"}}Provider{{/t.sort-by}}
<th># Allocs</th>
{{/t.head}}
{{#t.body key="model.name" as |row|}}
<tr class="is-interactive">
<td>
{{#link-to "csi.volumes.volume" row.model.plainId class="is-primary"}}{{row.model.name}}{{/link-to}}
</td>
<td>{{if row.model.schedulable "Schedulable" "Unschedulable"}}</td>
<td>
{{if (gt row.model.controllersHealthy 0) "Healthy" "Unhealthy"}}
({{row.model.controllersHealthy}}/{{row.model.controllersExpected}})
</td>
<td>
{{if (gt row.model.nodesHealthy 0) "Healthy" "Unhealthy"}}
({{row.model.nodesHealthy}}/{{row.model.nodesExpected}})
</td>
<td>{{row.model.provider}}</td>
<td>{{row.model.allocations.length}}</td>
</tr>
{{/t.body}}
{{/list-table}}
<div class="table-foot">
<nav class="pagination">
<div class="pagination-numbers">
{{p.startsAt}}&ndash;{{p.endsAt}} of {{sortedVolumes.length}}
</div>
{{#p.prev class="pagination-previous"}} &lt; {{/p.prev}}
{{#p.next class="pagination-next"}} &gt; {{/p.next}}
<ul class="pagination-list"></ul>
</nav>
</div>
{{/list-pagination}}
{{else}}
<div data-test-empty-jobs-list class="empty-message">
{{#if (eq sortedVolumes.length 0)}}
<h3 data-test-empty-jobs-list-headline class="empty-message-headline">No Volumes</h3>
<p class="empty-message-body">
The cluster currently has no CSI volumes.
</p>
{{/if}}
</div>
{{/if}}
{{/if}}
</section>

View File

@ -0,0 +1,102 @@
{{title "CSI Volume " model.name}}
<section class="section">
<h1 class="title">{{model.name}}</h1>
<div class="boxed-section is-small">
<div class="boxed-section-body inline-definitions">
<span class="label">Volume Details</span>
<span class="pair">
<span class="term">health</span>
{{if model.schedulable "Schedulable" "Unschedulable"}}
</span>
<span class="pair">
<span class="term">Provider</span>
{{model.provider}}
</span>
<span class="pair">
<span class="term">External ID</span>
{{model.externalId}}
</span>
<span class="pair">
<span class="term">Namespace</span>
{{model.namespace.name}}
</span>
</div>
</div>
<div class="boxed-section">
<div class="boxed-section-head">
Write Allocations
</div>
<div class="boxed-section-body {{if model.writeAllocations.length "is-full-bleed"}}">
{{#if model.writeAllocations.length}}
{{#list-table
source=model.writeAllocations
class="with-foot" as |t|}}
{{#t.head}}
<th class="is-narrow"></th>
<th>ID</th>
<th>Modified</th>
<th>Created</th>
<th>Status</th>
<th>Client</th>
<th>Job</th>
<th>Version</th>
<th>CPU</th>
<th>Memory</th>
{{/t.head}}
{{#t.body as |row|}}
{{allocation-row
data-test-allocation=row.model.id
allocation=row.model
context="volume"
onClick=(action "gotoAllocation" row.model)}}
{{/t.body}}
{{/list-table}}
{{else}}
<div class="empty-message" data-test-empty-recent-allocations>
<h3 class="empty-message-headline" data-test-empty-recent-allocations-headline>No Write Allocations</h3>
<p class="empty-message-body" data-test-empty-recent-allocations-message>No allocations are depending on this volume for read/write access.</p>
</div>
{{/if}}
</div>
</div>
<div class="boxed-section">
<div class="boxed-section-head">
Read Allocations
</div>
<div class="boxed-section-body {{if model.readAllocations.length "is-full-bleed"}}">
{{#if model.readAllocations.length}}
{{#list-table
source=model.readAllocations
class="with-foot" as |t|}}
{{#t.head}}
<th class="is-narrow"></th>
<th>ID</th>
<th>Modified</th>
<th>Created</th>
<th>Status</th>
<th>Client</th>
<th>Job</th>
<th>Version</th>
<th>CPU</th>
<th>Memory</th>
{{/t.head}}
{{#t.body as |row|}}
{{allocation-row
data-test-allocation=row.model.id
allocation=row.model
context="volume"
onClick=(action "gotoAllocation" row.model)}}
{{/t.body}}
{{/list-table}}
{{else}}
<div class="empty-message" data-test-empty-recent-allocations>
<h3 class="empty-message-headline" data-test-empty-recent-allocations-headline>No Read Allocations</h3>
<p class="empty-message-body" data-test-empty-recent-allocations-message>No allocations are depending on this volume for read-only access.</p>
</div>
{{/if}}
</div>
</div>
</section>

View File

@ -126,7 +126,15 @@
{{/t.head}}
{{#t.body as |row|}}
<tr data-test-volume>
<td data-test-volume-name>{{row.model.name}}</td>
<td data-test-volume-name>
{{#if row.model.isCSI}}
{{#link-to "csi.volumes.volume" row.model.name (query-params volumeNamespace=row.model.namespace.id)}}
{{row.model.name}}
{{/link-to}}
{{else}}
{{row.model.name}}
{{/if}}
</td>
<td data-test-volume-type>{{row.model.type}}</td>
<td data-test-volume-source>{{row.model.source}}</td>
<td data-test-volume-permissions>{{if row.model.readOnly "Read" "Read/Write"}}</td>

View File

@ -75,3 +75,25 @@ export function watchAll(modelName) {
}
}).drop();
}
export function watchQuery(modelName) {
return task(function*(params, throttle = 10000) {
const token = new XHRToken();
while (isEnabled && !Ember.testing) {
try {
yield RSVP.all([
this.store.query(modelName, params, {
reload: true,
adapterOptions: { watch: true, abortToken: token },
}),
wait(throttle),
]);
} catch (e) {
yield e;
break;
} finally {
token.abort();
}
}
}).drop();
}

View File

@ -23,6 +23,8 @@ export const HOSTS = provide(100, () => {
return `${ip}:${faker.random.number({ min: 4000, max: 4999 })}`;
});
export const STORAGE_PROVIDERS = ['ebs', 'zfs', 'nfs', 'cow', 'moo'];
export function generateResources(options = {}) {
return {
CPU: faker.helpers.randomize(CPU_RESERVATIONS),

View File

@ -227,6 +227,58 @@ export default function() {
return new Response(204, {}, '');
});
this.get(
'/volumes',
withBlockingSupport(function({ csiVolumes }, { queryParams }) {
if (queryParams.type !== 'csi') {
return new Response(200, {}, '[]');
}
return this.serialize(csiVolumes.all());
})
);
this.get(
'/volume/:id',
withBlockingSupport(function({ csiVolumes }, { params }) {
if (!params.id.startsWith('csi/')) {
return new Response(404, {}, null);
}
const id = params.id.replace(/^csi\//, '');
const volume = csiVolumes.find(id);
if (!volume) {
return new Response(404, {}, null);
}
return this.serialize(volume);
})
);
this.get('/plugins', function({ csiPlugins }, { queryParams }) {
if (queryParams.type !== 'csi') {
return new Response(200, {}, '[]');
}
return this.serialize(csiPlugins.all());
});
this.get('/plugin/:id', function({ csiPlugins }, { params }) {
if (!params.id.startsWith('csi/')) {
return new Response(404, {}, null);
}
const id = params.id.replace(/^csi\//, '');
const volume = csiPlugins.find(id);
if (!volume) {
return new Response(404, {}, null);
}
return this.serialize(volume);
});
this.get('/namespaces', function({ namespaces }) {
const records = namespaces.all();

View File

@ -0,0 +1,56 @@
import { Factory } from 'ember-cli-mirage';
import faker from 'nomad-ui/mirage/faker';
import { STORAGE_PROVIDERS } from '../common';
export default Factory.extend({
id: () => faker.random.uuid(),
// Topologies is currently unused by the UI. This should
// eventually become dynamic.
topologies: () => [{ foo: 'bar' }],
provider: faker.helpers.randomize(STORAGE_PROVIDERS),
version: '1.0.1',
controllerRequired: faker.random.boolean,
controllersHealthy: () => faker.random.number(10),
nodesHealthy: () => faker.random.number(10),
// Internal property to determine whether or not this plugin
// Should create one or two Jobs to represent Node and
// Controller plugins.
isMonolith: faker.random.boolean,
afterCreate(plugin, server) {
let storageNodes;
let storageControllers;
if (plugin.isMonolith) {
const pluginJob = server.create('job', { type: 'service', createAllocations: false });
const count = faker.random.number({ min: 1, max: 5 });
storageNodes = server.createList('storage-node', count, { job: pluginJob });
storageControllers = server.createList('storage-controller', count, { job: pluginJob });
} else {
const controllerJob = server.create('job', { type: 'service', createAllocations: false });
const nodeJob = server.create('job', { type: 'service', createAllocations: false });
storageNodes = server.createList('storage-node', faker.random.number({ min: 1, max: 5 }), {
job: nodeJob,
});
storageControllers = server.createList(
'storage-controller',
faker.random.number({ min: 1, max: 5 }),
{ job: controllerJob }
);
}
plugin.update({
controllers: storageControllers,
nodes: storageNodes,
});
server.createList('csi-volume', faker.random.number(5), {
plugin,
provider: plugin.provider,
});
},
});

View File

@ -0,0 +1,61 @@
import { Factory } from 'ember-cli-mirage';
import faker from 'nomad-ui/mirage/faker';
import { pickOne } from '../utils';
import { STORAGE_PROVIDERS } from '../common';
const ACCESS_MODES = ['multi-node-single-writer'];
const ATTACHMENT_MODES = ['file-system'];
export default Factory.extend({
id: i => `${faker.hacker.noun().dasherize()}-${i}`.toLowerCase(),
name() {
return this.id;
},
externalId: () => `vol-${faker.random.uuid().split('-')[0]}`,
// Topologies is currently unused by the UI. This should
// eventually become dynamic.
topologies: () => [{ foo: 'bar' }],
accessMode: faker.helpers.randomize(ACCESS_MODES),
attachmentMode: faker.helpers.randomize(ATTACHMENT_MODES),
schedulable: faker.random.boolean,
provider: faker.helpers.randomize(STORAGE_PROVIDERS),
version: '1.0.1',
controllerRequired: faker.random.boolean,
controllersHealthy: () => faker.random.number(10),
controllersExpected() {
return this.controllersHealthy + faker.random.number(10);
},
nodesHealthy: () => faker.random.number(10),
nodesExpected() {
return this.nodesHealthy + faker.random.number(10);
},
afterCreate(volume, server) {
if (!volume.namespaceId) {
const namespace = server.db.namespaces.length ? pickOne(server.db.namespaces).id : null;
volume.update({
namespace,
namespaceId: namespace,
});
} else {
volume.update({
namespace: volume.namespaceId,
});
}
if (!volume.plugin) {
const plugin = server.db.csiPlugins.length ? pickOne(server.db.csiPlugins) : null;
volume.update({
PluginId: plugin && plugin.id,
});
} else {
volume.update({
PluginId: volume.plugin.id,
});
}
},
});

View File

@ -0,0 +1,38 @@
import { Factory } from 'ember-cli-mirage';
import faker from 'nomad-ui/mirage/faker';
import { STORAGE_PROVIDERS } from '../common';
const REF_TIME = new Date();
export default Factory.extend({
provider: faker.helpers.randomize(STORAGE_PROVIDERS),
providerVersion: '1.0.1',
healthy: faker.random.boolean,
healthDescription() {
this.healthy ? 'healthy' : 'unhealthy';
},
updateTime: () => faker.date.past(2 / 365, REF_TIME) * 1000000,
requiresControllerPlugin: true,
requiresTopologies: true,
controllerInfo: () => ({
SupportsReadOnlyAttach: true,
SupportsAttachDetach: true,
SupportsListVolumes: true,
SupportsListVolumesAttachedNodes: false,
}),
afterCreate(storageController, server) {
const alloc = server.create('allocation', {
jobId: storageController.job.id,
});
storageController.update({
allocation: alloc,
allocId: alloc.id,
nodeId: alloc.nodeId,
});
},
});

View File

@ -0,0 +1,38 @@
import { Factory } from 'ember-cli-mirage';
import faker from 'nomad-ui/mirage/faker';
import { STORAGE_PROVIDERS } from '../common';
const REF_TIME = new Date();
export default Factory.extend({
provider: faker.helpers.randomize(STORAGE_PROVIDERS),
providerVersion: '1.0.1',
healthy: faker.random.boolean,
healthDescription() {
this.healthy ? 'healthy' : 'unhealthy';
},
updateTime: () => faker.date.past(2 / 365, REF_TIME) * 1000000,
requiresControllerPlugin: true,
requiresTopologies: true,
nodeInfo: () => ({
MaxVolumes: 51,
AccessibleTopology: {
key: 'value',
},
RequiresNodeStageVolume: true,
}),
afterCreate(storageNode, server) {
const alloc = server.create('allocation', {
jobId: storageNode.job.id,
});
storageNode.update({
allocId: alloc.id,
nodeId: alloc.nodeId,
});
},
});

View File

@ -0,0 +1,6 @@
import { Model, hasMany } from 'ember-cli-mirage';
export default Model.extend({
nodes: hasMany('storage-node'),
controllers: hasMany('storage-controller'),
});

View File

@ -0,0 +1,7 @@
import { Model, belongsTo, hasMany } from 'ember-cli-mirage';
export default Model.extend({
plugin: belongsTo('csi-plugin'),
writeAllocs: hasMany('allocation'),
readAllocs: hasMany('allocation'),
});

View File

@ -0,0 +1,7 @@
import { Model, belongsTo } from 'ember-cli-mirage';
export default Model.extend({
job: belongsTo(),
node: belongsTo(),
allocation: belongsTo(),
});

View File

@ -0,0 +1,7 @@
import { Model, belongsTo } from 'ember-cli-mirage';
export default Model.extend({
job: belongsTo(),
node: belongsTo(),
allocation: belongsTo(),
});

View File

@ -1,4 +1,5 @@
import config from 'nomad-ui/config/environment';
import { pickOne } from '../utils';
const withNamespaces = getConfigValue('mirageWithNamespaces', false);
const withTokens = getConfigValue('mirageWithTokens', true);
@ -41,6 +42,16 @@ function smallCluster(server) {
server.createList('job', 5);
server.createList('allocFile', 5);
server.create('allocFile', 'dir', { depth: 2 });
server.createList('csi-plugin', 2);
const csiAllocations = server.createList('allocation', 5);
const volumes = server.schema.csiVolumes.all().models;
csiAllocations.forEach(alloc => {
const volume = pickOne(volumes);
volume.writeAllocs.add(alloc);
volume.readAllocs.add(alloc);
volume.save();
});
}
function mediumCluster(server) {

View File

@ -0,0 +1,6 @@
import ApplicationSerializer from './application';
export default ApplicationSerializer.extend({
embed: true,
include: ['nodes', 'controllers'],
});

View File

@ -0,0 +1,28 @@
import ApplicationSerializer from './application';
const groupBy = (list, attr) => {
return list.reduce((group, item) => {
group[item[attr]] = item;
return group;
}, {});
};
export default ApplicationSerializer.extend({
embed: true,
include: ['writeAllocs', 'readAllocs'],
serialize() {
var json = ApplicationSerializer.prototype.serialize.apply(this, arguments);
if (json instanceof Array) {
json.forEach(serializeVolume);
} else {
serializeVolume(json);
}
return json;
},
});
function serializeVolume(volume) {
volume.WriteAllocs = groupBy(volume.WriteAllocs, 'ID');
volume.ReadAllocs = groupBy(volume.ReadAllocs, 'ID');
}

View File

@ -0,0 +1,28 @@
import { module, skip } from 'qunit';
import { setupApplicationTest } from 'ember-qunit';
import { setupMirage } from 'ember-cli-mirage/test-support';
module('Acceptance | volume detail', function(hooks) {
setupApplicationTest(hooks);
setupMirage(hooks);
hooks.beforeEach(function() {});
skip('/csi/volums/:id should have a breadcrumb trail linking back to Volumes and CSI', async function() {});
skip('/csi/volumes/:id should show the volume name in the title', async function() {});
skip('/csi/volumes/:id should list additional details for the volume below the title', async function() {});
skip('/csi/volumes/:id should list all write allocations the volume is attached to', async function() {});
skip('/csi/volumes/:id should list all read allocations the volume is attached to', async function() {});
skip('each allocation should have high-level details forthe allocation', async function() {});
skip('each allocation should link to the allocation detail page', async function() {});
skip('when there are no write allocations, the table presents an empty state', async function() {});
skip('when there are no read allocations, the table presents an empty state', async function() {});
});

View File

@ -0,0 +1,26 @@
import { module, skip } from 'qunit';
import { setupApplicationTest } from 'ember-qunit';
import { setupMirage } from 'ember-cli-mirage/test-support';
module('Acceptance | volumes list', function(hooks) {
setupApplicationTest(hooks);
setupMirage(hooks);
skip('visiting /csi', async function() {
// redirects to /csi/volumes
});
skip('visiting /csi/volumes', async function() {});
skip('/csi/volumes should list the first page of volumes sorted by name', async function() {});
skip('each volume row should contain information about the volume', async function() {});
skip('each volume row should link to the corresponding volume', async function() {});
skip('when there are no volumes, there is an empty message', async function() {});
skip('when the namespace query param is set, only matching volumes are shown and the namespace value is forwarded to app state', async function() {});
skip('when accessing volumes is forbidden, a message is shown with a link to the tokens page', async function() {});
});

View File

@ -0,0 +1,167 @@
import { run } from '@ember/runloop';
import { settled } from '@ember/test-helpers';
import { setupTest } from 'ember-qunit';
import { module, test } from 'qunit';
import { startMirage } from 'nomad-ui/initializers/ember-cli-mirage';
import XHRToken from 'nomad-ui/utils/classes/xhr-token';
module('Unit | Adapter | Volume', function(hooks) {
setupTest(hooks);
hooks.beforeEach(async function() {
this.store = this.owner.lookup('service:store');
this.subject = () => this.store.adapterFor('volume');
window.sessionStorage.clear();
window.localStorage.clear();
this.server = startMirage();
this.initializeUI = async () => {
this.server.create('namespace');
this.server.create('namespace', { id: 'some-namespace' });
this.server.create('node');
this.server.create('job', { id: 'job-1', namespaceId: 'default' });
this.server.create('csi-plugin', 2);
this.server.create('csi-volume', { id: 'volume-1', namespaceId: 'some-namespace' });
this.server.create('region', { id: 'region-1' });
this.server.create('region', { id: 'region-2' });
this.system = this.owner.lookup('service:system');
// Namespace, default region, and all regions are requests that all
// job requests depend on. Fetching them ahead of time means testing
// job adapter behavior in isolation.
await this.system.get('namespaces');
this.system.get('shouldIncludeRegion');
await this.system.get('defaultRegion');
// Reset the handledRequests array to avoid accounting for this
// namespaces request everywhere.
this.server.pretender.handledRequests.length = 0;
};
});
hooks.afterEach(function() {
this.server.shutdown();
});
test('The volume endpoint can be queried by type', async function(assert) {
const { pretender } = this.server;
await this.initializeUI();
this.subject().query(this.store, { modelName: 'volume' }, { type: 'csi' }, null, {});
await settled();
assert.deepEqual(pretender.handledRequests.mapBy('url'), ['/v1/volumes?type=csi']);
});
test('When a namespace is set in localStorage and the volume endpoint is queried, the namespace is in the query string', async function(assert) {
const { pretender } = this.server;
window.localStorage.nomadActiveNamespace = 'some-namespace';
await this.initializeUI();
this.subject().query(this.store, { modelName: 'volume' }, { type: 'csi' }, null, {});
await settled();
assert.deepEqual(pretender.handledRequests.mapBy('url'), [
'/v1/volumes?namespace=some-namespace&type=csi',
]);
});
test('When the volume has a namespace other than default, it is in the URL', async function(assert) {
const { pretender } = this.server;
const volumeName = 'csi/volume-1';
const volumeNamespace = 'some-namespace';
const volumeId = JSON.stringify([volumeName, volumeNamespace]);
await this.initializeUI();
this.subject().findRecord(this.store, { modelName: 'volume' }, volumeId);
await settled();
assert.deepEqual(pretender.handledRequests.mapBy('url'), [
`/v1/volume/${encodeURIComponent(volumeName)}?namespace=${volumeNamespace}`,
]);
});
test('query can be watched', async function(assert) {
await this.initializeUI();
const { pretender } = this.server;
const request = () =>
this.subject().query(this.store, { modelName: 'volume' }, { type: 'csi' }, null, {
reload: true,
adapterOptions: { watch: true },
});
request();
assert.equal(pretender.handledRequests[0].url, '/v1/volumes?type=csi&index=1');
await settled();
request();
assert.equal(pretender.handledRequests[1].url, '/v1/volumes?type=csi&index=2');
await settled();
});
test('query can be canceled', async function(assert) {
await this.initializeUI();
const { pretender } = this.server;
const token = new XHRToken();
pretender.get('/v1/volumes', () => [200, {}, '[]'], true);
this.subject()
.query(this.store, { modelName: 'volume' }, { type: 'csi' }, null, {
reload: true,
adapterOptions: { watch: true, abortToken: token },
})
.catch(() => {});
const { request: xhr } = pretender.requestReferences[0];
assert.equal(xhr.status, 0, 'Request is still pending');
// Schedule the cancelation before waiting
run.next(() => {
token.abort();
});
await settled();
assert.ok(xhr.aborted, 'Request was aborted');
});
test('query and findAll have distinct watchList entries', async function(assert) {
await this.initializeUI();
const { pretender } = this.server;
const request = () =>
this.subject().query(this.store, { modelName: 'volume' }, { type: 'csi' }, null, {
reload: true,
adapterOptions: { watch: true },
});
const findAllRequest = () =>
this.subject().findAll(null, { modelName: 'volume' }, null, {
reload: true,
adapterOptions: { watch: true },
});
request();
assert.equal(pretender.handledRequests[0].url, '/v1/volumes?type=csi&index=1');
await settled();
request();
assert.equal(pretender.handledRequests[1].url, '/v1/volumes?type=csi&index=2');
await settled();
findAllRequest();
assert.equal(pretender.handledRequests[2].url, '/v1/volumes?index=1');
});
});

View File

@ -0,0 +1,349 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
import VolumeModel from 'nomad-ui/models/volume';
module('Unit | Serializer | Volume', function(hooks) {
setupTest(hooks);
hooks.beforeEach(function() {
this.store = this.owner.lookup('service:store');
this.subject = () => this.store.serializerFor('volume');
});
const REF_DATE = new Date();
const normalizationTestCases = [
{
name:
'`default` is used as the namespace in the volume ID when there is no namespace in the payload',
in: {
ID: 'volume-id',
Name: 'volume-id',
PluginID: 'plugin-1',
ExternalID: 'external-uuid',
Topologies: {},
AccessMode: 'access-this-way',
AttachmentMode: 'attach-this-way',
Schedulable: true,
Provider: 'abc.123',
Version: '1.0.29',
ControllerRequired: true,
ControllersHealthy: 1,
ControllersExpected: 1,
NodesHealthy: 1,
NodesExpected: 2,
CreateIndex: 1,
ModifyIndex: 38,
WriteAllocs: {},
ReadAllocs: {},
},
out: {
data: {
id: '["csi/volume-id","default"]',
type: 'volume',
attributes: {
plainId: 'volume-id',
name: 'volume-id',
externalId: 'external-uuid',
topologies: {},
accessMode: 'access-this-way',
attachmentMode: 'attach-this-way',
schedulable: true,
provider: 'abc.123',
version: '1.0.29',
controllerRequired: true,
controllersHealthy: 1,
controllersExpected: 1,
nodesHealthy: 1,
nodesExpected: 2,
createIndex: 1,
modifyIndex: 38,
},
relationships: {
plugin: {
data: {
id: 'csi/plugin-1',
type: 'plugin',
},
},
readAllocations: {
data: [],
},
writeAllocations: {
data: [],
},
},
},
included: [],
},
},
{
name: 'The ID of the record is a composite of both the name and the namespace',
in: {
ID: 'volume-id',
Name: 'volume-id',
Namespace: 'namespace-2',
PluginID: 'plugin-1',
ExternalID: 'external-uuid',
Topologies: {},
AccessMode: 'access-this-way',
AttachmentMode: 'attach-this-way',
Schedulable: true,
Provider: 'abc.123',
Version: '1.0.29',
ControllerRequired: true,
ControllersHealthy: 1,
ControllersExpected: 1,
NodesHealthy: 1,
NodesExpected: 2,
CreateIndex: 1,
ModifyIndex: 38,
WriteAllocs: {},
ReadAllocs: {},
},
out: {
data: {
id: '["csi/volume-id","namespace-2"]',
type: 'volume',
attributes: {
plainId: 'volume-id',
name: 'volume-id',
externalId: 'external-uuid',
topologies: {},
accessMode: 'access-this-way',
attachmentMode: 'attach-this-way',
schedulable: true,
provider: 'abc.123',
version: '1.0.29',
controllerRequired: true,
controllersHealthy: 1,
controllersExpected: 1,
nodesHealthy: 1,
nodesExpected: 2,
createIndex: 1,
modifyIndex: 38,
},
relationships: {
plugin: {
data: {
id: 'csi/plugin-1',
type: 'plugin',
},
},
namespace: {
data: {
id: 'namespace-2',
type: 'namespace',
},
},
readAllocations: {
data: [],
},
writeAllocations: {
data: [],
},
},
},
included: [],
},
},
{
name:
'Allocations are interpreted as embedded records and are properly normalized into included resources in a JSON API shape',
in: {
ID: 'volume-id',
Name: 'volume-id',
Namespace: 'namespace-2',
PluginID: 'plugin-1',
ExternalID: 'external-uuid',
Topologies: {},
AccessMode: 'access-this-way',
AttachmentMode: 'attach-this-way',
Schedulable: true,
Provider: 'abc.123',
Version: '1.0.29',
ControllerRequired: true,
ControllersHealthy: 1,
ControllersExpected: 1,
NodesHealthy: 1,
NodesExpected: 2,
CreateIndex: 1,
ModifyIndex: 38,
WriteAllocs: {
'alloc-id-1': {
TaskGroup: 'foobar',
CreateTime: +REF_DATE * 1000000,
ModifyTime: +REF_DATE * 1000000,
JobID: 'the-job',
Namespace: 'namespace-2',
},
'alloc-id-2': {
TaskGroup: 'write-here',
CreateTime: +REF_DATE * 1000000,
ModifyTime: +REF_DATE * 1000000,
JobID: 'the-job',
Namespace: 'namespace-2',
},
},
ReadAllocs: {
'alloc-id-3': {
TaskGroup: 'look-if-you-must',
CreateTime: +REF_DATE * 1000000,
ModifyTime: +REF_DATE * 1000000,
JobID: 'the-job',
Namespace: 'namespace-2',
},
},
},
out: {
data: {
id: '["csi/volume-id","namespace-2"]',
type: 'volume',
attributes: {
plainId: 'volume-id',
name: 'volume-id',
externalId: 'external-uuid',
topologies: {},
accessMode: 'access-this-way',
attachmentMode: 'attach-this-way',
schedulable: true,
provider: 'abc.123',
version: '1.0.29',
controllerRequired: true,
controllersHealthy: 1,
controllersExpected: 1,
nodesHealthy: 1,
nodesExpected: 2,
createIndex: 1,
modifyIndex: 38,
},
relationships: {
plugin: {
data: {
id: 'csi/plugin-1',
type: 'plugin',
},
},
namespace: {
data: {
id: 'namespace-2',
type: 'namespace',
},
},
readAllocations: {
data: [{ type: 'allocation', id: 'alloc-id-3' }],
},
writeAllocations: {
data: [
{ type: 'allocation', id: 'alloc-id-1' },
{ type: 'allocation', id: 'alloc-id-2' },
],
},
},
},
included: [
{
id: 'alloc-id-1',
type: 'allocation',
attributes: {
createTime: REF_DATE,
modifyTime: REF_DATE,
taskGroupName: 'foobar',
wasPreempted: false,
states: [],
},
relationships: {
followUpEvaluation: {
data: null,
},
job: {
data: { type: 'job', id: '["the-job","namespace-2"]' },
},
nextAllocation: {
data: null,
},
previousAllocation: {
data: null,
},
preemptedAllocations: {
data: [],
},
preemptedByAllocation: {
data: null,
},
},
},
{
id: 'alloc-id-2',
type: 'allocation',
attributes: {
createTime: REF_DATE,
modifyTime: REF_DATE,
taskGroupName: 'write-here',
wasPreempted: false,
states: [],
},
relationships: {
followUpEvaluation: {
data: null,
},
job: {
data: { type: 'job', id: '["the-job","namespace-2"]' },
},
nextAllocation: {
data: null,
},
previousAllocation: {
data: null,
},
preemptedAllocations: {
data: [],
},
preemptedByAllocation: {
data: null,
},
},
},
{
id: 'alloc-id-3',
type: 'allocation',
attributes: {
createTime: REF_DATE,
modifyTime: REF_DATE,
taskGroupName: 'look-if-you-must',
wasPreempted: false,
states: [],
},
relationships: {
followUpEvaluation: {
data: null,
},
job: {
data: { type: 'job', id: '["the-job","namespace-2"]' },
},
nextAllocation: {
data: null,
},
previousAllocation: {
data: null,
},
preemptedAllocations: {
data: [],
},
preemptedByAllocation: {
data: null,
},
},
},
],
},
},
];
normalizationTestCases.forEach(testCase => {
test(`normalization: ${testCase.name}`, async function(assert) {
assert.deepEqual(this.subject().normalize(VolumeModel, testCase.in), testCase.out);
});
});
});