ui: Ensure dc selector correctly shows the currently selected dc (#11380)

* ui: Ensure dc selector correctly shows the currently selected dc

* ui: Restrict access to non-default partitions in non-primaries (#11420)

This PR restricts access via the UI to only the default partition when in a non-primary datacenter i.e. you can only have multiple (non-default) partitions in the primary datacenter.
This commit is contained in:
John Cowen 2021-10-26 19:26:04 +01:00 committed by GitHub
parent d764bac6af
commit 4dd7e34c96
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 190 additions and 98 deletions

3
.changelog/11380.txt Normal file
View File

@ -0,0 +1,3 @@
```release-note:bug
ui: Ensure dc selector correctly shows the currently selected dc
```

View File

@ -1,4 +1,4 @@
{{#if (can "choose partitions")}}
{{#if (can "choose partitions" dc=@dc)}}
<li
class="partitions"
data-test-partition-menu
@ -49,5 +49,12 @@
</BlockSlot>
</PopoverMenu>
</li>
{{else}}
<li
class="partition"
aria-label="Admin Partition"
>
{{@partition}}
</li>
{{/if}}

View File

@ -20,7 +20,10 @@ export default class PartitionAbility extends BaseAbility {
}
get canChoose() {
return this.canUse;
if(typeof this.dc === 'undefined') {
return false;
}
return this.canUse && this.dc.Primary;
}
get canUse() {

View File

@ -51,7 +51,7 @@
{{/if}}
</label>
{{/if}}
{{#if (can 'choose partitions')}}
{{#if (can 'choose partitions' dc=@dc)}}
<label data-test-source-partition class="type-select{{if item.error.SourcePartition ' has-error'}}">
<span>Source Partition</span>
<PowerSelectWithCreate
@ -123,7 +123,7 @@
{{/if}}
</label>
{{/if}}
{{#if (can 'choose partitions')}}
{{#if (can 'choose partitions' dc=@dc)}}
<label data-test-destination-partition class="type-select{{if item.error.DestinationPartition ' has-error'}}">
<span>Destination Partition</span>
<PowerSelectWithCreate

View File

@ -4,7 +4,7 @@
>
<DataForm
@type="intention"
@dc={{@dc}}
@dc={{@dc.Name}}
@nspace={{@nspace}}
@partition={{@partition}}
@autofill={{@autofill}}
@ -77,7 +77,7 @@ as |api|>
@src={{uri '/${partition}/*/${dc}/services'
(hash
partition=@partition
dc=@dc
dc=@dc.Name
)
}}
@onchange={{action this.createServices item}}
@ -88,7 +88,7 @@ as |api|>
@src={{uri '/${partition}/*/${dc}/namespaces'
(hash
partition=@partition
dc=@dc
dc=@dc.Name
)
}}
@onchange={{action this.createNspaces item}}
@ -99,7 +99,7 @@ as |api|>
<DataSource
@src={{uri '/*/*/${dc}/partitions'
(hash
dc=@dc
dc=@dc.Name
)
}}
@onchange={{action this.createPartitions item}}
@ -114,6 +114,7 @@ as |api|>
>
<Consul::Intention::Form::Fieldsets
@nspaces={{this.nspaces}}
@dc={{@dc}}
@partitions={{this.partitions}}
@services={{this.services}}
@SourceName={{this.SourceName}}

View File

@ -33,11 +33,18 @@
{{#each (sort-by 'Name' @dcs) as |item|}}
<MenuItem
data-test-datacenter-picker
class={{concat (if (eq @dc.Name item.Name) 'is-active') (if item.Local ' is-local') }}
class={{concat
(if (eq @dc.Name item.Name) 'is-active')
(if item.Local ' is-local')
(if item.Primary ' is-primary')
}}
@href={{href-to '.' params=(hash dc=item.Name)}}
>
<BlockSlot @name="label">
{{item.Name}}
{{#if item.Primary}}
<span>Primary</span>
{{/if}}
{{#if item.Local}}
<span>Local</span>
{{/if}}

View File

@ -1,4 +1,4 @@
.hashicorp-consul {
%hashicorp-consul {
[role='banner'] nav .dcs {
@extend %main-nav-vertical-hoisted;
left: 100px;
@ -27,3 +27,6 @@
margin-left: 2px;
}
}
.hashicorp-consul {
@extend %hashicorp-consul;
}

View File

@ -1,6 +1,15 @@
@import './skin';
@import './layout';
/* things that should look like nav buttons */
/* items are single things that look like button */
/* but aren't clickable */
%main-nav-vertical > ul > li[aria-label] {
@extend %main-nav-vertical-item;
}
/**/
/* actual clickable button-y things plus states */
%main-nav-vertical > ul > li > a {
@extend %main-nav-vertical-action;
}
@ -21,6 +30,8 @@
%main-nav-vertical > ul > li > label {
@extend %main-nav-vertical-action;
}
/**/
%main-nav-vertical .popover-menu {
margin-top: 0.5rem;
}

View File

@ -12,11 +12,15 @@
visibility: hidden;
}
%main-nav-vertical li.partitions,
%main-nav-vertical li.nspaces,
%main-nav-vertical li.dcs {
%main-nav-vertical li.partition,
%main-nav-vertical li.nspaces {
margin-bottom: 25px;
padding: 0 26px;
}
%main-nav-vertical li.dcs {
margin-bottom: 18px;
padding: 0 18px;
}
// TODO: We no longer have the rule that menu-panel buttons only contain two
// items, left and right aligned. We should remove this and look to use
// align-self for anything that needs right aligning instead.

View File

@ -32,6 +32,7 @@
%main-nav-vertical-action {
color: rgb(var(--tone-gray-800));
}
%main-nav-vertical-item,
%main-nav-vertical-action-intent,
%main-nav-vertical-action-active {
color: rgb(var(--tone-gray-999));
@ -40,13 +41,16 @@
background-color: rgb(var(--tone-gray-150));
border-color: rgb(var(--tone-gray-999));
}
%main-nav-vertical li[aria-label]::before,
%main-nav-vertical .popover-menu[aria-label]::before {
color: rgb(var(--tone-gray-700));
content: attr(aria-label);
display: block;
margin-top: -0.5rem;
margin-bottom: 0.5rem;
}
%main-nav-vertical .is-local span:last-of-type {
%main-nav-vertical .is-primary span,
%main-nav-vertical .is-local span {
@extend %pill-200;
color: rgb(var(--tone-gray-000));
background-color: rgb(var(--tone-gray-500));

View File

@ -2,6 +2,7 @@
{{will-destroy this.disconnect}}
<section
{{did-insert (fn this.attributeChanged 'element')}}
{{did-update (fn this.attributeChanged 'model' @model)}}
class="outlet"
data-outlet={{@name}}
data-route={{this.routeName}}

View File

@ -22,6 +22,8 @@ export default class Outlet extends Component {
@tracked previousState;
@tracked endTransition;
@tracked route;
get model() {
return this.args.model || {};
}
@ -52,6 +54,9 @@ export default class Outlet extends Component {
this.setAppRoute(this.router.currentRouteName);
}
break;
case 'model':
this.route._model = this.args.model;
break;
}
}

View File

@ -2,10 +2,8 @@ import Helper from '@ember/component/helper';
import { getOwner } from '@ember/application';
export default class CachedHelper extends Helper {
compute([model], hash) {
return () => {
const container = getOwner(this);
return container.lookup(`service:repository/${model}`).cached(hash);
};
compute([model, params], hash) {
const container = getOwner(this);
return container.lookup(`service:repository/${model}`).cached(params);
}
}

View File

@ -8,6 +8,7 @@ export default class Datacenter extends Model {
@attr('string') uid;
@attr('string') Name;
@attr('boolean') Local;
@attr('boolean') Primary;
@attr('string') DefaultACLPolicy;
@attr('boolean', { defaultValue: () => true }) MeshEnabled;

View File

@ -1,31 +1,42 @@
import { inject as service } from '@ember/service';
import Serializer from './application';
import { inject as service } from '@ember/service';
import { PRIMARY_KEY, SLUG_KEY } from 'consul-ui/models/dc';
import {
HEADERS_SYMBOL,
HEADERS_DEFAULT_ACL_POLICY as DEFAULT_ACL_POLICY,
} from 'consul-ui/utils/http/consul';
export default class DcSerializer extends Serializer {
@service('env') env;
primaryKey = 'Name';
primaryKey = PRIMARY_KEY;
slugKey = SLUG_KEY;
// datacenters come in as an array of plain strings. Convert to objects
// instead and collect all the other datacenter info from other places and
// add it to each datacenter object
respondForQuery(respond, query) {
return respond(function(headers, body) {
return {
body,
headers,
};
});
}
return super.respondForQuery(
cb => respond((headers, body) => {
body = body.map(item => ({
Datacenter: '',
[this.slugKey]: item,
}));
body = cb(headers, body);
headers = body[HEADERS_SYMBOL];
normalizePayload(payload, id, requestType) {
switch (requestType) {
case 'query':
return payload.body.map(item => {
return {
Local: this.env.var('CONSUL_DATACENTER_LOCAL') === item,
[this.primaryKey]: item,
DefaultACLPolicy: payload.headers['x-consul-default-acl-policy'],
};
});
}
return payload;
const Local = this.env.var('CONSUL_DATACENTER_LOCAL');
const Primary = this.env.var('CONSUL_DATACENTER_PRIMARY');
const DefaultACLPolicy = headers[DEFAULT_ACL_POLICY.toLowerCase()];
return body.map(item => ({
...item,
Local: item.Name === Local,
Primary: item.Name === Primary,
DefaultACLPolicy: DefaultACLPolicy,
}));
}),
query
);
}
}

View File

@ -124,6 +124,10 @@ export default class RepositoryService extends Service {
return this.store.peekRecord(this.getModelName(), id);
}
peekAll() {
return this.store.peekAll(this.getModelName());
}
cached(params) {
const entries = Object.entries(params);
return this.store.peekAll(this.getModelName()).filter(item => {

View File

@ -15,8 +15,10 @@ export default class DiscoveryChainService extends RepositoryService {
@dataSource('/:partition/:ns/:dc/discovery-chain/:id')
findBySlug(params, configuration = {}) {
const datacenter = this.dcs.peekOne(params.dc);
if (datacenter !== null && !get(datacenter, 'MeshEnabled')) {
// peekAll and find is fine here as datacenter count should be relatively
// low, and DCs are the top bucket (when talking dc's partitions, nspaces)
const datacenter = this.dcs.peekAll().findBy('Name', params.dc);
if (typeof datacenter !== 'undefined' && !get(datacenter, 'MeshEnabled')) {
return Promise.resolve();
}
return super.findBySlug(...arguments).catch(e => {
@ -24,7 +26,7 @@ export default class DiscoveryChainService extends RepositoryService {
const body = (get(e, 'errors.firstObject.detail') || '').trim();
switch (code) {
case '500':
if (datacenter !== null && body.endsWith(ERROR_MESH_DISABLED)) {
if (typeof datacenter !== 'undefined' && body.endsWith(ERROR_MESH_DISABLED)) {
set(datacenter, 'MeshEnabled', false);
}
return;

View File

@ -135,6 +135,7 @@ export default class RoutletService extends Service {
const outlet = outlets.get(keys[key]);
if (typeof outlet !== 'undefined') {
route._model = outlet.model;
outlet.route = route;
// TODO: Try to avoid the double computation bug
schedule('afterRender', () => {
outlet.routeName = route.args.name;

View File

@ -73,39 +73,45 @@ as |dcs|>
{{! Once we have a list of DCs make sure the DC we are asking for exists }}
{{! If not use the DC that the UI is running in }}
{{#let
(or
(get (object-at 0 (cached-model
'dc'
(hash
Name=notfound.dc
(if nofound.dc
(object-at 0 (cached-model
'dc'
(hash
Name=notfound.dc
)
)
)) 'Name')
)
)
(get (object-at 0 (cached-model
'dc'
(object-at 0 (cached-model
'dc'
(hash
Name=route.params.dc
)
)) 'Name')
)
)
(env "CONSUL_DATACENTER_LOCAL")
(hash
Name=(env "CONSUL_DATACENTER_LOCAL")
)
)
dcs.data
as |dc dcs|}}
{{#if (and (gt dc.length 0) dcs nspace partition)}}
{{#if (and (gt dc.Name.length 0) dcs nspace partition)}}
{{! figure out our current DC and convert it to a model }}
<DataSource
@src={{uri '/${partition}/*/${dc}/datacenter/${name}'
(hash
dc=dc
dc=dc.Name
partition=partition
name=dc
name=dc.Name
)
}}
as |dc|>

View File

@ -45,7 +45,7 @@ as |item|}}
<BlockSlot @name="content">
<Consul::Intention::Form
@item={{item}}
@dc={{route.params.dc}}
@dc={{route.model.dc}}
@nspace={{route.params.nspace}}
@partition={{route.params.partition}}
@onsubmit={{route-action 'transitionTo' 'dc.intentions.index'

View File

@ -1,7 +1,6 @@
<Route
@name={{routeName}}
as |route|>
<DataLoader
@src={{uri '/${partition}/${nspace}/${dc}/service-instances/for-service/${name}'
(hash
@ -69,7 +68,7 @@ as |items item dc|}}
{{! and use this to set MeshEnabled on the Datacenter }}
{{! if once chain is set, i.e. we've checked this dc we remove the DataSource }}
{{! which will mark it for closure, which possibly could be reopened if }}
{{! the user clicks the routing/disco-chain tab}}
{{! the user clicks the routing/disco-chain tab}}
{{#if (not chain)}}
<DataSource
@src={{uri '/${partition}/${nspace}/${dc}/discovery-chain/${name}'
@ -83,6 +82,7 @@ as |items item dc|}}
@onchange={{action (mut chain) value="data"}}
/>
{{/if}}
{{did-insert (set this 'chain' undefined) route.params.dc}}
{{/if}}
{{#let
(hash

View File

@ -2,10 +2,11 @@ import { get } from '@ember/object';
export default function(foreignKey, nspaceKey, partitionKey, hash = JSON.stringify) {
return function(primaryKey, slugKey, foreignKeyValue, nspaceValue, partitionValue) {
if (foreignKeyValue == null || foreignKeyValue.length < 1) {
throw new Error('Unable to create fingerprint, missing foreignKey value');
}
return function(item) {
foreignKeyValue = foreignKeyValue == null ? item[foreignKey] : foreignKeyValue;
if (foreignKeyValue == null) {
throw new Error('Unable to create fingerprint, missing foreignKey value');
}
const slugKeys = slugKey.split(',');
const slugValues = slugKeys.map(function(slugKey) {
if (get(item, slugKey) == null || get(item, slugKey).length < 1) {

View File

@ -2,6 +2,7 @@
export const HEADERS_PARTITION = 'X-Consul-Partition';
export const HEADERS_NAMESPACE = 'X-Consul-Namespace';
export const HEADERS_DATACENTER = 'X-Consul-Datacenter';
export const HEADERS_DEFAULT_ACL_POLICY = 'X-Consul-Default-Acl-Policy';
export const HEADERS_INDEX = 'X-Consul-Index';
export const HEADERS_TOKEN = 'X-Consul-Token';
export const HEADERS_DIGEST = 'X-Consul-ContentHash';

View File

@ -4,4 +4,4 @@
"*":
headers:
response:
x-consul-default-acl-policy: ${env('CONSUL_ACL_POLICY', fake.helpers.randomize(['allow', 'deny']))}
X-Consul-Default-Acl-Policy: ${env('CONSUL_ACL_POLICY', fake.helpers.randomize(['allow', 'deny']))}

View File

@ -36,7 +36,7 @@ Feature: dc / services / instances / gateway: Show Gateway Service Instance
When I click addresses on the tabs
And I see addressesIsSelected on the tabs
And I see 2 of the addresses object
And I see address on the addresses like yaml
And I see address on the addresses vertically like yaml
---
- 127.0.0.1:8080
- 92.68.0.0:8081

View File

@ -44,18 +44,19 @@ Feature: dc / services / show-routing: Show Routing for Service
---
And I don't see routing on the tabs
And I don't see the "[data-test-error]" element
# FIXME: Does one dc not having connect and another having connect ever actually happen
# And I visit the service page for yaml
# ---
# dc: dc2
# service: service-1
# ---
# And I see routing on the tabs
# And I visit the service page for yaml
# ---
# dc: dc1
# service: service-0
# ---
# Then a GET request wasn't made to "/v1/discovery-chain/service-0?dc=dc1&ns=@namespace"
# And I don't see routing on the tabs
# And I don't see the "[data-test-error]" element
# Not entirely sure if having one dc not having connect
# and another having connect ever actually happen
And I visit the service page for yaml
---
dc: dc2
service: service-1
---
And I see routing on the tabs
And I visit the service page for yaml
---
dc: dc1
service: service-0
---
Then a GET request wasn't made to "/v1/discovery-chain/service-0?dc=dc1&ns=@namespace"
And I don't see routing on the tabs
And I don't see the "[data-test-error]" element

View File

@ -1,24 +1,43 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
import { get } from 'consul-ui/tests/helpers/api';
import {
HEADERS_DEFAULT_ACL_POLICY as DEFAULT_ACL_POLICY,
} from 'consul-ui/utils/http/consul';
module('Integration | Serializer | dc', function(hooks) {
setupTest(hooks);
test('respondForQuery returns the correct data for list endpoint', function(assert) {
const serializer = this.owner.lookup('serializer:dc');
let env = this.owner.lookup('service:env');
env = env.var.bind(env);
const request = {
url: `/v1/catalog/datacenters`,
};
return get(request.url).then(function(payload) {
const expected = {
body: payload,
headers: {},
};
const ALLOW = 'allow';
const expected = payload.map(item => (
{
Name: item,
Datacenter: '',
Local: item === env('CONSUL_DATACENTER_LOCAL'),
Primary: item === env('CONSUL_DATACENTER_PRIMARY'),
DefaultACLPolicy: ALLOW
}
))
const actual = serializer.respondForQuery(function(cb) {
const headers = {};
const body = payload;
return cb(headers, body);
const headers = {
[DEFAULT_ACL_POLICY]: ALLOW
};
return cb(headers, payload);
}, {
dc: '*',
});
actual.forEach((item, i) => {
assert.equal(actual[i].Name, expected[i].Name);
assert.equal(actual[i].Local, expected[i].Local);
assert.equal(actual[i].Primary, expected[i].Primary);
assert.equal(actual[i].DefaultACLPolicy, expected[i].DefaultACLPolicy);
});
assert.deepEqual(actual, expected);
});
});
});

View File

@ -21,12 +21,10 @@ test('findAll returns the correct data for list endpoint', function(assert) {
return service.findAll();
},
function performAssertion(actual, expected) {
assert.deepEqual(
actual,
expected(function(payload) {
return payload.map((item, i) => ({ Name: item, Local: i === 0 ? true : false }));
})
);
actual.forEach((item, i) => {
assert.equal(actual[i].Name, item.Name);
assert.equal(item.Local, i === 0);
});
}
);
});

View File

@ -37,7 +37,7 @@ module('Unit | Utility | create fingerprinter', function() {
const fingerprint = createFingerprinter('Datacenter', 'Namespace', 'Partition');
[undefined, null].forEach(function(item) {
assert.throws(function() {
fingerprint('uid', 'ID', item);
fingerprint('uid', 'ID', item)({Datacenter: item});
}, /missing foreignKey/);
});
});