diff --git a/dashboard/src/components/api/environment/kubernetes-environment-manager.spec.ts b/dashboard/src/components/api/environment/kubernetes-environment-manager.spec.ts index eb8d227ddf..89e9953586 100644 --- a/dashboard/src/components/api/environment/kubernetes-environment-manager.spec.ts +++ b/dashboard/src/components/api/environment/kubernetes-environment-manager.spec.ts @@ -13,8 +13,8 @@ import {KubernetesEnvironmentManager} from './kubernetes-environment-manager'; import {IEnvironmentManagerMachine, IEnvironmentManagerMachineServer} from './environment-manager-machine'; -import {IPodList} from './kubernetes-environment-recipe-parser'; -import {IPodItem, IPodItemContainer} from './kubernetes-machine-recipe-parser'; +import {ISupportedItemList} from './kubernetes-environment-recipe-parser'; +import {IPodItem, IPodItemContainer, getPodItemOrNull, ISupportedListItem} from './kubernetes-machine-recipe-parser'; import {CheRecipeTypes} from '../recipe/che-recipe-types'; /** @@ -104,11 +104,15 @@ describe('KubernetesEnvironmentManager', () => { let getEnvironmentSource = (environment: che.IWorkspaceEnvironment, machine: IEnvironmentManagerMachine): string => { const [podName, containerName] = machine.name.split(/\//); - const recipe: IPodList = jsyaml.load(environment.recipe.content); - const machinePodItem = recipe.items.find((machinePodItem: IPodItem) => { + const recipe: ISupportedItemList = jsyaml.load(environment.recipe.content); + const machinePodItem = getPodItemOrNull(recipe.items.find((item: ISupportedListItem) => { + const machinePodItem = getPodItemOrNull(item); + if (!machinePodItem) { + return false; + } const podItemName = machinePodItem.metadata.name ? machinePodItem.metadata.name : machinePodItem.metadata.generateName; return podItemName === podName; - }); + })); const podContainer = machinePodItem.spec.containers.find((podContainer: IPodItemContainer) => { return podContainer.name === containerName; }); @@ -187,11 +191,16 @@ describe('KubernetesEnvironmentManager', () => { let getEnvironmentSource = (environment: che.IWorkspaceEnvironment, machine: IEnvironmentManagerMachine): string => { const podName = machine.recipe.metadata.name; const containerName = machine.recipe.spec.containers[0].name; - const recipe: IPodList = jsyaml.load(environment.recipe.content); - const machinePodItem = recipe.items.find((machinePodItem: IPodItem) => { + const recipe: ISupportedItemList = jsyaml.load(environment.recipe.content); + const machinePodItem = getPodItemOrNull(recipe.items.find((item: ISupportedListItem) => { + const machinePodItem = getPodItemOrNull(item); + if (!machinePodItem) { + return false; + } const podItemName = machinePodItem.metadata.name ? machinePodItem.metadata.name : machinePodItem.metadata.generateName; return podItemName === podName; - }); + })); + const podContainer = machinePodItem.spec.containers.find((podContainer: IPodItemContainer) => { return podContainer.name === containerName; }); diff --git a/dashboard/src/components/api/environment/kubernetes-environment-manager.ts b/dashboard/src/components/api/environment/kubernetes-environment-manager.ts index 95b4f573f1..a63e1cff11 100644 --- a/dashboard/src/components/api/environment/kubernetes-environment-manager.ts +++ b/dashboard/src/components/api/environment/kubernetes-environment-manager.ts @@ -14,8 +14,8 @@ import {EnvironmentManager} from './environment-manager'; import {IEnvironmentManagerMachine} from './environment-manager-machine'; import {CheRecipeTypes} from '../recipe/che-recipe-types'; -import {IPodList, KubernetesEnvironmentRecipeParser} from './kubernetes-environment-recipe-parser'; -import {IPodItem, IPodItemContainer, KubernetesMachineRecipeParser} from './kubernetes-machine-recipe-parser'; +import {ISupportedItemList, KubernetesEnvironmentRecipeParser} from './kubernetes-environment-recipe-parser'; +import {ISupportedListItem, IPodItem, IPodItemContainer, KubernetesMachineRecipeParser, getPodItemOrNull} from './kubernetes-machine-recipe-parser'; enum MemoryUnit { 'B', 'Ki', 'Mi', 'Gi' } @@ -73,19 +73,19 @@ export class KubernetesEnvironmentManager extends EnvironmentManager { /** * Parses machine recipe content. * @param content {string} recipe content - * @returns {IPodItem} recipe object + * @returns {ISupportedListItem} recipe object */ - parseMachineRecipe(content: string): IPodItem { + parseMachineRecipe(content: string): ISupportedListItem { return this.machineParser.parse(content); } /** * Parses recipe content. * @param content {string} recipe content - * @returns {IPodList} recipe object + * @returns {ISupportedItemList} recipe object */ - parseRecipe(content: string): IPodList { - let recipe: IPodList; + parseRecipe(content: string): ISupportedItemList { + let recipe: ISupportedItemList; try { recipe = this.parser.parse(content); } catch (e) { @@ -96,10 +96,10 @@ export class KubernetesEnvironmentManager extends EnvironmentManager { /** * Dumps recipe object. - * @param recipe {IPodList} recipe object + * @param recipe {ISupportedItemList} recipe object * @returns {string} recipe content */ - stringifyRecipe(recipe: IPodList): string { + stringifyRecipe(recipe: ISupportedItemList): string { return this.parser.dump(recipe); } @@ -115,16 +115,18 @@ export class KubernetesEnvironmentManager extends EnvironmentManager { return machines; } - const recipe: IPodList = this.parseRecipe(environment.recipe.content); + const recipe: ISupportedItemList = this.parseRecipe(environment.recipe.content); if (!recipe) { this.$log.error('EnvironmentManager: cannot parse recipe.'); return machines; } - recipe.items.forEach((podItem: IPodItem) => { - if (!podItem || podItem.kind !== POD || !podItem.metadata.name && podItem.spec || !angular.isArray(podItem.spec.containers)) { + recipe.items.forEach((item: ISupportedListItem) => { + const podItem = getPodItemOrNull(item); + if (!podItem) { return; } + const annotations = podItem.metadata.annotations; podItem.spec.containers.forEach((container: IPodItemContainer) => { if (!container || !container.name) { @@ -208,7 +210,8 @@ export class KubernetesEnvironmentManager extends EnvironmentManager { } machines.forEach((machine: IEnvironmentManagerMachine) => { let containerName: string; - let pod = recipe.items.find((podItem: IPodItem) => { + let podOrDeployment = recipe.items.find((item: ISupportedListItem) => { + const podItem = getPodItemOrNull(item); if (!podItem || !podItem.metadata) { return false; } @@ -231,6 +234,7 @@ export class KubernetesEnvironmentManager extends EnvironmentManager { return podItemName === podName; }); + const pod = getPodItemOrNull(podOrDeployment); if (pod && pod.kind === POD && pod.metadata.name && pod.spec && angular.isArray(pod.spec.containers)) { const containerIndex = pod.spec.containers.findIndex((container: IPodItemContainer) => { return container.name === containerName; @@ -324,7 +328,8 @@ export class KubernetesEnvironmentManager extends EnvironmentManager { let pod; let containerName: string; if (recipe && recipe.kind === LIST && angular.isArray(recipe.items)) { - pod = recipe.items.find((podItem: IPodItem) => { + pod = getPodItemOrNull(recipe.items.find((item: ISupportedListItem) => { + const podItem = getPodItemOrNull(item); if (!podItem || podItem.kind !== POD || !podItem.metadata.name || !podItem.spec || !angular.isArray(podItem.spec.containers)) { return false; } @@ -347,7 +352,7 @@ export class KubernetesEnvironmentManager extends EnvironmentManager { return true; } return false; - }); + })); } if (!pod) { this.$log.error('EnvironmentManager: cannot rename machine.'); @@ -439,8 +444,9 @@ export class KubernetesEnvironmentManager extends EnvironmentManager { const nameAnnotation = `${NAME_ANNOTATION_PREFIX}.${machineRecipe.spec.containers[0].name}.${MACHINE_NAME}`; if (angular.isArray(recipe.items)) { const machineRecipePod = this.getMachinePod(machineRecipe); - const usedPodIndex = recipe.items.findIndex((pod: IPodItem) => { - if (!pod.kind || pod.kind !== POD || pod.metadata.name !== machineRecipe.metadata.name) { + const usedPodIndex = recipe.items.findIndex((item: ISupportedListItem) => { + const pod = getPodItemOrNull(item); + if (!pod) { return false; } return machineRecipePod.metadata.name === this.getMachinePod(pod).metadata.name; @@ -448,7 +454,7 @@ export class KubernetesEnvironmentManager extends EnvironmentManager { if (usedPodIndex === -1) { recipe.items.push(machineRecipe); } else { - recipe.items[usedPodIndex].spec.containers.push(machineRecipe.spec.containers[0]); + getPodItemOrNull(recipe.items[usedPodIndex]).spec.containers.push(machineRecipe.spec.containers[0]); } // update machine name @@ -506,8 +512,9 @@ export class KubernetesEnvironmentManager extends EnvironmentManager { let podIndex: number; let containerName: string; if (envRecipe && envRecipe.kind === LIST && angular.isArray(envRecipe.items)) { - podIndex = envRecipe.items.findIndex((podItem: IPodItem) => { - if (!podItem || podItem.kind !== POD || !podItem.metadata.name || !podItem.spec || !angular.isArray(podItem.spec.containers)) { + podIndex = envRecipe.items.findIndex((item: ISupportedListItem) => { + const podItem = getPodItemOrNull(item); + if (!podItem || !podItem.metadata.name || !podItem.spec || !angular.isArray(podItem.spec.containers)) { return false; } const containerIndex = podItem.spec.containers.findIndex((container: IPodItemContainer) => { @@ -531,7 +538,7 @@ export class KubernetesEnvironmentManager extends EnvironmentManager { }); } if (podIndex > -1) { - const podItem = envRecipe.items[podIndex]; + const podItem = getPodItemOrNull(envRecipe.items[podIndex]); if (podItem && podItem.kind === POD && podItem.metadata.name && podItem.spec && angular.isArray(podItem.spec.containers)) { if (podItem.spec.containers.length) { const containerIndex = podItem.spec.containers.findIndex((podItemContainer: IPodItemContainer) => { diff --git a/dashboard/src/components/api/environment/kubernetes-environment-recipe-parser.ts b/dashboard/src/components/api/environment/kubernetes-environment-recipe-parser.ts index d2e767f71b..a50b33aa3a 100644 --- a/dashboard/src/components/api/environment/kubernetes-environment-recipe-parser.ts +++ b/dashboard/src/components/api/environment/kubernetes-environment-recipe-parser.ts @@ -10,12 +10,12 @@ * Red Hat, Inc. - initial API and implementation */ 'use strict'; -import {IPodItem, KubernetesMachineRecipeParser} from './kubernetes-machine-recipe-parser'; +import {ISupportedListItem, KubernetesMachineRecipeParser} from './kubernetes-machine-recipe-parser'; import {IParser} from './parser'; -export interface IPodList { +export interface ISupportedItemList { kind: string; - items: Array; + items: Array; } /** @@ -25,16 +25,16 @@ export interface IPodList { */ export class KubernetesEnvironmentRecipeParser implements IParser { private machineRecipeParser = new KubernetesMachineRecipeParser(); - private recipeByContent: Map = new Map(); + private recipeByContent: Map = new Map(); private recipeKeys: Array = []; /** * Parses recipe content * @param content {string} recipe content - * @returns {IPodList} recipe object + * @returns {ISupportedItemList} recipe object */ - parse(content: string): IPodList { - let recipe: IPodList; + parse(content: string): ISupportedItemList { + let recipe: ISupportedItemList; if (this.recipeByContent.has(content)) { recipe = angular.copy(this.recipeByContent.get(content)); this.validate(recipe); @@ -54,40 +54,40 @@ export class KubernetesEnvironmentRecipeParser implements IParser { /** * Dumps recipe object. - * @param recipe {IPodList} recipe object + * @param recipe {ISupportedItemList} recipe object * @returns {string} recipe content */ - dump(recipe: IPodList): string { + dump(recipe: ISupportedItemList): string { return jsyaml.safeDump(recipe, {'indent': 1}); } /** * Simple validation of recipe. - * @param recipe {IPodList} + * @param recipe {ISupportedItemList} */ - private validate(recipe: IPodList): void { + private validate(recipe: ISupportedItemList): void { if (!recipe || !recipe.kind) { throw new TypeError(`Recipe should contain a 'kind' section.`); } if (recipe.kind.toLowerCase() !== 'list') { throw new TypeError(`Recipe 'kind' section should be equals 'list'.`); } - const podItems = recipe.items; - if (!podItems) { - throw new TypeError(`Recipe pod list should contain an 'items' section.`); + const items = recipe.items; + if (!items) { + throw new TypeError(`Recipe kubernetes list should contain an 'items' section.`); } - if (!angular.isArray(podItems) || podItems.length === 0) { - throw new TypeError(`Recipe pod list should contain at least one 'item'.`); + if (!angular.isArray(items) || items.length === 0) { + throw new TypeError(`Recipe kubernetes list should contain at least one 'item'.`); } else { - podItems.forEach((podItem: IPodItem) => { - if (!podItem) { + items.forEach((item: ISupportedListItem) => { + if (!item) { return; } // skip services - if (podItem.kind && podItem.kind.toLowerCase() === 'service') { + if (item.kind && item.kind.toLowerCase() === 'service') { return; } - this.machineRecipeParser.validate(podItem); + this.machineRecipeParser.validate(item); }); } } diff --git a/dashboard/src/components/api/environment/kubernetes-machine-recipe-parser.ts b/dashboard/src/components/api/environment/kubernetes-machine-recipe-parser.ts index 8f83fe0eaf..8279d90f47 100644 --- a/dashboard/src/components/api/environment/kubernetes-machine-recipe-parser.ts +++ b/dashboard/src/components/api/environment/kubernetes-machine-recipe-parser.ts @@ -13,15 +13,55 @@ import {IParser} from './parser'; +export type ISupportedListItem = IPodItem | IDeploymentItem | IConfigMapItem; + +export function isDeploymentItem(item: ISupportedListItem): item is IDeploymentItem { + return (item.kind && item.kind.toLowerCase() === 'deployment'); +} + +export function isPodItem(item: ISupportedListItem): item is IPodItem { + return (item.kind && item.kind.toLowerCase() === 'pod'); +} + +export function isConfigMapItem(item: ISupportedListItem): item is IConfigMapItem { + return (item.kind && item.kind.toLowerCase() === 'configmap'); +} + +export function getPodItemOrNull(item: ISupportedListItem): IPodItem { + if (isDeploymentItem(item)) { + return item.spec.template; + } else if (isPodItem(item)) { + return item; + } else { + return null; + } +} + +export interface IObjectMetadata { + name?: string; + generateName?: string; + annotations?: { [propName: string]: string }; + labels?: { [propName: string ]: string }; + [propName: string]: string | Object; +} + +export interface IDeploymentItem { + apiVersion: string; + kind: string; + metadata: IObjectMetadata; + spec: { + replicas: number; + selector: { + matchLabels: { [propName: string]: string | Object }; + } + template: IPodItem + }; +} + export interface IPodItem { apiVersion: string; kind: string; - metadata: { - name?: string; - generateName?: string; - annotations?: { [propName: string]: string }; - [propName: string]: string | Object; - }; + metadata: IObjectMetadata; spec: { containers: Array }; [propName: string]: string | Object; } @@ -37,23 +77,30 @@ export interface IPodItemContainer { [propName: string]: string | Object; } +export interface IConfigMapItem { + apiVersion: string; + kind: string; + metadata: IObjectMetadata; + data: { [propName: string]: string | Object }; +} + /** * Wrapper for jsyaml and simple validator for kubernetes machine recipe. * * @author Oleksii Orel */ export class KubernetesMachineRecipeParser implements IParser { - private recipeByContent: Map = new Map(); + private recipeByContent: Map = new Map(); private recipeKeys: Array = []; /** * Parses recipe content * * @param content {string} recipe content - * @returns {IPodItem} recipe object + * @returns {ISupportedListItem} recipe object */ - parse(content: string): IPodItem { - let recipe: IPodItem; + parse(content: string): ISupportedListItem { + let recipe: ISupportedListItem; if (this.recipeByContent.has(content)) { recipe = angular.copy(this.recipeByContent.get(content)); this.validate(recipe); @@ -74,47 +121,87 @@ export class KubernetesMachineRecipeParser implements IParser { /** * Dumps recipe object. * - * @param recipe {IPodItem} recipe object + * @param recipe {IPodItem | IDeploymentItem | IConfigMapItem} recipe object * @returns {string} recipe content */ - dump(recipe: IPodItem): string { + dump(recipe: ISupportedListItem): string { return jsyaml.safeDump(recipe, {'indent': 1}); } - /** - * Simple validation of machine recipe. - * - * @param recipe {IPodItem} - */ - validate(recipe: IPodItem): void { + validate(recipe: ISupportedListItem ): void { if (!recipe || !recipe.kind) { - throw new TypeError(`Recipe should contain a 'kind' section.`); - } - if (recipe.kind.toLowerCase() !== 'pod') { - throw new TypeError(`Recipe 'kind' section should be equals 'pod'.`); + throw new TypeError(`Recipe item should contain a 'kind' section.`); } if (!recipe.apiVersion) { - throw new TypeError(`Recipe pod item should contain 'apiVersion' section.`); + throw new TypeError(`Recipe item should contain 'apiVersion' section`); } - if (!recipe.metadata) { - throw new TypeError(`Recipe pod item should contain 'metadata' section.`); + if (isDeploymentItem(recipe)) { + this.validateDeployment(recipe); + } else if (isPodItem(recipe)) { + this.validatePod(recipe); + } else if (isConfigMapItem(recipe)) { + this.validateConfigMap(recipe); } - if (!recipe.metadata.name && !recipe.metadata.generateName) { - throw new TypeError(`Recipe pod item metadata should contain 'name' section.`); + } + + /** + * Simple validation of Deployment recipe. + * + * @param deployment + */ + validateDeployment(deployment: IDeploymentItem): void { + this.validateMetadata(deployment.metadata); + if (!deployment.spec) { + throw new TypeError(`Recipe deployment item should contain a 'spec' section.`); } - if (recipe.metadata.name && !this.testName(recipe.metadata.name)) { - throw new TypeError(`Recipe pod item container name should not contain special characters like dollar, etc.`); + const spec = deployment.spec; + if (!spec.replicas || spec.replicas !== 1) { + throw new TypeError(`Recipe deployment spec should contain replicas value equal to 1.`); } - if (!recipe.spec) { + if (!spec.template) { + throw new TypeError(`Recipe deployment spec should contain template section.`); + } + if (!spec.selector || !spec.selector.matchLabels) { + throw new TypeError(`Recipe deployment spec should contain selector section.`); + } + this.validatePod(spec.template); + // for the deployment to work, the matchlabels section needs to match the labels + // applied to the Pod. + const matchLabels = spec.selector.matchLabels; + const podLabels = spec.template.metadata.labels; + if (!podLabels) { + throw new TypeError(`Recipe deployment spec matchLabels must match pod labels.`); + } + for (const key of Object.keys(matchLabels)) { + if (matchLabels[key] !== podLabels[key]) { + throw new TypeError(`Recipe deployment matchLabels must match pod labels.`); + } + } + // since match labels are ANDed, we also need the same labels + for (const key of Object.keys(podLabels)) { + if (podLabels[key] !== matchLabels[key]) { + throw new TypeError(`Recipe deployment matchLabels must match pod labels.`); + } + } + } + + /** + * Simple validation of Pod recipe. + * + * @param pod {IPodItem} + */ + validatePod(pod: IPodItem): void { + this.validateMetadata(pod.metadata); + if (!pod.spec) { throw new TypeError(`Recipe pod item should contain 'spec' section.`); } - if (!recipe.spec.containers) { + if (!pod.spec.containers) { throw new TypeError(`Recipe pod item spec should contain 'containers' section.`); } - if (!angular.isArray(recipe.spec.containers) || recipe.spec.containers.length === 0) { + if (!angular.isArray(pod.spec.containers) || pod.spec.containers.length === 0) { throw new TypeError(`Recipe pod item spec containers should contain at least one 'container'.`); } - recipe.spec.containers.forEach((podItemContainer: IPodItemContainer) => { + pod.spec.containers.forEach((podItemContainer: IPodItemContainer) => { if (!podItemContainer) { return; } @@ -128,7 +215,25 @@ export class KubernetesMachineRecipeParser implements IParser { throw new TypeError(`Recipe pod item container should contain 'image' section.`); } }); + } + validateConfigMap(configMap: IConfigMapItem) { + this.validateMetadata(configMap.metadata); + if (!configMap.data) { + throw new TypeError(`Recipe config map item should contain data section.`); + } + } + + validateMetadata(metadata: IObjectMetadata) { + if (!metadata) { + throw new TypeError(`Recipe item should contain 'metadata' section.`); + } + if (!metadata.name && !metadata.generateName) { + throw new TypeError(`Recipe item metadata should contain 'name' section.`); + } + if (metadata.name && !this.testName(metadata.name)) { + throw new TypeError(`Recipe item container name should not contain special characters like dollar, etc.`); + } } /**