Generic cloud credential and machine drivers

This commit is contained in:
Vincent Fiduccia 2021-06-15 03:11:23 -07:00
parent 7e1b925979
commit 31e0a7973e
No known key found for this signature in database
GPG Key ID: 2B29AD6BB2BB2582
13 changed files with 417 additions and 49 deletions

View File

@ -1106,6 +1106,8 @@ cluster:
rke2-ingress-nginx: 'NGINX Ingress Controller'
rke2-kube-proxy: 'Kube Proxy'
rke2-metrics-server: 'Metrics Server'
selectCredential:
genericDescription: "{vendor} has no built-in support for this driver. We've taken a guess, but consult the driver's documentation for the fields required for authentication."
tabs:
ace: Authorized Endpoint
advanced: Advanced

View File

@ -0,0 +1,75 @@
<script>
import CreateEditView from '@/mixins/create-edit-view';
import KeyValue from '@/components/form/KeyValue';
import Banner from '@/components/Banner';
import { _CREATE } from '@/config/query-params';
import { simplify, iffyFields, likelyFields } from '@/store/plugins';
export default {
components: { KeyValue, Banner },
mixins: [CreateEditView],
props: {
driverName: {
type: String,
required: true,
}
},
data() {
const keyOptions = this.$store.getters['plugins/fieldNamesForDriver'](this.driverName);
if ( this.mode === _CREATE ) {
// Prepopulate empty values for keys that sound like they're cloud-credential-ey
const keys = [];
for ( const k of keyOptions ) {
const sk = simplify(k);
if ( likelyFields.includes(sk) || iffyFields.includes(sk) ) {
keys.push(k);
}
}
for ( const k of keys ) {
if ( !this.value.decodedData[k] ) {
this.value.setData(k, '');
}
}
}
return {
keyOptions,
errors: null,
};
},
watch: {
'value.decodedData'(neu) {
this.$emit('validationChanged', !!neu);
}
},
methods: {
update(val) {
this.value.setData(val);
}
},
};
</script>
<template>
<div>
<Banner color="info" label-key="cluster.selectCredential.genericDescription" class="mt-0" />
<KeyValue
:value="value.decodedData"
:key-options="keyOptions"
:mode="mode"
:read-allowed="true"
:initial-empty-row="true"
@input="update"
/>
</div>
</template>

View File

@ -39,15 +39,15 @@ export function componentForQuestion(q) {
return 'string';
}
export function schemaToQuestions(schema) {
const keys = Object.keys(schema.resourceFields);
export function schemaToQuestions(fields) {
const keys = Object.keys(fields);
const out = [];
for ( const k of keys ) {
out.push({
variable: k,
label: k,
...schema.resourceFields[k],
...fields[k],
});
}
@ -192,12 +192,14 @@ export default {
computed: {
allQuestions() {
if ( this.source.type === 'schema' ) {
return schemaToQuestions(this.source);
} else if ( this.source.questions?.questions ) {
if ( this.source.questions?.questions ) {
return this.chartVersion.questions.questions;
} else if ( this.source.type === 'schema' && this.source.resourceFields ) {
return schemaToQuestions(this.source.resourceFields);
} else if ( typeof this.source === 'object' ) {
return schemaToQuestions(this.source);
} else {
throw new Error('Must specify sourec as a chartVersion or Schema resource');
return [];
}
},

View File

@ -47,7 +47,13 @@ export default {
computed: {
configComponent() {
return importMachineConfig(this.provider);
const haveProviders = this.$store.getters['plugins/machineDrivers'];
if ( haveProviders.includes(this.provider) ) {
return importMachineConfig(this.provider);
}
return importMachineConfig('generic');
}
},
};
@ -84,6 +90,7 @@ export default {
:uuid="uuid"
:mode="mode"
:value="value.config"
:provider="provider"
:credential-id="credentialId"
@error="e=>errors = e"
/>

View File

@ -85,16 +85,9 @@ export default {
driverName() {
let driver = this.provider;
const azureDrivers = ['azure', 'aks'];
// Map providers that share a common credential to one driver
if ( driver === 'amazonec2' || driver === 'amazoneks' ) {
driver = 'aws';
}
if ( azureDrivers.includes(driver) ) {
driver = 'azure';
}
driver = this.$store.getters['plugins/credentialDriverFor'](driver);
return driver;
},
@ -135,7 +128,13 @@ export default {
},
createComponent() {
return importCloudCredential(this.driverName);
const haveDrivers = this.$store.getters['plugins/credentialDrivers'];
if ( haveDrivers.includes(this.driverName) ) {
return importCloudCredential(this.driverName);
}
return importCloudCredential('generic');
},
validationPassed() {
@ -244,6 +243,8 @@ export default {
:is="createComponent"
ref="create"
v-model="newCredential"
mode="create"
:driver-name="driverName"
@validationChanged="createValidationChanged"
/>
</div>

View File

@ -175,11 +175,8 @@ export default {
const out = [];
const templates = this.templateOptions;
const vueMachineTypes = getters['plugins/machineDrivers'];
const vueKontainerTypes = getters['plugins/clusterDrivers'];
const machineTypes = this.nodeDrivers.filter(x => x.spec.active).map((x) => {
return !x.spec.builtin ? x.spec.displayName : x.id;
});
const machineTypes = this.nodeDrivers.filter(x => x.spec.active && x.state === 'active').map(x => x.spec.displayName || x.id);
this.kontainerDrivers.filter(x => (isImport ? x.showImport : x.showCreate)).forEach((obj) => {
if ( vueKontainerTypes.includes(obj.driverName) ) {
@ -210,7 +207,7 @@ export default {
addType('custom', 'custom1', false, '/g/clusters/add/launch/custom');
} else {
machineTypes.forEach((id) => {
addType(id, 'rke2', !vueMachineTypes.includes(id));
addType(id, 'rke2', false);
});
addType('custom', 'custom2', false);
@ -263,6 +260,10 @@ export default {
entry.types.push(row);
}
for ( const k in out ) {
out[k].types = sortBy(out[k].types, 'label');
}
return sortBy(Object.values(out), 'sort');
},
},

View File

@ -1157,7 +1157,7 @@ export default {
v-if="versionInfo[v.name].questions"
v-model="chartValues[v.name]"
:mode="mode"
:chart-version="versionInfo[v.name]"
:source="versionInfo[v.name]"
:target-namespace="value.metadata.namespace"
/>
<YamlEditor

View File

@ -1,20 +1,23 @@
<script>
import { TYPES } from '@/models/secret';
import { NAMESPACE } from '@/config/types';
import { MANAGEMENT, NAMESPACE } from '@/config/types';
import CreateEditView from '@/mixins/create-edit-view';
import NameNsDescription from '@/components/form/NameNsDescription';
import CruResource from '@/components/CruResource';
import { CLOUD_CREDENTIAL, _CREATE, _EDIT, _FLAGGED } from '@/config/query-params';
import Loading from '@/components/Loading';
import Tabbed from '@/components/Tabbed';
import Tab from '@/components/Tabbed/Tab';
import Labels from '@/components/form/Labels';
import { HIDE_SENSITIVE } from '@/store/prefs';
import { CAPI } from '@/config/labels-annotations';
import { clear } from '@/utils/array';
import { clear, uniq } from '@/utils/array';
import { importCloudCredential } from '@/utils/dynamic-importer';
import { NAME as MANAGER } from '@/config/product/manager';
import SelectIconGrid from '@/components/SelectIconGrid';
import { DEFAULT_WORKSPACE } from '@/models/provisioning.cattle.io.cluster';
import { sortBy } from '@/utils/sort';
import { ucFirst } from '@/utils/string';
const creatableTypes = [
TYPES.OPAQUE,
@ -28,6 +31,7 @@ export default {
name: 'CruSecret',
components: {
Loading,
NameNsDescription,
CruResource,
Tabbed,
@ -38,6 +42,12 @@ export default {
mixins: [CreateEditView],
async fetch() {
if ( this.isCloud ) {
this.nodeDrivers = await this.$store.dispatch('management/findAll', { type: MANAGEMENT.NODE_DRIVER });
}
},
data() {
const newCloudCred = this.$route.query[CLOUD_CREDENTIAL] === _FLAGGED;
const editCloudCred = this.mode === _EDIT && this.value._type === TYPES.CLOUD_CREDENTIAL;
@ -47,7 +57,11 @@ export default {
this.value.metadata.namespace = DEFAULT_WORKSPACE;
}
return { isCloud };
return {
isCloud,
nodeDrivers: null,
};
},
computed: {
@ -70,14 +84,21 @@ export default {
return require(`@/edit/secret/${ this.typeKey }`).default;
},
cloudComponent() {
driverName() {
const driver = this.value.metadata?.annotations?.[CAPI.CREDENTIAL_DRIVER];
if ( driver ) {
return driver;
},
cloudComponent() {
const driver = this.driverName;
const haveProviders = this.$store.getters['plugins/credentialDrivers'];
if ( haveProviders.includes(driver) ) {
return importCloudCredential(driver);
}
return null;
return importCloudCredential('generic');
},
// array of id, label, description, initials for type selection step
@ -86,7 +107,13 @@ export default {
// Cloud credentials
if ( this.isCloud ) {
for ( const id of this.$store.getters['plugins/credentialDrivers'] ) {
const machineTypes = uniq(this.nodeDrivers
.filter(x => x.spec.active)
.map(x => x.spec.displayName || x.id)
.map(x => this.$store.getters['plugins/credentialDriverFor'](x))
);
for ( const id of machineTypes ) {
let bannerImage, bannerAbbrv;
try {
@ -114,7 +141,7 @@ export default {
}
}
return out;
return sortBy(out, 'label');
},
namespaces() {
@ -206,7 +233,7 @@ export default {
},
initialDisplayFor(type) {
const fallback = (this.typeDisplay(type) || '').replace(/[^A-Z]/g, '') || type;
const fallback = (ucFirst(this.typeDisplay(type) || '').replace(/[^A-Z]/g, '') || type).substr(0, 3);
return this.$store.getters['i18n/withFallback'](`secret.initials."${ type }"`, null, fallback);
},
@ -216,7 +243,9 @@ export default {
<template>
<form>
<Loading v-if="$fetchState.pending" />
<CruResource
v-else
:mode="mode"
:validation-passed="true"
:selected-subtype="value._type"
@ -235,6 +264,7 @@ export default {
:is="cloudComponent"
v-if="isCloud"
ref="cloudComponent"
:driver-name="driverName"
:value="value"
:mode="mode"
:hide-sensitive-data="hideSensitiveData"

103
machine-config/generic.vue Normal file
View File

@ -0,0 +1,103 @@
<script>
import Loading from '@/components/Loading';
import Banner from '@/components/Banner';
import CreateEditView from '@/mixins/create-edit-view';
import { exceptionToErrorsArray, stringify } from '@/utils/error';
import { SECRET } from '@/config/types';
import Questions from '@/components/Questions';
import { iffyFields, simplify } from '@/store/plugins';
import { isEmpty } from '@/utils/object';
export default {
components: {
Loading, Banner, Questions
},
mixins: [CreateEditView],
props: {
credentialId: {
type: String,
required: true,
},
provider: {
type: String,
required: true,
}
},
async fetch() {
this.errors = [];
try {
this.credential = await this.$store.dispatch('management/find', { type: SECRET, id: this.credentialId });
this.fields = this.$store.getters['plugins/fieldsForDriver'](this.provider);
const name = `rke-machine-config.cattle.io.${ this.provider }config`;
if ( !this.fields ) {
throw new Error(`Machine Driver config schema not found for ${ name }`);
}
} catch (e) {
this.errors = exceptionToErrorsArray(e);
}
},
data() {
return {
errors: null,
fields: null,
};
},
computed: {
cloudCredentialKeys() {
const out = [];
const data = this.credential?.decodedData || {};
for ( const k in data ) {
if ( isEmpty(data[k]) || iffyFields.includes(simplify(k)) ) {
continue;
}
out.push(k);
}
return out;
}
},
watch: {
'credentialId'() {
this.$fetch();
},
},
methods: { stringify },
};
</script>
<template>
<Loading v-if="$fetchState.pending" :delayed="true" />
<div v-else-if="errors.length">
<div
v-for="(err, idx) in errors"
:key="idx"
>
<Banner
color="error"
:label="stringify(err)"
/>
</div>
</div>
<div v-else>
<Questions
v-model="value"
:mode="mode"
:tabbed="false"
:source="fields"
:ignore-variables="cloudCredentialKeys"
:target-namespace="value.metadata.namespace"
/>
</div>
</template>

View File

@ -3,8 +3,9 @@ import { CAPI, CERTMANAGER, KUBERNETES } from '@/config/labels-annotations';
import { base64Decode, base64Encode } from '@/utils/crypto';
import { removeObjects } from '@/utils/array';
import { SERVICE_ACCOUNT } from '@/config/types';
import { set } from '@/utils/object';
import { isEmpty, set } from '@/utils/object';
import { escapeHtml } from '@/utils/string';
import { fullFields, prefixFields, simplify, suffixFields } from '@/store/plugins';
export const TYPES = {
OPAQUE: 'Opaque',
@ -312,13 +313,25 @@ export default {
},
setData() {
return (key, value) => {
if ( !this.data ) {
return (key, value) => { // or (mapOfNewData)
const isMap = key && typeof key === 'object';
if ( !this.data || isMap ) {
set(this, 'data', {});
}
// The key is quoted so that keys like '.dockerconfigjson' that contain dot don't get parsed into an object path
set(this.data, `"${ key }"`, base64Encode(value));
let neu;
if ( isMap ) {
neu = key;
} else {
neu = { [key]: value };
}
for ( const k in neu ) {
// The key is quoted so that keys like '.dockerconfigjson' that contain dot don't get parsed into an object path
set(this.data, `"${ k }"`, base64Encode(neu[k]));
}
};
},
@ -333,7 +346,33 @@ export default {
},
cloudCredentialPublicData() {
const { publicKey, publicMode } = this.$rootGetters['plugins/credentialOptions'](this.cloudCredentialProvider);
let { publicKey, publicMode } = this.$rootGetters['plugins/credentialOptions'](this.cloudCredentialProvider);
const options = {
full: fullFields,
prefix: prefixFields,
suffix: suffixFields,
};
if ( !publicKey ) {
for ( const k in this.decodedData || {} ) {
if ( publicKey ) {
break;
}
if ( isEmpty(this.decodedData[k]) ) {
continue;
}
for ( const mode in options ) {
if ( options[mode].includes( simplify(k) ) ) {
publicKey = k;
publicMode = mode;
break;
}
}
}
}
if ( !publicKey ) {
return;

View File

@ -1112,7 +1112,7 @@ export default {
<Questions
v-model="chartValues"
:mode="mode"
:chart-version="versionInfo"
:source="versionInfo"
:target-namespace="targetNamespace"
/>
</Tabbed>

View File

@ -1,4 +1,5 @@
<script>
import Loading from '@/components/Loading';
import ResourceTable from '@/components/ResourceTable';
import Masthead from '@/components/ResourceList/Masthead';
import { SECRET } from '@/config/types';
@ -6,7 +7,9 @@ import { AGE, NAME, STATE } from '@/config/table-headers';
import { CLOUD_CREDENTIAL, _FLAGGED } from '@/config/query-params';
export default {
components: { ResourceTable, Masthead },
components: {
Loading, ResourceTable, Masthead
},
async fetch() {
this.allSecrets = await this.$store.dispatch('management/findAll', { type: SECRET });
@ -14,9 +17,9 @@ export default {
data() {
return {
allSecrets: [],
resource: SECRET,
schema: this.$store.getters['management/schemaFor'](SECRET),
allSecrets: null,
resource: SECRET,
schema: this.$store.getters['management/schemaFor'](SECRET),
};
},
@ -68,7 +71,8 @@ export default {
</script>
<template>
<div>
<Loading v-if="$fetchState.pending" />
<div v-else>
<Masthead
:schema="schema"
:resource="resource"

View File

@ -1,14 +1,72 @@
import { removeObjects } from '@/utils/array';
export function simplify(key) {
return key.toLowerCase().replace(/[^a-z0-9]/ig, '');
}
const credentialOptions = {
aws: { publicKey: 'accessKey', publicMode: 'full' },
digitalocean: { publicKey: 'accessToken', publicMode: 'prefix' },
azure: { publicKey: 'clientId', publicMode: 'full' },
aws: {
publicKey: 'accessKey',
publicMode: 'full',
keys: ['region', 'accessKey', 'secretKey']
},
digitalocean: {
publicKey: 'accessToken',
publicMode: 'prefix',
keys: 'accessToken'
},
azure: {
publicKey: 'clientId',
publicMode: 'full',
keys: ['subscriptionId', 'tenantId', 'clientId', 'clientSecret']
},
};
const driverMap = {
amazonec2: 'aws',
amazoneks: 'aws',
aks: 'azure',
};
export const likelyFields = [
'username', 'password',
'accesskey', 'secretkey',
'accesskeyid', 'secretkeyid', 'accesskeysecret',
'token', 'apikey',
'secret',
'clientid', 'clientsecret', 'subscriptionid', 'tenantid',
].map(x => simplify(x));
export const iffyFields = [
'location', 'region',
].map(x => simplify(x));
export const fullFields = [
'username',
'accesskey',
'accesskeyid',
'clientid'
].map(x => simplify(x));
export const prefixFields = [
'token',
'apikey',
'secret',
].map(x => simplify(x));
export const suffixFields = [
].map(x => simplify(x));
// Dynamically loaded drivers can call this eventually to register thier options
export function configureCredential(name, opt) {
credentialOptions[name] = opt;
}
// Map a driver to a different credential name, e.g. amazonec2 and amazoneks both use the 'aws' credential type.
export function mapDriver(name, to) {
driverMap[name] = to;
}
export const state = function() {
return {};
};
@ -28,8 +86,14 @@ export const getters = {
};
},
credentialDriverFor() {
return (name) => {
return driverMap[name] || name;
};
},
machineDrivers() {
// The subset of drivers supported by Vue
// The subset of drivers supported by Vue components
const ctx = require.context('@/machine-config', true, /.*/);
const drivers = ctx.keys().filter(path => !path.match(/\.(vue|js)$/)).map(path => path.substr(2));
@ -38,7 +102,47 @@ export const getters = {
},
clusterDrivers() {
// The subset of drivers supported by Vue
// The subset of drivers supported by Vue components
return [];
},
schemaForDriver(state, getters, rootState, rootGetters) {
return (name) => {
const id = `rke-machine-config.cattle.io.${ name }config`;
const schema = rootGetters['management/schemaFor'](id);
return schema;
};
},
fieldNamesForDriver(state, getters) {
return (name) => {
const schema = getters.schemaForDriver(name);
if ( !schema ) {
throw new Error(`Machine Driver Config schema not found for ${ name }`);
}
const out = Object.keys(schema?.resourceFields || {});
removeObjects(out, ['apiVersion', 'dockerPort', 'kind', 'metadata']);
return out;
};
},
fieldsForDriver(state, getters) {
return (name) => {
const schema = getters.schemaForDriver(name);
const names = getters.fieldNamesForDriver(name);
const out = {};
for ( const n of names ) {
out[n] = schema.resourceFields[n];
}
return out;
};
},
};