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

Issue #1609 - Improve Kustomize 2 parameters UI #125

Merged
merged 2 commits into from
May 28, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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