diff --git a/addon/docker/blueprints/docker/files/.dockerignore b/addon/docker/blueprints/docker/files/.dockerignore new file mode 100644 index 000000000000..62c9db541e5e --- /dev/null +++ b/addon/docker/blueprints/docker/files/.dockerignore @@ -0,0 +1,5 @@ +.git +.gitignore +.env* +node_modules +docker-compose*.yml diff --git a/addon/docker/blueprints/docker/files/Dockerfile b/addon/docker/blueprints/docker/files/Dockerfile new file mode 100644 index 000000000000..670fe5551dbe --- /dev/null +++ b/addon/docker/blueprints/docker/files/Dockerfile @@ -0,0 +1,8 @@ +# You are free to change the contents of this file +FROM nginx + +# Configure for angular fallback routes +COPY nginx.conf /etc/nginx/conf.d/default.conf + +# Copy built app to wwwroot +COPY dist /usr/share/nginx/html diff --git a/addon/docker/blueprints/docker/files/docker-compose__environment__.yml b/addon/docker/blueprints/docker/files/docker-compose__environment__.yml new file mode 100644 index 000000000000..584ee25d9330 --- /dev/null +++ b/addon/docker/blueprints/docker/files/docker-compose__environment__.yml @@ -0,0 +1,10 @@ +version: "2" +services: + <%= serviceName %>: + <% if (useImage) { + %>image: ${NG_APP_IMAGE}<% + } else { + %>build: . + image: <%= imageName %><% } %> + ports: + - "<%= servicePort %>:<%= containerPort %>" diff --git a/addon/docker/blueprints/docker/files/nginx.conf b/addon/docker/blueprints/docker/files/nginx.conf new file mode 100644 index 000000000000..22c14890682f --- /dev/null +++ b/addon/docker/blueprints/docker/files/nginx.conf @@ -0,0 +1,13 @@ +server { + listen 80; + index index.html; + root /usr/share/nginx/html; + + # These log paths are forwarded to the docker log collector + error_log /var/log/nginx/error.log; + access_log /var/log/nginx/access.log; + + location / { + try_files $uri$args $uri$args/ $uri/ /index.html =404; + } +} diff --git a/addon/docker/blueprints/docker/index.js b/addon/docker/blueprints/docker/index.js new file mode 100644 index 000000000000..4305dd786791 --- /dev/null +++ b/addon/docker/blueprints/docker/index.js @@ -0,0 +1,41 @@ +'use strict'; + +const stringUtils = require('ember-cli-string-utils'); + +module.exports = { + description: '', + + availableOptions: [ + { name: 'environment', type: String, aliases: ['env'] }, + { name: 'service-name', type: String, default: 'ngapp', aliases: ['s'] }, + { name: 'service-port', type: Number, default: 8000, aliases: ['sp'] }, + { name: 'container-port', type: Number, default: 80, aliases: ['cp'] }, + { name: 'use-image', type: Boolean, default: false, aliases: ['i'] }, + { name: 'registry', type: String, aliases: ['r'] }, + { name: 'image-org', type: String, aliases: ['o'] }, + { name: 'image-name', type: String, aliases: ['in'] } + ], + + locals: function (options) { + return { + environment: options.environment, + serviceName: options.serviceName, + servicePort: options.servicePort, + containerPort: options.containerPort, + useImage: options.useImage, + registry: options.registry, + imageOrg: options.imageOrg, + imageName: options.imageName + }; + }, + + fileMapTokens: function (options) { + // Return custom template variables here. + return { + __environment__: () => { + return (options.locals.environment) ? + '-' + stringUtils.dasherize(options.locals.environment) : ''; + } + }; + } +}; diff --git a/addon/docker/commands/deploy.js b/addon/docker/commands/deploy.js new file mode 100644 index 000000000000..d65686a2051d --- /dev/null +++ b/addon/docker/commands/deploy.js @@ -0,0 +1,111 @@ +'use strict'; + +const Command = require('ember-cli/lib/models/command'); +const ValidateDockerCli = require('../tasks/validate-docker-cli'); +const BuildTask = require('ember-cli/lib/tasks/build'); +const Promise = require('ember-cli/lib/ext/promise'); + +module.exports = Command.extend({ + name: 'docker:deploy', + description: 'Builds and deploys to a Docker environment.', + aliases: ['d:d'], + works: 'insideProject', + + availableOptions: [ + { name: 'dry-run', type: Boolean, default: false, aliases: ['d'] }, + { name: 'verbose', type: Boolean, default: false, aliases: ['v'] }, + { + name: 'tag', type: String, aliases: ['t'], + description: 'The Docker tag to use for deploying images.' + }, + { + name: 'machine', type: String, aliases: ['m'], + description: 'The Docker Machine name to use as the deploy destination.' + }, + { + name: 'services', type: Array, aliases: ['s'], + description: 'The specific service name(s) to deploy from the compose file.' + }, + { + name: 'config-env', type: String, default: 'prod', aliases: ['ce', 'cfg'], + description: 'The Angular configuration environment file to include in the build.' + }, + { + name: 'skip-build', type: Boolean, default: false, aliases: ['sb'], + description: 'Do not build the Angular application. Use current contents of "dist/".' + }, + { + name: 'no-cache', type: Boolean, default: false, aliases: ['nc'], + description: 'Do not use cache when building the image.' + }, + { + name: 'force-rm', type: Boolean, default: false, aliases: ['rm'], + description: 'Always remove intermediate containers.' + }, + { + name: 'pull', type: Boolean, default: false, + description: 'Always attempt to pull a newer version of the image.' + }, + { + name: 'force-recreate', type: Boolean, default: false, aliases: ['fr'], + description: 'Recreate containers even if their configuration and image haven\'t changed.' + }, + { + name: 'no-recreate', type: Boolean, default: false, aliases: ['nr'], + description: 'If containers already exist, don\'t recreate them.' + } + ], + + anonymousOptions: [''], + + run: function(commandOptions, rawArgs) { + var environment = (rawArgs.length) ? rawArgs[0] : null; + + // Validate Docker CLI options + var validateDockerCli = new ValidateDockerCli({ + ui: this.ui, + project: this.project + }); + var validateDockerOpts = { + verbose: commandOptions.verbose + }; + + // Build task options + var buildTask = new BuildTask({ + ui: this.ui, + analytics: this.analytics, + project: this.project + }); + + var buildOptions = { + environment: commandOptions.configEnv, + outputPath: 'dist/' + }; + + return validateDockerCli.run(validateDockerOpts) + .then(buildApp) + .then(buildImage) + .then(deploy); + + function buildApp() { + // TODO: If environment useImage == true, skip the build. + if (commandOptions.skipBuild) return Promise.resolve(); + return buildTask.run(buildOptions); + } + + function buildImage() { + // TODO: If environment useImage == true, skip the build. + // TODO: Use a reusable task for image builds + // TODO: Validate docker build env + // TODO: docker-compose build {serviceName} + return Promise.resolve(); + } + + // TODO: Move to a task + function deploy() { + // TODO: Validate docker deploy env + // TODO: docker-compose up -d [services] + return Promise.resolve(); + } + } +}); diff --git a/addon/docker/commands/init.js b/addon/docker/commands/init.js new file mode 100644 index 000000000000..6bae4ea0e643 --- /dev/null +++ b/addon/docker/commands/init.js @@ -0,0 +1,124 @@ +'use strict'; + +const Command = require('ember-cli/lib/models/command'); +const normalizeBlueprint = require('ember-cli/lib/utilities/normalize-blueprint-option'); +const ValidateDockerCli = require('../tasks/validate-docker-cli'); +const SetDockerConfig = require('../tasks/set-docker-config'); + +module.exports = Command.extend({ + name: 'docker:init', + description: 'Initializes settings and files for building and deploying to a Docker environment.', + aliases: ['d:i'], + works: 'insideProject', + + availableOptions: [ + { name: 'dry-run', type: Boolean, default: false, aliases: ['d'] }, + { name: 'verbose', type: Boolean, default: false, aliases: ['v'] }, + { name: 'blueprint', type: String, aliases: ['b'] }, + { + name: 'machine', type: String, aliases: ['m'], default: 'native', + description: 'The Docker Machine name of the deploy machine for this environment.' + }, + { + name: 'service-name', type: String, default: 'ngapp', aliases: ['s', 'service'], + description: 'The service name of the Angular app for use in the compose file.' + }, + { + name: 'service-port', type: Number, default: 8000, aliases: ['sp'], + description: 'The external port of the Angular service exposed on the host machine.' + }, + { + name: 'container-port', type: Number, default: 80, aliases: ['cp'], + description: 'The internal port of the Angular service within the container.' + }, + { + name: 'use-image', type: Boolean, default: false, aliases: ['ui'], + description: 'Use an image URI when deploying, instead of building.' + + ' Requires the image pushed to an external registry.' + }, + { + name: 'registry', type: String, aliases: ['r', 'reg'], + description: 'The default Docker registry address to use for the "docker:push" command.' + }, + { + name: 'image-org', type: String, aliases: ['o', 'org'], + description: 'The organization name for the image when pushing to a Docker registry.' + }, + { + name: 'image-name', type: String, aliases: ['im', 'image'], + description: 'The image name to use when building or pulling from a Docker registry.' + } + ], + + anonymousOptions: [''], + + _defaultBlueprint: function () { + return (this.project.isEmberCLIAddon()) ? 'addon' : 'docker'; + }, + + run: function(commandOptions, rawArgs) { + var environment = (rawArgs.length) ? rawArgs[0] : null; + var imageName = commandOptions.imageName || commandOptions.serviceName; + + // Validate Docker CLI options + var validateDockerCli = new ValidateDockerCli({ + ui: this.ui, + project: this.project + }); + var validateDockerOpts = { + verbose: commandOptions.verbose + }; + + // Set Docker configuration options + var setDockerConfig = new SetDockerConfig({ + ui: this.ui, + project: this.project + }); + + var configOpts = { + dryRun: commandOptions.dryRun + }; + + var dockerProjectCfg = { + imageName: imageName, + imageOrg: commandOptions.imageOrg, + registry: commandOptions.registry + }; + var dockerEnvCfg = { + name: environment || 'default', + useImage: commandOptions.useImage, + serviceName: commandOptions.serviceName, + machine: commandOptions.machine + }; + + // Install Docker blueprints options + var installBlueprint = new this.tasks.InstallBlueprint({ + ui: this.ui, + analytics: this.analytics, + project: this.project + }); + + var blueprintOpts = { + dryRun: commandOptions.dryRun, + blueprint: commandOptions.blueprint || this._defaultBlueprint(), + rawName: this.project.root, + targetFiles: '', + rawArgs: rawArgs.toString(), + environment: environment, + machine: commandOptions.machine, + serviceName: commandOptions.serviceName, + servicePort: commandOptions.servicePort, + containerPort: commandOptions.containerPort, + useImage: commandOptions.useImage, + registry: commandOptions.registry, + imageOrg: commandOptions.imageOrg, + imageName: imageName + }; + + blueprintOpts.blueprint = normalizeBlueprint(blueprintOpts.blueprint); + + return validateDockerCli.run(validateDockerOpts) + .then(() => setDockerConfig.run(dockerProjectCfg, dockerEnvCfg, configOpts)) + .then(() => installBlueprint.run(blueprintOpts)); + } +}); diff --git a/addon/docker/commands/push.js b/addon/docker/commands/push.js new file mode 100644 index 000000000000..ecdbe92a48c2 --- /dev/null +++ b/addon/docker/commands/push.js @@ -0,0 +1,112 @@ +'use strict'; + +const Command = require('ember-cli/lib/models/command'); +const ValidateDockerCli = require('../tasks/validate-docker-cli'); +const BuildTask = require('ember-cli/lib/tasks/build'); +const Promise = require('ember-cli/lib/ext/promise'); + +module.exports = Command.extend({ + name: 'docker:push', + description: 'Builds and pushes a Docker image to a registry.', + aliases: ['d:p'], + works: 'insideProject', + + availableOptions: [ + { name: 'dry-run', type: Boolean, default: false, aliases: ['d'] }, + { name: 'verbose', type: Boolean, default: false, aliases: ['v'] }, + { + name: 'tag-latest', type: Boolean, default: false, aliases: ['tl', 'latest'], + description: 'Additionally apply the "latest" tag to the image.' + }, + { + name: 'machine', type: String, aliases: ['m'], + description: 'The Docker Machine name to use as the build machine.' + + ' Defaults to the local native or "default" docker machine.' + }, + { + name: 'config-env', type: String, default: 'prod', aliases: ['ce', 'cfg'], + description: 'The Angular configuration environment file to include in the build.' + }, + { + name: 'skip-build', type: Boolean, default: false, aliases: ['sb'], + description: 'Do not build the Angular application. Use current contents of "dist/".' + }, + { + name: 'no-cache', type: Boolean, default: false, aliases: ['nc'], + description: 'Do not use cache when building the image.' + }, + { + name: 'force-rm', type: Boolean, default: false, aliases: ['rm'], + description: 'Always remove intermediate containers.' + }, + { + name: 'pull', type: Boolean, default: false, + description: 'Always attempt to pull a newer version of the image.' + } + ], + + anonymousOptions: [''], + + run: function(commandOptions, rawArgs) { + var tag = (rawArgs.length) ? rawArgs[0] : null; + + // Validate Docker CLI options + var validateDockerCli = new ValidateDockerCli({ + ui: this.ui, + project: this.project + }); + var validateDockerOpts = { + verbose: commandOptions.verbose + }; + + // Build task options + var buildTask = new BuildTask({ + ui: this.ui, + analytics: this.analytics, + project: this.project + }); + + var buildOptions = { + environment: commandOptions.configEnv, + outputPath: 'dist/' + }; + + return validateDockerCli.run(validateDockerOpts) + .then(buildApp) + .then(() => buildImage(commandOptions.machine)) + .then(() => tagImage(tag)) + .then(() => pushImage(tag)) + .then(buildAndTagLatest); + + function buildApp() { + if (commandOptions.skipBuild) return Promise.resolve(); + return buildTask.run(buildOptions); + } + + function buildAndTagLatest() { + if (!commandOptions.tagLatest) return Promise.resolve(); + return tagImage('latest') + .then(() => pushImage('latest')); + } + + // TODO: Move to a task + function buildImage(machine) { + // TODO: validate docker build env + // TODO: docker-compose build {serviceName} + return Promise.resolve(machine); + } + + // TODO: Move to a task + function tagImage(tag) { + // TODO: docker tag + return Promise.resolve(tag); + } + + // TODO: Move to a task + function pushImage(tag) { + // TODO: docker push + // TODO: docker login \ aws ecr get-login + return Promise.resolve(tag); + } + } +}); diff --git a/addon/docker/index.js b/addon/docker/index.js new file mode 100644 index 000000000000..cb318ee12d98 --- /dev/null +++ b/addon/docker/index.js @@ -0,0 +1,14 @@ +/* jshint node: true */ +'use strict'; + +module.exports = { + name: 'docker', + + includedCommands: function () { + return { + 'docker:init': require('./commands/init'), + 'docker:deploy': require('./commands/deploy'), + 'docker:push': require('./commands/push') + }; + } +}; diff --git a/addon/docker/package.json b/addon/docker/package.json new file mode 100644 index 000000000000..e1be8f794e90 --- /dev/null +++ b/addon/docker/package.json @@ -0,0 +1,10 @@ +{ + "name": "docker", + "version": "0.0.0", + "description": "An addon to generate an ng2 docker addon", + "author": "cgmartin", + "license": "MIT", + "keywords": [ + "ember-addon" + ] +} diff --git a/addon/docker/tasks/set-docker-config.js b/addon/docker/tasks/set-docker-config.js new file mode 100644 index 000000000000..3ae6ba4733df --- /dev/null +++ b/addon/docker/tasks/set-docker-config.js @@ -0,0 +1,95 @@ +'use strict'; + +const Promise = require('ember-cli/lib/ext/promise'); +const Task = require('ember-cli/lib/models/task'); +const CliConfig = require('../../ng2/models/config').CliConfig; + +module.exports = Task.extend({ + run: function (projectCfg, envCfg, options) { + options = options || {}; + + return new Promise((resolve) => { + const ngConfig = new CliConfig(); + var dockerCfg = getDockerConfig(ngConfig); + + setDockerProjectConfig(dockerCfg, projectCfg); + setDockerEnvironmentConfig(dockerCfg, envCfg); + + if (!options.dryRun) { + ngConfig.save(); + } + + resolve(); + }); + } +}); + +/** + * Finds an existing docker addon configuration, or creates + * a default config structure if one does not exist. + */ +function getDockerConfig(ngConfig) { + var dockerCfg; + var addonsCfg = ngConfig.get('addons').filter((addon) => { + return addon.name === 'docker'; + }); + + if (addonsCfg.length) { + dockerCfg = addonsCfg[0]; + } else { + dockerCfg = {}; + ngConfig.set('addons.push', dockerCfg); + } + + dockerCfg.name = 'docker'; + dockerCfg.description = 'Docker build and deployment settings.'; + dockerCfg.defaults = dockerCfg.defaults || {}; + dockerCfg.project = dockerCfg.project || {}; + dockerCfg.project.environments = dockerCfg.project.environments || []; + + return dockerCfg; +} + +/** + * Applies new docker project settings to the docker config. + */ +function setDockerProjectConfig(dockerCfg, projectCfg) { + if (!projectCfg) { + return; + } + + var projProps = ['imageName', 'imageOrg', 'registry']; + projProps.forEach((prop) => { + if (prop in projectCfg) { + dockerCfg.project[prop] = projectCfg[prop]; + } + }); +} + +/** + * Finds a docker environment config set by name (if existing) and sets new values. + */ +function setDockerEnvironmentConfig(dockerCfg, envCfg) { + if (!envCfg || !envCfg.name) { + return; + } + + var envProps = ['name', 'useImage', 'machine', 'serviceName']; + var environmentsCfg = dockerCfg.project.environments.filter((item) => { + return item.name === envCfg.name; + }); + var dockerCfgEnv; + + if (environmentsCfg.length) { + dockerCfgEnv = environmentsCfg[0]; + } else { + dockerCfgEnv = {}; + dockerCfg.project.environments.push(dockerCfgEnv); + } + + envProps.forEach((prop) => { + if (prop in envCfg) { + dockerCfgEnv[prop] = envCfg[prop]; + } + }); +} diff --git a/addon/docker/tasks/validate-docker-cli.js b/addon/docker/tasks/validate-docker-cli.js new file mode 100644 index 000000000000..b413c36184f5 --- /dev/null +++ b/addon/docker/tasks/validate-docker-cli.js @@ -0,0 +1,73 @@ +'use strict'; + +const Promise = require('ember-cli/lib/ext/promise'); +const Task = require('ember-cli/lib/models/task'); +const exec = require('child_process').exec; +const chalk = require('chalk'); + +module.exports = Task.extend({ + run: function (options) { + options = options || {}; + + var ui = this.ui; + const execPromise = Promise.denodeify(exec); + + return getDockerVersion() + .then(getDockerComposeVersion) + .then(getDockerMachineVersion); + + function getDockerVersion() { + return execPromise('docker --version') + .then((stdout) => { + var matches = stdout.match(/Docker version (.+?), build (.+)/); + if (!matches) { + return Promise.reject(new Error('Unknown Docker CLI output')); + } + if (options.verbose) { + ui.writeLine(chalk.green( + 'found docker version ' + matches[1] + '/' + matches[2] + )); + } + }) + .catch(() => Promise.reject(new Error( + 'The docker CLI must be installed to use this feature.' + ))); + } + + function getDockerComposeVersion() { + return execPromise('docker-compose --version') + .then((stdout) => { + var matches = stdout.match(/docker-compose version (.+?), build (.+)/); + if (!matches) { + return Promise.reject(new Error('Unknown docker-compose CLI output')); + } + if (options.verbose) { + ui.writeLine(chalk.green( + 'found docker-compose version ' + matches[1] + '/' + matches[2]) + ); + } + }) + .catch(() => Promise.reject(new Error( + 'The docker-compose CLI must be installed to use this feature.' + ))); + } + + function getDockerMachineVersion() { + return execPromise('docker-machine --version') + .then((stdout) => { + var matches = stdout.match(/docker-machine version (.+?), build (.+)/); + if (!matches) { + return Promise.reject(new Error('Unknown docker-machine CLI output')); + } + if (options.verbose) { + ui.writeLine(chalk.green( + 'found docker-machine version ' + matches[1] + '/' + matches[2]) + ); + } + }) + .catch(() => Promise.reject(new Error( + 'The docker-machine CLI must be installed to use this feature.' + ))); + } + } +}); diff --git a/package.json b/package.json index d1ed79d5f4a9..b2e3567679f5 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,8 @@ }, "ember-addon": { "paths": [ - "./addon/ng2/" + "./addon/ng2/", + "./addon/docker/" ] }, "devDependencies": {