diff --git a/package-lock.json b/package-lock.json index 3fbf98f..d6d0504 100644 --- a/package-lock.json +++ b/package-lock.json @@ -983,12 +983,6 @@ "resolved": "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.2.tgz", "integrity": "sha512-6ckxMjBBD8URvjB6J3NcnuAn5Pkl7t3TizAg+xdlzzQGSPSmBcXf8KoIH0ua/i+tio+ZRUHEXp0HEmvaR4kt0w==" }, - "@types/clipboardy": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@types/clipboardy/-/clipboardy-1.1.0.tgz", - "integrity": "sha512-KOxf4ah9diZWmREM5jCupx2+pZaBPwKk5d5jeNK2+TY6IgEO35uhG55NnDT4cdXeRX8irDSHQPtdRrr0JOTQIw==", - "dev": true - }, "@types/events": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@types/events/-/events-3.0.0.tgz", @@ -1012,9 +1006,9 @@ } }, "@types/got": { - "version": "9.4.1", - "resolved": "https://registry.npmjs.org/@types/got/-/got-9.4.1.tgz", - "integrity": "sha512-m7Uc07bG/bZ+Dis7yI3mGssYDcAdUvP4irF3ZmBzf0ig7zEd1FyADfnELVGcf+p1Ol/iPCXbZYwcSNOJA2a+Qg==", + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@types/got/-/got-9.4.2.tgz", + "integrity": "sha512-k9F2iT5+Ym1jmAGjZyLsd0P3F8XB+4/gntbkx6Lofu7JvRINVIHa+l+fufLqtrJhgUw7UZqTfA+S6McPNXlPug==", "dev": true, "requires": { "@types/node": "*", @@ -1410,71 +1404,6 @@ "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.8.0.tgz", "integrity": "sha512-ReZxvNHIOv88FlT7rxcXIIC0fPt4KZqZbOlivyWtXLt8ESx84zd3kMC6iK5jVeS2qt+g7ftS7ye4fi06X5rtRQ==" }, - "babel-code-frame": { - "version": "6.26.0", - "resolved": "https://registry.npmjs.org/babel-code-frame/-/babel-code-frame-6.26.0.tgz", - "integrity": "sha1-Y/1D99weO7fONZR9uP42mj9Yx0s=", - "dev": true, - "requires": { - "chalk": "^1.1.3", - "esutils": "^2.0.2", - "js-tokens": "^3.0.2" - }, - "dependencies": { - "ansi-regex": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", - "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", - "dev": true - }, - "ansi-styles": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", - "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=", - "dev": true - }, - "chalk": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", - "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", - "dev": true, - "requires": { - "ansi-styles": "^2.2.1", - "escape-string-regexp": "^1.0.2", - "has-ansi": "^2.0.0", - "strip-ansi": "^3.0.0", - "supports-color": "^2.0.0" - } - }, - "esutils": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.2.tgz", - "integrity": "sha1-Cr9PHKpbyx96nYrMbepPqqBLrJs=", - "dev": true - }, - "js-tokens": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-3.0.2.tgz", - "integrity": "sha1-mGbfOVECEw449/mWvOtlRDIJwls=", - "dev": true - }, - "strip-ansi": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", - "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", - "dev": true, - "requires": { - "ansi-regex": "^2.0.0" - } - }, - "supports-color": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", - "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=", - "dev": true - } - } - }, "babel-jest": { "version": "24.7.1", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-24.7.1.tgz", @@ -11797,12 +11726,12 @@ "integrity": "sha512-4krF8scpejhaOgqzBEcGM7yDIEfi0/8+8zDRZhNZZ2kjmHJ4hv3zCbQWxoJGz1iw5U0Jl0nma13xzHXcncMavQ==" }, "tslint": { - "version": "5.15.0", - "resolved": "https://registry.npmjs.org/tslint/-/tslint-5.15.0.tgz", - "integrity": "sha512-6bIEujKR21/3nyeoX2uBnE8s+tMXCQXhqMmaIPJpHmXJoBJPTLcI7/VHRtUwMhnLVdwLqqY3zmd8Dxqa5CVdJA==", + "version": "5.16.0", + "resolved": "https://registry.npmjs.org/tslint/-/tslint-5.16.0.tgz", + "integrity": "sha512-UxG2yNxJ5pgGwmMzPMYh/CCnCnh0HfPgtlVRDs1ykZklufFBL1ZoTlWFRz2NQjcoEiDoRp+JyT0lhBbbH/obyA==", "dev": true, "requires": { - "babel-code-frame": "^6.22.0", + "@babel/code-frame": "^7.0.0", "builtin-modules": "^1.1.1", "chalk": "^2.3.0", "commander": "^2.12.1", diff --git a/package.json b/package.json index f815ff1..e407fff 100644 --- a/package.json +++ b/package.json @@ -60,9 +60,8 @@ "@semantic-release/gitlab": "^3.1.2", "@semantic-release/npm": "^5.1.4", "@smartive/tslint-config": "^6.0.0", - "@types/clipboardy": "^1.1.0", "@types/fs-extra": "^5.0.5", - "@types/got": "^9.4.1", + "@types/got": "^9.4.2", "@types/inquirer": "6.0.0", "@types/jest": "^24.0.11", "@types/node": "^11.13.4", @@ -75,7 +74,7 @@ "pkg": "^4.3.7", "semantic-release": "^15.13.3", "ts-jest": "^24.0.2", - "tslint": "^5.15.0", + "tslint": "^5.16.0", "tsutils": "^3.10.0", "typescript": "^3.4.3" } diff --git a/src/commands/apply/index.ts b/src/commands/apply/index.ts index c0e5290..49cd18e 100644 --- a/src/commands/apply/index.ts +++ b/src/commands/apply/index.ts @@ -55,6 +55,7 @@ export const applyCommand: CommandModule = { await kubeConfigCommand.handler({ ...args, noInteraction: true, + force: true, }); } diff --git a/src/commands/delete/index.ts b/src/commands/delete/index.ts index 469b1b5..bae477a 100644 --- a/src/commands/delete/index.ts +++ b/src/commands/delete/index.ts @@ -60,6 +60,7 @@ export const deleteCommand: CommandModule = { await kubeConfigCommand.handler({ ...args, noInteraction: true, + force: true, }); } diff --git a/src/commands/index.ts b/src/commands/index.ts index 83ff02d..03139e2 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -9,6 +9,7 @@ import { kubeConfigCommand } from './kube-config'; import { kubectlCommand } from './kubectl'; import { namespaceCommand } from './namespace'; import { prepareCommand } from './prepare'; +import { previewDeployCommand } from './preview-deploy'; import { secretCommand } from './secret'; import { versionCommand } from './version'; @@ -22,6 +23,7 @@ export const commands: CommandModule[] = [ kubectlCommand, namespaceCommand, prepareCommand, + previewDeployCommand, secretCommand, versionCommand, ]; diff --git a/src/commands/kube-config/index.ts b/src/commands/kube-config/index.ts index cb3399a..ceec29c 100644 --- a/src/commands/kube-config/index.ts +++ b/src/commands/kube-config/index.ts @@ -1,4 +1,3 @@ -import chalk from 'chalk'; import { outputFile, pathExists } from 'fs-extra'; import { Arguments, Argv, CommandModule } from 'yargs'; @@ -13,6 +12,7 @@ const defaultEnv = 'KUBE_CONFIG'; type KubeConfigArguments = RootArguments & { configContent?: string; noInteraction: boolean; + force: boolean; }; interface KubeConfigCommandModule extends CommandModule { @@ -23,16 +23,22 @@ export const kubeConfigCommand: KubeConfigCommandModule = { command: 'kube-config [configContent]', aliases: 'kc', describe: - `Use the given kube-config content ${chalk.yellow('(base64 encoded)')} ` + - `and create the ~/.kube/config file. If the content is omitted, the content ` + - `of the env var "$KUBE_CONFIG" is used.`, + 'Use the given kube-config content ' + + 'and create the ~/.kube/config file. If the content is omitted, the content ' + + 'of the env var "$KUBE_CONFIG" is used.', builder: (argv: Argv) => (argv .positional('configContent', { - description: 'Base64 encoded kube-config content.', + description: 'kube-config content (base64 encoded or not).', type: 'string', }) + .option('f', { + alias: 'force', + boolean: true, + default: false, + description: 'Force login, overwrite existing ~/.kube/config.', + }) .option('n', { alias: 'no-interaction', boolean: true, @@ -50,7 +56,7 @@ export const kubeConfigCommand: KubeConfigCommandModule = { return; } - const content = (args.configContent || process.env[defaultEnv] || '').trim(); + let content = (args.configContent || process.env[defaultEnv] || '').trim(); if (!content) { logger.error('Config content is empty. Aborting.'); @@ -58,28 +64,28 @@ export const kubeConfigCommand: KubeConfigCommandModule = { return; } - if (!content.isBase64()) { - logger.error('The content is not base64 encoded. Aborting.'); - process.exit(ExitCode.error); - return; + if (content.isBase64()) { + logger.info('The content was base64 encoded.'); + content = content.base64Decode(); } if (await pathExists(Filepathes.configPath)) { - if (args.noInteraction) { + if (args.noInteraction && !args.force) { logger.info('Config already exists, exitting.'); return; } if ( - !(await simpleConfirm('The kube config (~/.kube/config) already exists. ' + 'Do you want to overwrite it?', false)) + !args.force && + !(await simpleConfirm('The kube config (~/.kube/config) already exists. Do you want to overwrite it?', false)) ) { return; } } logger.info('Writing ~/.kube/config file.'); - await outputFile(Filepathes.configPath, content.base64Decode()); + await outputFile(Filepathes.configPath, content); - logger.success('Login done.'); + logger.success('~/.kube/config written.'); }, }; diff --git a/src/commands/preview-deploy/index.ts b/src/commands/preview-deploy/index.ts new file mode 100644 index 0000000..9b931f0 --- /dev/null +++ b/src/commands/preview-deploy/index.ts @@ -0,0 +1,124 @@ +import { V1Namespace, V1ObjectMeta } from '@kubernetes/client-node'; +import { async } from 'fast-glob'; +import { readFile } from 'fs-extra'; +import { EOL } from 'os'; +import { posix } from 'path'; +import { Arguments, Argv, CommandModule } from 'yargs'; + +import { RootArguments } from '../../root-arguments'; +import { envsubst } from '../../utils/envsubst'; +import { exec } from '../../utils/exec'; +import { ExitCode } from '../../utils/exit-code'; +import { KubernetesApi } from '../../utils/kubernetes-api'; +import { Logger } from '../../utils/logger'; +import { kubeConfigCommand } from '../kube-config'; + +type PreviewDeployArguments = RootArguments & { + name: string; + sourceFolder: string; + prefix: string; + kubeConfig?: string; +}; + +const logger = new Logger('preview deployment'); + +async function createNamespace(api: KubernetesApi, name: string): Promise { + try { + logger.info(`Create preview namespace "${name}".`); + await api.core.readNamespace(name); + logger.info(`Namespace "${name}" already exists.`); + } catch (e) { + if (e.response.statusCode !== 404) { + throw e; + } + + await api.core.createNamespace({ + ...new V1Namespace(), + apiVersion: 'v1', + kind: 'Namespace', + metadata: { + ...new V1ObjectMeta(), + name, + }, + }); + logger.success(`Created namespace "${name}".`); + } +} + +export const previewDeployCommand: CommandModule = { + command: 'preview-deploy [sourceFolder]', + aliases: 'prev-dep', + describe: + 'Create opinionated preview deployment. This command creates a namespace ' + + 'and deploys the application in the given namespace.', + + builder: (argv: Argv) => + argv + .option('prefix', { + string: true, + description: 'Defines the prefix for the namespace name.', + default: 'prev-', + }) + .option('kube-config', { + string: true, + description: 'Define the kube config to use for namespace creation. Defaults to $KUBE_CONFIG variable.', + }) + .positional('name', { + description: 'Name of the preview namespace.', + type: 'string', + }) + .positional('sourceFolder', { + description: 'Folder to search for yaml files.', + type: 'string', + default: './k8s/', + }) as Argv, + + async handler(args: Arguments): Promise { + if (args.getYargsCompletions) { + return; + } + + logger.debug('Execute preview deployment'); + + const name = `${args.prefix}${args.name}`; + if (name.length > 64) { + logger.error(`Name of the namespace "${name}" is too long (max: 64 characters, is: ${args.name.length})`); + process.exit(ExitCode.error); + return; + } + + const api = args.kubeConfig ? + KubernetesApi.fromString(args.kubeConfig.isBase64() ? args.kubeConfig.base64Decode() : args.kubeConfig) : + KubernetesApi.fromDefault(); + + await createNamespace(api, name); + + const files = await async(['**/*.{yml,yaml}'], { + cwd: args.sourceFolder, + }); + logger.debug(`Found ${files.length} files for processing.`); + + const yamls = (await Promise.all(files.map(async f => await readFile(posix.join(args.sourceFolder, f), 'utf8')))) + .map(yaml => envsubst(yaml)) + .join(`${EOL}---${EOL}`); + + await kubeConfigCommand.handler({ + ...args, + configContent: args.kubeConfig, + noInteraction: true, + force: args.ci, + }); + + logger.debug('Executing apply -f ->'); + try { + const result = await exec(`echo "${yamls}" | kubectl -n ${name} apply -f -`); + logger.info(result); + } catch (e) { + logger.error(`Error: ${e}`); + process.exit(ExitCode.error); + return; + } + + logger.success(`Preview Deployment applied to namespace "${name}".`); + }, +}; diff --git a/src/kubernetes-helpers.ts b/src/kubernetes-helpers.ts index e0725e9..7cbb09f 100644 --- a/src/kubernetes-helpers.ts +++ b/src/kubernetes-helpers.ts @@ -106,7 +106,7 @@ alias('h', 'help'); help('help'); wrap(terminalWidth()); -epilog(chalk.dim(`This tool intends to help with everyday kubernetes administration.`)); +epilog(chalk.dim('This tool intends to help with everyday kubernetes administration.')); const args = parse(); if (args._.length === 0 && !args.getYargsCompletions) { diff --git a/src/utils/kubernetes-api.ts b/src/utils/kubernetes-api.ts index d7ce8a7..05552a6 100644 --- a/src/utils/kubernetes-api.ts +++ b/src/utils/kubernetes-api.ts @@ -7,24 +7,7 @@ export class KubernetesApi { public static namespaceOverride: string | undefined; /** - * Manage elements of the kubernetes core api: - * - bindings - * - componentstatuses - * - configmaps - * - endpoints - * - events - * - limitranges - * - namespaces - * - nodes - * - persistentvolumeclaims - * - persistentvolumes - * - pods - * - podtemplates - * - replicationcontrollers - * - resourcequotas - * - secrets - * - serviceaccounts - * - services + * Manage elements of the kubernetes core api. */ public readonly core: Core_v1Api; @@ -75,4 +58,13 @@ export class KubernetesApi { kubeConfig.loadFromDefault(); return new KubernetesApi(kubeConfig); } + + /** + * Creates the kubernetes api from a given string content. + */ + public static fromString(content: string): KubernetesApi { + const kubeConfig = new KubeConfig(); + kubeConfig.loadFromString(content); + return new KubernetesApi(kubeConfig); + } } diff --git a/test/commands/kube-config/kube-config.spec.ts b/test/commands/kube-config/kube-config.spec.ts index 162706c..f8f6b80 100644 --- a/test/commands/kube-config/kube-config.spec.ts +++ b/test/commands/kube-config/kube-config.spec.ts @@ -50,20 +50,22 @@ describe('commands / kube-config', () => { expect((Logger as any).instance.error.mock.calls[0][0]).toBe('Config content is empty. Aborting.'); }); - it('should exit when content is not base64', async () => { + it('should not convert content when it is not base64', async () => { await kubeConfigCommand.handler({ noInteraction: true, configContent: 'FOO', } as any); - expect(process.exit).toHaveBeenCalledWith(1); - expect((Logger as any).instance.error.mock.calls[0][0]).toBe('The content is not base64 encoded. Aborting.'); + expect(vol.toJSON()).toEqual({ + [configPath]: 'FOO', + }); }); - it('should exit when env var is not base64', async () => { + it('should not convert content when env var is not base64', async () => { process.env['KUBE_CONFIG'] = 'FOO'; await kubeConfigCommand.handler({ noInteraction: true } as any); - expect(process.exit).toHaveBeenCalledWith(1); - expect((Logger as any).instance.error.mock.calls[0][0]).toBe('The content is not base64 encoded. Aborting.'); + expect(vol.toJSON()).toEqual({ + [configPath]: 'FOO', + }); }); it('should write the ~/.kube/config file when it does not exist', async () => { @@ -73,6 +75,13 @@ describe('commands / kube-config', () => { }); }); + it('should write the ~/.kube/config file when it does not exist (non b64)', async () => { + await kubeConfigCommand.handler({ configContent: 'foobar' } as any); + expect(vol.toJSON()).toEqual({ + [configPath]: 'foobar', + }); + }); + it('should do nothing when config exists and in no interaction mode', async () => { vol.fromJSON({ [configPath]: 'whatever', @@ -84,7 +93,7 @@ describe('commands / kube-config', () => { expect(vol.toJSON()).toEqual({ [configPath]: 'whatever', }); - expect((Logger as any).instance.info.mock.calls[0][0]).toBe('Config already exists, exitting.'); + expect((Logger as any).instance.info.mock.calls[1][0]).toBe('Config already exists, exitting.'); }); it('should ask the user if the config should be overwritten', async () => { diff --git a/test/commands/preview-deploy/__snapshots__/preview-deploy.spec.ts.snap b/test/commands/preview-deploy/__snapshots__/preview-deploy.spec.ts.snap new file mode 100644 index 0000000..b80a3f2 --- /dev/null +++ b/test/commands/preview-deploy/__snapshots__/preview-deploy.spec.ts.snap @@ -0,0 +1,11 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`commands / preview-deploy should create a namespace with the given name 1`] = ` +Object { + "apiVersion": "v1", + "kind": "Namespace", + "metadata": Object { + "name": "prev-foobar", + }, +} +`; diff --git a/test/commands/preview-deploy/preview-deploy.spec.ts b/test/commands/preview-deploy/preview-deploy.spec.ts new file mode 100644 index 0000000..af1ac80 --- /dev/null +++ b/test/commands/preview-deploy/preview-deploy.spec.ts @@ -0,0 +1,109 @@ +import { vol } from 'memfs'; + +import { previewDeployCommand } from '../../../src/commands/preview-deploy'; +import { exec } from '../../../src/utils/exec'; +import { KubernetesApi } from '../../../src/utils/kubernetes-api'; +import { Logger } from '../../../src/utils/logger'; +import { clearGlobalMocks } from '../../helpers'; + +function defaultFiles(): void { + vol.fromJSON({ + '/foo.yml': 'foo:\n bar: yaml', + '/bar.yml': 'bar:\n foo: yaml', + }); +} + +describe('commands / preview-deploy', () => { + beforeAll(() => { + process.exit = jest.fn() as any; + }); + + beforeEach(() => { + (KubernetesApi as any).create(); + }); + + afterEach(() => { + clearGlobalMocks(); + }); + + it('should exit when the name is too long', async () => { + await previewDeployCommand.handler({ + name: '1234567890123456789012345678901234567890123456789012345678901234567890', + } as any); + expect(process.exit).toHaveBeenCalledWith(1); + expect((Logger as any).instance.error.mock.calls[0][0]).toContain('Name of the namespace'); + }); + + it('should use the default kube config when no flag is set', async () => { + defaultFiles(); + const spy = jest.spyOn(KubernetesApi, 'fromDefault'); + await previewDeployCommand.handler({ + name: 'foobar', + prefix: 'prev-', + sourceFolder: '/', + } as any); + + try { + expect(spy).toHaveBeenCalled(); + } finally { + spy.mockRestore(); + } + }); + + it('should use the given kube config content when the flag is set', async () => { + defaultFiles(); + const spy = jest.spyOn(KubernetesApi, 'fromString'); + await previewDeployCommand.handler({ + name: 'foobar', + prefix: 'prev-', + sourceFolder: '/', + kubeConfig: 'yeeeehaaaw', + } as any); + + try { + expect(spy).toHaveBeenCalled(); + } finally { + spy.mockRestore(); + } + }); + + it('should create a namespace with the given name', async () => { + defaultFiles(); + (KubernetesApi as any).instance.core.readNamespace.mockRejectedValueOnce({ + response: { + statusCode: 404, + }, + }); + + await previewDeployCommand.handler({ + name: 'foobar', + prefix: 'prev-', + sourceFolder: '/', + } as any); + + expect((KubernetesApi as any).instance.core.createNamespace.mock.calls[0][0]).toMatchSnapshot(); + }); + + it('should not create the namespace when it already exists', async () => { + defaultFiles(); + await previewDeployCommand.handler({ + name: 'foobar', + prefix: 'prev-', + sourceFolder: '/', + } as any); + + expect((KubernetesApi as any).instance.core.createNamespace).not.toHaveBeenCalled(); + }); + + it('should execute kubectl with the found yamls', async () => { + defaultFiles(); + + await previewDeployCommand.handler({ + name: 'foobar', + prefix: 'prev-', + sourceFolder: '/', + } as any); + + expect(exec).toHaveBeenCalled(); + }); +}); diff --git a/test/setup.ts b/test/setup.ts index 888402b..b430f4e 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -1,12 +1,12 @@ -import 'clipboardy'; -import 'inquirer'; - -import { Core_v1Api, V1Secret } from '@kubernetes/client-node'; import '../src/utils/exec'; import '../src/utils/extensions'; import '../src/utils/kubernetes-api'; import '../src/utils/logger'; import '../src/utils/spawn'; +import 'clipboardy'; +import 'inquirer'; + +import { Core_v1Api, V1Namespace, V1Secret } from '@kubernetes/client-node'; jest.mock('fs'); jest.mock('../src/utils/spawn', () => ({ @@ -56,9 +56,11 @@ jest.mock('../src/utils/kubernetes-api', () => ({ public core: Core_v1Api = ({ createNamespacedSecret: jest.fn().mockImplementation(async (_ns: string, secret: V1Secret) => ({ body: secret })), + readNamespace: jest.fn().mockResolvedValue({}), + createNamespace: jest.fn().mockImplementation(async (_ns: string, namespace: V1Namespace) => ({ body: namespace })), } as unknown) as Core_v1Api; - private constructor() {} + private constructor() { } public static create(): void { FakeKubernetesApi.instance = new FakeKubernetesApi(); @@ -67,5 +69,9 @@ jest.mock('../src/utils/kubernetes-api', () => ({ public static fromDefault(): FakeKubernetesApi { return FakeKubernetesApi.instance; } + + public static fromString(): FakeKubernetesApi { + return FakeKubernetesApi.instance; + } }, }));