Add user dashboard-side validation for deployments/configmaps in recipes

Add validation and processing on the dashboard side for recipes
containing deployments and/or configmaps. This allows users to create
new stacks using these items in recipes.

Signed-off-by: Angel Misevski <amisevsk@redhat.com>
6.19.x
Angel Misevski 2018-12-19 12:32:15 -05:00
parent 129788ba31
commit e0f774c456
4 changed files with 203 additions and 82 deletions

View File

@ -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;
});

View File

@ -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) => {

View File

@ -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<IPodItem>;
items: Array<ISupportedListItem>;
}
/**
@ -25,16 +25,16 @@ export interface IPodList {
*/
export class KubernetesEnvironmentRecipeParser implements IParser {
private machineRecipeParser = new KubernetesMachineRecipeParser();
private recipeByContent: Map<string, IPodList> = new Map();
private recipeByContent: Map<string, ISupportedItemList> = new Map();
private recipeKeys: Array<string> = [];
/**
* 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);
});
}
}

View File

@ -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<IPodItemContainer> };
[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<string, IPodItem> = new Map();
private recipeByContent: Map<string, ISupportedListItem> = new Map();
private recipeKeys: Array<string> = [];
/**
* 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(<IDeploymentItem>recipe);
} else if (isPodItem(recipe)) {
this.validatePod(<IPodItem>recipe);
} else if (isConfigMapItem(recipe)) {
this.validateConfigMap(<IConfigMapItem>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.`);
}
}
/**