diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1d085ca --- /dev/null +++ b/.dockerignore @@ -0,0 +1 @@ +** diff --git a/.gitignore b/.gitignore index 5d8d9ed..07cf5bb 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ node_modules dist *.log + +develop.ts diff --git a/.vscode/launch.json b/.vscode/launch.json index 5d06db0..be65aef 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -12,22 +12,27 @@ "console": "integratedTerminal", "internalConsoleOptions": "neverOpen", "program": "${workspaceFolder}/node_modules/mocha/bin/_mocha", - "args": [ - "src/**/*.spec.ts", - "-r", "chai", - "-r", "chai-as-promised", - "-r", "ts-node/register" - ], + "args": ["src/**/*.spec.ts", "-r", "chai", "-r", "chai-as-promised", "-r", "ts-node/register"], "sourceMaps": true }, { + "name": "Debug", "type": "node", "request": "launch", - "name": "Launch Program", - "program": "${workspaceFolder}\\index.js", - "outFiles": [ - "${workspaceFolder}/**/*.js" - ] + "args": ["${workspaceRoot}/develop.ts"], + "runtimeArgs": ["--nolazy", "-r", "ts-node/register"], + "sourceMaps": true, + "cwd": "${workspaceRoot}", + "protocol": "inspector" + }, + { + "name": "Current TS Tests File", + "type": "node", + "request": "launch", + "program": "${workspaceRoot}/node_modules/mocha/bin/_mocha", + "args": ["-r", "ts-node/register", "${relativeFile}"], + "cwd": "${workspaceRoot}", + "protocol": "inspector" } ] } diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2a08a89 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,2 @@ +FROM scratch +ARG TEST diff --git a/README.md b/README.md index d244767..36fbbc0 100644 --- a/README.md +++ b/README.md @@ -6,68 +6,52 @@ A [semantic-release](https://github.com/semantic-release/semantic-release) plugin to use semantic versioning for docker images. -## verifyConditions - -verifies that environment variables for authentication via username and password are set. -It uses a registry server provided via config or environment variable (preferred) or defaults to docker hub if none is given. -It also verifies that the credentials are correct by logging in to the given registry. - -## prepare - -tags the specified image with the version number determined by semantic-release and additional tags provided in the configuration. -In addition it supports specifying a complete image name (CIN) via configuration settings according to the canonical format specified by docker: - -`[registryhostname[:port]/][username/]imagename[:tag]` - -## publish +## Configuration -pushes the tagged images to the registry. +### Installation -## Configuration +`npm i --save @iteratec/semantic-release-docker` -### docker registry authentication +### Docker registry authentication The `docker registry` authentication is **required** and can be set via environment variables. ### Environment variables | Variable | Description | -|--------------------------|-------------------------------------------------------------------------------------------| +| ------------------------ | ----------------------------------------------------------------------------------------- | | DOCKER_REGISTRY_URL | The hostname and port used by the desired docker registry. Leave blank to use docker hub. | | DOCKER_REGISTRY_USER | The user name to authenticate with at the registry. | | DOCKER_REGISTRY_PASSWORD | The password used for authentication at the registry. | -### Options - -| Option | Description | -|----------------|--------------------------------------------------------------------------------------------| -| additionalTags | _Optional_. An array of strings allowing to specify additional tags to apply to the image. | -| imageName | **_Required_** The name of the image to release. | -| registryUrl | _Optional_. The hostname and port used by the the registry in format `hostname[:port]`. Omit the port if the registry uses the default port | -| repositoryName | _Optional_. The name of the repository in the registry, e.g. username on docker hub | - ### Usage -full configuration: -``` json +#### Full configuration + +```json { "verifyConfig": ["@iteratec/semantic-release-docker"], - "prepare": { - "path": "@iteratec/semantic-release-docker", - "additionalTags": ["test", "demo"], - "imageName": "my-image", - "registryUrl": "my-private-registry:5678", - "respositoryName": "my-repository" - }, + "prepare": [ + { + "path": "@iteratec/semantic-release-docker", + "additionalTags": ["test", "demo"], + "imageName": "my-image", + "registryUrl": "my-private-registry:5678", + "respositoryName": "my-repository", + "pushVersionTag": true + } + ], "publish": { "path": "@iteratec/semantic-release-docker" } } ``` -results in `my-private-registry:5678/my-repository/my-image` with tags `test`, `demo` and the `` determined by `semantic-release`. -minimum configuration: -``` json +Results in `my-private-registry:5678/my-repository/my-image` with tags `test`, `demo` and the `` determined by `semantic-release`. + +#### Minimum configuration + +```json { "verifyConfig": ["@iteratec/semantic-release-docker"], "prepare": { @@ -79,4 +63,97 @@ minimum configuration: } } ``` -results in `my-image:` \ No newline at end of file + +Results in `my-image:`. + +### Options + +| Option | Description | +| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| additionalTags | _Optional_. An array of strings allowing to specify additional tags to apply to the image. | +| imageName | **_Required_** The name of the image to release. | +| registryUrl | _Optional_. The hostname and port used by the the registry in format `hostname[:port]`. Omit the port if the registry uses the default port | +| repositoryName | _Optional_. The name of the repository in the registry, e.g. username on docker hub | +| pushVersionTag | _Optional_. Whether the semantic release tag, determined by the version, should be pushed. Default is `true`. | + +## Steps + +### verifyConditions + +It uses a registry server provided via config or environment variable (preferred) or defaults to docker hub if none is given. + +1. Verifies that environment variables for authentication via username and password are set. +2. It also verifies that the credentials are correct by logging in to the given registry. + +### prepare + +Tags the specified image with the version number determined by semantic-release and additional tags provided in the configuration. +In addition it supports specifying a complete image name (CIN) via configuration settings according to the canonical format specified by docker: + +`[registryhostname[:port]/][username/]imagename[:tag]` + +### publish + +Pushes the tagged images to the registry. + +## Contribute + +### Develop + +1. Create a develop.ts file in the root of this Git-Repository and copy this: + +```typescript +import { SemanticReleaseConfig, SemanticReleaseContext } from 'semantic-release'; +import { prepare, publish, verifyConditions } from './src'; +import { DockerPluginConfig } from './src/models'; + +process.env.DOCKER_REGISTRY_USER = ''; +process.env.DOCKER_REGISTRY_PASSWORD = ''; + +const config: SemanticReleaseConfig = { + branch: '', + noCi: true, + repositoryUrl: '', + tagFormat: '' +}; +const context: SemanticReleaseContext = { + logger: { + // tslint:disable-next-line:no-empty + log: (message: string) => {} + }, + options: { + branch: '', + noCi: true, + prepare: [ + { + additionalTags: ['latest'], + imageName: 'testimage', + repositoryName: '', + path: '@iteratec/semantic-release-docker' + } as DockerPluginConfig, + { + additionalTags: ['latest'], + imageName: 'testimage1', + repositoryName: '', + path: '@iteratec/semantic-release-docker' + } as DockerPluginConfig + ], + repositoryUrl: '', + tagFormat: '' + }, + nextRelease: { + version: '1.0.3', + gitHead: '45jh345g', + gitTag: 'v1.0.3', + notes: 'Nothing special' + } +}; +context.logger.log = (string: string) => { + console.log(string); +}; +verifyConditions(config, context); +prepare(config, context); +publish(config, context); +``` + +2. Simply run the "Debug" VS Code Task diff --git a/azure-pipelines.yml b/azure-pipelines.yml new file mode 100644 index 0000000..53124fe --- /dev/null +++ b/azure-pipelines.yml @@ -0,0 +1,30 @@ +jobs: + - job: Build + pool: + name: Hosted Ubuntu 1604 + demands: npm + steps: + - task: NodeTool@0 + displayName: "Use Node 10" + inputs: + versionSpec: 10.x + - task: Npm@1 + displayName: "Install dependencies" + inputs: + verbose: false + - task: Npm@1 + displayName: Build + inputs: + command: custom + customCommand: run build + - task: Npm@1 + displayName: Test + inputs: + command: custom + customCommand: run test + - script: | + npm run release + displayName: Publish + env: + NPM_TOKEN: $(NPMTOKEN) + GITHUB_TOKEN: $(GITHUBTOKEN) diff --git a/package-lock.json b/package-lock.json index 173b4da..d984ad0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -610,21 +610,14 @@ } }, "@types/dockerode": { - "version": "2.5.4", - "resolved": "https://registry.npmjs.org/@types/dockerode/-/dockerode-2.5.4.tgz", - "integrity": "sha512-Mvdxibijt8nYY8lLiQ9IAWRUiQ2LUin8IatLGz1ZlVxzpfFzKks15Kpz8COXDSrR64xjECUs9hzYsJ/Tk6jzVg==", + "version": "2.5.13", + "resolved": "https://registry.npmjs.org/@types/dockerode/-/dockerode-2.5.13.tgz", + "integrity": "sha512-TgSP2nhCZgKOYcuMyuUs1SvLWZCd20z6SczPadLL11iCEEMDiblE23cwIyc1BR7FPpntwT9Z+IcdFNAXUAKmKQ==", "dev": true, "requires": { - "@types/events": "*", "@types/node": "*" } }, - "@types/events": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@types/events/-/events-1.2.0.tgz", - "integrity": "sha512-KEIlhXnIutzKwRbQkGWb/I4HFqBuUykAdHgDED6xqwXJfONCjF5VoE0cXEiurh3XauygxzeDzgtXUqvLkxFzzA==", - "dev": true - }, "@types/mocha": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-5.2.0.tgz", @@ -632,9 +625,9 @@ "dev": true }, "@types/node": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-10.1.0.tgz", - "integrity": "sha512-sELcX/cJHwRp8kn4hYSvBxKGJ+ubl3MvS8VJQe5gz/sp7CifYxsiCxIJ35wMIYyGVMgfO2AzRa8UcVReAcJRlw==", + "version": "11.13.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-11.13.4.tgz", + "integrity": "sha512-+rabAZZ3Yn7tF/XPGHupKIL5EcAbrLxnTr/hgQICxbeuAfWtT0UZSfULE+ndusckBItcv4o6ZeOJplQikVcLvQ==", "dev": true }, "JSONStream": { @@ -960,7 +953,7 @@ }, "bl": { "version": "1.2.2", - "resolved": "http://registry.npmjs.org/bl/-/bl-1.2.2.tgz", + "resolved": "https://registry.npmjs.org/bl/-/bl-1.2.2.tgz", "integrity": "sha512-e8tQYnZodmebYDWGH7KMRvtzKXaJHx3BbilrgZCfvyLUYdKpK1t5PSPmpkny/SgiTSCnjfLW7v5rlONXVFkQEA==", "requires": { "readable-stream": "^2.3.5", @@ -1423,7 +1416,7 @@ }, "concat-stream": { "version": "1.6.2", - "resolved": "http://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", "requires": { "buffer-from": "^1.0.0", @@ -1770,7 +1763,7 @@ }, "readable-stream": { "version": "1.0.34", - "resolved": "http://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz", "integrity": "sha1-Elgg40vIQtLyqq+v5MKRbuMsFXw=", "requires": { "core-util-is": "~1.0.0", @@ -1781,7 +1774,7 @@ }, "string_decoder": { "version": "0.10.31", - "resolved": "http://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=" } } diff --git a/package.json b/package.json index 5dc660a..e7b394e 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "main": "index.js", "scripts": { "build": "rimraf dist && tsc", - "postbuild": "cpx package.json dist/ && cpx package-lock.json dist/", + "postbuild": "cpx package.json dist/ && cpx package-lock.json dist/ && cpx README.md dist/", "commit": "git-cz", "test": "mocha -r chai -r chai-as-promised -r ts-node/register src/**/*.spec.ts" }, @@ -31,7 +31,7 @@ "@commitlint/config-conventional": "^7.1.2", "@types/chai": "^4.1.3", "@types/chai-as-promised": "^7.1.0", - "@types/dockerode": "^2.5.4", + "@types/dockerode": "^2.5.13", "@types/mocha": "^5.2.0", "chai": "^4.1.2", "chai-as-promised": "^7.1.1", diff --git a/src/dockerPluginConfig.ts b/src/dockerPluginConfig.ts deleted file mode 100644 index 9ac3c61..0000000 --- a/src/dockerPluginConfig.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { SemanticReleasePlugin } from 'semantic-release'; -export interface DockerPluginConfig extends SemanticReleasePlugin { - additionalTags?: string[]; - imageName: string; - registryUrl?: string; - repositoryName?: string; -} diff --git a/src/model/auth.ts b/src/model/auth.ts deleted file mode 100644 index 3207409..0000000 --- a/src/model/auth.ts +++ /dev/null @@ -1,5 +0,0 @@ -export interface Auth { - username: string; - password: string; - serveraddress?: string; -} diff --git a/src/model/registry.ts b/src/model/registry.ts deleted file mode 100644 index 65b0039..0000000 --- a/src/model/registry.ts +++ /dev/null @@ -1,25 +0,0 @@ -import Docker from 'dockerode'; - -import { Auth } from './auth'; - -export class Registry { - private docker = new Docker(); - - constructor(readonly url?: string) {} - - public login(username: string, password: string) { - const auth: Auth = { - password: `${password}`, - serveraddress: `${this.url ? `${this.url}` : ''}`, - username: `${username}`, - }; - - return this.docker.checkAuth(auth) - .then((data) => { - return true; - }) - .catch((error) => { - throw new Error(error); - }); - } -} diff --git a/src/models/PluginSettings.ts b/src/models/PluginSettings.ts new file mode 100644 index 0000000..fb34060 --- /dev/null +++ b/src/models/PluginSettings.ts @@ -0,0 +1,6 @@ +import { DockerPluginConfig } from './dockerPluginConfig'; + +export interface PluginSettings { + path: '@iteratec/semantic-release-docker'; + defaultValues: DockerPluginConfig; +} diff --git a/src/models/authentication.ts b/src/models/authentication.ts new file mode 100644 index 0000000..f724072 --- /dev/null +++ b/src/models/authentication.ts @@ -0,0 +1,9 @@ +import { Credentials } from './credentials'; + +/** + * Authentication + * From: https://docs.docker.com/engine/api/v1.37/#section/Authentication + */ +export interface Authentication extends Credentials { + serveraddress: string; +} diff --git a/src/models/credentials.ts b/src/models/credentials.ts new file mode 100644 index 0000000..279aabe --- /dev/null +++ b/src/models/credentials.ts @@ -0,0 +1,4 @@ +export interface Credentials { + username: string; + password: string; +} diff --git a/src/models/dockerPluginConfig.ts b/src/models/dockerPluginConfig.ts new file mode 100644 index 0000000..a590ec6 --- /dev/null +++ b/src/models/dockerPluginConfig.ts @@ -0,0 +1,8 @@ +import { SemanticReleasePlugin } from "semantic-release"; +export interface DockerPluginConfig extends SemanticReleasePlugin { + additionalTags?: string[]; + imageName: string; + registryUrl?: string; + repositoryName?: string; + pushVersionTag?: boolean; +} diff --git a/src/models/index.ts b/src/models/index.ts new file mode 100644 index 0000000..2b9fb18 --- /dev/null +++ b/src/models/index.ts @@ -0,0 +1,3 @@ +export { Authentication } from './authentication'; +export { DockerPluginConfig } from './dockerPluginConfig'; +export { Credentials } from './credentials'; diff --git a/src/plugin-settings.ts b/src/plugin-settings.ts new file mode 100644 index 0000000..8a1080f --- /dev/null +++ b/src/plugin-settings.ts @@ -0,0 +1,13 @@ +import { PluginSettings } from "./models/PluginSettings"; + +export const pluginSettings: PluginSettings = { + path: "@iteratec/semantic-release-docker", + defaultValues: { + additionalTags: [], + imageName: "", + path: "@iteratec/semantic-release-docker", + pushVersionTag: true, + registryUrl: "", + repositoryName: "" + } +}; diff --git a/src/prepare/index.spec.ts b/src/prepare/index.spec.ts index 618d596..e353c93 100644 --- a/src/prepare/index.spec.ts +++ b/src/prepare/index.spec.ts @@ -3,65 +3,295 @@ import chaiAsPromised from 'chai-as-promised'; import Dockerode from 'dockerode'; import { SemanticReleaseConfig, SemanticReleaseContext } from 'semantic-release'; -import { DockerPluginConfig } from '../dockerPluginConfig'; +import { DockerPluginConfig } from '../models'; import { prepare } from './index'; +import { setVerified } from '../verifyConditions'; +import { buildImage } from '../test/test-helpers'; +import { afterEach } from 'mocha'; describe('@iteratec/semantic-release-docker', function() { - describe('prepare', function() { const config: SemanticReleaseConfig = { branch: '', noCi: true, repositoryUrl: '', - tagFormat: '', - }; - const context: SemanticReleaseContext = { - - // tslint:disable-next-line:no-empty - logger: { log: (message: string) => {}}, - nextRelease: { - gitTag: '', - notes: '', - version: 'next', - }, - options: { - branch: '', - noCi: true, - prepare: [ - { - imageName: '', - path: '@iteratec/semantic-release-docker', - } as DockerPluginConfig, - ], - repositoryUrl: '', - tagFormat: '', - }, + tagFormat: '' }; - before(function() { + const testImage1 = 'test1'; + const testImage2 = 'test2'; + + const docker = new Dockerode(); + + before(async function() { use(chaiAsPromised); + setVerified(); + + process.env.DOCKER_REGISTRY_USER = 'username'; + process.env.DOCKER_REGISTRY_PASSWORD = 'password'; }); - before(async function() { - this.timeout(10000); - const docker = new Dockerode(); - return await docker.pull('hello-world', {}); + beforeEach(async function() { + this.timeout(20000); + await buildImage(testImage1); + await buildImage(testImage2); + }); + + it('should tag image with next version', async function() { + const context = { + // tslint:disable-next-line:no-empty + logger: { log: (message: string) => {} }, + nextRelease: { + gitTag: '', + notes: '', + version: 'next' + }, + options: { + branch: '', + noCi: true, + prepare: [ + { + imageName: testImage1, + path: '@iteratec/semantic-release-docker' + } as DockerPluginConfig + ], + repositoryUrl: '', + tagFormat: '' + } + } as SemanticReleaseContext; + let prepareResult = await prepare(config, context); + + expect(prepareResult).to.deep.equal([[testImage1]]); + + let imagelist2 = await docker.listImages({ filters: { reference: [`${testImage1}:next`] } }); + expect(imagelist2.length).to.equal(1); + }); + + it('should tag image with next version and repositoryName', async function() { + const context = { + // tslint:disable-next-line:no-empty + logger: { log: (message: string) => {} }, + nextRelease: { + gitTag: '', + notes: '', + version: 'next' + }, + options: { + branch: '', + noCi: true, + prepare: [ + { + imageName: testImage1, + repositoryName: 'repository', + path: '@iteratec/semantic-release-docker' + } as DockerPluginConfig + ], + repositoryUrl: '', + tagFormat: '' + } + } as SemanticReleaseContext; + let prepareResult = await prepare(config, context); + + expect(prepareResult).to.deep.equal([[testImage1]]); + + let imagelist2 = await docker.listImages({ filters: { reference: [`repository/${testImage1}:next`] } }); + expect(imagelist2.length).to.equal(1); + }); + + it('should tag image with next version and repositoryName and url', async function() { + const context = { + // tslint:disable-next-line:no-empty + logger: { log: (message: string) => {} }, + nextRelease: { + gitTag: '', + notes: '', + version: 'next' + }, + options: { + branch: '', + noCi: true, + prepare: [ + { + imageName: testImage1, + repositoryName: 'repository', + registryUrl: 'repositoryurl', + path: '@iteratec/semantic-release-docker' + } as DockerPluginConfig + ], + repositoryUrl: '', + tagFormat: '' + } + } as SemanticReleaseContext; + let prepareResult = await prepare(config, context); + + expect(prepareResult).to.deep.equal([[testImage1]]); + + let imagelist2 = await docker.listImages({ + filters: { reference: [`repositoryurl/repository/${testImage1}:next`] } + }); + expect(imagelist2.length).to.equal(1); + }); + + it('should tag image without next tag', async function() { + const context = { + // tslint:disable-next-line:no-empty + logger: { log: (message: string) => {} }, + nextRelease: { + gitTag: '', + notes: '', + version: 'next' + }, + options: { + branch: '', + noCi: true, + prepare: [ + { + pushVersionTag: false, + imageName: testImage1, + path: '@iteratec/semantic-release-docker' + } as DockerPluginConfig + ], + repositoryUrl: '', + tagFormat: '' + } + } as SemanticReleaseContext; + let prepareResult = await prepare(config, context); + + expect(prepareResult).to.deep.equal([[]]); + + let imagelist2 = await docker.listImages({ filters: { reference: [`${testImage1}:next`] } }); + expect(imagelist2.length).to.equal(0); }); - it('should throw if no imagename is provided', function() { - return expect(prepare(config, context)).to.be.rejectedWith('\'imageName\' is not set in plugin configuration'); + it('should add multiple tags to an image (with next version)', async function() { + const context = { + // tslint:disable-next-line:no-empty + logger: { log: (message: string) => {} }, + nextRelease: { + gitTag: '', + notes: '', + version: 'next' + }, + options: { + branch: '', + noCi: true, + prepare: [ + { + imageName: testImage1, + path: '@iteratec/semantic-release-docker', + additionalTags: ['tag1', 'tag2'] + } as DockerPluginConfig + ], + repositoryUrl: '', + tagFormat: '' + } + } as SemanticReleaseContext; + + let prepareResult = await prepare(config, context).then(data => data[0]); + + expect(prepareResult).to.have.length(3); + + let imagelist = await docker.listImages({ filters: { reference: [`${testImage1}:next`] } }); + expect(imagelist.length).to.equal(1); + + let imagelist1 = await docker.listImages({ filters: { reference: [`${testImage1}:tag1`] } }); + expect(imagelist1.length).to.equal(1); + + let imagelist2 = await docker.listImages({ filters: { reference: [`${testImage1}:tag2`] } }); + expect(imagelist2.length).to.equal(1); }); - it('should tag an image', function() { - (context.options.prepare![0] as DockerPluginConfig).imageName = 'hello-world'; - return expect(prepare(config, context)).to.eventually.deep.equal(['hello-world']); + it('should add multiple tags to an image (without next version)', async function() { + const context = { + // tslint:disable-next-line:no-empty + logger: { log: (message: string) => {} }, + nextRelease: { + gitTag: '', + notes: '', + version: 'next' + }, + options: { + branch: '', + noCi: true, + prepare: [ + { + pushVersionTag: false, + imageName: testImage1, + path: '@iteratec/semantic-release-docker', + additionalTags: ['tag1', 'tag2'] + } as DockerPluginConfig + ], + repositoryUrl: '', + tagFormat: '' + } + } as SemanticReleaseContext; + + let prepareResult = await prepare(config, context).then(data => data[0]); + + expect(prepareResult).to.have.length(2); + + let imagelist1 = await docker.listImages({ filters: { reference: [`${testImage1}:tag1`] } }); + expect(imagelist1.length).to.equal(1); + + let imagelist2 = await docker.listImages({ filters: { reference: [`${testImage1}:tag2`] } }); + expect(imagelist2.length).to.equal(1); }); - it('should add multiple tags to an image', function() { - (context.options.prepare![0] as DockerPluginConfig).imageName = 'hello-world'; - (context.options.prepare![0] as DockerPluginConfig).additionalTags = ['tag1', 'tag2']; - return expect(prepare(config, context)).to.eventually.have.length(3); + it('should add multiple images', async function() { + const context = { + // tslint:disable-next-line:no-empty + logger: { log: (message: string) => {} }, + nextRelease: { + gitTag: '', + notes: '', + version: 'next' + }, + options: { + branch: '', + noCi: true, + prepare: [ + { + imageName: testImage1, + path: '@iteratec/semantic-release-docker' + } as DockerPluginConfig, + { + imageName: testImage2, + path: '@iteratec/semantic-release-docker' + } as DockerPluginConfig + ], + repositoryUrl: '', + tagFormat: '' + } + } as SemanticReleaseContext; + let prepareResult = await prepare(config, context); + + expect(prepareResult).to.have.length(2); + + let imagelist1 = await docker.listImages({ filters: { reference: [`${testImage1}:next`] } }); + expect(imagelist1.length).to.equal(1); + + let imagelist2 = await docker.listImages({ filters: { reference: [`${testImage2}:next`] } }); + expect(imagelist2.length).to.equal(1); }); + afterEach(async function() { + this.timeout(5000); + const imagelist1 = await docker.listImages({ filters: { reference: [testImage1] } }); + await Promise.all( + imagelist1.map(image => { + return docker.getImage(image.Id).remove({ + force: true + }); + }) + ); + const imagelist2 = await docker.listImages({ filters: { reference: [testImage2] } }); + await Promise.all( + imagelist2.map(image => { + return docker.getImage(image.Id).remove({ + force: true + }); + }) + ); + }); }); }); diff --git a/src/prepare/index.ts b/src/prepare/index.ts index b60f6e6..09747a2 100644 --- a/src/prepare/index.ts +++ b/src/prepare/index.ts @@ -1,37 +1,56 @@ import Dockerode from 'dockerode'; import { SemanticReleaseConfig, SemanticReleaseContext } from 'semantic-release'; -import { DockerPluginConfig } from '../dockerPluginConfig'; +import { DockerPluginConfig } from '../models'; +import { pluginSettings } from '../plugin-settings'; +import { constructImageName, getImageTagsFromConfig } from '../shared-logic'; +import { verified, verifyConditions } from '../verifyConditions'; export var prepared = false; -export async function prepare(pluginConfig: SemanticReleaseConfig, context: SemanticReleaseContext): Promise { - const preparePlugin = context.options.prepare! - .find((p) => p.path === '@iteratec/semantic-release-docker') as DockerPluginConfig; - if (!preparePlugin.imageName) { - throw new Error('\'imageName\' is not set in plugin configuration'); - } - const docker = new Dockerode(); - const image = docker.getImage(preparePlugin.imageName); - let tags = [context.nextRelease!.version!]; - if (preparePlugin.additionalTags && preparePlugin.additionalTags.length > 0) { - tags = tags.concat(preparePlugin.additionalTags); +export async function prepare(pluginConfig: SemanticReleaseConfig, context: SemanticReleaseContext): Promise { + if (!verified) { + await verifyConditions(pluginConfig, context).then( + () => {}, + reject => { + return Promise.reject(reject); + } + ); } - return Promise.all(tags.map((imagetag) => { - return image.tag({ - repo: `${preparePlugin.registryUrl ? `${preparePlugin.registryUrl}/` : ''}` + - `${preparePlugin.repositoryName ? `${preparePlugin.repositoryName}/` : ''}` + - `${preparePlugin.imageName}`, - tag: imagetag, - }); - })) - .then((data) => { + + const preparePlugins = context.options.prepare!.filter(p => p.path === pluginSettings.path) as DockerPluginConfig[]; + + return Promise.all( + preparePlugins.map(preparePlugin => { + const docker = new Dockerode(); + const image = docker.getImage(preparePlugin.imageName); + const tags = getImageTagsFromConfig(preparePlugin, context); + return Promise.all( + tags.map(imagetag => { + return image.tag({ + repo: constructImageName(preparePlugin), + tag: imagetag + }); + }) + ) + .then(data => { + if (!prepared) { + prepared = true; + } + return data.map(result => result.name); + }) + .catch(error => { + throw new Error(error); + }); + }) + ) + .then(data => { if (!prepared) { prepared = true; } - return data.map((result) => result.name); + return data.map(result => result); }) - .catch((error) => { + .catch(error => { throw new Error(error); }); } diff --git a/src/publish/index.ts b/src/publish/index.ts index 40c5384..103fd69 100644 --- a/src/publish/index.ts +++ b/src/publish/index.ts @@ -1,11 +1,12 @@ import Dockerode from 'dockerode'; import { SemanticReleaseConfig, SemanticReleaseContext } from 'semantic-release'; -import { DockerPluginConfig } from '../dockerPluginConfig'; -import { Auth } from '../model/auth'; +import { Authentication, DockerPluginConfig } from '../models'; +import { pluginSettings } from '../plugin-settings'; import { prepare, prepared } from '../prepare'; +import { constructImageName, getRegistryUrlFromConfig, getImageTagsFromConfig } from '../shared-logic'; -interface PushOptions extends Auth { +interface PushOptions extends Authentication { tag: string; } @@ -15,42 +16,64 @@ export interface PublishedRelease { export async function publish(pluginConfig: SemanticReleaseConfig, context: SemanticReleaseContext) { if (!prepared) { - prepare(pluginConfig, context); + await prepare(pluginConfig, context).then( + () => {}, + reject => { + return Promise.reject(reject); + } + ); } + const docker = new Dockerode(); - let tags = [context.nextRelease!.version!]; - const preparePlugin = context.options.prepare! - .find((p) => p.path === '@iteratec/semantic-release-docker')! as DockerPluginConfig; - if (preparePlugin.additionalTags && preparePlugin.additionalTags.length > 0) { - tags = tags.concat(preparePlugin.additionalTags); - } - const imageName = `${preparePlugin.registryUrl ? `${preparePlugin.registryUrl}/` : ''}` + - `${preparePlugin.repositoryName ? `${preparePlugin.repositoryName}/` : ''}` + - `${preparePlugin.imageName}`; - const image = docker.getImage(imageName); - const options: PushOptions = { - password: process.env.DOCKER_REGISTRY_PASSWORD!, - serveraddress: process.env.DOCKER_REGISTRY_URL ? - process.env.DOCKER_REGISTRY_URL : preparePlugin.registryUrl ? preparePlugin.registryUrl : '', - tag: '', - username: process.env.DOCKER_REGISTRY_USER!, - }; - return Promise.all(tags.map((imageTag: string) => { - options.tag = imageTag; - context.logger.log(`pushing image ${imageName}:${imageTag}`); - return image.push(options); - })) - .then((streams) => Promise.all(streams.map((stream) => new Promise((resolve, reject) => { - stream.on('data', (chunk) => context.logger.log(chunk.toString())); - stream.on('end', () => resolve()); - stream.on('error', (error) => reject(error)); - })))) - .then(() => { - return { - completeImageName: tags.map((tag: string) => `${imageName}:${tag}`), - } as PublishedRelease; + + const preparePlugins = context.options.prepare!.filter(p => p.path === pluginSettings.path) as DockerPluginConfig[]; + + return Promise.all( + preparePlugins.map(preparePlugin => { + const tags = getImageTagsFromConfig(preparePlugin, context); + + const imageName = constructImageName(preparePlugin); + + const image = docker.getImage(imageName); + const options: PushOptions = { + password: process.env.DOCKER_REGISTRY_PASSWORD!, + serveraddress: getRegistryUrlFromConfig(preparePlugin), + tag: '', + username: process.env.DOCKER_REGISTRY_USER! + }; + return Promise.all( + tags.map((imageTag: string) => { + options.tag = imageTag; + context.logger.log(`pushing image ${imageName}:${imageTag}`); + return image.push(options); + }) + ) + .then(streams => + Promise.all( + streams.map( + stream => + new Promise((resolve, reject) => { + stream.on('data', chunk => context.logger.log(chunk.toString())); + stream.on('end', () => resolve()); + stream.on('error', error => { + reject(error); + }); + }) + ) + ) + ) + .then(() => { + return { + completeImageName: tags.map((tag: string) => `${imageName}:${tag}`) + } as PublishedRelease; + }) + .catch(error => { + throw new Error(error); + }); }) - .catch((error) => { - throw new Error(error); - }); + ).then(publishedImages => { + return { + publishedImages: publishedImages + }; + }); } diff --git a/src/shared-logic.spec.ts b/src/shared-logic.spec.ts new file mode 100644 index 0000000..e1bbd32 --- /dev/null +++ b/src/shared-logic.spec.ts @@ -0,0 +1,73 @@ +import { expect } from 'chai'; +import { Credentials, DockerPluginConfig } from './models'; +import { constructImageName, getCredentials, getRegistryUrlFromConfig } from './shared-logic'; + +describe('@iteratec/semantic-release-docker', function() { + describe('shared-logic', function() { + afterEach(function() { + process.env.DOCKER_REGISTRY_USER = ''; + process.env.DOCKER_REGISTRY_PASSWORD = ''; + process.env.DOCKER_REGISTRY_URL = ''; + }); + + it('should use only image name', function() { + const config: DockerPluginConfig = { + path: '@iteratec/semantic-release-docker', + imageName: 'test' + }; + expect(constructImageName(config)).to.be.equal('test'); + }); + + it('should use image name and repository', function() { + const config: DockerPluginConfig = { + path: '@iteratec/semantic-release-docker', + imageName: 'test', + repositoryName: 'repo' + }; + expect(constructImageName(config)).to.be.equal('repo/test'); + }); + + it('should use image name, repository and registry', function() { + const config: DockerPluginConfig = { + path: '@iteratec/semantic-release-docker', + imageName: 'test', + repositoryName: 'repo', + registryUrl: 'registry' + }; + expect(constructImageName(config)).to.be.equal('registry/repo/test'); + }); + + it('should use the registry from the config', function() { + const config: DockerPluginConfig = { + path: '@iteratec/semantic-release-docker', + imageName: 'test', + registryUrl: 'registry' + }; + expect(getRegistryUrlFromConfig(config)).to.be.equal('registry'); + }); + + it('should prefer the registry from the environment variable over the one from the config', function() { + process.env.DOCKER_REGISTRY_URL = 'my_other_private_registry'; + const config: DockerPluginConfig = { + path: '@iteratec/semantic-release-docker', + imageName: 'test', + registryUrl: 'registry' + }; + expect(getRegistryUrlFromConfig(config)).to.be.equal('my_other_private_registry'); + }); + + it('should default to empty string if no registry is specified', function() { + const config: DockerPluginConfig = { + path: '@iteratec/semantic-release-docker', + imageName: 'test' + }; + expect(getRegistryUrlFromConfig(config)).to.be.equal(''); + }); + + it('should get Credentials', function() { + process.env.DOCKER_REGISTRY_USER = 'username'; + process.env.DOCKER_REGISTRY_PASSWORD = 'password'; + expect(getCredentials()).to.eql({ password: 'password', username: 'username' } as Credentials); + }); + }); +}); diff --git a/src/shared-logic.ts b/src/shared-logic.ts new file mode 100644 index 0000000..617ac8e --- /dev/null +++ b/src/shared-logic.ts @@ -0,0 +1,50 @@ +import { Credentials, DockerPluginConfig } from './models'; +import { SemanticReleaseContext } from 'semantic-release'; + +export function constructImageName(config: DockerPluginConfig): string { + return ( + `${config.registryUrl ? `${config.registryUrl}/` : ''}` + + `${config.repositoryName ? `${config.repositoryName}/` : ''}` + + `${config.imageName}` + ); +} + +export function getRegistryUrlFromConfig(config: DockerPluginConfig): string { + return process.env.DOCKER_REGISTRY_URL + ? process.env.DOCKER_REGISTRY_URL + : config.registryUrl + ? config.registryUrl + : ''; +} + +export function getImageTagsFromConfig(config: DockerPluginConfig, context: SemanticReleaseContext): string[] { + let tags = []; + if (config.pushVersionTag == null || config.pushVersionTag === true) { + tags.push(context.nextRelease!.version!); + } + if (config.additionalTags && config.additionalTags.length > 0) { + tags = tags.concat(config.additionalTags); + } + return tags; +} + +/** + * Get Authentication object from Environment Variables + * Throws Error if Variables are not set. + */ +export function getCredentials(): Credentials { + // Check DOCKER_REGISTRY_USER Environment Variable + if (!process.env.DOCKER_REGISTRY_USER) { + throw new Error('Environment variable DOCKER_REGISTRY_USER must be set in order to login to the registry.'); + } + + // Check DOCKER_REGISTRY_PASSWORD Environment Variable + if (!process.env.DOCKER_REGISTRY_PASSWORD) { + throw new Error('Environment variable DOCKER_REGISTRY_PASSWORD must be set in order to login to the registry.'); + } + + return { + username: process.env.DOCKER_REGISTRY_USER, + password: process.env.DOCKER_REGISTRY_PASSWORD + }; +} diff --git a/src/test/test-helpers.ts b/src/test/test-helpers.ts new file mode 100644 index 0000000..bef986b --- /dev/null +++ b/src/test/test-helpers.ts @@ -0,0 +1,27 @@ +import Dockerode from 'dockerode'; + +export function buildImage(imageName: string): Promise { + const docker = new Dockerode(); + return new Promise((resolve, reject) => { + docker.buildImage( + { + context: './', + src: ['Dockerfile'] + }, + { + t: imageName + }, + function(error, stream) { + if (error) { + reject(error); + } + if (stream) { + stream.resume(); + stream.on('end', function() { + resolve(); + }); + } + } + ); + }); +} diff --git a/src/verifyConditions/index.spec.ts b/src/verifyConditions/index.spec.ts index dd98959..8fb17a1 100644 --- a/src/verifyConditions/index.spec.ts +++ b/src/verifyConditions/index.spec.ts @@ -1,88 +1,172 @@ import { expect, use } from 'chai'; import chaiAsPromised from 'chai-as-promised'; +import Docker from 'dockerode'; import { SemanticReleaseConfig, SemanticReleaseContext } from 'semantic-release'; -import { DockerPluginConfig } from '../dockerPluginConfig'; +import { DockerPluginConfig } from '../models'; import { verifyConditions } from './index'; +import { buildImage } from '../test/test-helpers'; describe('@iteratec/semantic-release-docker', function() { - describe('verifyConditions', function() { + const imageName = 'abcdefghijklmnopqrstuvwxyz'; const config: SemanticReleaseConfig = { branch: '', noCi: true, repositoryUrl: '', - tagFormat: '', + tagFormat: '' }; const context: SemanticReleaseContext = { logger: { // tslint:disable-next-line:no-empty - log: (message: string) => {}, + log: (message: string) => {} }, options: { branch: '', noCi: true, prepare: [ { - imageName: '', - path: '@iteratec/semantic-release-docker', - } as DockerPluginConfig, + imageName, + path: '@iteratec/semantic-release-docker' + } as DockerPluginConfig ], repositoryUrl: '', - tagFormat: '', - }, + tagFormat: '' + } }; before(function() { use(chaiAsPromised); }); - afterEach(function() { - process.env.DOCKER_REGISTRY_USER = ''; - process.env.DOCKER_REGISTRY_PASSWORD = ''; - process.env.DOCKER_REGISTRY_URL = ''; - }); - it('should throw when the username is not set', function() { - return expect(verifyConditions(config, context)).to.eventually.be - .rejectedWith('Environment variable DOCKER_REGISTRY_USER must be set in order to login to the registry.'); + delete process.env.DOCKER_REGISTRY_USER; + return expect(verifyConditions(config, context)).to.eventually.be.rejectedWith( + 'Environment variable DOCKER_REGISTRY_USER must be set in order to login to the registry.' + ); }); - it('should throw when the password is not set', function() { + it('should NOT throw when the username is set', function() { process.env.DOCKER_REGISTRY_USER = 'username'; - return expect(verifyConditions(config, context)).to.eventually.be - .rejectedWith('Environment variable DOCKER_REGISTRY_PASSWORD must be set in order to login to the registry.'); + return expect(verifyConditions(config, context)).to.not.eventually.be.rejectedWith( + 'Environment variable DOCKER_REGISTRY_USER must be set in order to login to the registry.' + ); }); - it('should use the registry from the config', function() { - this.timeout(5000); + it('should throw when the password is not set', function() { process.env.DOCKER_REGISTRY_USER = 'username'; - process.env.DOCKER_REGISTRY_PASSWORD = 'password'; - (context.options.prepare![0] as DockerPluginConfig).registryUrl = 'my_private_registry'; - return expect(verifyConditions(config, context)) - .to.eventually.be.rejectedWith(/(?:my_private_registry)/); + delete process.env.DOCKER_REGISTRY_PASSWORD; + return expect(verifyConditions(config, context)).to.eventually.be.rejectedWith( + 'Environment variable DOCKER_REGISTRY_PASSWORD must be set in order to login to the registry.' + ); }); - it('should prefer the registry from the environment variable over the one from the config', function() { - this.timeout(5000); - process.env.DOCKER_REGISTRY_USER = 'username'; + it('should NOT throw when the password is set', function() { process.env.DOCKER_REGISTRY_PASSWORD = 'password'; - process.env.DOCKER_REGISTRY_URL = 'my_other_private_registry'; - (context.options.prepare![0] as DockerPluginConfig).registryUrl = 'my_private_registry'; - return expect(verifyConditions(config, context)) - .to.eventually.be.rejectedWith(/(?:my_other_private_registry)/); + return expect(verifyConditions(config, context)).to.not.eventually.be.rejectedWith( + 'Environment variable DOCKER_REGISTRY_PASSWORD must be set in order to login to the registry.' + ); }); - it('should default to docker hub if no registry is specified', function() { + it('should default to docker hub if no registry is specified', async function() { this.timeout(10000); + await buildImage(imageName); (context.options.prepare![0] as DockerPluginConfig).registryUrl = ''; - (context.options.prepare![0] as DockerPluginConfig).imageName = ''; process.env.DOCKER_REGISTRY_USER = 'badusername'; process.env.DOCKER_REGISTRY_PASSWORD = 'pass@w0rd'; - return expect(verifyConditions(config, context)).to.eventually.be - .rejectedWith(/(?:index.docker.com|registry-1.docker.io)/); + return expect(verifyConditions(config, context)).to.eventually.be.rejectedWith( + /(?:index.docker.com|registry-1.docker.io)/ + ); }); - }); + it('should throw if no imagename is provided', function() { + const context = { + // tslint:disable-next-line:no-empty + logger: { log: (message: string) => {} }, + nextRelease: { + gitTag: '', + notes: '', + version: 'next' + }, + options: { + branch: '', + noCi: true, + prepare: [ + { + path: '@iteratec/semantic-release-docker' + } as DockerPluginConfig + ], + repositoryUrl: '', + tagFormat: '' + } + } as SemanticReleaseContext; + return expect(verifyConditions(config, context)).to.eventually.be.rejectedWith( + "'imageName' is not set in plugin configuration" + ); + }); + it('should throw if image with imagename does not exist', async function() { + const docker = new Docker(); + await docker.getImage(imageName).remove(); + + const context = { + // tslint:disable-next-line:no-empty + logger: { log: (message: string) => {} }, + nextRelease: { + gitTag: '', + notes: '', + version: 'next' + }, + options: { + branch: '', + noCi: true, + prepare: [ + { + imageName, + path: '@iteratec/semantic-release-docker' + } as DockerPluginConfig + ], + repositoryUrl: '', + tagFormat: '' + } + } as SemanticReleaseContext; + return expect(verifyConditions(config, context)).to.eventually.be.rejectedWith( + `Image with name '${imageName}' does not exist on this machine.` + ); + }); + + it('should NOT throw if image with imagename does exist', async function() { + await buildImage(imageName); + const context = { + // tslint:disable-next-line:no-empty + logger: { log: (message: string) => {} }, + nextRelease: { + gitTag: '', + notes: '', + version: 'next' + }, + options: { + branch: '', + noCi: true, + prepare: [ + { + imageName, + path: '@iteratec/semantic-release-docker' + } as DockerPluginConfig + ], + repositoryUrl: '', + tagFormat: '' + } + } as SemanticReleaseContext; + + return expect(verifyConditions(config, context)).to.not.eventually.be.rejectedWith( + `Image with name '${imageName}' does not exist on this machine.` + ); + }); + + after(async function() { + const docker = new Docker(); + await docker.getImage(imageName).remove(); + }); + }); }); diff --git a/src/verifyConditions/index.ts b/src/verifyConditions/index.ts index 8ec40bf..2c30fb2 100644 --- a/src/verifyConditions/index.ts +++ b/src/verifyConditions/index.ts @@ -1,34 +1,74 @@ +import Docker from 'dockerode'; import { SemanticReleaseConfig, SemanticReleaseContext } from 'semantic-release'; -import { DockerPluginConfig } from '../dockerPluginConfig'; -import { Registry } from '../model/registry'; +import { Credentials, DockerPluginConfig } from '../models'; +import { Authentication } from '../models'; +import { pluginSettings } from '../plugin-settings'; +import { getCredentials, getRegistryUrlFromConfig } from '../shared-logic'; export var verified = false; +/** + * Just for test purposes. + * @param val + */ +export function setVerified() { + verified = true; +} -export async function verifyConditions(pluginConfig: SemanticReleaseConfig, context: SemanticReleaseContext) { - if (!process.env.DOCKER_REGISTRY_USER) { - throw new Error('Environment variable DOCKER_REGISTRY_USER must be set in order to login to the registry.'); - } - if (!process.env.DOCKER_REGISTRY_PASSWORD) { - throw new Error('Environment variable DOCKER_REGISTRY_PASSWORD must be set in order to login to the registry.'); +/** + * First Step + * Verify all conditions in order to proceed with the release + */ +export async function verifyConditions( + pluginConfig: SemanticReleaseConfig, + context: SemanticReleaseContext +): Promise { + let cred: Credentials; + + // Check if Username and Password are set if not reject Promise with Error Message + try { + cred = getCredentials(); + } catch (err) { + return Promise.reject(err.message); } - let preparePlugin: DockerPluginConfig; - if (!context.options.prepare || - !context.options.prepare!.find((p) => p.path === '@iteratec/semantic-release-docker')) { - throw new Error('\'prepare\' is not configured'); + + // Check if plugin is configured in prepare step + if (!context.options.prepare || !context.options.prepare!.find(p => p.path === pluginSettings.path)) { + throw new Error("'prepare' is not configured"); } - preparePlugin = context.options.prepare - .find((p) => p.path === '@iteratec/semantic-release-docker') as DockerPluginConfig; - let registryUrl: string; - if (process.env.DOCKER_REGISTRY_URL || preparePlugin.registryUrl) { - registryUrl = process.env.DOCKER_REGISTRY_URL ? process.env.DOCKER_REGISTRY_URL : preparePlugin.registryUrl!; - } else { - registryUrl = ''; + + const preparePlugins = context.options.prepare!.filter(p => p.path === pluginSettings.path) as DockerPluginConfig[]; + + for (let i = 0; i < preparePlugins.length; i++) { + const preparePlugin = preparePlugins[i]; + + // Check if imagename is set + if (preparePlugin.imageName == null || preparePlugin.imageName.length === 0) { + throw new Error("'imageName' is not set in plugin configuration"); + } + + const docker = new Docker(); + + // Check if image exists on machine + const imagelist = await docker.listImages({ filters: { reference: [preparePlugin.imageName] } }); + if (imagelist.length === 0) { + throw new Error(`Image with name '${preparePlugin.imageName}' does not exist on this machine.`); + } + + // Check Authentication + const auth: Authentication = { + ...cred, + serveraddress: getRegistryUrlFromConfig(preparePlugin) + }; + + return docker + .checkAuth(auth) + .then(data => { + if (!verified) { + verified = true; + } + }) + .catch(error => { + throw new Error(error); + }); } - const registry = new Registry(registryUrl); - return registry.login(process.env.DOCKER_REGISTRY_USER, process.env.DOCKER_REGISTRY_PASSWORD) - .then((result) => { - if (!verified) { - verified = true; - } - }); }