import { isArray } from '@ember/array'; import Component from '@ember/component'; import { computed, get, observer, set, setProperties } from '@ember/object'; import { alias, equal, union } from '@ember/object/computed'; import { on } from '@ember/object/evented'; import { inject as service } from '@ember/service'; import { isEmpty } from '@ember/utils'; import ipaddr from 'ipaddr.js'; import { hash } from 'rsvp'; import Semver from 'semver'; import ClusterDriver from 'shared/mixins/cluster-driver'; import C from 'shared/utils/constants'; import { addQueryParams } from 'shared/utils/util'; import { DEFAULT_AKS_CONFIG, DEFAULT_AKS_NODE_POOL_CONFIG } from 'ui/models/cluster'; import { regionsWithAZs } from 'ui/utils/azure-choices'; import layout from './template'; import { satisfies, coerceVersion } from 'shared/utils/parse-version'; const NETWORK_POLICY = [ { label: 'None', value: null }, { label: 'Calico', value: 'calico' }, { label: 'Azure (requires Azure CNI)', value: 'azure', disabled: true } ]; const CHINA_REGION_API_URL = 'https://management.chinacloudapi.cn/'; const CHINA_REGION_AUTH_URL = 'https://login.chinacloudapi.cn/'; const NETWORK_PLUGINS = [ { label: 'Kubenet', value: 'kubenet' }, { label: 'Azure CNI', value: 'azure' } ]; const LB_SKUS = [ { label: 'Standard', value: 'Standard' }, { label: 'Basic', value: 'Basic' } ]; // Because aks just put out 1.25.0 as a preview we're releasing // it as experimental until we can adequately test it. // https://github.com/rancher/dashboard/issues/7217 const EXPERIMENTAL_RANGE = '>= 1.25.0' const MINIMUM_VERSION = '>= 1.23.0' export default Component.extend(ClusterDriver, { globalStore: service(), intl: service(), settings: service(), versionChoiceService: service('version-choices'), layout, configField: 'aksConfig', clusterAdvanced: false, clusterErrors: null, clusterLocationSaved: false, clusterLocationSaving: false, disableAzs: false, enabledAuthorizedIpRanges: false, errors: null, lbSkus: LB_SKUS, loadBalancerImmutable: false, monitoringRegionConent: [], networkPlugins: NETWORK_PLUGINS, otherErrors: null, regions: null, step: 1, versions: null, vmSizes: null, originalSecret: null, regionsWithAZs, defaultNodePoolConfig: DEFAULT_AKS_NODE_POOL_CONFIG, defaultAksConfig: DEFAULT_AKS_CONFIG, defaultK8sVersionRange: alias(`settings.${ C.SETTING.VERSION_SYSTEM_K8S_DEFAULT_RANGE }`), editing: equal('mode', 'edit'), isNew: equal('mode', 'new'), allErrors: union('errors', 'otherErrors', 'clusterErrors'), init() { this._super(...arguments); let config = get(this, 'cluster.aksConfig'); if ( !config ) { config = this.globalStore.createRecord(JSON.parse(JSON.stringify(this.defaultAksConfig))); const defNodePool = this.globalStore.createRecord(JSON.parse(JSON.stringify(this.defaultNodePoolConfig))) setProperties(defNodePool, { isNew: true, name: 'agentpool', }); set(config, 'nodePools', [defNodePool]); setProperties(this, { 'cluster.aksConfig': config, 'loadBalancerSku': 'Standard', }); } else { if ( this.editing && this.importedClusterIsPending || (this.clusterIsPending && config?.privateCluster) ) { set(this, 'step', 4); } else { this.syncUpstreamConfig(); const tags = { ...( config.tags || {} ) }; set(this, 'tags', tags); if (!isEmpty(config?.authorizedIpRanges)) { set(this, 'enabledAuthorizedIpRanges', true); } } } let cur = config?.azureCredentialSecret; if ( cur && !cur.startsWith('cattle-global-data:') ) { cur = `cattle-global-data:${ cur }`; } set(this, 'originalSecret', cur); }, actions: { addNodePool() { const config = get(this, `primaryResource.aksConfig`); const kubernetesVersion = get(config, 'kubernetesVersion'); let nodePools = (get(config, 'nodePools') || []).slice(); const npConfig = JSON.parse(JSON.stringify(this.defaultNodePoolConfig)); if (!isArray(nodePools)) { nodePools = []; } const nodePool = this.globalStore.createRecord(npConfig); set(nodePool, 'mode', 'User') setProperties(nodePool, { orchestratorVersion: kubernetesVersion, isNew: true, }); nodePools.pushObject(nodePool); set(this, 'config.nodePools', nodePools); }, removeNodePool(nodePool) { let { config: { nodePools = [] } } = this; if (!isEmpty(nodePools)) { nodePools.removeObject(nodePool); } set(this, 'config.nodePools', nodePools); }, updateAuthorizedIpRanges(ipRanges) { set(this, 'config.authorizedIpRanges', ipRanges); }, async finishAndSelectCloudCredential(cred) { // Workaround for when embedding in the dashboard UI // Need to go and fetch the cloud credentials again try { const store = get(this, 'globalStore'); const creds = await store.findAll('cloudcredential', { forceReload: true }); if (creds?.content) { set(this, 'cloudCredentials', creds.content); } } catch (err) { console.warn('Unable to fetch cloud credentials', err); // eslint-disable-line } if (cred) { set(this, 'config.azureCredentialSecret', get(cred, 'id')); this.send('loadRegions', (ok, error) => { if (!ok) { this.send('errorHandler', error); } }); } }, async loadRegions(cb) { const store = get(this, 'globalStore') const selectedCloudCredential = this.selectedCloudCredential; const regionsWithAZs = this.regionsWithAZs const data = { cloudCredentialId: get(selectedCloudCredential, 'id'), // tenantId: get(this, 'config.tenantId'), }; const url = addQueryParams('/meta/aksLocations', data); try { const regions = await store.rawRequest({ url, method: 'GET', }); const filteredRegions = (regions?.body ?? []).map((reg) => { if (regionsWithAZs.includes(reg?.displayName || '')) { return { ...reg, ...{ group: 'High Availablity' } } } else { return { ...reg, ...{ group: 'Other' } }; } }); set(this, 'regions', filteredRegions); cb(true); set(this, 'step', 2); if (this.editing) { set(this, 'clusterLocationSaving', true); this.send('authenticate', (ok, error) => { if (!ok) { this.send('errorHandler', error); } set(this, 'clusterLocationSaving', false); }) } } catch (error) { this.send('errorHandler', error); cb(false); } }, authenticate(cb) { const store = get(this, 'globalStore') const { selectedCloudCredential } = this; const data = { cloudCredentialId: get(selectedCloudCredential, 'id'), // tenantId: get(this, 'config.tenantId'), region: get(this, 'config.resourceLocation'), clusterId: get(this, 'cluster.id') }; if ( get(this, 'isChinaRegion') ) { setProperties(data, { baseUrl: CHINA_REGION_API_URL, authBaseUrl: CHINA_REGION_AUTH_URL }) } const versionsUrl = addQueryParams('/meta/aksVersions', data); const upgradesUrl = addQueryParams('/meta/aksUpgrades', data); const vNetsUrl = addQueryParams('/meta/aksVirtualNetworks', data); const vmSizes = addQueryParams('/meta/aksVMSizes', data); const aksRequest = { versions: store.rawRequest({ url: versionsUrl, method: 'GET', }), upgradeVersions: data.clusterId ? store.rawRequest({ url: upgradesUrl, method: 'GET' }) : Promise.resolve({}), virtualNetworks: store.rawRequest({ url: vNetsUrl, method: 'GET', }), vmSizes: store.rawRequest({ url: vmSizes, method: 'GET', }), } return hash(aksRequest).then((resp) => { const { mode } = this; const { versions, upgradeVersions, virtualNetworks, vmSizes } = resp; const isEdit = mode === 'edit'; const versionz = (get(versions, 'body') || []); const upgradeVersionz = (get(upgradeVersions, 'body.upgrades') || []); const nonExperimentalVersions = versionz.filter((v) => !satisfies(coerceVersion(v), EXPERIMENTAL_RANGE)); const initialVersion = isEdit ? this.config.kubernetesVersion : Semver.maxSatisfying(nonExperimentalVersions, this.defaultK8sVersionRange); // default in azure ui if (!isEdit && initialVersion) { set(this, 'cluster.aksConfig.kubernetesVersion', initialVersion); } const enabledVersions = upgradeVersionz .filter((upgrade) => upgrade.enabled) .map((upgrade) => upgrade.version); setProperties(this, { step: 3, versions: enabledVersions.length > 0 ? [initialVersion, ...enabledVersions] : versionz, virtualNetworks: (get(virtualNetworks, 'body') || []), vmSizes: vmSizes?.body || [], }); cb(true); }).catch((xhr) => { const err = xhr.body.message || xhr.body.code || xhr.body.error; setProperties(this, { errors: [err], }); cb(false, [err]); }); }, setTags(section) { set(this, 'config.tags', section); }, }, resetNetworkPolicy: observer('config.{networkPolicy,networkPlugin}', function() { const { networkPolicy, networkPlugin } = this.config; if (networkPlugin === 'kubenet' && networkPolicy === 'azure') { set(this, 'config.networkPolicy', null); } if (this.config.networkPolicy === null) { set(this, 'cluster.enableNetworkPolicy', false) } }), resetIpRanges: observer('config.privateCluster', 'loadBalancerSku', function() { if (( this.config?.privateCluster || this.loadBalancerSku === 'Basic' ) && !isEmpty(this.config?.authorizedIpRanges)) { setProperties(this, { 'config.authorizedIpRanges': [], enabledAuthorizedIpRanges: false, }); } }), resourceLocationChanged: observer('config.resourceLocation', function() { const { regions, config: { resourceLocation } } = this; const match = regions.findBy('name', resourceLocation); const regionHasAz = this.regionsWithAZs.includes(match?.displayName || ''); if (regionHasAz) { set(this, 'disableAzs', false); } else { set(this, 'disableAzs', true); } }), postSaveChanged: observer('isPostSave', function() { const { isNew, isPostSave, config: { privateCluster }, importedClusterIsPending, } = this; if ((privateCluster || importedClusterIsPending) && isPostSave) { if (isNew) { set(this, 'step', 4); } else { this.close(); } } else { this.close(); } }), availablityZonesChanged: on('init', observer('config.nodePools.[]', function() { const nodePools = get(this, 'config.nodePools') || []; const azs = []; nodePools.forEach((np) => { if (np?.availabilityZones && np.availabilityZones.length > 0) { azs.pushObjects(np.availabilityZones); } }); const anySet = azs.uniq().any((az) => az); if (anySet) { setProperties(this, { 'loadBalancerSku': 'Standard', 'loadBalancerImmutable': true, }); } else { set(this, 'loadBalancerImmutable', false); } })), resetAdvancedOptions: on('init', observer('config.networkPlugin', function() { if (get(this, 'isNew') && get(this, 'config.networkPlugin') === 'azure') { const config = get(this, 'config'); const defaultConfigOptions = { dnsServiceIp: '10.0.0.10', dockerBridgeCidr: '172.17.0.1/16', podCidr: '172.244.0.0/16', serviceCidr: '10.0.0.0/16', subnet: '', virtualNetwork: '', virtualNetworkResourceGroup: '', }; const { subnet, virtualNetwork, virtualNetworkResourceGroup, serviceCidr, dnsServiceIp, dockerBridgeCidr } = defaultConfigOptions; setProperties(config, { subnet, virtualNetwork, virtualNetworkResourceGroup, serviceCidr, dnsServiceIp, dockerBridgeCidr }); } })), monitoringConfigDisabled: computed('model.originalCluster.aksStatus.upstreamSpec.monitoring', 'isNewOrEditable', 'editing', function(){ const { isNewOrEditable, editing } = this; const upstreamMonitoringEnabled = get(this, 'model.originalCluster.aksStatus.upstreamSpec.monitoring') ?? false; if (isNewOrEditable) { return false; } if (editing && !upstreamMonitoringEnabled) { return false; } return true; }), importedClusterIsPending: computed('clusterIsPending', 'model.originalCluster', function() { const { clusterIsPending } = this; const originalCluster = get(this, 'model.originalCluster'); const ourClusterSpec = get(( originalCluster ?? {} ), 'aksConfig'); const upstreamSpec = get(( originalCluster ?? {} ), 'aksStatus.upstreamSpec'); return clusterIsPending && get(ourClusterSpec, 'imported') && !isEmpty(upstreamSpec); }), clusterIsPending: computed('clusterState', function() { const { clusterState } = this; return ['pending', 'provisioning', 'waiting'].includes(clusterState); }), anyWindowsNodes: computed('config.nodePools.@each.osType', function() { const nodePools = this?.config?.nodePools ?? []; if (isEmpty(nodePools)) { return false; } return nodePools.any((np) => np?.osType === 'Windows'); }), hasProvisioned: computed('model.cluster', function() { const cluster = get(this, 'model.cluster'); const { state = '', isError = false } = cluster; let clusterHasProvisioned = true; if (isError && state === 'provisioning') { if (isEmpty(cluster?.aksStatus?.upstreamSpec)) { clusterHasProvisioned = false; } } return clusterHasProvisioned; }), isNewOrEditable: computed('hasProvisioned', 'isNew', 'mode', function() { const isNew = get(this, 'isNew'); if (isNew) { return true; } return this.mode === 'edit' && !this.hasProvisioned; }), cloudCredentials: computed('model.cloudCredentials', 'originalSecret', function() { const out = this.model.cloudCredentials.filter((cc) => { const isAzure = Object.prototype.hasOwnProperty.call(cc, 'azurecredentialConfig'); if (isAzure) { return true; } return false; }); if ( this.originalSecret && !out.find((x) => x.id === this.originalSecret ) ) { const obj = this.globalStore.createRecord({ name: `${ this.originalSecret.replace(/^cattle-global-data:/, '') } (current)`, id: this.originalSecret, type: 'cloudCredential', azurecredentialConfig: {}, }); out.push(obj); } return out; }), selectedCloudCredential: computed('cloudCredentials.@each.id', 'config.azureCredentialSecret', function() { const cur = this.config?.azureCredentialSecret; const cloudCredentials = this.cloudCredentials; if (isEmpty(cloudCredentials) && isEmpty(cur)) { return null; } else { return cloudCredentials.findBy('id', cur.includes('cattle-global-data:') ? cur : `cattle-global-data:${ cur }`); } }), versionChoices: computed('versions', function() { const { versions = [], mode, config : { kubernetesVersion: initialVersion } } = this; // azure versions come in oldest to newest return this.versionChoiceService.parseCloudProviderVersionChoices(( versions || [] ).reverse(), initialVersion, mode, null, false, EXPERIMENTAL_RANGE, MINIMUM_VERSION); }), networkChoice: computed({ set( _key, value = '' ) { const [subnet, virtualNetwork, virtualNetworkResourceGroup] = value.split(':'); const config = get(this, 'config'); if (subnet && virtualNetwork && virtualNetworkResourceGroup) { setProperties(config, { subnet, virtualNetwork, virtualNetworkResourceGroup }); } return value; } }), filteredVirtualNetworks: computed('config.virtualNetwork', 'virtualNetworks', function() { const vnets = get(this, 'virtualNetworks') || []; const subNets = []; vnets.forEach( (vnet) => { (get(vnet, 'subnets') || []).forEach( (subnet) => { subNets.pushObject({ name: `${ get(subnet, 'name') } (${ get(subnet, 'addressRange') })`, group: get(vnet, 'name'), value: `${ get(subnet, 'name') }:${ get(vnet, 'name') }:${ get(vnet, 'resourceGroup') }` }) }); }); return subNets; }), networkChoiceDisplay: computed('virtualNetworks', 'config.virtualNetwork', 'config.subnet', function() { const selected = (get(this, 'virtualNetworks') || []).findBy('name', get(this, 'config.virtualNetwork')) || {} const subnet = (get(selected, 'subnets') || []).findBy('name', get(this, 'config.subnet')) || {} return `${ get(subnet, 'name') } (${ get(subnet, 'addressRange') })` }), isChinaRegion: computed('config.resourceLocation', function() { return get(this, 'config.resourceLocation').startsWith('china'); }), networkPolicyContent: computed('config.networkPlugin', function() { const netPolicies = [...NETWORK_POLICY]; const azureNetPolicy = netPolicies.findBy('value', 'azure'); if (this?.config?.networkPlugin === 'azure') { set(azureNetPolicy, 'disabled', false); } else { set(azureNetPolicy, 'disabled', true); } return netPolicies; }), validate() { const intl = get(this, 'intl'); let model = get(this, 'cluster'); let clusterErrors = model.validationErrors() || []; const nodePoolErrors = this.validateNodePools(); const vnetSet = !!get(this, 'config.virtualNetwork'); if (vnetSet) { clusterErrors = clusterErrors.concat(this.validateVnetInputs()); } if ( this.isNew && !get(this, 'config.resourceGroup') ) { clusterErrors.push(intl.t('validation.required', { key: intl.t('clusterNew.azureaks.resourceGroup.label') })); } if ( this.isNew && !get(this, 'config.dnsPrefix') ) { clusterErrors.push(intl.t('validation.required', { key: intl.t('clusterNew.azureaks.dns.label') })); } set(this, 'errors', [...(this.errors ?? []), ...clusterErrors, ...nodePoolErrors]); return this.errors.length === 0; }, validateNodePools() { const nodePools = get(this, 'primaryResource.aksConfig.nodePools'); const errors = []; if (!isEmpty(nodePools)) { const nodePoolErrors = []; nodePools.forEach((np) => { const npErr = np.validationErrors(); const npOs = np?.osType; const npName = np?.name; // aka.ms/aks-naming-rules if (npOs === 'Linux') { if (npName.length > 11) { errors.push(this.intl.t('clusterNew.azureaks.nodePools.errors.linuxName')); } } else if (npOs === 'Windows') { if (npName.length > 6) { errors.push(this.intl.t('clusterNew.azureaks.nodePools.errors.windowsName')); } } if (!/^[a-z][a-z0-9]+$/.test(npName)) { errors.push(this.intl.t('clusterNew.azureaks.nodePools.errors.nameFormat')); } nodePoolErrors.push(npErr) }); if (!isEmpty(nodePoolErrors)) { errors.pushObjects(nodePoolErrors.flat()); } } return errors; }, validateVnetInputs() { const intl = get(this, 'intl'); const errors = []; const config = get(this, 'config'); const vnet = get(this, 'virtualNetworks').findBy('name', get(config, 'virtualNetwork')); if (vnet) { let subnet = get(vnet, `subnets`).findBy('name', get(config, 'subnet')); let vnetRange = ipaddr.parseCIDR(get(subnet, 'addressRange')); let { serviceCidr, dnsServiceIp, dockerBridgeCidr } = config; let parsedServiceCidr = null; let parsedDnsServiceIp = null; let parsedDockerBridgeCidr = null; if (!serviceCidr && !dnsServiceIp && !dockerBridgeCidr) { errors.pushObject('You must include all required fields when using a Virtual Network'); } try { parsedServiceCidr = ipaddr.parseCIDR(serviceCidr); // check if serviceCidr falls within the VNet/Subnet range if (parsedServiceCidr && vnetRange[0].match(parsedServiceCidr)) { errors.pushObject(intl.t('clusterNew.azureaks.errors.included.parsedServiceCidr')); } } catch ( err ) { errors.pushObject(intl.t('clusterNew.azureaks.errors.included.serviceCidr')); } try { parsedDnsServiceIp = ipaddr.parse(dnsServiceIp); // check if dnsService exists in range if (parsedDnsServiceIp && vnetRange[0].match(parsedDnsServiceIp, vnetRange[1])) { errors.pushObject(intl.t('clusterNew.azureaks.errors.included.parsedDnsServiceIp')); } } catch ( err ) { errors.pushObject(intl.t('clusterNew.azureaks.errors.included.dnsServiceIp')); } try { parsedDockerBridgeCidr = ipaddr.parseCIDR(dockerBridgeCidr); // check that dockerBridge doesn't overlap if (parsedDockerBridgeCidr && ( vnetRange[0].match(parsedDockerBridgeCidr) || parsedServiceCidr[0].match(parsedDockerBridgeCidr) )) { errors.pushObject(intl.t('clusterNew.azureaks.errors.included.parsedDockerBridgeCidr')); } } catch ( err ) { errors.pushObject(intl.t('clusterNew.azureaks.errors.included.dockerBridgeCidr')); } } return errors; }, willSave() { const enableMonitoring = get(this, 'config.monitoring') const config = get(this, 'config') if (!enableMonitoring) { setProperties(config, { logAnalyticsWorkspaceResourceGroup: null, logAnalyticsWorkspace: null, }) } set(config, 'clusterName', this.cluster.name); if ( get(this, 'isChinaRegion') ) { setProperties(config, { baseUrl: CHINA_REGION_API_URL, authBaseUrl: CHINA_REGION_AUTH_URL }) } else { delete config['baseUrl']; delete config['authBaseUrl']; } Object.keys(config).forEach((k) => { const val = get(config, k); if (isEmpty(val)) { delete config[k]; } }); return this._super(...arguments); }, syncUpstreamConfig() { const originalCluster = get(this, 'model.originalCluster').clone(); const ourClusterSpec = get(originalCluster, 'aksConfig'); const upstreamSpec = get(originalCluster, 'aksStatus.upstreamSpec'); if (!isEmpty(upstreamSpec)) { Object.keys(upstreamSpec).forEach((k) => { if (isEmpty(get(ourClusterSpec, k)) && !isEmpty(get(upstreamSpec, k))) { set(this, `config.${ k }`, get(upstreamSpec, k)); } }); } }, });