import Errors from 'ui/utils/errors'; import { get, set, computed, setProperties, observer } from '@ember/object'; import { scheduleOnce } from '@ember/runloop'; import { alias, notEmpty } from '@ember/object/computed'; import { inject as service } from '@ember/service'; import Component from '@ember/component'; import NewOrEdit from 'shared/mixins/new-or-edit'; import C from 'ui/utils/constants'; import Util from 'ui/utils/util'; import { task } from 'ember-concurrency'; import YAML from 'yamljs'; import layout from './template'; import { stringifyAnswer } from 'shared/utils/evaluate'; import { isEmpty } from '@ember/utils'; import CatalogApp from 'shared/mixins/catalog-app'; import { all } from 'rsvp'; import { evaluate } from 'shared/utils/evaluate'; const OVERRIDE_HEADERS = [ { translationKey: 'newMultiClusterApp.overrides.table.scope', name: 'scope', sort: ['scope'], }, { translationKey: 'newMultiClusterApp.overrides.table.question', name: 'question', sort: ['question'], }, { translationKey: 'newMultiClusterApp.overrides.table.answer', name: 'answer', sort: ['answer'], }, ]; export default Component.extend(NewOrEdit, CatalogApp, { catalog: service(), intl: service(), scope: service(), router: service(), settings: service(), globalStore: service(), layout, allTemplates: null, templateResource: null, versionsArray: null, versionsLinks: null, actuallySave: true, showHeader: true, showPreview: true, decoding: false, upgradeStrategy: false, titleAdd: 'newCatalog.titleAdd', titleUpgrade: 'newCatalog.titleUpgrade', selectVersionAdd: 'newCatalog.selectVersionAdd', selectVersionUpgrade: 'newCatalog.selectVersionUpgrade', saveUpgrade: 'newCatalog.saveUpgrade', saveNew: 'newCatalog.saveNew', sectionClass: 'box mb-20', showDefaultVersionOption: false, classNames: ['launch-catalog', 'launch-multicluster-app'], multiClusterApp: null, srcSet: false, detailExpanded: false, previewOpen: false, previewTab: null, questionsArray: null, selectedTemplateUrl: null, selectedTemplateModel: null, readmeContent: null, appReadmeContent: null, pastedAnswers: null, noAppReadme: null, selectedFileContetnt: null, answerOverrides: null, projects: null, clusters: null, isClone: false, projectsToAddOnUpgrade: null, projectsToRemoveOnUpgrade: null, overridesHeaders: OVERRIDE_HEADERS, isGKE: alias('scope.currentCluster.isGKE'), primaryResource: alias('multiClusterApp'), editing: notEmpty('primaryResource.id'), init() { this._super(...arguments); this.initAttrs(); this.initUpgradeStrategy(); scheduleOnce('afterRender', () => { if ( get(this, 'selectedTemplateUrl') ) { this.templateChanged(); } else { this.initSelectedTemplateModel(); } }); }, didRender() { this.initCatalogIcon(); }, actions: { addTarget(targetIn) { if (targetIn && !get(targetIn, 'type')) { const { multiClusterApp, editing, projectsToAddOnUpgrade, projectsToRemoveOnUpgrade } = this; let target = null; let toRemoveMatch = (projectsToRemoveOnUpgrade || []).findBy('projectId', get(targetIn, 'value')); if (toRemoveMatch) { // a project was remove then re-added this.projectsToRemoveOnUpgrade.removeObject(targetIn); target = toRemoveMatch; } else { target = this.globalStore.createRecord({ type: 'target', projectId: get(targetIn, 'value'), }); } if (editing) { projectsToAddOnUpgrade.pushObject(target); } if (multiClusterApp.targets) { multiClusterApp.targets.pushObject(target); } else { set(multiClusterApp, 'targets', [target]); } } }, removeTarget(target) { const { editing, projectsToRemoveOnUpgrade, projectsToAddOnUpgrade } = this; let targetToAddMatch = (projectsToAddOnUpgrade || []).findBy('projectId', get(target, 'projectId')); if (targetToAddMatch) { // a project was added then removed this.projectsToAddOnUpgrade.removeObject(targetToAddMatch); } else { if (editing) { projectsToRemoveOnUpgrade.pushObject(target); } } get(this, 'multiClusterApp.targets').removeObject(target); }, addRole(roleId, roleToRemove) { let { roles } = this.multiClusterApp; if (!roles) { roles = []; } if (roles.indexOf(roleToRemove) > -1) { roles.removeObject(roleToRemove); } if (roleId !== '') { roles.pushObject(roleId); set(this, 'multiClusterApp.roles', roles); } else { // its possible that the user set extra roles via the api, we shouldn't clobber those roles as well. if (roles.length > 1) { set(this, 'multiClusterApp.roles', roles); } else { set(this, 'multiClusterApp.roles', null); } } }, addAuthorizedPrincipal(principal) { if (principal) { let { members = [] } = this.multiClusterApp; if (!members) { members = []; } members.pushObject(principal); set(this, 'multiClusterApp.members', members); } }, removeMember(member) { let { members } = this.multiClusterApp; members.removeObject(member); }, gotError(err) { set(this, 'errors', [Errors.stringify(err)]); }, addAnswerOverride() { let { answerOverrides } = this; let nueOverride = { scope: null, question: null, answer: null, isSubQuestion: false, } if (answerOverrides) { answerOverrides.pushObject(nueOverride); } else { answerOverrides = [nueOverride]; } set(this, 'answerOverrides', answerOverrides); }, removeAnswerOverride(answer) { this.answerOverrides.removeObject(answer); }, addDependentSubQuestions(answers) { let { answerOverrides } = this; answerOverrides.pushObjects(answers); }, removeDependentSubQuestions(answers) { let { answerOverrides } = this; answerOverrides.removeObjects(answers); }, toogleDetailedDescriptions() { set(this, 'detailExpanded', true); }, cancel() { if (this.cancel) { this.cancel(); } }, togglePreview() { this.toggleProperty('previewOpen'); }, selectPreviewTab(tab) { set(this, 'previewTab', tab); }, }, updateAnswerOverrides: observer('selectedTemplateModel', 'multiClusterApp.answers.@each.{values}', function() { let { selectedTemplateModel = {} } = this; const questions = get(selectedTemplateModel, 'questions') const customAnswers = questions ? get(selectedTemplateModel, 'customAnswers') : get(this, 'multiClusterApp.answers'); const answerOverrides = []; Object.keys(customAnswers).forEach( (customAnswerKey) => { let answer = get(customAnswers, customAnswerKey); if (isGlobalAnswersCollection(answer)) { if (this.isClone || this.editing) { Object.keys(answer.values).forEach((valueKey) => { ( questions || []).forEach((q) => { if (get(q, 'variable') === valueKey) { customAnswers[customAnswerKey]; let allAnswersFromInput = get(customAnswers, `${ customAnswerKey }.values`); let answerFromInput = allAnswersFromInput ? allAnswersFromInput[valueKey] : null; try { answerFromInput = JSON.parse(answerFromInput) } catch (e) { } set(q, 'answer', answerFromInput); } else if (get(q, 'subquestions') && get(q, 'subquestions').findBy('variable', valueKey)) { let sqMatch = get(q, 'subquestions').findBy('variable', valueKey); let allAnswersFromInput = get(customAnswers, `${ customAnswerKey }.values`); let answerFromInput = allAnswersFromInput ? allAnswersFromInput[valueKey] : null; set(sqMatch, 'answer', answerFromInput); } }); }); } } else { Object.keys(answer.values).forEach((valueKey) => { let isSubQuestion = false; (questions || []).forEach((q) => { if (get(q, 'subquestions') && get(q, 'subquestions').findBy('variable', valueKey)) { isSubQuestion = true; } }); let nueOverride = { scope: answer.clusterId || answer.projectId, answer: get(answer.values, valueKey), question: valueKey, isSubQuestion, } answerOverrides.pushObject(nueOverride); }); } }); set(this, 'answerOverrides', answerOverrides); function isGlobalAnswersCollection(answer) { if (isEmpty(answer.clusterId) && isEmpty(answer.projectId)) { return true; } else { return false; } } }), upgradeStrategyChanged: observer('upgradeStrategy', function() { const { upgradeStrategy, multiClusterApp, globalStore } = this; if (upgradeStrategy) { set(multiClusterApp, 'upgradeStrategy', globalStore.createRecord({ type: 'upgradeStrategy', rollingUpdate: globalStore.createRecord({ type: 'rollingUpdate', batchSize: 1, interval: 1, }) })); } else { set(multiClusterApp, 'upgradeStrategy', null); } }), templateOrHelmChartQuestions: computed('selectedTemplateModel', function() { let { selectedTemplateModel, multiClusterApp } = this; let nueQuestions = []; if (get(selectedTemplateModel, 'questions')) { return get(selectedTemplateModel, 'questions'); } else { if (get(multiClusterApp, 'answers.firstObject.values')) { let helmQuestions = get(multiClusterApp, 'answers.firstObject.values'); nueQuestions = Object.keys(helmQuestions).map((qk) => { return { variable: qk, answer: get(helmQuestions, qk), }; }); } return nueQuestions; } }), answers: computed('templateOrHelmChartQuestions.@each.{variable,answer}', function() { const out = {}; const allQuestions = []; (get(this, 'templateOrHelmChartQuestions') || []).forEach((item) => { allQuestions.push(item); (get(item, 'subquestions') || []).forEach((sub) => { allQuestions.push(sub); }); }); const filteredQuestions = allQuestions.filter((q) => evaluate(q, allQuestions)); filteredQuestions.forEach((item) => { out[item.variable] = stringifyAnswer(item.answer); }); return out; }), answersArray: computed('selectedTemplateModel.questions', 'selectedTemplateModel.customAnswers', 'primaryResource.answers', function() { const model = get(this, 'selectedTemplateModel'); if (get(model, 'questions')) { const questions = []; (get(this, 'selectedTemplateModel.questions') || []).forEach((q) => { questions.push(q); const subquestions = get(q, 'subquestions'); if ( subquestions ) { questions.pushObjects(subquestions); } }); const customAnswers = get(this, 'selectedTemplateModel.customAnswers') || {}; Object.keys(customAnswers).forEach((key) => { questions.push({ variable: key, answer: customAnswers[key], }); }); return questions; } else { return get(this, 'primaryResource.answers.firstObject.values'); } }), answersString: computed('answersArray.@each.{variable,answer}', function() { const model = get(this, 'selectedTemplateModel'); if (get(model, 'questions')) { let neu = {}; (get(this, 'answersArray') || []).filter((a) => typeof a.answer !== 'object' ).forEach((a) => { neu[a.variable] = isEmpty(a.answer) ? a.default : a.answer; }); const customAnswers = get(model, 'customAnswers') || {}; Object.keys(customAnswers).forEach((key) => { if ( typeof customAnswers[key] !== 'object' ) { neu[key] = customAnswers[key]; } }); return YAML.stringify(neu); } else { return JSON.stringify(get(this, 'answersArray')); } }), allProjectsAndClustersUngrouped: computed('projects.[]', 'primaryResource.targets.@each.projectId', function() { let out = []; get(this, 'clusters').forEach( (c) => { out.pushObject({ name: get(c, 'name'), value: get(c, 'id'), isCluster: true, }); c.get('projects').forEach( (p) => { out.pushObject({ name: get(p, 'name'), value: get(p, 'id'), isProject: true, }); }); }); return out; }), getTemplate: task(function * () { let url = get(this, 'selectedTemplateUrl'); if ( url === 'default' ) { let defaultUrl = get(this, 'defaultUrl'); if ( defaultUrl ) { url = defaultUrl; } else { url = null; } } if (url) { let version = get(this, 'settings.rancherVersion'); if ( version ) { url = Util.addQueryParam(url, 'rancherVersion', version); } let current = get(this, 'primaryResource.answers'); if ( !current ) { current = {}; set(this, 'primaryResource.answers', current); } var selectedTemplateModel = yield get(this, 'catalog').fetchByUrl(url) .then((response) => { if (response.questions) { const questions = []; const customAnswers = {}; response.questions.forEach((q) => { questions.push(q); const subquestions = get(q, 'subquestions'); if ( subquestions ) { questions.pushObjects(subquestions); } }); questions.forEach((item) => { // This will be the component that is rendered to edit this answer item.inputComponent = `schema/input-${ item.type }`; // Only types marked supported will show the component, Ember will explode if the component doesn't exist item.supported = C.SUPPORTED_SCHEMA_INPUTS.indexOf(item.type) >= 0; if (typeof current[item.variable] !== 'undefined') { // If there's an existing value, use it (for upgrade) item.answer = current[item.variable]; } else if (item.type === 'service' || item.type === 'certificate') { // Loaded async and then the component picks the default } else if ( item.type === 'boolean' ) { // Coerce booleans item.answer = (item.default === 'true' || item.default === true); } else { // Everything else item.answer = item.default || null; } }); Object.keys(current).forEach((key) => { const q = questions.findBy('variable', key); if ( !q ) { customAnswers[key] = current[key]; } }); response.customAnswers = customAnswers; } return response; }); setProperties(this, { selectedTemplateModel, 'primaryResource.templateVersionId': selectedTemplateModel.id, }); const files = Object.keys(selectedTemplateModel.get('files')) || []; if ( files.length > 0 ) { const valuesYaml = files.find((file) => file.endsWith('/values.yaml')); set(this, 'previewTab', valuesYaml ? valuesYaml : files[0]); } } else { setProperties(this, { selectedTemplateModel: null, readmeContent: null, appReadmeContent: null, noAppReadme: false, }) } this.updateReadme(); }), validate() { this._super(...arguments); const errors = get(this, 'errors') || []; errors.pushObjects(get(this, 'selectedTemplateModel').validationErrors() || []); errors.pushObjects(this.validateTargetsProjectIds()); set(this, 'errors', errors.uniq()); return errors.length === 0; }, validateTargetsProjectIds() { let errors = []; let targets = get(this, 'multiClusterApp.targets'); if (targets && targets.length >= 1) { targets.forEach((target) => { if (!get(target, 'projectId')) { errors.push(this.intl.t('validation.targets.missingProjectId')); } }); } return errors; }, willSave() { set(this, 'errors', null); const { primaryResource } = this; set(primaryResource, 'answers', this.buildAnswerMap()) const ok = this.validate(); if (!ok) { // Validation failed return false; } if ( get(this, 'actuallySave') ) { if (this.editing) { return this.doProjectActions(); } else { return true; } } else { return false; } }, doProjectActions() { const { primaryResource } = this; const { projectsToAddOnUpgrade, projectsToRemoveOnUpgrade } = this; const promises = []; const toAdd = (projectsToAddOnUpgrade || []).map((p) => get(p, 'projectId')).uniq(); const toRemove = (projectsToRemoveOnUpgrade || []).map((p) => get(p, 'projectId')).uniq(); const filteredToAdd = toAdd.filter((id) => !toRemove.includes(id)); const filteredToRemove = toRemove.filter((id) => !toAdd.includes(id)); if ( filteredToAdd.length > 0 ) { promises.push(primaryResource.doAction('addProjects', { projects: filteredToAdd })); } if ( filteredToRemove.length > 0 ) { promises.push(primaryResource.doAction('removeProjects', { projects: filteredToRemove })); } if (promises.length > 0) { return all(promises) .then(() => { return true; }) .catch((/* handled by growl error */) => { return false; }); } else { return true; } }, doneSaving() { return this.router.transitionTo('global-admin.multi-cluster-apps'); }, buildAnswerMap() { const { globalStore, answers, answerOverrides } = this; let answer = { type: 'answer', clusterId: null, projectId: null, values: null }; let out = []; let globalAnswers = answer; set(globalAnswers, 'values', answers); out.pushObject(globalStore.createRecord(globalAnswers)); if (answerOverrides && answerOverrides.length > 0) { answerOverrides.forEach( (override) => { let outMatch = out.findBy('clusterId', override.scope) || out.findBy('projectId', override.scope); let questionVariable = get(override, 'question.variable') ? override.question.variable : override.question; if (outMatch) { outMatch.values[questionVariable] = stringifyAnswer(override.answer); } else { let newOverrideAnswer = { type: 'answer', clusterId: null, projectId: null, values: {} }; let overrideScope = get(this, 'allProjectsAndClustersUngrouped').findBy('value', override.scope); if (get(overrideScope, 'isProject')) { set(newOverrideAnswer, 'projectId', override.scope); } if (get(overrideScope, 'isCluster')) { set(newOverrideAnswer, 'clusterId', override.scope); } newOverrideAnswer.values[questionVariable] = stringifyAnswer(override.answer); out.pushObject(globalStore.createRecord(newOverrideAnswer)); } }); } return out; }, initAttrs() { setProperties(this, { selectedTemplateModel: null, projectsToAddOnUpgrade: [], projectsToRemoveOnUpgrade: [], }); }, initCatalogIcon() { if (!this.get('srcSet')) { set(this, 'srcSet', true); const $icon = this.$('img'); $icon.attr('src', $icon.data('src')); this.$('img').on('error', () => { $icon.attr('src', `${ this.get('app.baseAssets') }assets/images/generic-catalog.svg`); }); } }, initSelectedTemplateModel() { let def = get(this, 'templateResource.defaultVersion'); const latest = get(this, 'templateResource.latestVersion'); const links = get(this, 'versionLinks'); const app = get(this, 'primaryResource'); if (get(app, 'id') && !get(this, 'upgrade')) { def = get(app, 'externalIdInfo.version'); } set(this, 'selectedTemplateUrl', links[def] || links[latest] || null) return; }, initUpgradeStrategy() { const { multiClusterApp } = this; if (get(multiClusterApp, 'upgradeStrategy.rollingUpdate')) { set(this, 'upgradeStrategy', true); } }, });