Skip to content
This repository has been archived by the owner on Jul 27, 2019. It is now read-only.

Commit

Permalink
Issue #1609 - Improve Kustomize 2 parameters UI (#125)
Browse files Browse the repository at this point in the history
* Issue #1609 - Improve Kustomize 2 parameters UI

* Add unit tests for kustomize image parsing
  • Loading branch information
Alexander Matyushentsev authored May 28, 2019
1 parent 71f3351 commit a49314b
Show file tree
Hide file tree
Showing 10 changed files with 1,882 additions and 67 deletions.
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM node:9.4.0 as build
FROM node:11.15.0 as build

WORKDIR /src
ADD ["package.json", "yarn.lock", "./"]
Expand Down
4 changes: 4 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node'
};
14 changes: 11 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
"start": "webpack-dev-server --config ./src/app/webpack.config.js --mode development",
"docker": "./scripts/build_docker.sh",
"build": "yarn lint && rm -rf dist && webpack --config ./src/app/webpack.config.js",
"lint": "tslint -p ./src/app"
"lint": "tslint -p ./src/app",
"test": "jest"
},
"dependencies": {
"@types/classnames": "^2.2.3",
Expand Down Expand Up @@ -65,15 +66,22 @@
"superagent": "^3.8.2",
"superagent-promise": "^1.1.0",
"ts-node": "^4.1.0",
"tslint": "^5.9.1",
"tslint": "^5.16.0",
"tslint-react": "^3.4.0",
"typescript": "^2.8.1",
"typescript": "^3.4.5",
"webpack": "^4.29.5",
"webpack-cli": "^3.2.3",
"webpack-dev-server": "^3.2.1"
},
"resolutions": {
"@types/react": "16.8.5",
"@types/react-dom": "16.8.2"
},
"devDependencies": {
"@types/jest": "^24.0.13",
"add": "^2.0.6",
"jest": "^24.8.0",
"ts-jest": "^24.0.2",
"yarn": "^1.16.0"
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { FormField, FormSelect } from 'argo-ui';
import { FormField, FormSelect, getNestedField } from 'argo-ui';
import * as React from 'react';
import { FieldApi, FormApi, FormField as ReactFormField, Text } from 'react-form';

import { CheckboxField, EditablePanel, EditablePanelItem, TagsInputField } from '../../../shared/components';
import * as models from '../../../shared/models';
import { ImageTagFieldEditor } from './kustomize';
import * as kustomize from './kustomize-image';

const TextWithMetadataField = ReactFormField((props: {metadata: { value: string }, fieldApi: FieldApi, className: string }) => {
const { fieldApi: {getValue, setValue}} = props;
Expand All @@ -12,12 +14,6 @@ const TextWithMetadataField = ReactFormField((props: {metadata: { value: string
return <input className={props.className} value={metadata.value} onChange={(el) => setValue({...metadata, value: el.target.value})}/>;
});

const TextForArray = ReactFormField((props: {fieldApi: FieldApi, className: string }) => {
const { fieldApi: {getValue, setValue}} = props;

return <input className={props.className} value={(getValue() || []).join(' ')} onChange={(el) => setValue(el.target.value.split(' ').filter((v) => v !== ''))}/>;
});

function distinct<T>(first: IterableIterator<T>, second: IterableIterator<T>) {
return Array.from(new Set(Array.from(first).concat(Array.from(second))));
}
Expand All @@ -32,6 +28,7 @@ function overridesFirst(first: { overrideIndex: number}, second: { overrideIndex
}

function getParamsEditableItems<T extends { name: string, value: string }>(
app: models.Application,
title: string,
fieldsPath: string,
removedOverrides: boolean[],
Expand All @@ -42,13 +39,14 @@ function getParamsEditableItems<T extends { name: string, value: string }>(
original: string,
metadata: { name: string; value: string; }
}[],
component: React.ComponentType = TextWithMetadataField,
) {
return params.sort(overridesFirst).map((param, i) => ({
key: param.key,
title: param.metadata.name,
view: (
<span title={param.metadata.value}>
{param.metadata.value !== param.original && <span className='fa fa-heart-broken' title={`Original value: ${param.original}`}/>} {param.metadata.value}
{param.overrideIndex > -1 && <span className='fa fa-exclamation-triangle' title={`Original value: ${param.original}`}/>} {param.metadata.value}
</span>
),
edit: (formApi: FormApi) => {
Expand All @@ -60,7 +58,7 @@ function getParamsEditableItems<T extends { name: string, value: string }>(
{overrideRemoved && (
<span>{param.original}</span>
) || (
<FormField formApi={formApi} field={fieldItemPath} component={TextWithMetadataField} componentProps={{
<FormField formApi={formApi} field={fieldItemPath} component={component} componentProps={{
metadata: param.metadata,
}}/>
)}
Expand All @@ -71,7 +69,7 @@ function getParamsEditableItems<T extends { name: string, value: string }>(
}} style={labelStyle}>
Remove override</a>}
{overrideRemoved && <a onClick={() => {
formApi.setValue(fieldItemPath, param.metadata);
formApi.setValue(fieldItemPath, getNestedField(app, fieldsPath)[i]);
removedOverrides[i] = false;
setRemovedOverrides(removedOverrides);
}} style={labelStyle}>
Expand Down Expand Up @@ -109,7 +107,7 @@ export const ApplicationParameters = (props: { application: models.Application,
(props.details.ksonnet && props.details.ksonnet.parameters || []).forEach((param) => paramsByComponentName.set(`${param.component}-${param.name}` , param));
const overridesByComponentName = new Map<string, number>();
(source.ksonnet && source.ksonnet.parameters || []).forEach((override, i) => overridesByComponentName.set(`${override.component}-${override.name}`, i));
attributes = attributes.concat(getParamsEditableItems('PARAMETERS', 'spec.source.ksonnet.parameters', removedOverrides, setRemovedOverrides,
attributes = attributes.concat(getParamsEditableItems(app, 'PARAMETERS', 'spec.source.ksonnet.parameters', removedOverrides, setRemovedOverrides,
distinct(paramsByComponentName.keys(), overridesByComponentName.keys()).map((componentName) => {
let param = paramsByComponentName.get(componentName);
const original = param && param.value || '';
Expand All @@ -130,27 +128,27 @@ export const ApplicationParameters = (props: { application: models.Application,
edit: (formApi: FormApi) => <FormField formApi={formApi} field='spec.source.kustomize.namePrefix' component={Text}/>,
});

const images = props.details && props.details.kustomize && props.details.kustomize.images || [];

if (images.length > 0) {
attributes.push({
title: 'IMAGES',
view: source.kustomize && source.kustomize.images || [],
edit: (formApi: FormApi) => (
<div>
<FormField formApi={formApi} field='spec.source.kustomize.images' component={TextForArray}/>
<p>Use this to change the images used in your app.</p>
<ul>
<li>For a different tag, use <code>REPO:NEW_TAG</code>, e.g <code>busybox:3.6</code>.</li>
<li>For a different image, use <code>REPO=NEW_REPO:NEW_TAG</code>, e.g <code>busybox=alpine:3.6</code>.</li>
</ul>
<p>
Images available to override are:<br/>
<code>{images}</code>
</p>
</div>
),
});
const srcImages = (props.details && props.details.kustomize && props.details.kustomize.images || []).map((val) => kustomize.parse(val));
const images = (source.kustomize && source.kustomize.images || []).map((val) => kustomize.parse(val));

if (srcImages.length > 0) {
const imagesByName = new Map<string, kustomize.Image>();
srcImages.forEach((img) => imagesByName.set(img.name, img));

const overridesByName = new Map<string, number>();
images.forEach((override, i) => overridesByName.set(override.name, i));

attributes = attributes.concat(getParamsEditableItems(app, 'IMAGES', 'spec.source.kustomize.images', removedOverrides, setRemovedOverrides,
distinct(imagesByName.keys(), overridesByName.keys()).map((name) => {
const param = imagesByName.get(name);
const original = param && kustomize.format(param);
let overrideIndex = overridesByName.get(name);
if (overrideIndex === undefined) {
overrideIndex = -1;
}
const value = overrideIndex > -1 && kustomize.format(images[overrideIndex]) || original;
return { overrideIndex, original, metadata: { name, value } };
}), ImageTagFieldEditor));
}

const imageTags = props.details && props.details.kustomize && props.details.kustomize.imageTags || [];
Expand All @@ -162,7 +160,7 @@ export const ApplicationParameters = (props: { application: models.Application,
const overridesByName = new Map<string, number>();
(source.kustomize && source.kustomize.imageTags || []).forEach((override, i) => overridesByName.set(override.name, i));

attributes = attributes.concat(getParamsEditableItems('IMAGE TAGS', 'spec.source.kustomize.imageTags', removedOverrides, setRemovedOverrides,
attributes = attributes.concat(getParamsEditableItems(app, 'IMAGE TAGS', 'spec.source.kustomize.imageTags', removedOverrides, setRemovedOverrides,
distinct(imagesByName.keys(), overridesByName.keys()).map((name) => {
const param = imagesByName.get(name);
const original = param && param.value || '';
Expand Down Expand Up @@ -191,6 +189,7 @@ export const ApplicationParameters = (props: { application: models.Application,
const overridesByName = new Map<string, number>();
(source.helm && source.helm.parameters || []).forEach((override, i) => overridesByName.set(override.name, i));
attributes = attributes.concat(getParamsEditableItems(
app,
'PARAMETERS',
'spec.source.helm.parameters', removedOverrides, setRemovedOverrides, distinct(paramsByName.keys(), overridesByName.keys()).map((name) => {
const param = paramsByName.get(name);
Expand Down Expand Up @@ -228,8 +227,12 @@ export const ApplicationParameters = (props: { application: models.Application,
if (input.spec.source.kustomize && input.spec.source.kustomize.imageTags) {
input.spec.source.kustomize.imageTags = input.spec.source.kustomize.imageTags.filter(isDefined);
}
props.save(input);
if (input.spec.source.kustomize && input.spec.source.kustomize.images) {
input.spec.source.kustomize.images = input.spec.source.kustomize.images.filter(isDefined);
}
await props.save(input);
setRemovedOverrides(new Array<boolean>());
})}
values={app} title={app.metadata.name.toLocaleUpperCase()} items={attributes} />
values={app} title={props.details.type.toLocaleUpperCase()} items={attributes} />
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { format, parse } from './kustomize-image';

test('parse image version override', () => {
const image = parse('foo/bar:v1.0.0');

expect(image.name).toBe('foo/bar');
expect(image.newTag).toBe('v1.0.0');
});

test('format image version override', () => {
const formatted = format({ name: 'foo/bar', newTag: 'v1.0.0' });
expect(formatted).toBe('foo/bar:v1.0.0');
});

test('parse image name override', () => {
const image = parse('foo/bar=foo/bar1:v1.0.0');

expect(image.name).toBe('foo/bar');
expect(image.newName).toBe('foo/bar1');
expect(image.newTag).toBe('v1.0.0');
});

test('format image name override', () => {
const formatted = format({ name: 'foo/bar', newTag: 'v1.0.0', newName: 'foo/bar1' });
expect(formatted).toBe('foo/bar=foo/bar1:v1.0.0');
});

test('parse image digest override', () => {
const image = parse('foo/bar@sha:123');

expect(image.name).toBe('foo/bar');
expect(image.digest).toBe('sha:123');
});

test('format image digest override', () => {
const formatted = format({ name: 'foo/bar', digest: 'sha:123' });
expect(formatted).toBe('foo/bar@sha:123');
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
const pattern = /^(.*):([a-zA-Z0-9._-]*)$/;

export interface Image {
name: string;
newName?: string;
newTag?: string;
digest?: string;
}

function parseOverwrite(arg: string, overwriteImage: boolean): { name: string; digest?: string, tag?: string } {
// match <image>@<digest>
const parts = arg.split('@');
if (parts.length > 1) {
return { name: parts[0], digest: parts[1]};
}

// match <image>:<tag>
const groups = pattern.exec(arg);
if (groups && groups.length === 3) {
return { name: groups[1], tag: groups[2]};
}

// match <image>
if (arg.length > 0 && overwriteImage) {
return { name: arg };
}
return { name: arg };
}

export function parse(arg: string): Image {
// matches if there is an image name to overwrite
// <image>=<new-image><:|@><new-tag>
const parts = arg.split('=');
if (parts.length === 2) {
const overwrite = parseOverwrite(parts[1], true);
return {
name: parts[0],
newName: overwrite.name,
newTag: overwrite.tag,
digest: overwrite.digest,
};
}

// matches only for <tag|digest> overwrites
// <image><:|@><new-tag>
const p = parseOverwrite(arg, false);
return {name: p.name, newTag: p.tag, digest: p.digest};
}

export function format(image: Image) {
const imageName = image.newName ? `${image.name}=${image.newName}` : image.name;
if (image.newTag) {
return `${imageName}:${image.newTag}`;
} else if (image.digest) {
return `${imageName}@${image.digest}`;
}
return imageName;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { Checkbox } from 'argo-ui';
import * as React from 'react';
import { FieldApi, FormField as ReactFormField } from 'react-form';

import { format, parse } from './kustomize-image';

export const ImageTagFieldEditor = ReactFormField((props: {metadata: { value: string }, fieldApi: FieldApi, className: string }) => {
const { fieldApi: {getValue, setValue}} = props;
const origImage = parse(props.metadata.value);
const val = getValue();
const image = val ? parse(val) : { name: origImage.name };
const mustBeDigest = (image.digest || '').indexOf(':') > -1;
return (
<div>
<input style={{width: 'calc(50% - 1em)', marginRight: '1em'}} placeholder={origImage.name} className={props.className} value={image.newName || ''} onChange={(el) => {
setValue(format({...image, newName: el.target.value}));
}}/>
<input style={{width: 'calc(50% - 12em)'}} className={props.className} onChange={(el) => {
const forceDigest = el.target.value.indexOf(':') > -1;
if (image.digest || forceDigest) {
setValue(format({...image, newTag: null, digest: el.target.value}));
} else {
setValue(format({...image, newTag: el.target.value, digest: null}));
}
}} placeholder={origImage.newTag || origImage.digest} value={image.newTag || image.digest || ''}/>
<div style={{width: '6em', display: 'inline-block'}}>
<Checkbox checked={!!image.digest} id={`${image.name}_is-digest`} onChange={() => {
const nextImg = {...image};
if (mustBeDigest) {
return;
}
if (nextImg.digest) {
nextImg.newTag = nextImg.digest;
nextImg.digest = null;
} else {
nextImg.digest = nextImg.newTag;
nextImg.newTag = null;
}
setValue(format(nextImg));
}}/> <label htmlFor={`${image.name}_is-digest`}> Digest?</label>
</div>
</div>
);
});
3 changes: 3 additions & 0 deletions src/app/applications/components/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,9 @@ export function getAppOverridesCount(app: appModels.Application) {
if (app.spec.source.kustomize && app.spec.source.kustomize.imageTags) {
return app.spec.source.kustomize.imageTags.length;
}
if (app.spec.source.kustomize && app.spec.source.kustomize.images) {
return app.spec.source.kustomize.images.length;
}
if (app.spec.source.helm && app.spec.source.helm.parameters) {
return app.spec.source.helm.parameters.length;
}
Expand Down
4 changes: 4 additions & 0 deletions src/app/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,9 @@
},
"include": [
"./**/*"
],
"exclude": [
"node_modules",
"./**/*.test.ts"
]
}
Loading

0 comments on commit a49314b

Please sign in to comment.