Merge pull request #3632 from vincent99/master

[Master] Use Norman cloud creds for all the things
This commit is contained in:
Vincent Fiduccia 2021-08-04 01:25:37 -07:00 committed by GitHub
commit d3f40fef23
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 806 additions and 167 deletions

View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="206" viewBox="0 0 256 206">
<path fill="#EA4335" d="M170.2517,56.8186 L192.5047,34.5656 L193.9877,25.1956 C153.4367,-11.6774 88.9757,-7.4964 52.4207,33.9196 C42.2667,45.4226 34.7337,59.7636 30.7167,74.5726 L38.6867,73.4496 L83.1917,66.1106 L86.6277,62.5966 C106.4247,40.8546 139.8977,37.9296 162.7557,56.4286 L170.2517,56.8186 Z"/>
<path fill="#4285F4" d="M224.2048,73.9182 C219.0898,55.0822 208.5888,38.1492 193.9878,25.1962 L162.7558,56.4282 C175.9438,67.2042 183.4568,83.4382 183.1348,100.4652 L183.1348,106.0092 C198.4858,106.0092 210.9318,118.4542 210.9318,133.8052 C210.9318,149.1572 198.4858,161.2902 183.1348,161.2902 L127.4638,161.2902 L121.9978,167.2242 L121.9978,200.5642 L127.4638,205.7952 L183.1348,205.7952 C223.0648,206.1062 255.6868,174.3012 255.9978,134.3712 C256.1858,110.1682 244.2528,87.4782 224.2048,73.9182"/>
<path fill="#34A853" d="M71.8704,205.7957 L127.4634,205.7957 L127.4634,161.2897 L71.8704,161.2897 C67.9094,161.2887 64.0734,160.4377 60.4714,158.7917 L52.5844,161.2117 L30.1754,183.4647 L28.2234,191.0387 C40.7904,200.5277 56.1234,205.8637 71.8704,205.7957"/>
<path fill="#FBBC05" d="M71.8704,61.4250342 C31.9394,61.6635 -0.2366,94.2275 0.0014,134.1575 C0.1344,156.4555 10.5484,177.4455 28.2234,191.0385 L60.4714,158.7915 C46.4804,152.4705 40.2634,136.0055 46.5844,122.0155 C52.9044,108.0255 69.3704,101.8085 83.3594,108.1285 C89.5244,110.9135 94.4614,115.8515 97.2464,122.0155 L129.4944,89.7685 C115.7734,71.8315 94.4534,61.3445 71.8704,61.4250342"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,66 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
version="1.0"
width="662.84644"
height="94.145668"
id="svg115845">
<defs
id="defs115847">
<clipPath
id="clp82">
<path
d="M 1001.6,870.49 L 1036.3,870.49 L 1036.3,857.41 L 1001.6,857.41 L 1001.6,870.49 z "
id="path1826" />
</clipPath>
<clipPath
id="clp83">
<path
d="M 1001.6,870.49 L 1036.3,870.49 L 1036.3,857.41 L 1001.6,857.41 L 1001.6,870.49 z "
id="path1835" />
</clipPath>
<clipPath
id="clp84">
<path
d="M 1001.6,870.49 L 1036.3,870.49 L 1036.3,857.41 L 1001.6,857.41 L 1001.6,870.49 z "
id="path1844" />
</clipPath>
<clipPath
id="clp81">
<path
d="M 1000.9,934.34 L 1038.6,934.34 L 1038.6,922.24 L 1000.9,922.24 L 1000.9,934.34 z "
id="path1800" />
</clipPath>
<clipPath
id="clipPath116030">
<path
d="M 1001.6,870.49 L 1036.3,870.49 L 1036.3,857.41 L 1001.6,857.41 L 1001.6,870.49 z "
id="path116032" />
</clipPath>
<clipPath
id="clipPath116038">
<path
d="M 1001.6,870.49 L 1036.3,870.49 L 1036.3,857.41 L 1001.6,857.41 L 1001.6,870.49 z "
id="path116040" />
</clipPath>
<clipPath
id="clipPath116046">
<path
d="M 1001.6,870.49 L 1036.3,870.49 L 1036.3,857.41 L 1001.6,857.41 L 1001.6,870.49 z "
id="path116048" />
</clipPath>
</defs>
<g
transform="translate(-702.6538,-712.5837)"
id="layer1">
<g
id="g16337">
<path
d="M 980.65099,771.70039 L 1021.3029,771.70039 L 999.80762,737.1177 L 960.35637,799.64472 L 942.40142,799.64472 L 990.38729,724.53644 C 992.47368,721.50175 995.95082,719.66834 999.80762,719.66834 C 1003.5375,719.66834 1007.0147,721.43856 1009.0379,724.41 L 1057.2134,799.64472 L 1039.2584,799.64472 L 1030.7865,785.67256 L 989.62847,785.67256 L 980.65099,771.70039 z M 1167.1573,785.67256 L 1167.1573,720.42701 L 1151.9207,720.42701 L 1151.9207,792.05805 C 1151.9207,794.01795 1152.6795,795.9146 1154.1335,797.3687 C 1155.5874,798.82285 1157.5474,799.64472 1159.697,799.64472 L 1229.1786,799.64472 L 1238.1561,785.67256 L 1167.1573,785.67256 z M 915.08933,773.97641 C 929.88361,773.97641 941.89588,762.02739 941.89588,747.23331 C 941.89588,732.43928 929.88361,720.42701 915.08933,720.42701 L 848.43367,720.42701 L 848.43367,799.64472 L 863.66423,799.64472 L 863.66423,734.39918 L 914.07773,734.39918 C 921.15891,734.39918 926.84882,740.15238 926.84882,747.23331 C 926.84882,754.31423 921.15891,760.06749 914.07773,760.06749 L 871.12457,760.00424 L 916.60647,799.64472 L 938.7347,799.64472 L 908.13505,773.97641 L 915.08933,773.97641 z M 754.67521,799.64472 C 732.80632,799.64472 715.05966,781.94244 715.05966,760.06749 C 715.05966,738.19249 732.80632,720.42701 754.67521,720.42701 L 800.71978,720.42701 C 822.59473,720.42701 840.32876,738.19249 840.32876,760.06749 C 840.32876,781.94244 822.59473,799.64472 800.71978,799.64472 L 754.67521,799.64472 z M 799.69555,785.67256 C 813.86396,785.67256 825.33883,774.22928 825.33883,760.06749 C 825.33883,745.90564 813.86396,734.39918 799.69555,734.39918 L 755.69287,734.39918 C 741.53103,734.39918 730.04958,745.90564 730.04958,760.06749 C 730.04958,774.22928 741.53103,785.67256 755.69287,785.67256 L 799.69555,785.67256 z M 1089.0142,799.64472 C 1067.1392,799.64472 1049.3739,781.94244 1049.3739,760.06749 C 1049.3739,738.19249 1067.1392,720.42701 1089.0142,720.42701 L 1143.7016,720.42701 L 1134.7873,734.39918 L 1090.0258,734.39918 C 1075.8639,734.39918 1064.3577,745.90564 1064.3577,760.06749 C 1064.3577,774.22928 1075.8639,785.67256 1090.0258,785.67256 L 1144.9659,785.67256 L 1135.9885,799.64472 L 1089.0142,799.64472 z M 1275.3309,785.67256 C 1263.6346,785.67256 1253.7087,777.83296 1250.6739,767.02192 L 1315.7932,767.02192 L 1324.7707,753.04976 L 1250.6739,753.04976 C 1253.7087,742.30196 1263.6346,734.39918 1275.3309,734.39918 L 1320.0292,734.39918 L 1329.0699,720.42701 L 1274.3193,720.42701 C 1252.4443,720.42701 1234.679,738.19249 1234.679,760.06749 C 1234.679,781.94244 1252.4443,799.64472 1274.3193,799.64472 L 1321.2936,799.64472 L 1330.271,785.67256 L 1275.3309,785.67256"
style="fill:#f80000;fill-rule:nonzero;stroke:none"
id="path16197" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

@ -946,6 +946,21 @@ cluster:
password:
label: Password
note: 'Note: The free ESXi license does not support API access. Only servers with a valid or evaluation license are supported.'
gcp:
authEncodedJson:
label: Service Account
placeholder: Service Account private key JSON file
help: |-
<p>Create a <a href="https://console.cloud.google.com/projectselector/iam-admin/serviceaccounts" target="_blank" rel="noopener noreferrer nofollow">Service Account</a> with a JSON private key and provide the JSON here.
These IAM roles are required:</p>
<ul>
<li><b>Compute Engine:</b> Compute Viewer (roles/compute.viewer)</li>
<li><b>Project:</b> Viewer (roles/viewer)</li>
<li><b>Kubernetes Engine:</b> Kubernetes Engine Admin (roles/container.admin)</li>
<li><b>Service Accounts:</b> Service Account User (roles/iam.serviceAccountUser)</li>
</ul>
More info on roles can be found <a href="https://cloud.google.com/kubernetes-engine/docs/how-to/iam-integration" target="_blank" rel="noopener noreferrer nofollow">here</a>.
harvester:
import: Imported Harvester
external: External Harvester
@ -1179,7 +1194,7 @@ cluster:
aliyun: Alibaba ACK
amazonec2: Amazon EC2
amazoneks: Amazon EKS
aws: Amazon AWS
aws: Amazon
azure: Azure
azureaks: Azure AKS
aks: Azure AKS
@ -1192,9 +1207,11 @@ cluster:
docker: Docker
eks: Amazon EKS
exoscale: Exoscale
gcp: Google
google: Google GCE
googlegke: Google GKE
gke: Google GKE
harvester: Harvester
huaweicce: Huawei CCE
import: Generic
imported: Imported
@ -1208,6 +1225,7 @@ cluster:
openstack: OpenStack
opentelekomcloudcontainerengine: Open Telekom Cloud CCE
otccce: Open Telekom Cloud CCE
oracle: Oracle
oracleoke: Oracle OKE
otc: Open Telekom Cloud
other: Other
@ -1222,7 +1240,7 @@ cluster:
softlayer: IBM Cloud
tencenttke: Tencent TKE
upcloud: UpCloud
vmwarevsphere: vSphere
vmwarevsphere: VMware vSphere
zstack: ZStack
providerGroup:
create-custom1: Use existing nodes and create a cluster using RKE
@ -4632,16 +4650,21 @@ typeLabel:
one { Namespaced Repo }
other { Namespaced Repos }
}
chartInstallAction: |-
chartinstallaction: |-
{count, plural,
one { App }
other { Apps }
}
chartUpgradeAction: |-
chartupgradeaction: |-
{count, plural,
one { App }
other { Apps }
}
cloudcredential: |-
{count, plural,
one { Cloud Credential }
other { Cloud Credentials }
}
endpoints: |-
{count, plural,
one { Endpoint }

64
cloud-credential/gcp.vue Normal file
View File

@ -0,0 +1,64 @@
<script>
import CreateEditView from '@/mixins/create-edit-view';
import LabeledInput from '@/components/form/LabeledInput';
import FileSelector from '@/components/form/FileSelector';
export default {
components: { LabeledInput, FileSelector },
mixins: [CreateEditView],
watch: {
'value.decodedData.authEncodedJson'(neu) {
this.$emit('validationChanged', !!neu);
}
},
methods: {
onFileSelected(data) {
this.value.setData('authEncodedJson', data);
},
async test() {
let credentials = null;
let config = null;
let projectId = null;
try {
credentials = this.value.decodedData.authEncodedJson;
config = JSON.parse(credentials || '{}');
projectId = config?.project_id;
} catch (error) {
return false;
}
try {
await this.$store.dispatch('management/request', {
url: '/meta/gkeZones',
method: 'POST',
data: { credentials, projectId },
redirectUnauthorized: false,
});
return true;
} catch (e) {
return false;
}
},
}
};
</script>
<template>
<div>
<LabeledInput
:value="value.decodedData.authEncodedJson"
label-key="cluster.credential.gcp.authEncodedJson.label"
placeholder-key="cluster.credential.gcp.authEncodedJson.placeholder"
type="multiline"
:mode="mode"
@input="value.setData('authEncodedJson', $event);"
/>
<FileSelector class="role-primary btn-sm mt-20 mb-20" :label="t('generic.readFromFile')" @selected="onFileSelected" />
<p class="text-muted" v-html="t('cluster.credential.gcp.authEncodedJson.help', {}, true)" />
</div>
</template>

View File

@ -17,7 +17,16 @@ export default {
},
data() {
const keyOptions = this.$store.getters['plugins/fieldNamesForDriver'](this.driverName);
let keyOptions = [];
const normanType = this.$store.getters['plugins/credentialFieldForDriver'](this.driverName);
const normanSchema = this.$store.getters['rancher/schemaFor'](`${ normanType }credentialconfig`);
if ( normanSchema ) {
keyOptions = Object.keys(normanSchema.resourceFields || {});
} else {
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
@ -27,7 +36,7 @@ export default {
for ( const k of keyOptions ) {
const sk = simplify(k);
if ( likelyFields.includes(sk) || iffyFields.includes(sk) ) {
if ( normanSchema || likelyFields.includes(sk) || iffyFields.includes(sk) ) {
keys.push(k);
}
}
@ -40,6 +49,7 @@ export default {
}
return {
hasSupport: !!normanSchema,
keyOptions,
errors: null,
};
@ -62,12 +72,15 @@ export default {
<template>
<div>
<Banner color="info" label-key="cluster.selectCredential.genericDescription" class="mt-0" />
<Banner v-if="!hasSupport" color="info" label-key="cluster.selectCredential.genericDescription" class="mt-0" />
<KeyValue
:value="value.decodedData"
:key-options="keyOptions"
:key-options="hasSupport || !keyOptions.length ? null : keyOptions"
:key-editable="!hasSupport"
:mode="mode"
:read-allowed="true"
:add-allowed="!hasSupport"
:remove-allowed="!hasSupport"
:initial-empty-row="true"
@input="update"
/>

View File

@ -66,7 +66,7 @@ export default {
parentRouteOverride: {
type: String,
default: null,
}
},
},
computed: {

View File

@ -61,6 +61,11 @@ export default {
},
},
keyEditable: {
type: Boolean,
default: true,
},
// Offer a set of suggestions for the keys as a Select instead of Input
keyOptions: {
type: Array,
@ -523,7 +528,7 @@ export default {
v-else
ref="key"
v-model="row[keyName]"
:disabled="isView"
:disabled="isView || !keyEditable"
:placeholder="keyPlaceholder"
@input="queueUpdate"
@paste="onPaste(i, $event)"

View File

@ -69,8 +69,10 @@ export default {
};
});
if (this.clusterFilter.length > 0) {
out = out.filter(item => item.label.indexOf(this.clusterFilter) === 0);
const search = (this.clusterFilter || '').toLowerCase();
if ( search ) {
out = out.filter(item => item.label.toLowerCase().includes(search));
}
const sorted = sortBy(out, ['ready:desc', 'label']);

View File

@ -1,5 +1,5 @@
import { AGE, NAME as NAME_COL, STATE } from '@/config/table-headers';
import { CAPI } from '@/config/types';
import { CAPI, NORMAN } from '@/config/types';
import { MULTI_CLUSTER } from '@/store/features';
import { DSL } from '@/store/type-map';
@ -12,7 +12,8 @@ export function init(store) {
headers,
configureType,
virtualType,
weightType
weightType,
weightGroup
} = DSL(store, NAME);
product({
@ -24,8 +25,40 @@ export function init(store) {
showClusterSwitcher: false,
});
virtualType({
name: 'cloud-credentials',
label: 'Cloud Credentials',
group: 'Root',
namespaced: false,
icon: 'globe',
weight: 99,
route: { name: 'c-cluster-manager-cloudCredential' },
});
virtualType({
labelKey: 'legacy.psps',
name: 'pod-security-policies',
group: 'Root',
namespaced: false,
weight: 0,
icon: 'folder',
route: { name: 'c-cluster-manager-pages-page', params: { cluster: 'local', page: 'pod-security-policies' } },
exact: true
});
basicType([
CAPI.RANCHER_CLUSTER,
'cloud-credentials',
'drivers',
'pod-security-policies'
]);
configureType(CAPI.RANCHER_CLUSTER, { showListMasthead: false, namespaced: false });
// configureType(NORMAN.CLOUD_CREDENTIAL, { showListMasthead: false, namespaced: false });
weightType(CAPI.RANCHER_CLUSTER, 100, true);
configureType(NORMAN.CLOUD_CREDENTIAL, {
showState: false, showAge: false, canYaml: false
});
virtualType({
label: 'Drivers',
@ -47,16 +80,6 @@ export function init(store) {
exact: true
});
virtualType({
label: 'Cloud Credentials',
name: 'rke-cloud-credentials',
group: 'Root',
namespaced: false,
icon: 'globe',
route: { name: 'c-cluster-manager-pages-page', params: { cluster: 'local', page: 'cloud-credentials' } },
exact: true
});
virtualType({
label: 'Node Templates',
name: 'rke-node-templates',
@ -69,7 +92,6 @@ export function init(store) {
basicType([
'rke-templates',
'rke-cloud-credentials',
'rke-node-templates'
], 'RKE1 Configuration');
@ -77,40 +99,14 @@ export function init(store) {
weightType(CAPI.MACHINE_SET, 2, true);
weightType(CAPI.MACHINE, 1, true);
virtualType({
label: 'Cloud Credentials',
group: 'Root',
namespaced: false,
icon: 'globe',
name: 'secret',
weight: 99,
route: { name: 'c-cluster-manager-secret' },
});
basicType([
CAPI.RANCHER_CLUSTER,
'secret',
'drivers',
]);
virtualType({
labelKey: 'legacy.psps',
name: 'pod-security-policies',
group: 'Root',
namespaced: false,
weight: 0,
icon: 'folder',
route: { name: 'c-cluster-manager-pages-page', params: { cluster: 'local', page: 'pod-security-policies' } },
exact: true
});
basicType([
CAPI.MACHINE_DEPLOYMENT,
CAPI.MACHINE_SET,
CAPI.MACHINE,
'pod-security-policies'
], 'Advanced');
weightGroup('Advanced', -1, true);
const MACHINE_SUMMARY = {
name: 'summary',
labelKey: 'tableHeaders.machines',

View File

@ -16,6 +16,7 @@ export const NORMAN = {
ETCD_BACKUP: 'etcdbackup',
CLUSTER_TOKEN: 'clusterregistrationtoken',
CLUSTER_ROLE_TEMPLATE_BINDING: 'clusterRoleTemplateBinding',
CLOUD_CREDENTIAL: 'cloudcredential',
GROUP: 'group',
// Note - This allows access to node resources, not schema's or custom components (both are accessed via 'type' which clashes with kube node)
NODE: 'node',

243
edit/cloudcredential.vue Normal file
View File

@ -0,0 +1,243 @@
<script>
import { TYPES } from '@/models/secret';
import { MANAGEMENT, NORMAN } from '@/config/types';
import CreateEditView from '@/mixins/create-edit-view';
import NameNsDescription from '@/components/form/NameNsDescription';
import CruResource from '@/components/CruResource';
import { _CREATE } from '@/config/query-params';
import Loading from '@/components/Loading';
import Labels from '@/components/form/Labels';
import { HIDE_SENSITIVE } from '@/store/prefs';
import { CAPI } from '@/config/labels-annotations';
import { clear, uniq } from '@/utils/array';
import { importCloudCredential } from '@/utils/dynamic-importer';
import SelectIconGrid from '@/components/SelectIconGrid';
import { DEFAULT_WORKSPACE } from '@/models/provisioning.cattle.io.cluster';
import { sortBy } from '@/utils/sort';
import { ucFirst } from '@/utils/string';
import { set } from '@/utils/object';
import { mapFeature, RKE2 as RKE2_FEATURE } from '@/store/features';
import { rke1Supports } from '~/store/plugins';
export default {
name: 'CruCloudCredential',
components: {
Loading,
NameNsDescription,
CruResource,
Labels,
SelectIconGrid
},
mixins: [CreateEditView],
async fetch() {
this.nodeDrivers = await this.$store.dispatch('management/findAll', { type: MANAGEMENT.NODE_DRIVER });
this.kontainerDrivers = await this.$store.dispatch('management/findAll', { type: MANAGEMENT.KONTANIER_DRIVER });
if ( !this.value._name ) {
set(this.value, '_name', '');
}
if ( this.value.provider ) {
this.selectType(this.value.provider);
}
},
data() {
return {
nodeDrivers: null,
kontainerDrivers: null
};
},
computed: {
rke2Enabled: mapFeature(RKE2_FEATURE),
storeOverride() {
return 'rancher';
},
driverName() {
return this.value?.provider;
},
cloudComponent() {
const driver = this.driverName;
const haveProviders = this.$store.getters['plugins/credentialDrivers'];
if ( haveProviders.includes(driver) ) {
return importCloudCredential(driver);
}
return importCloudCredential('generic');
},
// array of id, label, description, initials for type selection step
secretSubTypes() {
const out = [];
const drivers = [...this.nodeDrivers, ...this.kontainerDrivers]
.filter(x => x.spec.active && x.id !== 'rancherkubernetesengine')
.map(x => x.spec.displayName || x.id);
let types = uniq(drivers.map(x => this.$store.getters['plugins/credentialDriverFor'](x)));
if ( !this.rke2Enabled ) {
types = types.filter(x => rke1Supports.includes(x));
}
const schema = this.$store.getters['rancher/schemaFor'](NORMAN.CLOUD_CREDENTIAL);
types = types.filter((name) => {
const key = this.$store.getters['plugins/credentialFieldForDriver'](name);
const subSchemaName = schema.resourceFields[`${ key }credentialConfig`]?.type;
if ( !subSchemaName ) {
return;
}
const subSchema = this.$store.getters['rancher/schemaFor'](subSchemaName);
if ( !subSchema ) {
return false;
}
const fields = subSchema.resourceFields;
return fields && Object.keys(fields).length > 0;
});
for ( const id of types ) {
let bannerImage, bannerAbbrv;
try {
bannerImage = require(`~/assets/images/providers/${ id }.svg`);
} catch (e) {
bannerImage = null;
bannerAbbrv = this.initialDisplayFor(id);
}
out.push({
id,
label: this.typeDisplay(CAPI.CREDENTIAL_DRIVER, id),
bannerImage,
bannerAbbrv
});
}
return sortBy(out, 'label');
},
hideSensitiveData() {
return this.$store.getters['prefs/get'](HIDE_SENSITIVE);
},
doneRoute() {
return 'c-cluster-manager-cloudCredential';
},
},
methods: {
async saveCredential(btnCb) {
if ( this.errors ) {
clear(this.errors);
}
if ( typeof this.$refs.cloudComponent?.test === 'function' ) {
try {
const res = await this.$refs.cloudComponent.test();
if ( !res || res?.errors) {
if (res?.errors) {
this.errors = res.errors;
} else {
this.errors = ['Authentication test failed, please check your credentials'];
}
btnCb(false);
return;
}
} catch (e) {
this.errors = [e];
btnCb(false);
return;
}
}
return this.save(btnCb);
},
selectType(type) {
let driver;
if ( type === TYPES.CLOUD_CREDENTIAL ) {
// Clone goes through here
driver = this.driverName;
} else {
driver = type;
type = TYPES.CLOUD_CREDENTIAL;
}
if ( this.mode === _CREATE ) {
this.value.setAnnotation(CAPI.CREDENTIAL_DRIVER, driver);
this.value.metadata.generateName = 'cc-';
this.value.metadata.namespace = DEFAULT_WORKSPACE;
const field = this.$store.getters['plugins/credentialFieldForDriver'](driver);
set(this.value, `${ field }credentialConfig`, {});
}
this.$set(this.value, '_type', type);
this.$emit('set-subtype', this.typeDisplay(type, driver));
},
typeDisplay(type, driver) {
return this.$store.getters['i18n/withFallback'](`cluster.provider."${ driver }"`, null, driver);
},
initialDisplayFor(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);
},
},
};
</script>
<template>
<form>
<Loading v-if="$fetchState.pending" />
<CruResource
v-else
:mode="mode"
:validation-passed="true"
:selected-subtype="value._type"
:resource="value"
:errors="errors"
:done-route="doneRoute"
:subtypes="secretSubTypes"
:can-yaml="false"
@finish="saveCredential"
@select-type="selectType"
@error="e=>errors = e"
>
<NameNsDescription v-model="value" name-key="_name" :mode="mode" :namespaced="false" />
<component
:is="cloudComponent"
ref="cloudComponent"
:driver-name="driverName"
:value="value"
:mode="mode"
:hide-sensitive-data="hideSensitiveData"
/>
</CruResource>
</form>
</template>
<style lang='scss'>
</style>

View File

@ -50,7 +50,9 @@ export default {
methods: {
update() {
this.$emit('input', { ...this.config });
const out = { ...this.config };
this.$emit('input', out);
},
},
};
@ -67,30 +69,31 @@ export default {
:allow-aws="true"
:namespace="namespace"
generate-name="etcd-backup-s3-"
@input="update"
/>
<div class="row mt-20">
<div class="col span-6">
<LabeledInput v-model="config.bucket" label="Bucket" />
<LabeledInput v-model="config.bucket" label="Bucket" @input="update" />
</div>
<div class="col span-6">
<LabeledInput v-model="config.folder" label="Folder" />
<LabeledInput v-model="config.folder" label="Folder" @input="update" />
</div>
</div>
<div class="row mt-20">
<div class="col span-6">
<LabeledInput v-model="config.region" label="Region" />
<LabeledInput v-model="config.region" label="Region" @input="update" />
</div>
<div class="col span-6">
<LabeledInput v-model="config.endpoint" label="Endpoint" />
<LabeledInput v-model="config.endpoint" label="Endpoint" @input="update" />
</div>
</div>
<div class="mt-20">
<Checkbox v-model="config.skipSSLVerify" :mode="mode" label="Accept any certificate (insecure)" />
<Checkbox v-model="config.skipSSLVerify" :mode="mode" label="Accept any certificate (insecure)" @input="update" />
<LabeledInput v-if="!config.skipSSLVerify" v-model="config.endpointCA" type="multiline" label="Endpoint CA Cert" />
<LabeledInput v-if="!config.skipSSLVerify" v-model="config.endpointCA" type="multiline" label="Endpoint CA Cert" @input="update" />
</div>
</div>
</template>

View File

@ -1,7 +1,7 @@
<script>
import Loading from '@/components/Loading';
import LabeledSelect from '@/components/form/LabeledSelect';
import { SECRET } from '@/config/types';
import { NORMAN, SECRET } from '@/config/types';
import CreateEditView from '@/mixins/create-edit-view';
import CruResource from '@/components/CruResource';
import NameNsDescription from '@/components/form/NameNsDescription';
@ -40,11 +40,7 @@ export default {
},
async fetch() {
const secretSchema = this.$store.getters['management/schemaFor'](SECRET);
if (secretSchema?.collectionMethods.find(x => x.toLowerCase() === 'get')) {
this.allSecrets = await this.$store.dispatch('management/findAll', { type: SECRET });
}
this.allCredentials = await this.$store.dispatch('rancher/findAll', { type: NORMAN.CLOUD_CREDENTIAL });
this.newCredential = await this.$store.dispatch('management/create', {
type: SECRET,
@ -56,17 +52,17 @@ export default {
data: {},
});
if ( this.filteredSecrets.length === 1 ) {
if ( this.filteredCredentials.length === 1 ) {
// Auto pick the first credential if there's only one
this.credentialId = this.filteredSecrets[0].id;
} else if ( !this.filteredSecrets.length ) {
this.credentialId = this.filteredCredentials[0].id;
} else if ( !this.filteredCredentials.length ) {
this.credentialId = _NEW;
}
},
data() {
return {
allSecrets: [],
allCredentials: [],
nodeComponent: null,
credentialId: this.value || _NONE,
newCredential: null,
@ -96,19 +92,12 @@ export default {
return driver;
},
filteredSecrets() {
// @TODO better thing to filter secrets by, limit to matching provider
const out = this.allSecrets.filter((obj) => {
return obj.metadata.namespace === DEFAULT_WORKSPACE &&
obj.metadata.annotations?.[CAPI.CREDENTIAL_DRIVER] === this.driverName;
});
return out;
filteredCredentials() {
return this.allCredentials.filter(x => x.provider === this.driverName);
},
options() {
// @TODO better thing to filter secrets by, limit to matching provider
const out = this.filteredSecrets.map((obj) => {
const out = this.filteredCredentials.map((obj) => {
return {
label: obj.nameDisplay,
value: obj.id,

View File

@ -6,7 +6,7 @@ import merge from 'lodash/merge';
import { mapGetters } from 'vuex';
import CreateEditView from '@/mixins/create-edit-view';
import { CAPI, MANAGEMENT, SECRET } from '@/config/types';
import { CAPI, MANAGEMENT, NORMAN } from '@/config/types';
import { _CREATE, _EDIT } from '@/config/query-params';
import { DEFAULT_WORKSPACE } from '@/models/provisioning.cattle.io.cluster';
@ -119,6 +119,14 @@ export default {
set(this.value, 'spec', {});
}
if ( !this.value.spec.machineSelectorConfig ) {
set(this.value.spec, 'machineSelectorConfig', []);
}
if ( !this.value.spec.machineSelectorConfig.find(x => !x.machineLabelSelector) ) {
this.value.spec.machineSelectorConfig.unshift({ config: {} });
}
if ( this.value.spec.cloudCredentialSecretName ) {
this.credentialId = `${ this.value.metadata.namespace }/${ this.value.spec.cloudCredentialSecretName }`;
}
@ -585,12 +593,20 @@ export default {
},
watch: {
s3Backup(neu) {
if ( neu ) {
set(this.rkeConfig.etcd, 's3', {});
} else {
set(this.rkeConfig.etcd, 's3', null);
}
},
credentialId(val) {
if ( val ) {
this.credential = this.$store.getters['management/byId'](SECRET, this.credentialId);
this.credential = this.$store.getters['rancher/byId'](NORMAN.CLOUD_CREDENTIAL, this.credentialId);
if ( this.credential ) {
this.value.spec.cloudCredentialSecretName = this.credential.metadata.name;
this.value.spec.cloudCredentialSecretName = this.credential.id;
} else {
this.value.spec.cloudCredentialSecretName = null;
}
@ -1145,7 +1161,7 @@ export default {
/>
<S3Config
v-if="s3Backup"
v-if="rkeConfig.etcd.s3"
v-model="rkeConfig.etcd.s3"
:namespace="value.metadata.namespace"
:register-before-hook="registerBeforeHook"
@ -1302,7 +1318,7 @@ export default {
<h3>Add additional Kubelet args:</h3>
</template>
<h3 v-else>
For all nodes:
For all machines:
</h3>
<ArrayList

View File

@ -1,27 +0,0 @@
<script>
import { CAPI } from '@/config/labels-annotations';
export default {
props: {
value: {
type: Object,
required: true,
},
mode: {
type: String,
required: true,
}
},
data() {
const driver = this.value.metadata.annotations?.[CAPI.CREDENTIAL_DRIVER];
return { driver };
},
};
</script>
<template>
<div>Cloud -{{ driver }}-</div>
</template>

View File

@ -8,7 +8,7 @@ import KeyValue from '@/components/form/KeyValue';
import UnitInput from '@/components/form/UnitInput';
import RadioGroup from '@/components/form/RadioGroup';
import Checkbox from '@/components/form/Checkbox';
import { SECRET } from '@/config/types';
import { NORMAN } from '@/config/types';
import { allHash } from '@/utils/promise';
import { addObject, addObjects, findBy } from '@/utils/array';
import { sortBy } from '@/utils/sort';
@ -43,7 +43,7 @@ export default {
try {
if ( this.credential?.id !== this.credentialId ) {
this.credential = await this.$store.dispatch('management/find', { type: SECRET, id: this.credentialId });
this.credential = await this.$store.dispatch('rancher/find', { type: NORMAN.CLOUD_CREDENTIAL, id: this.credentialId });
}
if ( !this.instanceInfo ) {

View File

@ -1,7 +1,7 @@
<script>
import Loading from '@/components/Loading';
import CreateEditView from '@/mixins/create-edit-view';
import { SECRET } from '@/config/types';
import { NORMAN } from '@/config/types';
import { stringify, exceptionToErrorsArray } from '@/utils/error';
import Banner from '@/components/Banner';
import merge from 'lodash/merge';
@ -114,10 +114,7 @@ export default {
this.errors = [];
try {
this.credential = await this.$store.dispatch('management/find', {
type: SECRET,
id: this.credentialId,
});
this.credential = await this.$store.dispatch('rancher/find', { type: NORMAN.CLOUD_CREDENTIAL, id: this.credentialId });
const {
clientId,

View File

@ -3,7 +3,7 @@ import Loading from '@/components/Loading';
import CreateEditView from '@/mixins/create-edit-view';
import LabeledSelect from '@/components/form/LabeledSelect';
import Checkbox from '@/components/form/Checkbox';
import { SECRET } from '@/config/types';
import { NORMAN } from '@/config/types';
import { stringify, exceptionToErrorsArray } from '@/utils/error';
import Banner from '@/components/Banner';
@ -25,7 +25,7 @@ export default {
this.errors = [];
try {
this.credential = await this.$store.dispatch('management/find', { type: SECRET, id: this.credentialId });
this.credential = await this.$store.dispatch('rancher/find', { type: NORMAN.CLOUD_CREDENTIAL, id: this.credentialId });
this.regionOptions = await this.$store.dispatch('digitalocean/regionOptions', { credentialId: this.credentialId });
let defaultRegion = 'sfo3';

View File

@ -3,7 +3,7 @@ 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 { NORMAN } from '@/config/types';
import Questions from '@/components/Questions';
import { iffyFields, simplify } from '@/store/plugins';
import { isEmpty } from '@/utils/object';
@ -31,7 +31,7 @@ export default {
this.errors = [];
try {
this.credential = await this.$store.dispatch('management/find', { type: SECRET, id: this.credentialId });
this.credential = await this.$store.dispatch('rancher/find', { type: NORMAN.CLOUD_CREDENTIAL, id: this.credentialId });
this.fields = this.$store.getters['plugins/fieldsForDriver'](this.provider);
const name = `rke-machine-config.cattle.io.${ this.provider }config`;

View File

@ -11,7 +11,7 @@ import Banner from '@/components/Banner';
import { get } from '@/utils/object';
import { mapGetters } from 'vuex';
import {
SECRET, HCI, NAMESPACE, MANAGEMENT, CONFIG_MAP
HCI, NAMESPACE, MANAGEMENT, CONFIG_MAP, NORMAN
} from '@/config/types';
import { base64Decode, base64Encode } from '@/utils/crypto';
import { allHashSettled } from '@/utils/promise';
@ -41,7 +41,7 @@ export default {
this.errors = [];
try {
this.credential = await this.$store.dispatch('management/find', { type: SECRET, id: this.credentialId });
this.credential = await this.$store.dispatch('rancher/find', { type: NORMAN.CLOUD_CREDENTIAL, id: this.credentialId });
const clusterId = get(this.credential, `metadata.annotations."${ HCI_ANNOTATIONS.CLUSTER_ID }"`);
const url = `/k8s/clusters/${ clusterId }/v1`;

View File

@ -3,7 +3,7 @@ import Loading from '@/components/Loading';
import CreateEditView from '@/mixins/create-edit-view';
import LabeledSelect from '@/components/form/LabeledSelect';
// import Checkbox from '@/components/form/Checkbox';
import { SECRET } from '@/config/types';
import { NORMAN } from '@/config/types';
import { stringify } from '@/utils/error';
import Banner from '@/components/Banner';
import UnitInput from '@/components/form/UnitInput';
@ -150,7 +150,7 @@ export default {
async fetch() {
this.errors = [];
this.credential = await this.$store.dispatch('management/find', { type: SECRET, id: this.cloudCredentialId });
this.credential = await this.$store.dispatch('rancher/find', { type: NORMAN.CLOUD_CREDENTIAL, id: this.cloudCredentialId });
},
data() {
@ -262,7 +262,7 @@ export default {
},
cloudCredentialId() {
return this.credentialId.split('/')[1];
return this.credentialId.split(/[:/]/)[1];
},
host: {

View File

@ -35,7 +35,7 @@ export default {
},
schema() {
const inStore = this.$store.getters['currentStore'](this.value.type);
const inStore = this.storeOverride || this.$store.getters['currentStore'](this.value.type);
return this.$store.getters[`${ inStore }/schemaFor`](this.value.type);
},

170
models/cloudcredential.js Normal file
View File

@ -0,0 +1,170 @@
import { CAPI } from '@/config/labels-annotations';
import { fullFields, prefixFields, simplify, suffixFields } from '@/store/plugins';
import { isEmpty, set } from '~/utils/object';
import { SECRET } from '~/config/types';
import { escapeHtml } from '~/utils/string';
export default {
hasSensitiveData: () => true,
canCustomEdit: () => true,
_detailLocation() {
return {
name: `c-cluster-manager-cloudCredential-id`,
params: {
product: this.$rootGetters['productId'],
cluster: this.$rootGetters['clusterId'],
id: this.id,
}
};
},
parentLocationOverride() {
return {
name: `c-cluster-manager-cloudCredential`,
params: { cluster: this.$rootGetters['clusterId'] }
};
},
secret() {
return this.$rootGetters['management/byId'](SECRET, this.id.replace(':', '/'));
},
configKey() {
return Object.keys(this).find( k => k.endsWith('credentialConfig'));
},
provider() {
const annotation = this.annotations?.[CAPI.CREDENTIAL_DRIVER];
if ( annotation ) {
return annotation;
}
const configKey = this.configKey;
// Call [amazoneks,amazonec2] -> aws
if ( configKey ) {
const out = this.$rootGetters['plugins/credentialDriverFor'](configKey.replace(/credentialConfig$/, ''));
return out;
}
return null;
},
setProvider() {
return (neu) => {
this.setAnnotation(CAPI.CREDENTIAL_DRIVER, neu);
Object.keys(this).forEach((k) => {
k = k.toLowerCase();
if ( k.endsWith('config') && k !== `${ neu }config` ) {
set(this, k, null);
}
});
if ( !this[`${ neu }credentialConfig`] ) {
set(this, `${ neu }credentialConfig`, {});
}
};
},
decodedData() {
const k = this.configKey;
if ( k ) {
return this[k];
}
return {};
},
setData() {
return (key, value) => { // or (mapOfNewData)
const isMap = key && typeof key === 'object';
if ( !this[this.configKey] || isMap ) {
set(this, this.configKey, {});
}
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, `"${ this.configKey }"."${ k }"`, neu[k]);
}
};
},
providerDisplay() {
const provider = (this.provider || '').toLowerCase();
return this.$rootGetters['i18n/withFallback'](`cluster.provider."${ provider }"`, null, provider);
},
publicData() {
let { publicKey, publicMode } = this.$rootGetters['plugins/credentialOptions'](this.provider);
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;
}
let val = this.decodedData[publicKey];
if ( !val ) {
val = this.secret?.decodedData?.[`${ this.provider }credentialConfig-${ publicKey }`];
}
if ( !val ) {
return;
}
const maxLength = Math.min(8, Math.floor(val.length / 2));
if ( publicMode === 'prefix' ) {
return `${ escapeHtml(val.substr(0, maxLength)) }&hellip;`;
} else if ( publicMode === 'suffix' ) {
return `&hellip;${ escapeHtml(val.substr(-1 * maxLength)) }`;
} else {
return escapeHtml(val);
}
},
doneRoute() {
return 'c-cluster-manager-secret';
},
};

View File

@ -418,7 +418,8 @@ export default {
method: 'post',
}, { root: true });
} else {
const args = {};
const now = this.spec?.rkeConfig?.etcdSnapshotCreate.generation || 1;
const args = { generation: now + 1 };
if ( this.spec?.rkeConfig?.etcd?.s3 ) {
args.s3 = this.spec.rkeConfig.etcd.s3;

View File

@ -1,11 +1,9 @@
import r from 'jsrsasign';
import { CAPI, CERTMANAGER, KUBERNETES } from '@/config/labels-annotations';
import { CERTMANAGER, KUBERNETES } from '@/config/labels-annotations';
import { base64Decode, base64Encode } from '@/utils/crypto';
import { removeObjects } from '@/utils/array';
import { SERVICE_ACCOUNT } from '@/config/types';
import { isEmpty, set } from '@/utils/object';
import { escapeHtml } from '@/utils/string';
import { fullFields, prefixFields, simplify, suffixFields } from '@/store/plugins';
import { set } from '@/utils/object';
import { NAME as MANAGER } from '@/config/product/manager';
export const TYPES = {
@ -35,7 +33,7 @@ export default {
},
isCloudCredential() {
return this._type === TYPES.CLOUD_CREDENTIAL;
return this._type === TYPES.CLOUD_CREDENTIAL || (this.metadata.namespace === 'cattle-global-data' && this.metadata.generateName === 'cc-');
},
dockerJSON() {
@ -336,6 +334,7 @@ export default {
};
},
/*
cloudCredentialProvider() {
return this.metadata?.annotations?.[CAPI.CREDENTIAL_DRIVER];
},
@ -391,6 +390,7 @@ export default {
return escapeHtml(val);
}
},
*/
doneRoute() {
if ( this.$rootGetters['currentProduct'].name === MANAGER ) {

View File

@ -0,0 +1,16 @@
<script>
import ResourceDetail from '@/components/ResourceDetail';
export default {
name: 'CloudCredentialEdit',
components: { ResourceDetail },
};
</script>
<template>
<ResourceDetail
store-override="rancher"
resource-override="cloudcredential"
parent-route-override="c-cluster-manager-cloudCredential"
/>
</template>

View File

@ -0,0 +1,16 @@
<script>
import ResourceDetail from '@/components/ResourceDetail';
export default {
name: 'CloudCredentialCreate',
components: { ResourceDetail },
};
</script>
<template>
<ResourceDetail
store-override="rancher"
resource-override="cloudcredential"
parent-route-override="c-cluster-manager-cloudCredential"
/>
</template>

View File

@ -2,8 +2,8 @@
import Loading from '@/components/Loading';
import ResourceTable from '@/components/ResourceTable';
import Masthead from '@/components/ResourceList/Masthead';
import { SECRET } from '@/config/types';
import { AGE, NAME, STATE } from '@/config/table-headers';
import { NORMAN, SECRET } from '@/config/types';
import { AGE_NORMAN, DESCRIPTION, NAME_UNLINKED } from '@/config/table-headers';
import { CLOUD_CREDENTIAL, _FLAGGED } from '@/config/query-params';
export default {
@ -13,48 +13,41 @@ export default {
async fetch() {
this.allSecrets = await this.$store.dispatch('management/findAll', { type: SECRET });
this.allCredentials = await this.$store.dispatch('rancher/findAll', { type: NORMAN.CLOUD_CREDENTIAL });
},
data() {
return {
allSecrets: null,
resource: SECRET,
schema: this.$store.getters['management/schemaFor'](SECRET),
allCredentials: null,
resource: NORMAN.CLOUD_CREDENTIAL,
schema: this.$store.getters['rancher/schemaFor'](NORMAN.CLOUD_CREDENTIAL),
};
},
computed: {
rows() {
return this.allSecrets.filter(x => x.isCloudCredential);
return this.allCredentials || [];
},
headers() {
return [
STATE,
NAME,
{
name: 'provider',
label: 'Provider',
value: 'cloudCredentialProviderDisplay',
sort: 'cloudCredentialProviderDisplay',
search: 'cloudCredentialProviderDisplay',
dashIfEmpty: true,
},
NAME_UNLINKED,
{
name: 'apikey',
label: 'API Key',
value: 'cloudCredentialPublicData',
sort: 'cloudCredentialPublicData',
search: 'cloudCredentialPublicData',
value: 'publicData',
sort: 'publicData',
search: 'publicData',
dashIfEmpty: true,
},
AGE
DESCRIPTION,
AGE_NORMAN,
];
},
createLocation() {
return {
name: 'c-cluster-product-resource-create',
name: 'c-cluster-manager-cloudCredential-create',
params: {
product: this.$store.getters['currentProduct'].name,
resource: this.resource
@ -80,9 +73,10 @@ export default {
type-display="Cloud Credentials"
/>
<ResourceTable :schema="schema" :rows="rows" :headers="headers" :namespaced="false">
<ResourceTable :schema="schema" :rows="rows" :headers="headers" :namespaced="false" group-by="providerDisplay">
<template #cell:apikey="{row}">
<span v-html="row.cloudCredentialPublicData" />
<span v-if="row.publicData" v-html="row.publicData" />
<span v-else class="text-muted">&mdash;</span>
</template>
</ResourceTable>
</div>

View File

@ -209,8 +209,8 @@ export default {
</div>
</div>
<hr />
<div class="row">
<div class="col">
<div class="row mb-20">
<div class="col span-12">
<h4 v-t="'prefs.helm.label'" />
<ButtonGroup v-model="showPreRelease" :options="helmOptions" />
</div>

View File

@ -320,7 +320,7 @@ export default {
},
nameDisplay() {
return this.displayName || this.spec?.displayName || this.metadata?.annotations?.[NORMAN_NAME] || this.metadata?.name || this.name || this.id;
return this.displayName || this.spec?.displayName || this.metadata?.annotations?.[NORMAN_NAME] || this.name || this.metadata?.name || this.id;
},
nameSort() {
@ -883,6 +883,10 @@ export default {
opt.data.type = opt.data._type;
}
if (opt?.data._name) {
opt.data.name = opt.data._name;
}
try {
const res = await this.$dispatch('request', opt);

View File

@ -22,10 +22,36 @@ const credentialOptions = {
},
};
export const rke1Supports = [
'aws',
'azure',
'digitalocean',
'gcp',
'harvester',
'linode',
'oracle',
'pnap',
'vmwarevsphere'
];
const driverMap = {
aks: 'azure',
amazonec2: 'aws',
amazoneks: 'aws',
aks: 'azure',
amazonelasticcontainerservice: 'aws',
azurekubernetesservice: 'azure',
google: 'gcp',
googlekubernetesengine: 'gcp',
huaweicontainercloudengine: 'huawei',
linodekubernetesengine: 'linode',
oci: 'oracle',
opentelekomcloudcontainerengine: 'otc',
oraclecontainerengine: 'oracle',
};
const driverToFieldMap = {
aws: 'amazonec2',
gcp: 'google',
};
export const likelyFields = [
@ -82,16 +108,28 @@ export const getters = {
credentialOptions() {
return (name) => {
name = (name || '').toLowerCase();
return credentialOptions[name] || {};
};
},
credentialDriverFor() {
return (name) => {
name = (name || '').toLowerCase();
return driverMap[name] || name;
};
},
credentialFieldForDriver() {
return (name) => {
name = (name || '').toLowerCase();
return driverToFieldMap[name] || name;
};
},
machineDrivers() {
// The subset of drivers supported by Vue components
const ctx = require.context('@/machine-config', true, /.*/);
@ -120,7 +158,10 @@ export const getters = {
const schema = getters.schemaForDriver(name);
if ( !schema ) {
throw new Error(`Machine Driver Config schema not found for ${ name }`);
// eslint-disable-next-line no-console
console.error(`Machine Driver Config schema not found for ${ name }`);
return [];
}
const out = Object.keys(schema?.resourceFields || {});

View File

@ -337,7 +337,7 @@ export const getters = {
labelFor(state, getters, rootState, rootGetters) {
return (schema, count = 1) => {
return _applyMapping(schema, state.typeMappings, 'id', false, () => {
const key = `typeLabel."${ schema.id }"`;
const key = `typeLabel."${ schema.id.toLowerCase() }"`;
if ( rootGetters['i18n/exists'](key) ) {
return rootGetters['i18n/t'](key, { count }).trim();