├── doc ├── .nojekyll ├── pro.gif ├── favicon.ico ├── mutato.png ├── img │ └── bg-pattern.png ├── mutato-transparent.png ├── _coverpage.md ├── _sidebar.md ├── css │ └── style.css ├── mutato-cdk.md ├── mutato-docker.md ├── index.html ├── mutato-architecture.md ├── mutato-vs-mu.md ├── mutato-workflow.md ├── mutato-concepts.md ├── README.md └── mutato-yaml.md ├── cdk.json ├── .eslintignore ├── lib ├── actions │ ├── index.ts │ ├── interface.ts │ ├── approval.ts │ ├── docker.ts │ └── codebuild.ts ├── parser │ ├── index.ts │ ├── loader.ts │ ├── parser.ts │ └── preprocessor.ts ├── index.ts ├── resources │ ├── index.ts │ ├── network.ts │ ├── storage.ts │ ├── database.ts │ ├── container.ts │ └── service.ts ├── config.ts └── app.ts ├── .dockerignore ├── .vscode ├── settings.json ├── extensions.json └── launch.json ├── .npmignore ├── test ├── sanity.test.ts ├── parser.test.ts └── container.test.ts ├── deploy.sh ├── bin └── mutato.ts ├── jsdoc2md.json ├── patches ├── @aws-cdk+aws-codebuild+1.32.0.patch └── @aws-cdk+app-delivery+1.32.0.patch ├── Dockerfile ├── tsconfig.json ├── .github └── workflows │ └── main.yml ├── mutato.yml ├── LICENSE ├── README.md ├── .devcontainer ├── devcontainer.json └── Dockerfile ├── .gitignore ├── CONTRIBUTING.md └── package.json /doc/.nojekyll: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /cdk.json: -------------------------------------------------------------------------------- 1 | { 2 | "app": "npx ts-node bin/mutato.ts" 3 | } 4 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | docs 4 | **/*.d.ts 5 | -------------------------------------------------------------------------------- /doc/pro.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stelligent/mutato/HEAD/doc/pro.gif -------------------------------------------------------------------------------- /doc/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stelligent/mutato/HEAD/doc/favicon.ico -------------------------------------------------------------------------------- /doc/mutato.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stelligent/mutato/HEAD/doc/mutato.png -------------------------------------------------------------------------------- /doc/img/bg-pattern.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stelligent/mutato/HEAD/doc/img/bg-pattern.png -------------------------------------------------------------------------------- /doc/mutato-transparent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stelligent/mutato/HEAD/doc/mutato-transparent.png -------------------------------------------------------------------------------- /lib/actions/index.ts: -------------------------------------------------------------------------------- 1 | export * from './approval'; 2 | export * from './codebuild'; 3 | export * from './docker'; 4 | -------------------------------------------------------------------------------- /lib/parser/index.ts: -------------------------------------------------------------------------------- 1 | export * from './loader'; 2 | export * from './parser'; 3 | export * from './preprocessor'; 4 | -------------------------------------------------------------------------------- /lib/index.ts: -------------------------------------------------------------------------------- 1 | export * from './app'; 2 | export * from './config'; 3 | export * from './parser'; 4 | export * from './resources'; 5 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | **/* 2 | !lib 3 | !bin 4 | !doc 5 | !tsconfig.json 6 | !package.json 7 | !package-lock.json 8 | !cdk.json 9 | !jsdoc2md.json 10 | !.gitignore 11 | !patches 12 | -------------------------------------------------------------------------------- /lib/resources/index.ts: -------------------------------------------------------------------------------- 1 | export * from './container'; 2 | export * from './database'; 3 | export * from './network'; 4 | export * from './service'; 5 | export * from './storage'; 6 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.enable": true, 3 | "editor.tabSize": 2, 4 | "editor.rulers": [80], 5 | "editor.codeActionsOnSave": { 6 | "source.fixAll.eslint": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .devcontainer 2 | .github 3 | .vscode 4 | cdk.out 5 | dist 6 | test 7 | .dockerignore 8 | .eslintignore 9 | .gitignore 10 | CONTRIBUTING.md 11 | Dockerfile 12 | jsdoc2md.json 13 | mutato.yml 14 | -------------------------------------------------------------------------------- /doc/_coverpage.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ![](mutato-transparent.png) 4 | 5 | # Mutato 2.0 6 | 7 | a tool for managing your microservices platform 8 | 9 | [GitHub](https://github.com/stelligent/mutato) 10 | [Get Started](#mutato) 11 | -------------------------------------------------------------------------------- /lib/actions/interface.ts: -------------------------------------------------------------------------------- 1 | import * as codePipeline from '@aws-cdk/aws-codepipeline'; 2 | 3 | export interface ActionPropsInterface { 4 | readonly name: string; 5 | } 6 | 7 | export interface ActionInterface { 8 | readonly name: string; 9 | action(suffix: string): codePipeline.IAction; 10 | } 11 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "christian-kohler.npm-intellisense", 4 | "DavidAnson.vscode-markdownlint", 5 | "dbaeumer.vscode-eslint", 6 | "eg2.vscode-npm-script", 7 | "esbenp.prettier-vscode", 8 | "ms-vscode.vscode-typescript-tslint-plugin", 9 | "stkb.rewrap", 10 | "yzhang.markdown-all-in-one" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /test/sanity.test.ts: -------------------------------------------------------------------------------- 1 | import chai from 'chai'; 2 | import chaiAsPromised from 'chai-as-promised'; 3 | 4 | chai.use(chaiAsPromised); 5 | 6 | describe('Sanity Check', () => { 7 | it('should always pass sync', () => { 8 | chai.assert(2 + 2 === 4); 9 | }); 10 | 11 | it('should always pass async', async () => { 12 | await chai.assert.isFulfilled(Promise.resolve()); 13 | await chai.assert.isRejected(Promise.reject()); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /deploy.sh: -------------------------------------------------------------------------------- 1 | #! bash -eux 2 | 3 | VAULT_PROFILE=${1-"mutato-dev"} 4 | docker build --no-cache -t stelligent/mutato . 5 | echo "NPM_TOKEN=$NPM_TOKEN" > mutato.env 6 | echo "GITHUB_TOKEN=$GITHUB_TOKEN" >> mutato.env 7 | echo "DOCKER_USERNAME=$DOCKER_USERNAME" >> mutato.env 8 | echo "DOCKER_PASSWORD=$DOCKER_PASSWORD" >> mutato.env 9 | aws-vault exec $VAULT_PROFILE -- env | grep AWS_ >> mutato.env 10 | docker run -it --rm --env-file mutato.env -v `pwd`:/project stelligent/mutato deploy 11 | -------------------------------------------------------------------------------- /bin/mutato.ts: -------------------------------------------------------------------------------- 1 | import debug from 'debug'; 2 | import 'source-map-support/register'; 3 | import * as mutato from '../lib'; 4 | 5 | const _debug = debug('mutato'); 6 | 7 | (async (): Promise => { 8 | _debug('creating a new Mutato App'); 9 | const app = new mutato.App(); 10 | _debug('synthesizing Mutato App'); 11 | await app.synthesizeFromFile(); 12 | })() 13 | .then(() => { 14 | _debug('synthesized with Mutato.'); 15 | }) 16 | .catch((err) => { 17 | _debug('failed to deploy with Mutato: %o', err); 18 | process.exit(1); 19 | }); 20 | -------------------------------------------------------------------------------- /doc/_sidebar.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | - [home](/) 4 | - [concepts](mutato-concepts.md 'Mutato Concepts') 5 | - [workflow](mutato-workflow.md 'Deploy workflow') 6 | - [architecture](mutato-architecture.md 'Pipeline Architecture') 7 | - [mutato.yml](mutato-yaml.md 'YAML Reference specification') 8 | - [mutato container](mutato-docker.md 'Docker Container Documentation') 9 | - [extending mutato](mutato-cdk.md 'Extending Mutato in CDK') 10 | - [mutato vs mu](mutato-vs-mu.md 'Mutato Compared to OG Stelligent Mu') 11 | - [API](api.md 'Low level CDK API documentation') 12 | -------------------------------------------------------------------------------- /jsdoc2md.json: -------------------------------------------------------------------------------- 1 | { 2 | "source": { 3 | "includePattern": ".+\\.ts(doc|x)?$", 4 | "excludePattern": ".+\\.(test|spec).ts" 5 | }, 6 | "plugins": ["plugins/markdown", "node_modules/jsdoc-babel"], 7 | "babel": { 8 | "extensions": ["ts", "tsx"], 9 | "ignore": ["**/*.(test|spec).ts"], 10 | "babelrc": false, 11 | "presets": [ 12 | ["@babel/preset-env", { "targets": { "node": true } }], 13 | "@babel/preset-typescript" 14 | ], 15 | "plugins": [ 16 | "@babel/proposal-class-properties", 17 | "@babel/proposal-object-rest-spread" 18 | ] 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /patches/@aws-cdk+aws-codebuild+1.32.0.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/@aws-cdk/aws-codebuild/lib/project.js b/node_modules/@aws-cdk/aws-codebuild/lib/project.js 2 | index 059b726..967645d 100644 3 | --- a/node_modules/@aws-cdk/aws-codebuild/lib/project.js 4 | +++ b/node_modules/@aws-cdk/aws-codebuild/lib/project.js 5 | @@ -966,7 +966,7 @@ var BuildEnvironmentVariableType; 6 | /** 7 | * An environment variable in plaintext format. 8 | */ 9 | - BuildEnvironmentVariableType["PLAINTEXT"] = "PLAINTEXT"; 10 | + //BuildEnvironmentVariableType["PLAINTEXT"] = "PLAINTEXT"; 11 | /** 12 | * An environment variable stored in Systems Manager Parameter Store. 13 | */ 14 | -------------------------------------------------------------------------------- /lib/parser/loader.ts: -------------------------------------------------------------------------------- 1 | import debug from 'debug'; 2 | import assert from 'assert'; 3 | import _ from 'lodash'; 4 | import yaml from 'yaml'; 5 | 6 | const _debug = debug('mutato:parser:Loader'); 7 | 8 | /** single document YAML loader */ 9 | export class Loader { 10 | /** 11 | * @param input a single-document YAML string 12 | * @returns YAML document in the form of single JSON object 13 | */ 14 | public load(input: string): object { 15 | _debug('loading input YAML: %s', input); 16 | const document = yaml.parseDocument(input); 17 | _debug('parsed document: %o', document); 18 | assert.ok(_.isEmpty(document.errors), 'failed to parse the input YAML'); 19 | return document.toJSON(); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /doc/css/style.css: -------------------------------------------------------------------------------- 1 | /* COVER */ 2 | section.cover { 3 | color: #fff; 4 | background: url(../img/bg-pattern.png), linear-gradient(to left, #f4842b, #7b4397) !important; 5 | } 6 | 7 | section.cover h1 { 8 | font-size: 5rem; 9 | font-weight: 600; 10 | } 11 | 12 | section.cover h1 a span { 13 | color: #fff; 14 | } 15 | 16 | /* Cover buttons */ 17 | 18 | section.cover .cover-main > p:last-child a { 19 | border: 1px solid #fff; 20 | color: #fff; 21 | } 22 | section.cover .cover-main > p:last-child a:last-child { 23 | background-color: #fff; 24 | color: var(--theme-color,#fff); 25 | } 26 | section.cover .cover-main > p:last-child a:last-child:hover { 27 | color: var(--theme-color,#fff); 28 | opacity: 0.8; 29 | } 30 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:lts 2 | 3 | ADD . /mutato 4 | WORKDIR /mutato 5 | 6 | ENV DEBUG=mutato* 7 | ENV DEBUG_COLORS=0 8 | ENV USER=root 9 | ENV TEMP=/tmp 10 | ENV HOME=/home/root 11 | ENV DEBIAN_FRONTEND=noninteractive 12 | 13 | RUN mkdir -p ${HOME} && chmod a+rwx ${TEMP} ${HOME} \ 14 | && apt-get update -qq \ 15 | && apt-get upgrade -y \ 16 | && apt-get install -y --no-install-recommends git dumb-init \ 17 | && git config --global user.email "support@stelligent.com" \ 18 | && git config --global user.name "mutato-docker" \ 19 | && npm install --allow-root --unsafe-perm && npm run build \ 20 | && rm -rf /var/lib/apt/lists/* 21 | 22 | ENV mutato_opts__git__local=/project 23 | 24 | ENTRYPOINT ["/usr/bin/dumb-init", "--", "/usr/local/bin/npm", "run"] 25 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2018", 4 | "module": "commonjs", 5 | "lib": ["es2018"], 6 | "declaration": true, 7 | "strict": true, 8 | "noImplicitAny": true, 9 | "strictNullChecks": true, 10 | "noImplicitThis": true, 11 | "alwaysStrict": true, 12 | "noUnusedLocals": false, 13 | "noUnusedParameters": false, 14 | "noImplicitReturns": true, 15 | "noFallthroughCasesInSwitch": false, 16 | "inlineSourceMap": true, 17 | "inlineSources": true, 18 | "experimentalDecorators": true, 19 | "strictPropertyInitialization": false, 20 | "typeRoots": ["./node_modules/@types"], 21 | "resolveJsonModule": true, 22 | "esModuleInterop": true, 23 | "outDir": "out", 24 | "allowJs": true 25 | }, 26 | "exclude": ["cdk.out", "out", "dist", "docs"] 27 | } 28 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "type": "node", 6 | "request": "launch", 7 | "name": "Debug Current Mocha Test", 8 | "program": "${workspaceFolder}/node_modules/mocha/bin/_mocha", 9 | "args": ["--timeout", "0", "--colors", "${file}"], 10 | "internalConsoleOptions": "openOnSessionStart", 11 | "skipFiles": ["/**"] 12 | }, 13 | { 14 | "type": "node", 15 | "request": "launch", 16 | "name": "Debug EntryPoint", 17 | "program": "${workspaceFolder}/bin/mutato.ts", 18 | "preLaunchTask": "npm: build", 19 | "outFiles": ["${workspaceFolder}/out/**/*.js"], 20 | "skipFiles": ["/**"], 21 | "cwd": "${workspaceFolder}", 22 | "env": { "DEBUG": "mutato*" }, 23 | "console": "integratedTerminal" 24 | } 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Docs 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v2 14 | - name: Setup Node 15 | uses: actions/setup-node@v1 16 | with: 17 | node-version: 12.x 18 | - name: Cache Modules 19 | uses: actions/cache@v1 20 | with: 21 | path: ~/.npm # npm cache files are stored in `~/.npm` on Linux/macOS 22 | key: ${{ runner.OS }}-node-${{ hashFiles('**/package-lock.json') }} 23 | restore-keys: | 24 | ${{ runner.OS }}-node- 25 | ${{ runner.OS }}- 26 | - run: npm install 27 | - name: Deploy 28 | uses: JamesIves/github-pages-deploy-action@releases/v3 29 | with: 30 | ACCESS_TOKEN: ${{ secrets.GITHUB_TOKEN }} 31 | BRANCH: gh-pages 32 | FOLDER: doc 33 | -------------------------------------------------------------------------------- /mutato.yml: -------------------------------------------------------------------------------- 1 | {% set productionBranch = "master" %} 2 | {% set imageTag = "latest" if git.branch == productionBranch else git.branch %} 3 | 4 | containers: 5 | - docker: 6 | name: mutato 7 | file: Dockerfile 8 | uri: stelligent/mutato:{{ imageTag }} 9 | events: 10 | pre-build: npm-test 11 | {% if git.branch == productionBranch %} 12 | post-build: npm-deploy 13 | {% endif %} 14 | 15 | actions: 16 | - docker: 17 | name: npm-test 18 | container: node:lts 19 | cmd: 20 | - npm install --allow-root --unsafe-perm 21 | - USER=root npm test 22 | 23 | {% if git.branch == productionBranch %} 24 | - docker: 25 | name: npm-deploy 26 | container: node:lts 27 | cmd: 28 | - npm install --allow-root --unsafe-perm 29 | - npm run build 30 | - echo //registry.npmjs.org/:_authToken={{ env("NPM_TOKEN") }} > .npmrc 31 | - npm publish --access=public 32 | {% endif %} 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2020 Mphasis Stelligent 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /patches/@aws-cdk+app-delivery+1.32.0.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/@aws-cdk/app-delivery/lib/pipeline-deploy-stack-action.js b/node_modules/@aws-cdk/app-delivery/lib/pipeline-deploy-stack-action.js 2 | index 06de0db..29074e5 100644 3 | --- a/node_modules/@aws-cdk/app-delivery/lib/pipeline-deploy-stack-action.js 4 | +++ b/node_modules/@aws-cdk/app-delivery/lib/pipeline-deploy-stack-action.js 5 | @@ -18,7 +18,7 @@ class PipelineDeployStackAction { 6 | const assets = this.stack.node.metadata.filter(md => md.type === cxapi.ASSET_METADATA); 7 | if (assets.length > 0) { 8 | // FIXME: Implement the necessary actions to publish assets 9 | - throw new Error(`Cannot deploy the stack ${this.stack.stackName} because it references ${assets.length} asset(s)`); 10 | + //throw new Error(`Cannot deploy the stack ${this.stack.stackName} because it references ${assets.length} asset(s)`); 11 | } 12 | const createChangeSetRunOrder = props.createChangeSetRunOrder || 1; 13 | const executeChangeSetRunOrder = props.executeChangeSetRunOrder || (createChangeSetRunOrder + 1); 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Mutato 2 | 3 |

4 | 5 | The Great Mutato! 6 | 7 |

8 | 9 | [Stelligent Mutato](https://github.com/stelligent/mutato) is an open-source 10 | framework for building containerized micro-services on the AWS ecosystem (e.g 11 | ECS or Fargate). Mutato is designed to leverage [AWS Cloud Development Kit 12 | (CDK)](https://docs.aws.amazon.com/cdk/latest/guide/home.html) constructs which 13 | abstract the complexity of writing a safe and secure CloudFormation file to 14 | deploy and automate micro-service deployments. 15 | 16 | - Documentation: [stelligent.github.io/mutato](https://stelligent.github.io/mutato) 17 | - Contributions: [CONTRIBUTING.md](CONTRIBUTING.md) 18 | - Issues & Bugs: [Github Issues](https://github.com/stelligent/mutato/issues) 19 | - Active Development: [Github Project](https://github.com/stelligent/mutato/projects/3) 20 | - Roadmap: [Github Project](https://github.com/stelligent/mutato/projects/2) 21 | - CDK Roadmap: [Github Project](https://github.com/orgs/aws/projects/7) 22 | - [Watching RFC 49](https://github.com/aws/aws-cdk-rfcs/blob/master/text/0049-continuous-delivery.md) 23 | -------------------------------------------------------------------------------- /lib/resources/network.ts: -------------------------------------------------------------------------------- 1 | import * as ec2 from '@aws-cdk/aws-ec2'; 2 | import * as ecs from '@aws-cdk/aws-ecs'; 3 | import * as cdk from '@aws-cdk/core'; 4 | import debug from 'debug'; 5 | import _ from 'lodash'; 6 | 7 | interface NetworkProps { 8 | vpc?: ec2.VpcProps; 9 | cluster?: ecs.ClusterProps; 10 | } 11 | 12 | /** ECS Cluster and VPC */ 13 | export class Network extends cdk.Construct { 14 | private readonly _props: NetworkProps; 15 | private readonly _debug: debug.IDebugger; 16 | public readonly vpc: ec2.Vpc; 17 | public readonly cluster: ecs.Cluster; 18 | 19 | /** 20 | * @hideconstructor 21 | * @param scope CDK scope 22 | * @param id CDK construct id 23 | * @param props CDK construct parameters 24 | */ 25 | constructor(scope: cdk.Construct, id: string, props?: NetworkProps) { 26 | super(scope, id); 27 | 28 | this._debug = debug(`mutato:constructs:Network:${id}`); 29 | this._props = _.defaults(props, {}); 30 | 31 | this._debug('creating a network construct with props: %o', this._props); 32 | this.vpc = new ec2.Vpc(this, 'VPC', this._props.vpc); 33 | this.cluster = new ecs.Cluster(this, 'Cluster', { 34 | ...this._props.cluster, 35 | vpc: this.vpc, 36 | }); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mutato", 3 | "dockerFile": "Dockerfile", 4 | 5 | // Use 'appPort' to create a container with published ports. If the port isn't working, be sure 6 | // your server accepts connections from all interfaces (0.0.0.0 or '*'), not just localhost. 7 | "appPort": [], 8 | 9 | // Comment out the next line to run as root instead. 10 | "remoteUser": "node", 11 | 12 | // Use 'settings' to set *default* container specific settings.json values on container create. 13 | // You can edit these settings after create using File > Preferences > Settings > Remote. 14 | "settings": { 15 | "terminal.integrated.shell.linux": "/bin/bash" 16 | }, 17 | 18 | // Specifies a command that should be run after the container has been created. 19 | "postCreateCommand": "npm install", 20 | 21 | "forwardPorts": [3000], 22 | 23 | // Add the IDs of extensions you want installed when the container is created in the array below. 24 | "extensions": [ 25 | "dbaeumer.vscode-eslint", 26 | "esbenp.prettier-vscode", 27 | "stkb.rewrap", 28 | "christian-kohler.npm-intellisense", 29 | "DavidAnson.vscode-markdownlint", 30 | "eg2.vscode-npm-script", 31 | "ms-vscode.vscode-typescript-tslint-plugin", 32 | "yzhang.markdown-all-in-one" 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /doc/mutato-cdk.md: -------------------------------------------------------------------------------- 1 | # Extending Mutato in CDK 2 | 3 | > [!WARNING] Advanced users only! If you do not know what you are doing and do 4 | > not know how to work with CDK, you are at the wrong place. This page is for 5 | > CDK users who wish to integrate and extend Mutato in their CDK applications. 6 | 7 | Mutato at its core is just a CDK application. You can use its constructs and 8 | helpers in any CDK application. To get started: 9 | 10 | ```bash 11 | $ npm install @stelligent/mutato --save 12 | ``` 13 | 14 | And you can start using our constructs or helpers: 15 | 16 | ```TypeScript 17 | import * as mutato from '@stelligent/mutato'; 18 | import * as sns from '@aws-cdk/aws-sns'; 19 | 20 | async function createCustomMutatoApp() { 21 | // mutato.App is a subclass of cdk.App 22 | const app = new mutato.App(); 23 | await app.synthesizeFromFile('/path/to/mutato.yml'); 24 | 25 | // here you have a functioning CDK app, you can use it to add more resources 26 | const customStack = new cdk.Stack(app, 'MyCustomStack', { 27 | description: 'example of a custom stack attached to a Mutato app', 28 | }); 29 | 30 | // add a SNS topic into your custom stack 31 | const topic = new sns.Topic(customStack, 'MyTopic', { 32 | displayName: 'Customer subscription topic' 33 | }); 34 | 35 | // if you wish to customize what mutato synthesizes, you can also use all the 36 | // CDK "escape hatches" or other mechanisms you know of here. 37 | // https://docs.aws.amazon.com/cdk/latest/guide/cfn_layer.html 38 | } 39 | ``` -------------------------------------------------------------------------------- /doc/mutato-docker.md: -------------------------------------------------------------------------------- 1 | # Docker Container Documentation 2 | 3 | Mutato's Docker container is the preferred and recommended way to quickly get up 4 | and running with the toolset. 5 | 6 | The container conveniently packages all the dependencies needed to successfully 7 | deploy with Mutato. 8 | 9 | The container ships with the version of CDK we use in Mutato to ensure no 10 | collision happens between user's environment and what Mutato uses. 11 | 12 | ## Container Commands 13 | 14 | ### `docker run ... stelligent/mutato bootstrap` 15 | 16 | This is the one-time setup needed to be executed for each AWS account mutato is 17 | going to be used. This is currently just the same as `cdk bootstrap`. 18 | 19 | ### `docker run ... stelligent/mutato destroy` 20 | 21 | This destroys all the resources deployed by mutato for the current branch. 22 | 23 | ### `docker run ... stelligent/mutato deploy` 24 | 25 | This deploys _mutato.yml_ for the current branch into your AWS account. 26 | 27 | ### `docker run ... stelligent/mutato synth` 28 | 29 | This is essentially a dry-run of deploy. It only generates the CloudFormation 30 | files used during deployment. This can be used in a CI to catch errors in a 31 | _mutato.yml_ file before it actually deploys. 32 | 33 | ### `docker run ... stelligent/mutato cdk` 34 | 35 | > [!WARNING] Advanced users only! If you do not know what you are doing and do 36 | > not know how to work with CDK, running commands directly through CDK may 37 | > damage your deployed stacks! 38 | 39 | This gives you access to the bundled CDK. 40 | -------------------------------------------------------------------------------- /lib/actions/approval.ts: -------------------------------------------------------------------------------- 1 | import * as codePipelineActions from '@aws-cdk/aws-codepipeline-actions'; 2 | import assert from 'assert'; 3 | import debug from 'debug'; 4 | import _ from 'lodash'; 5 | import { config } from '../config'; 6 | import { ActionInterface, ActionPropsInterface } from './interface'; 7 | 8 | const _debug = debug('mutato:actions:Approval'); 9 | 10 | interface ApprovalProps extends ActionPropsInterface { 11 | emails?: string[]; 12 | } 13 | 14 | /** manual approval action in the pipeline */ 15 | export class Approval implements ActionInterface { 16 | private readonly _props: ApprovalProps; 17 | public readonly name: string; 18 | 19 | /** 20 | * @hideconstructor 21 | * @param props approval parameters 22 | */ 23 | constructor(props: ApprovalProps) { 24 | this._props = _.defaults(props, { order: 1 }); 25 | assert.ok(this._props.name); 26 | this.name = this._props.name; 27 | } 28 | 29 | /** 30 | * creates a manual approval action in the pipeline 31 | * 32 | * @param requester a unique ID used to prevent action duplication 33 | * @returns action construct to be added into a code pipeline 34 | */ 35 | public action( 36 | requester = 'default', 37 | ): codePipelineActions.ManualApprovalAction { 38 | _debug('creating a manual approval with props: %o', this._props); 39 | const git = config.getGithubMetaData(); 40 | return new codePipelineActions.ManualApprovalAction({ 41 | actionName: `${this.name}-${requester}`, 42 | notifyEmails: this._props?.emails, 43 | additionalInformation: this._props?.emails 44 | ? [ 45 | 'an approval action in a Mutato pipeline needs your attention.', 46 | `repository: ${git.repo}. branch: ${git.branch}.`, 47 | 'check your AWS console to make a decision.', 48 | ].join(' ') 49 | : undefined, 50 | }); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /doc/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | mutato - simplify the declaration and administration of the AWS resources necessary to support microservices. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /lib/actions/docker.ts: -------------------------------------------------------------------------------- 1 | import * as codeBuild from '@aws-cdk/aws-codebuild'; 2 | import * as codePipeline from '@aws-cdk/aws-codepipeline'; 3 | import * as codePipelineActions from '@aws-cdk/aws-codepipeline-actions'; 4 | import { Container } from '../resources/container'; 5 | import { CodeBuild } from './codebuild'; 6 | import { ActionPropsInterface } from './interface'; 7 | import _ from 'lodash'; 8 | 9 | interface DockerBuildProps extends ActionPropsInterface { 10 | container: Container; 11 | pipeline: codePipeline.Pipeline; 12 | source: codePipeline.Artifact; 13 | sourceAction: codePipelineActions.GitHubSourceAction; 14 | } 15 | 16 | /** "docker build" convenience action */ 17 | export class DockerBuild extends CodeBuild { 18 | /** 19 | * @hideconstructor 20 | * @param props build parameters 21 | */ 22 | constructor(props: DockerBuildProps) { 23 | super({ 24 | ...props, 25 | privileged: true, 26 | buildImage: codeBuild.LinuxBuildImage.STANDARD_2_0, 27 | spec: { 28 | version: 0.2, 29 | phases: { 30 | install: { 'runtime-versions': { docker: 18 } }, 31 | pre_build: { commands: [props.container.loginCommand] }, 32 | build: { commands: [props.container.buildCommand] }, 33 | post_build: { commands: [props.container.pushCommand] }, 34 | }, 35 | }, 36 | }); 37 | } 38 | } 39 | 40 | interface DockerRunProps extends ActionPropsInterface { 41 | container: Container; 42 | pipeline: codePipeline.Pipeline; 43 | source: codePipeline.Artifact; 44 | sourceAction: codePipelineActions.GitHubSourceAction; 45 | cmd?: string | string[]; 46 | privileged?: boolean; 47 | } 48 | 49 | /** "docker run" convenience action */ 50 | export class DockerRun extends CodeBuild { 51 | /** 52 | * @hideconstructor 53 | * @param props run parameters 54 | */ 55 | constructor(props: DockerRunProps) { 56 | super({ 57 | ...props, 58 | spec: { 59 | version: 0.2, 60 | phases: { 61 | build: { 62 | commands: _.isString(props.cmd) ? [props.cmd] : props.cmd, 63 | }, 64 | }, 65 | }, 66 | }); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /doc/mutato-architecture.md: -------------------------------------------------------------------------------- 1 | # Pipeline Architecture 2 | 3 | > [!NOTE] this page is aimed at advanced audiences, already familiar with CDK. 4 | 5 | Mutato is a TypeScript CDK application. It is executed via CDK's CLI. During 6 | execution, the entrypoint of the CDK application looks for two things: 7 | 8 | 1. A GitHub repository on the disk 9 | 2. A `mutato.yml` in it 10 | 11 | After that, Mutato processes `mutato.yml` and creates all the relevant resources 12 | using CDK constructs. 13 | 14 | Mutato deploys itself using [CDK's 15 | app-delivery](https://docs.aws.amazon.com/cdk/api/latest/docs/app-delivery-readme.html) 16 | module. Mutato's pipeline then manages exactly one other CloudFormation resource 17 | per `environment` tag in the `mutato.yml` file. 18 | 19 | All the containers and actions are owned by the master app-delivery pipeline and 20 | are processed before `environment` CloudFormation resources are updated on every 21 | push to the GitHub repository. 22 | 23 | This is the graph of a Mutato pipeline: 24 | 25 | ```MARKDOWN 26 | ┏━━━━━━━━━━━━━━━━┓ ┏━━━━━━━━━━━━━━━━┓ ┏━━━━━━━━━━━━━━━━━┓ ┏━━━━━━━━━━━━━━━━━┓ ┏━━━━━━━━━━━━━━━━━┓ ┏━━━━━━━━━━━━━━━━━┓ ┏━━━━━━━━━━━━━━━━━┓ ┏━━━━━━━━━━━━━━━━━┓ ┏━━━━━━━━━━━━━━━━━┓ 27 | ┃ Source ┃ ┃ Synth ┃ ┃ Update ┃ ┃ Container ┃ ┃ Container ┃ ┃ Container ┃ ┃ Environment ┃ ┃ Environment ┃ ┃ Environment ┃ ... 28 | ┃ ┃ ┃ ┃ ┃ ┃ ┃ Pre-Build ┃ ┃ Build & Push ┃ ┃ PostBuild ┃ ┃ PreDeploy ┃ ┃ Deploy ┃ ┃ Post-Deploy ┃ ... 29 | ┃ ┌────────────┐ ┃ ┃ ┌────────────┐ ┃ ┃ ┌─────────────┐ ┃ ┃ ┌─────────────┐ ┃ ┃ ┌─────────────┐ ┃ ┃ ┌─────────────┐ ┃ ┃ ┌─────────────┐ ┃ ┃ ┌─────────────┐ ┃ ┃ ┌─────────────┐ ┃ ... 30 | ┃ │ GitHub ┣━╋━━╋━▶ Mutato/CDK ┣━╋━━╋━▶ CFN ┣━╋━━╋━▶ Actions ┣━╋━━╋━▶ CodeBuild ┣━╋━━╋━▶ Actions ┣━╋━━╋━▶ Actions ┣━╋━━╋━▶ CFN ┣━╋━━╋━▶ Actions │ ┃ ... 31 | ┃ └────────────┘ ┃ ┃ └────────────┘ ┃ ┃ └─────────────┘ ┃ ┃ └─────────────┘ ┃ ┃ └─────────────┘ ┃ ┃ └─────────────┘ ┃ ┃ └─────────────┘ ┃ ┃ └─────────────┘ ┃ ┃ └─────────────┘ ┃ ... 32 | ┗━━━━━━━━━━━━━━━━┛ ┗━━━━━━━━━━━━━━━━┛ ┗━━━━━━━━━━━━━━━━━┛ ┗━━━━━━━━━━━━━━━━━┛ ┗━━━━━━━━━━━━━━━━━┛ ┗━━━━━━━━━━━━━━━━━┛ ┗━━━━━━━━━━━━━━━━━┛ ┗━━━━━━━━━━━━━━━━━┛ ┗━━━━━━━━━━━━━━━━━┛ 33 | ``` 34 | 35 | _CFN_ refers to CloudFormation. The last three boxes loop over for how ever many 36 | `environment` tags present in the `mutato.yml` file. 37 | -------------------------------------------------------------------------------- /doc/mutato-vs-mu.md: -------------------------------------------------------------------------------- 1 | # Mutato vs. Mu 2 | 3 | The OG [Stelligent Mu](https://github.com/stelligent/mu) is a framework similar 4 | to Mutato where it aims at deploying microservices with ease. 5 | 6 | Throughout the years thanks to the community of Mu-users, Stelligent received a 7 | lot of valuable feedback which sparked the creation of Mutato. 8 | 9 | Mutato at its core does exactly what Mu does but with a lot of added benefits. 10 | Some of these benefits are listed below. 11 | 12 | ## Extensibility 13 | 14 | The original Mu is written in Go and leaves almost no room for integration into 15 | third party applications. Stelligent has received a lot of requests from its 16 | enterprise users to change Mu so that it can be extended and integrated so 17 | enterprise applications can customize it to their heart's desire. 18 | 19 | Mutato on the other hand is written on top of the powerful AWS Cloud Development 20 | Kit and at its core is a collection of CDK constructs which can be customized to 21 | the teeth! 22 | 23 | ## Multi-Branch Deploys 24 | 25 | In mutato, every single branch is isolated from other branches. This is similar 26 | to how a properly configured CI system reacts to changes in a repository. You 27 | can have entirely different isolated AWS deployments over different branches. 28 | 29 | This feature is absent in the original Mu and was priority number 1 since day 1 30 | for Mutato and its delivery pipeline. 31 | 32 | ## CI-Agnostic 33 | 34 | The OG Stelligent Mu is heavily integrated with CodeBuild and leaves almost no 35 | room to integrate with external CI systems such as Drone CI and Jenkins. Users 36 | of the original Mu has always requested us to add support for Jenkins into Mu 37 | for enterprise applications. 38 | 39 | This is now possible due to the architecture of Mutato and the fact that it is 40 | built upon CDK and its constructs. Mutato uses CodeBuild internally, but it also 41 | offers ad-hoc support foundation for other CI systems. 42 | 43 | ## Nunjucks and Powerful Templating 44 | 45 | [Nunjucks](https://mozilla.github.io/nunjucks/templating.html) is a powerful 46 | templating tool provided by Mozilla. Every `mutato.yml` is a valid Nunjucks file 47 | and this allows `mutato.yml` to include complex conditionals and 48 | environment-specific deploys in a single file. On top of that, Mutato extends 49 | Nunjucks and adds `env`, `cmd` and other helpers to allow users to customize 50 | their template based on their execution environment even more. 51 | 52 | The original Mu is very basic in terms of templating capabilities which would 53 | often result in managing multiple templates for different environments. 54 | -------------------------------------------------------------------------------- /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | #------------------------------------------------------------------------------------------------------------- 2 | # Copyright (c) Microsoft Corporation. All rights reserved. 3 | # Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information. 4 | #------------------------------------------------------------------------------------------------------------- 5 | 6 | # For information on the contents of the container image below, see following Dockerfile: 7 | # https://github.com/microsoft/vscode-dev-containers/tree/v0.43.0/containers/javascript-node-12/.devcontainer/Dockerfile 8 | FROM mcr.microsoft.com/vscode/devcontainers/javascript-node:0.43-12 9 | 10 | # The image referenced above includes a non-root user with sudo access. Add 11 | # the "remoteUser" property to devcontainer.json to use it. On Linux, the container 12 | # user's GID/UIDs will be updated to match your local UID/GID when using the image 13 | # or dockerFile property. Update USER_UID/USER_GID below if you are using the 14 | # dockerComposeFile property or want the image itself to start with different ID 15 | # values. See https://aka.ms/vscode-remote/containers/non-root-user for details. 16 | ARG USER_UID=1000 17 | ARG USER_GID=$USER_UID 18 | ARG USERNAME=node 19 | 20 | # [Optional] Update UID/GID if needed 21 | RUN if [ "$USER_GID" != "1000" ] || [ "$USER_UID" != "1000" ]; then \ 22 | groupmod --gid $USER_GID $USERNAME \ 23 | && usermod --uid $USER_UID --gid $USER_GID $USERNAME \ 24 | && chown -R $USER_UID:$USER_GID /home/$USERNAME; \ 25 | fi \ 26 | # 27 | # Avoid having to use "sudo" with "npm -g" when running as non-root user 28 | && SNIPPET='if [ "$(stat -c %U /usr/local/lib/node_modules)" != "$(id -u)" ]; then \ 29 | sudo chown -R $(id -u):root /usr/local/lib/node_modules \ 30 | && sudo chown -R $(id -u):root /usr/local/bin; \ 31 | fi' \ 32 | && echo $SNIPPET >> /home/$USERNAME/.bashrc \ 33 | && echo $SNIPPET >> /home/$USERNAME/.zshrc \ 34 | && chown $USERNAME /home/$USERNAME/.bashrc /home/$USERNAME/.zshrc \ 35 | 36 | 37 | # ************************************************************* 38 | # * Uncomment this section to use RUN instructions to install * 39 | # * any needed dependencies after executing "apt-get update". * 40 | # * See https://docs.docker.com/engine/reference/builder/#run * 41 | # ************************************************************* 42 | # ENV DEBIAN_FRONTEND=noninteractive 43 | # RUN apt-get update \ 44 | # && apt-get -y install --no-reccomends \ 45 | # # 46 | # # Clean up 47 | # && apt-get autoremove -y \ 48 | # && apt-get clean -y \ 49 | # && rm -rf /var/lib/apt/lists/* 50 | # ENV DEBIAN_FRONTEND=dialog 51 | 52 | # Uncomment to default to non-root user 53 | # USER $USER_UID 54 | -------------------------------------------------------------------------------- /doc/mutato-workflow.md: -------------------------------------------------------------------------------- 1 | # Deploy Workflow 2 | 3 | This workflow is to be followed from top to bottom. 4 | 5 | ## prepare your environment 6 | 7 | You need the following environment variables defined: 8 | 9 | - `GITHUB_TOKEN`: your GitHub Personal Access Token 10 | - `AWS_*`: your AWS account credentials 11 | - `DOCKER_USERNAME` and `DOCKER_PASSWORD` _optional_ and only if you are using 12 | Docker Hub for your containers 13 | 14 | ## prepare your `mutato.yml` 15 | 16 | Everything begins with a `mutato.yml` in a branch of a GitHub repository. Once 17 | you have yourself a valid `mutato.yml` file. You can use Mutato's Docker image 18 | to deploy that branch to your AWS account. 19 | 20 | ## bootstrap (one time) 21 | 22 | ```bash 23 | $ docker run ... stelligent/mutato bootstrap 24 | ``` 25 | 26 | Before you can deploy, you need to execute a one-time `bootstrap` script. This 27 | currently just bootstraps CDK in your AWS account. 28 | 29 | > [!TIP] You can skip this if you have other CDK projects in your account. 30 | 31 | ## deploy (one time) 32 | 33 | ```bash 34 | $ docker run ... stelligent/mutato deploy 35 | ``` 36 | 37 | You may deploy to your account with this command. You only need to execute this 38 | once in your computer's local terminal. This command freezes your environment's 39 | relevant variables and sends them to your AWS account. 40 | 41 | Mutato pipeline is self-healing, meaning that most changes to your `mutato.yml` 42 | automatically updates the pipeline itself in your AWS account. You do not need 43 | to manually run `deploy` every time you make a change, The pipeline detects 44 | changes and updates itself. 45 | 46 | > [!NOTE] __†__ if you ever makes changes to your `mutato.yml` that involves 47 | > adding or removing environment variables through Nunjucks's `env(...)` and 48 | > `cmd(...)` directives, you need to deploy from your computer's terminal again. 49 | 50 | ## push changes 51 | 52 | As mentioned before, the pipeline is self healing for the most part and does not 53 | need your manual intervention anymore. Just push changes to your branch and see 54 | those changes deploy. 55 | 56 | > [!NOTE] If you make changes to environment variables used in your `mutato.yml` 57 | or the core Mutato codebase itself, you have to deploy manually from your 58 | terminal again. 59 | 60 | ## destroy 61 | 62 | > [!NOTE] It is in our development roadmap to automate this procedure. 63 | 64 | Currently deleting stacks is a little bugged (from the CDK side). Deleting must 65 | be done in this order: 66 | 67 | 1. first delete the `Mutato-Pipeline-` stack 68 | 2. iterate and delete stacks of your deployed environments 69 | 70 | In case any of the stacks throw errors while deleting (very likely), after throw 71 | you have to: 72 | 73 | 1. open up all the IAM roles remaining 74 | 2. associate the "Administrator Access" inline policy to them 75 | 3. delete the stack again 76 | 4. it gives you an error again, this time only roles are remaining 77 | 5. delete the stack again while retaining all the roles 78 | 6. stack should delete successfully at this point 79 | 7. manually delete the roles in the IAM console 80 | -------------------------------------------------------------------------------- /lib/parser/parser.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import assert from 'assert'; 3 | import debug from 'debug'; 4 | import _ from 'lodash'; 5 | import { Loader } from './loader'; 6 | import { PreProcessor } from './preprocessor'; 7 | 8 | const _debug = debug('mutato:parser:Parser'); 9 | 10 | type ActionSpec = object; 11 | type ResourceSpec = object; 12 | type ContainerSpec = object; 13 | export interface MutatoSpec { 14 | actions: ActionSpec[]; 15 | containers: ContainerSpec[]; 16 | environments: Map; 17 | environmentVariables: { [key: string]: string }; 18 | } 19 | 20 | /** 21 | * mutato.yml parser 22 | * 23 | * this class glues all the components for the parser together 24 | */ 25 | export class Parser { 26 | /** 27 | * parses the input mutato.yml string 28 | * 29 | * @param input mutato.yml as a string 30 | * @returns parsed and organized mutato.yml object 31 | */ 32 | public parse(input: string): MutatoSpec { 33 | _debug('attempting to parse mutato.yml string: %s', input); 34 | 35 | // during the first pass, we just want to figure out what environments we 36 | // are targeting, what containers we are building and what actions we are 37 | // constructing. during this pass, environment specific configuration is 38 | // not supported. 39 | const environmentLoader = new Loader(); 40 | const environmentPreprocessor = new PreProcessor({ environment: '' }); 41 | _debug('first pass preprocessing the YAML string: %s', input); 42 | const yaml = environmentPreprocessor.render(input); 43 | _debug('first pass loading the YAML string: %s', yaml); 44 | const parsed = environmentLoader.load(yaml); 45 | 46 | const actionSpecs = _.get(parsed, 'actions', []); 47 | _debug('actions: %o, ', actionSpecs); 48 | const containerSpecs = _.get(parsed, 'containers', []); 49 | _debug('containers: %o', containerSpecs); 50 | const environmentSpecs = _.get(parsed, 'environments', ['development']); 51 | _debug('environments %o', environmentSpecs); 52 | 53 | const environments = new Map(); 54 | 55 | // second pass parsing to create environment specific resources 56 | environmentSpecs.forEach((env: object) => { 57 | const key = (_.isObject(env) ? _.head(_.keys(env)) : env) as string; 58 | const tag = _.isObject(env) ? _.get(env, key) : {}; 59 | assert.ok(_.isString(key)); 60 | const environmentLoader = new Loader(); 61 | const environmentPreprocessor = new PreProcessor({ environment: key }); 62 | const yaml = environmentPreprocessor.render(input); 63 | _debug('second pass rendered YAML string: %s', yaml); 64 | const parsed = environmentLoader.load(yaml); 65 | _debug('second pass parsed YAML: %o', parsed); 66 | const resources = _.get(parsed, 'resources', []); 67 | resources.push({ environment: { ...tag } }); 68 | _debug('resources for environment "%s": %o', key, resources); 69 | environments.set(key, resources); 70 | }); 71 | 72 | return { 73 | actions: actionSpecs, 74 | containers: containerSpecs, 75 | environmentVariables: environmentPreprocessor.usedEnvironmentVariables, 76 | environments, 77 | }; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /lib/resources/storage.ts: -------------------------------------------------------------------------------- 1 | import * as s3 from '@aws-cdk/aws-s3'; 2 | import * as sqs from '@aws-cdk/aws-sqs'; 3 | import * as cdk from '@aws-cdk/core'; 4 | import assert from 'assert'; 5 | import debug from 'debug'; 6 | import _ from 'lodash'; 7 | import { Service } from './service'; 8 | 9 | enum StorageProvider { 10 | S3 = 's3', 11 | SQS = 'sqs', 12 | } 13 | 14 | interface StorageProps { 15 | provider?: StorageProvider; 16 | config?: s3.BucketProps | sqs.QueueProps; 17 | } 18 | 19 | /** Storage provider for service constructs */ 20 | export class Storage extends cdk.Construct { 21 | public readonly props: StorageProps; 22 | public readonly resource: s3.Bucket | sqs.Queue; 23 | private readonly _debug: debug.Debugger; 24 | 25 | /** 26 | * @hideconstructor 27 | * @param scope CDK construct scope 28 | * @param id CDK construct ID 29 | * @param props storage configuration 30 | */ 31 | constructor(scope: cdk.Construct, id: string, props: StorageProps) { 32 | super(scope, id); 33 | 34 | this._debug = debug(`tato:constructs:storage:${id}`); 35 | this.props = _.defaults(props, { 36 | provider: StorageProvider.S3, 37 | }); 38 | 39 | this._debug('creating storage with props: %o', this.props); 40 | switch (this.props.provider) { 41 | case StorageProvider.S3: 42 | this.resource = new s3.Bucket(this, 'Bucket', { 43 | removalPolicy: cdk.RemovalPolicy.DESTROY, 44 | encryption: s3.BucketEncryption.S3_MANAGED, 45 | ...(this.props.config as s3.BucketProps), 46 | }); 47 | break; 48 | case StorageProvider.SQS: 49 | this.resource = new sqs.Queue(this, 'Queue', { 50 | encryption: sqs.QueueEncryption.KMS_MANAGED, 51 | ...(this.props.config as sqs.QueueProps), 52 | }); 53 | break; 54 | default: 55 | assert.fail('storage type not supported'); 56 | } 57 | } 58 | 59 | grantAccess(service: Service): void { 60 | assert.ok(service.resource, 'service resource does not exist'); 61 | // this is a hack but it works, adds env vars to taskDefinition after synth 62 | const _addEnv = (key: string, val: string): void => { 63 | _.set( 64 | service.resource.taskDefinition, 65 | `defaultContainer.props.environment["${key}_${this.resource.node.id}"]`, 66 | val, 67 | ); 68 | }; 69 | switch (typeof this.resource) { 70 | case typeof s3.Bucket: 71 | const b = this.resource as s3.Bucket; 72 | b.grantReadWrite(service.resource.taskDefinition.taskRole); 73 | b.grantDelete(service.resource.taskDefinition.taskRole); 74 | b.grantPut(service.resource.taskDefinition.taskRole); 75 | _addEnv(`STORAGE_BUCKET_ARN`, b.bucketArn); 76 | _addEnv(`STORAGE_BUCKET_NAME`, b.bucketName); 77 | _addEnv(`STORAGE_BUCKET_DOMAIN`, b.bucketDomainName); 78 | case typeof sqs.Queue: 79 | const q = this.resource as sqs.Queue; 80 | q.grantConsumeMessages(service.resource.taskDefinition.taskRole); 81 | q.grantSendMessages(service.resource.taskDefinition.taskRole); 82 | q.grantPurge(service.resource.taskDefinition.taskRole); 83 | _addEnv(`STORAGE_QUEUE_ARN`, q.queueArn); 84 | _addEnv(`STORAGE_QUEUE_URL`, q.queueUrl); 85 | _addEnv(`STORAGE_QUEUE_NAME`, q.queueName); 86 | break; 87 | default: 88 | assert.fail('storage type not supported'); 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /lib/actions/codebuild.ts: -------------------------------------------------------------------------------- 1 | import * as codeBuild from '@aws-cdk/aws-codebuild'; 2 | import * as codePipeline from '@aws-cdk/aws-codepipeline'; 3 | import * as codePipelineActions from '@aws-cdk/aws-codepipeline-actions'; 4 | import * as cdk from '@aws-cdk/core'; 5 | import assert from 'assert'; 6 | import debug from 'debug'; 7 | import _ from 'lodash'; 8 | import { config } from '../config'; 9 | import { Container } from '../resources/container'; 10 | import { ActionInterface, ActionPropsInterface } from './interface'; 11 | 12 | const _debug = debug('mutato:actions:CodeBuild'); 13 | 14 | interface CodeBuildProps extends ActionPropsInterface { 15 | buildImage?: codeBuild.IBuildImage; 16 | container?: Container; 17 | pipeline: codePipeline.Pipeline; 18 | source: codePipeline.Artifact; 19 | sourceAction: codePipelineActions.GitHubSourceAction; 20 | spec: object | string; 21 | privileged?: boolean; 22 | } 23 | 24 | /** manual approval action in the pipeline */ 25 | export class CodeBuild implements ActionInterface { 26 | private readonly _props: CodeBuildProps; 27 | public readonly name: string; 28 | 29 | /** 30 | * @hideconstructor 31 | * @param props codebuild parameters 32 | */ 33 | constructor(props: CodeBuildProps) { 34 | this._props = _.defaults(props, { order: 1, privileged: false }); 35 | assert.ok(this._props.pipeline); 36 | assert.ok(this._props.source); 37 | assert.ok(this._props.spec); 38 | this.name = this._props.name; 39 | } 40 | 41 | /** 42 | * creates a codebuild approval action in the pipeline 43 | * 44 | * @param requester a unique ID used to prevent action duplication 45 | * @returns action construct to be added into a code pipeline 46 | */ 47 | public action(requester = 'default'): codePipelineActions.CodeBuildAction { 48 | _debug('creating a code build action with props: %o', this._props); 49 | const project = new codeBuild.PipelineProject( 50 | cdk.Stack.of(this._props.pipeline), 51 | `action-project-${this.name}-${requester}`, 52 | { 53 | environment: { 54 | buildImage: this._props.buildImage 55 | ? this._props.buildImage 56 | : this._props.container 57 | ? this._props.container.repo 58 | ? codeBuild.LinuxBuildImage.fromEcrRepository( 59 | this._props.container.repo, 60 | ) 61 | : codeBuild.LinuxBuildImage.fromDockerRegistry( 62 | this._props.container?.getImageUri( 63 | this._props.pipeline, 64 | true /* latest */, 65 | ), 66 | ) 67 | : undefined, 68 | privileged: this._props.privileged, 69 | environmentVariables: config.toBuildEnvironmentMap(), 70 | }, 71 | buildSpec: _.isObject(this._props.spec) 72 | ? codeBuild.BuildSpec.fromObject(this._props.spec) 73 | : codeBuild.BuildSpec.fromSourceFilename(this._props.spec), 74 | }, 75 | ); 76 | 77 | this._props.container?.repo?.grantPullPush(project); 78 | const action = new codePipelineActions.CodeBuildAction({ 79 | actionName: `${this.name}-${requester}`, 80 | input: this._props.source, 81 | environmentVariables: { 82 | mutato_opts__git__commit: { 83 | value: this._props.sourceAction.variables.commitId, 84 | }, 85 | }, 86 | project, 87 | }); 88 | 89 | return action; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /doc/mutato-concepts.md: -------------------------------------------------------------------------------- 1 | # Concepts 2 | 3 | Mutato is an extremely opinionated framework out of the box, while leaving total 4 | control and extendibility through [AWS CDK](https://aws.amazon.com/cdk/). 5 | 6 | Mutato works over a single GitHub repository and a single branch of that repo. 7 | 8 | The combination of a Github repo and a branch is considered a _project_ in this 9 | toolset. That means you can have separate projects over different branches of 10 | your repositories. 11 | 12 | > [!NOTE] Keep in mind that the practicality of having separate projects over 13 | > multiple branches is almost non existent. Ideally you always want to have a 14 | > single Mutato pipeline over your _master_ branch and update your AWS resources 15 | > on pushes to that branch only. 16 | 17 | > [!WARNING] AWS multi-account and multi-region deploys are not supported yet. 18 | 19 | ## environments 20 | 21 | An environment isolates _a set of AWS resources_ inside a single CloudFormation 22 | file. You'd normally use environments to separate development resources from 23 | production resources. Stelligent recommends projects to use at least three 24 | different environments: 25 | 26 | - __development__: to be used by developers to rapidly deploying changes 27 | - __acceptance__: to be used by QA engineers to regularly testing pre-release 28 | - __production__: to be used for user-facing resources post-release 29 | 30 | If you do not specify different environments in your _mutato.yml_, it is assumed 31 | that you are just developing a non production application and Mutato gives you a 32 | default development environment for it. 33 | 34 | ## containers 35 | 36 | Containers are at at the heart of Mutato. You can define containers to be built 37 | as a part of your project in your AWS account. Currently you can choose to have 38 | containers stored either on AWS ECR or Docker Hub. 39 | 40 | > [!TIP] You do not have to have containers defined if you are just using third 41 | > party containers off of Docker Hub (e.g the [nginx 42 | > container](https://hub.docker.com/_/nginx)). 43 | 44 | At the moment only Docker containers are supported. It is in our development 45 | roadmap to support other types of containers in future (e.g [_LXC 46 | containers_](https://linuxcontainers.org/)). 47 | 48 | ## actions 49 | 50 | Actions are one-off tasks that run in your Mutato pipeline on every push to the 51 | repository's deployed branch. Think of this similar to how GitHub actions work, 52 | but in a broader sense. Mutato actions are not limited to Docker tasks. 53 | 54 | > [!TIP] You can use actions to define automated unit tests. Both in and out of 55 | > container tests are supported. 56 | 57 | Currently you can have a CodeBuild job, a [Manual 58 | Approval](https://docs.aws.amazon.com/codepipeline/latest/userguide/approvals-action-add.html), 59 | or an in-container Docker command to be executed in the pipeline. It is in our 60 | development roadmap to support other types of actions such as Jenkins jobs and 61 | External CI jobs (e.g Drone CI). 62 | 63 | Actions are triggered by events in the pipeline. 64 | 65 | ## events 66 | 67 | Events are specific points in time in your Mutato pipeline. You can have actions 68 | attached to these events to control the flow of your Mutato pipeline and stop 69 | the execution whenever an action fails. 70 | 71 | Events trigger actions in the pipeline. 72 | 73 | > [!TIP] Use events to stop your Mutato pipeline early from flowing once unit 74 | > tests fail or code linting does not succeed. 75 | -------------------------------------------------------------------------------- /lib/parser/preprocessor.ts: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import cp from 'child_process'; 3 | import debug from 'debug'; 4 | import _ from 'lodash'; 5 | import ms from 'ms'; 6 | import nunjucks from 'nunjucks'; 7 | import { config } from '../config'; 8 | 9 | const _debug = debug('mutato:parser:PreProcessor'); 10 | type StringMap = { [key: string]: string }; 11 | 12 | /** 13 | * global env function of our Nunjucks Environment 14 | * 15 | * @param name environment variable name 16 | * @returns environment variable value, empty string if not found 17 | * @ignore 18 | */ 19 | function nunjucks_env_global(name: string): string { 20 | _debug('attempting to resolve environment variable %s', name); 21 | assert.ok(_.isString(name)); 22 | return _.get(process.env, name, ''); 23 | } 24 | 25 | /** 26 | * global cmd function of our Nunjucks Environment 27 | * 28 | * @param command shell command execute. can contain shell operators 29 | * @returns string output of the executed command the output is trimmed 30 | * from whitespace and newlines (trailing newline as well) 31 | * @ignore 32 | */ 33 | function nunjucks_cmd_global(command: string): string { 34 | _debug('attempting to execute command %s', command); 35 | assert.ok(_.isString(command)); 36 | return _.trim( 37 | cp.execSync(command, { 38 | encoding: 'utf8', 39 | timeout: ms(config.opts.preprocessor.timeout), 40 | }), 41 | ); 42 | } 43 | 44 | /** 45 | * mutato.yml template pre processor 46 | * 47 | * internally this class sets up a custom Nunjucks Environment with useful 48 | * helper functions and processes input Nunjucks templates 49 | */ 50 | export class PreProcessor { 51 | private readonly env: nunjucks.Environment; 52 | private readonly ctx: object; 53 | public readonly usedEnvironmentVariables: { [key: string]: string } = {}; 54 | 55 | /** 56 | * @hideconstructor 57 | * @param context additional context variables given to Nunjucks 58 | * @todo implement the "ssm" Nunjucks global function 59 | * @todo implement the "asm" Nunjucks global function 60 | */ 61 | constructor(context: StringMap = {}) { 62 | this.env = new nunjucks.Environment(null, { 63 | autoescape: false, 64 | noCache: true, 65 | throwOnUndefined: true, 66 | watch: false, 67 | }); 68 | this.ctx = { 69 | ...context, 70 | build_time: Date.now(), 71 | git: { 72 | ...config.getGithubMetaData(), 73 | commit: config.opts.git.commit, 74 | }, 75 | }; 76 | _debug('a new preprocessor is initialized with context: %o', this.ctx); 77 | 78 | this.env.addGlobal('env', (name: string) => { 79 | const resolved = nunjucks_env_global(name); 80 | _.set(this.usedEnvironmentVariables, name, resolved); 81 | return resolved; 82 | }); 83 | this.env.addGlobal('cmd', nunjucks_cmd_global); 84 | } 85 | 86 | /** @returns {object} build context of preprocessor */ 87 | public get context(): object { 88 | return this.ctx; 89 | } 90 | 91 | /** 92 | * renders the input string template through our Nunjucks Environment 93 | * 94 | * @param input unprocessed input template string 95 | * @returns processed template 96 | */ 97 | public render(input: string): string { 98 | _debug('attempting to render a string through Nunjucks: %s', input); 99 | return this.env.renderString(input, this.ctx); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /lib/config.ts: -------------------------------------------------------------------------------- 1 | import { BuildEnvironmentVariable } from '@aws-cdk/aws-codebuild'; 2 | import assert from 'assert'; 3 | import cp from 'child_process'; 4 | import debug from 'debug'; 5 | import _ from 'lodash'; 6 | import parseGithubUrl from 'parse-github-url'; 7 | import rc from 'rc'; 8 | import traverse from 'traverse'; 9 | import parse = require('parse-strings-in-object'); 10 | 11 | const log = debug('mutato:config'); 12 | 13 | /** 14 | * @param name "rc" namespace 15 | * @param defaults default configuration object 16 | * @returns overridden configuration with "rc" 17 | */ 18 | function rcTyped(name: string, defaults: T): T { 19 | const userConfig = rc(name, defaults); 20 | const parsedConfig = parse(userConfig); 21 | return parsedConfig as T; 22 | } 23 | 24 | // args passed to cp.execSync down when we extract defaults from environment 25 | const gitC = _.get(process.env, 'mutato_opts__git__local', process.cwd()); 26 | const gitRemoteCmd = `git -C "${gitC}" config --get remote.origin.url || true`; 27 | const gitBranchCmd = `git -C "${gitC}" rev-parse --abbrev-ref HEAD || true`; 28 | const gitCommitCmd = `git -C "${gitC}" rev-parse HEAD || true`; 29 | 30 | type StringEnvironmentVariableMap = { [key: string]: string }; 31 | type BuildEnvironmentVariableMap = { [key: string]: BuildEnvironmentVariable }; 32 | 33 | log('extracting configuration from environment: %o', process.env); 34 | export const config = rcTyped('mutato', { 35 | opts: { 36 | git: { 37 | local: gitC, 38 | commit: cp 39 | .execSync(gitCommitCmd, { encoding: 'utf8', timeout: 1000 }) 40 | .trim(), 41 | remote: cp 42 | .execSync(gitRemoteCmd, { encoding: 'utf8', timeout: 1000 }) 43 | .trim(), 44 | branch: cp 45 | .execSync(gitBranchCmd, { encoding: 'utf8', timeout: 1000 }) 46 | .trim(), 47 | secret: _.get(process.env, 'GITHUB_TOKEN', ''), 48 | }, 49 | docker: { 50 | user: _.get(process.env, 'DOCKER_USERNAME', ''), 51 | pass: _.get(process.env, 'DOCKER_PASSWORD', ''), 52 | }, 53 | preprocessor: { 54 | timeout: '10s', 55 | }, 56 | bundle: { 57 | bucket: '', 58 | object: '', 59 | }, 60 | }, 61 | getGithubMetaData() { 62 | const meta = parseGithubUrl(this.opts.git.remote); 63 | /** @todo properly handle non Github repositories */ 64 | assert.ok(meta, 'only Github remotes are supported'); 65 | assert.ok(meta?.name, 'Github repo could not be determined'); 66 | assert.ok(meta?.owner, 'Github owner could not be determined'); 67 | const repo = meta?.name as string; 68 | const owner = meta?.owner as string; 69 | const branch = this.opts.git.branch; 70 | const id = `${owner}-${repo}-${branch}`.replace(/[^A-Za-z0-9-]/g, '-'); 71 | return { 72 | repo: meta?.name as string, 73 | owner: meta?.owner as string, 74 | branch: this.opts.git.branch, 75 | /** this can be used in CDK names and IDs to uniquely ID a resource */ 76 | identifier: id, 77 | }; 78 | }, 79 | toStringEnvironmentMap() { 80 | return traverse(this).reduce(function (acc, x) { 81 | if (this.isLeaf && this.key !== '_' && !_.isFunction(x)) 82 | acc[`mutato_${this.path.join('__')}`] = `${x}`; 83 | return _.omit(acc, [ 84 | 'mutato_opts__git__local', 85 | 'mutato_opts__git__commit', 86 | 'mutato_opts__bundle__bucket', 87 | 'mutato_opts__bundle__object', 88 | ]); 89 | }, {}) as StringEnvironmentVariableMap; 90 | }, 91 | toBuildEnvironmentMap(variables?: StringEnvironmentVariableMap) { 92 | return _.transform( 93 | variables ? variables : this.toStringEnvironmentMap(), 94 | (result: BuildEnvironmentVariableMap, value, key) => { 95 | result[key] = { value }; 96 | }, 97 | {}, 98 | ); 99 | }, 100 | }); 101 | 102 | log('Mutato configuration: %o', config); 103 | -------------------------------------------------------------------------------- /doc/README.md: -------------------------------------------------------------------------------- 1 | # Mutato 2 | 3 | > [!NOTE] If you are coming from the original [Stelligent 4 | > Mu](https://github.com/stelligent/mu), keep in mind that Mutato is a successor 5 | > to Mu. While there are many similarities between the two projects, currently 6 | > we are not planning to provide any compatibilities between the two. 7 | 8 | [Stelligent Mutato](https://github.com/stelligent/mutato) is an open-source 9 | framework for building containerized micro-services on the AWS ecosystem (e.g 10 | ECS or Fargate). Mutato is designed to leverage [AWS Cloud Development Kit 11 | (CDK)](https://docs.aws.amazon.com/cdk/latest/guide/home.html) constructs which 12 | abstract the complexity of writing a safe and secure CloudFormation file to 13 | deploy and automate micro-service deployments. 14 | 15 | ## Getting Started 16 | 17 | Create a simple _mutato.yml_ file in your Github repository: 18 | 19 | ```YAML 20 | environments: 21 | - acceptance 22 | - production 23 | resources: 24 | - service: 25 | provider: fargate 26 | container: nginx:latest 27 | - network: 28 | vpc: 29 | maxAZs: {{ 1 if environment == "acceptance" else 3 }} 30 | ``` 31 | 32 | [Obtain](https://github.com/settings/tokens) a [GitHub Personal Access 33 | Token](https://help.github.com/en/actions/configuring-and-managing-workflows/authenticating-with-the-github_token). 34 | We assume you have this available in your `$PATH` under `$GITHUB_TOKEN`. Execute 35 | the following to deploy your microservice: 36 | 37 | ```bash 38 | $ cd /path/to/your/github/repo 39 | $ echo "GITHUB_TOKEN=$GITHUB_TOKEN" > mutato.env 40 | $ aws-vault exec -- env | grep AWS_ >> mutato.env 41 | $ docker run -it --rm --env-file mutato.env -v `pwd`:/project stelligent/mutato bootstrap 42 | $ docker run -it --rm --env-file mutato.env -v `pwd`:/project stelligent/mutato deploy 43 | ``` 44 | 45 | This gives you a load balanced NGINX server in two separate environments and the 46 | _acceptance_ environment has its VPC AZ capacity set to 1 to reduce costs. 47 | 48 | ## Where To Head Next 49 | 50 | You want to familiarize yourself with the [concepts](mutato-concepts.md) first. 51 | 52 | After that, you need to know the [workflow](mutato-workflow.md) of a Mutato 53 | enabled project. The following documentation comes next: 54 | 55 | - To learn more about what you can write in _mutato.yml_, head over to its 56 | [reference schema](mutato-yaml.md) page. 57 | - To learn more about what you can do with the Mutato Docker container, head 58 | over [to its page](mutato-docker.md). 59 | - To learn more about extensibility of Mutato, read [its extensibility 60 | documentation](mutato-cdk.md). 61 | - If you are looking for the auto generated low level CDK api documentation, [go 62 | to API](api.md) 63 | 64 | ## AWS Environment 65 | 66 | We highly recommend you use [aws-vault](https://github.com/99designs/aws-vault) 67 | to manage your AWS environment. If you choose not to, you should manually create 68 | your `mutato.env` file with at least the following environment variables: 69 | 70 | - _AWS_DEFAULT_REGION_ 71 | - _AWS_ACCESS_KEY_ID_ 72 | - _AWS_SECRET_ACCESS_KEY_ 73 | 74 | Consult [Docker run](https://docs.docker.com/engine/reference/commandline/run)'s 75 | documentation on the format of the `mutato.env` file. Consult [AWS 76 | CLI](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-envvars.html)'s 77 | documentation for the environment variables you may need to set manually. 78 | 79 | ## Supported Platforms 80 | 81 | Mutato has been mostly developed and tested on Linux 64bit (Ubuntu 18.04 LTS). 82 | The core codebase is in typescript and uses CDK, therefore in theory it should 83 | run anywhere that is capable of running a Docker container or node and npm, but 84 | the official support is mostly provided for the Linux 64bit platform. 85 | 86 | ## Stability 87 | 88 | Currently Mutato is under active development. Expect an alpha quality software 89 | and things rapidly changing until we announce our stable v1.0. 90 | 91 | Do not use this in production yet! 92 | 93 | ...or do. test in production amirite? 94 | 95 | ![At that part, I'm a pro](pro.gif) 96 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.js 2 | !jest.config.js 3 | *.d.ts 4 | node_modules 5 | 6 | # CDK asset staging directory 7 | .cdk.staging 8 | cdk.out 9 | 10 | # Autogenerated Typedoc documentation 11 | docs/**/* 12 | !docs/*.md 13 | 14 | # Created by https://www.gitignore.io/api/node,linux,macos,visualstudiocode,typescript 15 | # Edit at https://www.gitignore.io/?templates=node,linux,macos,visualstudiocode,typescript 16 | 17 | ### Linux ### 18 | *~ 19 | 20 | # temporary files which can be created if a process still has a handle open of a deleted file 21 | .fuse_hidden* 22 | 23 | # KDE directory preferences 24 | .directory 25 | 26 | # Linux trash folder which might appear on any partition or disk 27 | .Trash-* 28 | 29 | # .nfs files are created when an open file is removed but is still being accessed 30 | .nfs* 31 | 32 | ### macOS ### 33 | # General 34 | .DS_Store 35 | .AppleDouble 36 | .LSOverride 37 | 38 | # Icon must end with two \r 39 | Icon 40 | 41 | # Thumbnails 42 | ._* 43 | 44 | # Files that might appear in the root of a volume 45 | .DocumentRevisions-V100 46 | .fseventsd 47 | .Spotlight-V100 48 | .TemporaryItems 49 | .Trashes 50 | .VolumeIcon.icns 51 | .com.apple.timemachine.donotpresent 52 | 53 | # Directories potentially created on remote AFP share 54 | .AppleDB 55 | .AppleDesktop 56 | Network Trash Folder 57 | Temporary Items 58 | .apdisk 59 | 60 | ### Node ### 61 | # Logs 62 | logs 63 | *.log 64 | npm-debug.log* 65 | yarn-debug.log* 66 | yarn-error.log* 67 | lerna-debug.log* 68 | 69 | # Diagnostic reports (https://nodejs.org/api/report.html) 70 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 71 | 72 | # Runtime data 73 | pids 74 | *.pid 75 | *.seed 76 | *.pid.lock 77 | 78 | # Directory for instrumented libs generated by jscoverage/JSCover 79 | lib-cov 80 | 81 | # Coverage directory used by tools like istanbul 82 | coverage 83 | *.lcov 84 | 85 | # nyc test coverage 86 | .nyc_output 87 | 88 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 89 | .grunt 90 | 91 | # Bower dependency directory (https://bower.io/) 92 | bower_components 93 | 94 | # node-waf configuration 95 | .lock-wscript 96 | 97 | # Compiled binary addons (https://nodejs.org/api/addons.html) 98 | build/Release 99 | 100 | # Dependency directories 101 | node_modules/ 102 | jspm_packages/ 103 | 104 | # TypeScript v1 declaration files 105 | typings/ 106 | 107 | # TypeScript cache 108 | *.tsbuildinfo 109 | 110 | # Optional npm cache directory 111 | .npm 112 | 113 | # Optional eslint cache 114 | .eslintcache 115 | 116 | # Optional REPL history 117 | .node_repl_history 118 | 119 | # Output of 'npm pack' 120 | *.tgz 121 | 122 | # Yarn Integrity file 123 | .yarn-integrity 124 | 125 | # dotenv environment variables file 126 | .env 127 | .env.test 128 | 129 | # parcel-bundler cache (https://parceljs.org/) 130 | .cache 131 | 132 | # next.js build output 133 | .next 134 | 135 | # nuxt.js build output 136 | .nuxt 137 | 138 | # rollup.js default build output 139 | dist/ 140 | 141 | # Uncomment the public line if your project uses Gatsby 142 | # https://nextjs.org/blog/next-9-1#public-directory-support 143 | # https://create-react-app.dev/docs/using-the-public-folder/#docsNav 144 | # public 145 | 146 | # Storybook build outputs 147 | .out 148 | .storybook-out 149 | 150 | # vuepress build output 151 | .vuepress/dist 152 | 153 | # Serverless directories 154 | .serverless/ 155 | 156 | # FuseBox cache 157 | .fusebox/ 158 | 159 | # DynamoDB Local files 160 | .dynamodb/ 161 | 162 | # Temporary folders 163 | tmp/ 164 | temp/ 165 | 166 | #!! ERROR: typescript is undefined. Use list command to see defined gitignore types !!# 167 | 168 | ### VisualStudioCode ### 169 | .vscode/* 170 | !.vscode/settings.json 171 | !.vscode/tasks.json 172 | !.vscode/launch.json 173 | !.vscode/extensions.json 174 | 175 | ### VisualStudioCode Patch ### 176 | # Ignore all local history of files 177 | .history 178 | 179 | # End of https://www.gitignore.io/api/node,linux,macos,visualstudiocode,typescript 180 | 181 | # TSC artifacts 182 | out 183 | doc/api.md 184 | !bin/*.ts 185 | !bin/*.js 186 | mutato.env 187 | -------------------------------------------------------------------------------- /doc/mutato-yaml.md: -------------------------------------------------------------------------------- 1 | # YAML Reference Specification 2 | 3 | _mutato.yml_ is a 4 | [Nunjucks](https://mozilla.github.io/nunjucks/templating.html) enabled file. You 5 | can use all that Nunjucks has to offer. 6 | 7 | Mutato offers two builtin Nunjucks functions: 8 | 9 | - `env("string")`: that resolves to environment variable `string` 10 | - `cmd("string")`: that resolves to the output of shell command `string` 11 | 12 | Mutato also offers a special `environment` Nunjucks variable inside the 13 | _resources_ section to provide environment specific configuration. 14 | 15 | Example use of Nunjucks is provided below in the reference YAML. 16 | 17 | ```YAML 18 | # this section defines the isolated environments "resources" will deploy to. 19 | # best practice is to separate development and production environments on AWS. 20 | environments: 21 | # an environment can simply be a string 22 | - acceptance 23 | # an environment can also be an object 24 | - production: 25 | # events guard the deployment of an environment. use it to run tests 26 | events: 27 | # two supported events are: "pre-deploy" and "post-deploy" 28 | pre-deploy: production-pre-deploy-action 29 | post-deploy: production-post-deploy-action 30 | # this section defines actions that are triggered on certain events 31 | # "actions" section is an array of objects 32 | actions: 33 | # use docker action to run a simple docker command. your GitHub's source is 34 | # automatically mounted in command's current working directory 35 | - docker: 36 | name: production-post-deploy-action 37 | # you can reference containers on DockerHub just by their name 38 | container: ubuntu 39 | cmd: exit 0 40 | 41 | # use approval action to wait for a real human's approval in the pipeline 42 | - approval: 43 | name: production-pre-deploy-action 44 | # optionally provide a list of emails to notify 45 | emails: 46 | - sysadmin1@example.com 47 | - sysadmin2@example.com 48 | 49 | # use codebuild action execute a CodeBuild build spec 50 | - codebuild: 51 | name: container-pre-build-action 52 | # this can also be an inline object here 53 | spec: /path/to/buildspec.yml 54 | privileged: true 55 | 56 | - approval: 57 | name: container-pre-build-action 58 | # this section defines containers used by actions and resources 59 | # "containers" section is an array of objects 60 | containers: 61 | # array key is container's type. currently only "docker" is supported 62 | - docker: 63 | # a required name for your container 64 | name: mutato 65 | # path to a docker file to build in the pipeline 66 | file: Dockerfile 67 | events: 68 | # two supported events are: "pre-build" and "post-build" 69 | pre-build: container-pre-build-action 70 | post-build: container-post-build-action 71 | 72 | # you can also specify a container on the DockerHub 73 | - docker: 74 | name: latest-nginx 75 | uri: nginx:latest 76 | # this section defines the actual resources deployed in each environment 77 | # you can use environment specific resources definitions here 78 | resources: 79 | # a service can be any of: 80 | # - "fargate": container running in Fargate 81 | # - "classic": container running in EC2 ECS 82 | # - "fargate-task": Fargate container triggered by CloudWatch rate expressions 83 | # - "classic-task": EC2 ECS container triggered by CloudWatch rate expressions 84 | # - "fargate-queue": Fargate container triggered by SQS messages 85 | # - "classic-queue": EC2 ECS container triggered by SQS messages 86 | - service: 87 | provider: fargate 88 | # you can reference your container from the "container" section here by 89 | # name or you can just provide a DockerHub uri 90 | container: mutato 91 | 92 | # you can create a storage for your container, all your "service" containers 93 | # get access to "storage"s in runtime via environment variables 94 | - storage: 95 | provider: s3 96 | # this is how "env" function can be used 97 | name: bucket-{{ env("BUCKET_NAME") }} 98 | 99 | - storage: 100 | provider: sqs 101 | # this is how "cmd" function can be used 102 | name: queue-{{ cmd("echo $BUCKET_NAME") }} 103 | 104 | # you can also create a database for your service definitions. similar to 105 | # storage definitions, you get access to these in runtime via environment 106 | # variables into your containers 107 | - database: 108 | provider: dynamo 109 | 110 | # you can perform environment specific configuration through Nunjucks 111 | - network: 112 | vpc: 113 | # this is how "environment" Nunjucks variable can be used 114 | maxAZs: {{ 1 if environment == "acceptance" else 3 }} 115 | ``` 116 | -------------------------------------------------------------------------------- /test/parser.test.ts: -------------------------------------------------------------------------------- 1 | import chai from 'chai'; 2 | import chaiAsPromised from 'chai-as-promised'; 3 | import { Parser } from '../lib'; 4 | 5 | chai.use(chaiAsPromised); 6 | 7 | describe('Parser Tests', () => { 8 | it('should not throw when parsing an empty schema', () => { 9 | const parser = new Parser(); 10 | chai.assert.doesNotThrow(() => { 11 | parser.parse(''); 12 | }); 13 | }); 14 | 15 | it('should make a development environment by default', () => { 16 | const parser = new Parser(); 17 | const parsed = parser.parse(''); 18 | chai.assert.isTrue(parsed.environments.size === 1); 19 | chai.assert.isTrue(parsed.environments.has('development')); 20 | const development = parsed.environments.get('development'); 21 | chai.assert.deepEqual(development, [{ environment: {} }]); 22 | chai.assert.deepEqual(parsed.actions, []); 23 | chai.assert.deepEqual(parsed.containers, []); 24 | }); 25 | 26 | it('should be able to parse environments', () => { 27 | const parser = new Parser(); 28 | const parsed = parser.parse(` 29 | environments: 30 | - develop: 31 | events: 32 | pre-deploy: action-name 33 | post-deploy: action-name 34 | - acceptance 35 | - production`); 36 | chai.assert.deepEqual(parsed.actions, []); 37 | chai.assert.deepEqual(parsed.containers, []); 38 | chai.assert.isTrue(parsed.environments.size === 3); 39 | chai.assert.isTrue(parsed.environments.has('develop')); 40 | chai.assert.isTrue(parsed.environments.has('acceptance')); 41 | chai.assert.isTrue(parsed.environments.has('production')); 42 | chai.assert.deepEqual(parsed.environments.get('develop'), [ 43 | { 44 | environment: { 45 | events: { 46 | 'pre-deploy': 'action-name', 47 | 'post-deploy': 'action-name', 48 | }, 49 | }, 50 | }, 51 | ]); 52 | chai.assert.deepEqual(parsed.environments.get('acceptance'), [ 53 | { environment: {} }, 54 | ]); 55 | chai.assert.deepEqual(parsed.environments.get('production'), [ 56 | { environment: {} }, 57 | ]); 58 | }); 59 | 60 | it('should support env() and cmd() in environments', () => { 61 | const parser = new Parser(); 62 | const parsed = parser.parse(` 63 | environments: 64 | - acceptance: 65 | user: {{ env("USER") }} 66 | - production: 67 | data: {{ cmd("whoami | xargs echo") }}`); 68 | chai.assert.isTrue(parsed.environments.size === 2); 69 | chai.assert.isTrue(parsed.environments.has('acceptance')); 70 | chai.assert.isTrue(parsed.environments.has('production')); 71 | chai.assert.deepEqual(parsed.environments.get('acceptance'), [ 72 | { 73 | environment: { user: process.env.USER }, 74 | }, 75 | ]); 76 | chai.assert.deepEqual(parsed.environments.get('production'), [ 77 | { 78 | environment: { data: process.env.USER }, 79 | }, 80 | ]); 81 | }); 82 | 83 | it('should support environment specific configuration', () => { 84 | const parser = new Parser(); 85 | const parsed = parser.parse(` 86 | environments: 87 | - acceptance 88 | - production 89 | containers: 90 | - nginx: 91 | uri: nginx:latest 92 | actions: 93 | - action-name-1: 94 | foo: bar 95 | - action-name-2: 96 | test: val 97 | resources: 98 | - service: 99 | name: web-server-{{ environment }} 100 | provider: fargate 101 | container: nginx 102 | - network: 103 | cluster: 104 | maxAzs: {{ 1 if environment == "acceptance" else 3 }}`); 105 | chai.assert.isTrue(parsed.environments.size === 2); 106 | chai.assert.isTrue(parsed.environments.has('acceptance')); 107 | chai.assert.isTrue(parsed.environments.has('production')); 108 | chai.assert.deepEqual(parsed.actions, [ 109 | { 'action-name-1': { foo: 'bar' } }, 110 | { 'action-name-2': { test: 'val' } }, 111 | ]); 112 | chai.assert.deepEqual(parsed.containers, [ 113 | { nginx: { uri: 'nginx:latest' } }, 114 | ]); 115 | chai.assert.deepEqual(parsed.environments.get('acceptance'), [ 116 | { 117 | service: { 118 | name: 'web-server-acceptance', 119 | provider: 'fargate', 120 | container: 'nginx', 121 | }, 122 | }, 123 | { network: { cluster: { maxAzs: 1 } } }, 124 | { environment: {} }, 125 | ]); 126 | chai.assert.deepEqual(parsed.environments.get('production'), [ 127 | { 128 | service: { 129 | name: 'web-server-production', 130 | provider: 'fargate', 131 | container: 'nginx', 132 | }, 133 | }, 134 | { network: { cluster: { maxAzs: 3 } } }, 135 | { environment: {} }, 136 | ]); 137 | }); 138 | }); 139 | -------------------------------------------------------------------------------- /lib/resources/database.ts: -------------------------------------------------------------------------------- 1 | import * as ddb from '@aws-cdk/aws-dynamodb'; 2 | import * as rds from '@aws-cdk/aws-rds'; 3 | import * as ec2 from '@aws-cdk/aws-ec2'; 4 | import * as cdk from '@aws-cdk/core'; 5 | import assert from 'assert'; 6 | import debug from 'debug'; 7 | import _ from 'lodash'; 8 | import { Network } from './network'; 9 | import { Service } from './service'; 10 | 11 | enum DatabaseProvider { 12 | RDS = 'rds', 13 | RDSCluster = 'rds-cluster', 14 | Dynamo = 'dynamo', 15 | } 16 | 17 | interface DatabaseProps { 18 | provider?: DatabaseProvider; 19 | config?: 20 | | ddb.TableProps 21 | | rds.DatabaseClusterProps 22 | | rds.DatabaseInstanceProps; 23 | network: Network; 24 | } 25 | 26 | /** Database provider for service constructs */ 27 | export class Database extends cdk.Construct { 28 | public readonly props: DatabaseProps; 29 | public readonly resource: 30 | | ddb.Table 31 | | rds.DatabaseInstance 32 | | rds.DatabaseCluster; 33 | private readonly _debug: debug.Debugger; 34 | 35 | /** 36 | * @hideconstructor 37 | * @param scope CDK construct scope 38 | * @param id CDK construct ID 39 | * @param props database configuration 40 | */ 41 | constructor(scope: cdk.Construct, id: string, props: DatabaseProps) { 42 | super(scope, id); 43 | 44 | this._debug = debug(`mutato:constructs:database:${id}`); 45 | this.props = _.defaults(props, { 46 | provider: DatabaseProvider.Dynamo, 47 | }); 48 | 49 | this._debug('creating storage with props: %o', this.props); 50 | switch (this.props.provider) { 51 | case DatabaseProvider.Dynamo: 52 | this.resource = new ddb.Table(this, 'Table', { 53 | billingMode: ddb.BillingMode.PAY_PER_REQUEST, 54 | partitionKey: { name: 'id', type: ddb.AttributeType.STRING }, 55 | ...(this.props.config as ddb.TableProps), 56 | }); 57 | break; 58 | case DatabaseProvider.RDS: 59 | this.resource = new rds.DatabaseInstance(this, 'Instance', { 60 | engine: rds.DatabaseInstanceEngine.AURORA, 61 | instanceClass: ec2.InstanceType.of( 62 | ec2.InstanceClass.BURSTABLE2, 63 | ec2.InstanceSize.SMALL, 64 | ), 65 | masterUsername: 'admin', 66 | ...(this.props.config as rds.DatabaseInstanceProps), 67 | vpc: this.props.network.vpc, 68 | }); 69 | // todo: fix this wide network permission 70 | this.resource.connections.allowFromAnyIpv4(ec2.Port.allTraffic()); 71 | break; 72 | case DatabaseProvider.RDSCluster: 73 | this.resource = new rds.DatabaseCluster(this, 'Cluster', { 74 | engine: rds.DatabaseClusterEngine.AURORA, 75 | masterUser: { 76 | username: 'admin', 77 | }, 78 | instanceProps: { 79 | vpc: this.props.network.vpc, 80 | instanceType: ec2.InstanceType.of( 81 | ec2.InstanceClass.BURSTABLE2, 82 | ec2.InstanceSize.SMALL, 83 | ), 84 | }, 85 | ...(this.props.config as rds.DatabaseClusterProps), 86 | }); 87 | // todo: fix this wide network permission 88 | this.resource.connections.allowFromAnyIpv4(ec2.Port.allTraffic()); 89 | break; 90 | default: 91 | assert.fail('storage type not supported'); 92 | } 93 | } 94 | 95 | grantAccess(service: Service): void { 96 | assert.ok(service.resource, 'service resource does not exist'); 97 | // this is a hack but it works, adds env vars to taskDefinition after synth 98 | const _addEnv = (key: string, val: string | number): void => { 99 | _.set( 100 | service.resource.taskDefinition, 101 | `defaultContainer.props.environment["${key}_${this.resource.node.id}"]`, 102 | val, 103 | ); 104 | }; 105 | switch (typeof this.resource) { 106 | case typeof ddb.Table: 107 | const dynamo = this.resource as ddb.Table; 108 | dynamo.grantFullAccess(service.resource.taskDefinition.taskRole); 109 | _addEnv(`DATABASE_TABLE_ARN`, dynamo.tableArn); 110 | _addEnv(`DATABASE_TABLE_NAME`, dynamo.tableName); 111 | break; 112 | case typeof rds.DatabaseInstance: 113 | const rdsi = this.resource as rds.DatabaseInstance; 114 | _addEnv(`DATABASE_ADDRESS`, rdsi.dbInstanceEndpointAddress); 115 | _addEnv(`DATABASE_PORT`, rdsi.dbInstanceEndpointPort); 116 | // TODO: add user/pass here 117 | break; 118 | case typeof rds.DatabaseCluster: 119 | const rdsc = this.resource as rds.DatabaseCluster; 120 | _addEnv(`DATABASE_ADDRESS`, rdsc.clusterEndpoint.hostname); 121 | _addEnv(`DATABASE_PORT`, rdsc.clusterEndpoint.port); 122 | // TODO: add user/pass here 123 | break; 124 | default: 125 | assert.fail('storage type not supported'); 126 | } 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /test/container.test.ts: -------------------------------------------------------------------------------- 1 | import * as cdkAssert from '@aws-cdk/assert'; 2 | import * as cdk from '@aws-cdk/core'; 3 | import chai from 'chai'; 4 | import chaiAsPromised from 'chai-as-promised'; 5 | import { Container } from '../lib/resources/container'; 6 | 7 | chai.use(chaiAsPromised); 8 | 9 | describe('Container Construct Tests', () => { 10 | describe('DockerHub Tests', () => { 11 | it('should not create an ECR repository when a tag is given', () => { 12 | const app = new cdk.App(); 13 | const stack = new cdk.Stack(app, 'MyTestStack'); 14 | const construct = new Container(stack, 'MyTestContainer', { 15 | file: 'Dockerfile', 16 | uri: 'stelligent/mutato', 17 | }); 18 | cdkAssert 19 | .expect(stack) 20 | .notTo(cdkAssert.haveResource('AWS::ECR::Repository')); 21 | chai.assert.equal(construct.props.uri, 'stelligent/mutato'); 22 | }); 23 | 24 | it('should ba able to generate a build command with a tag', () => { 25 | const app = new cdk.App(); 26 | const stack = new cdk.Stack(app, 'MyTestStack'); 27 | const construct = new Container(stack, 'MyTestContainer', { 28 | file: 'Dockerfile', 29 | uri: 'stelligent/mutato', 30 | }); 31 | chai.assert.equal(construct.props.uri, 'stelligent/mutato'); 32 | chai 33 | .expect(construct.buildCommand) 34 | .to.be.equal( 35 | `docker build -t stelligent/mutato:latest -t stelligent/mutato:$mutato_opts__git__commit -f Dockerfile .`, 36 | ); 37 | const construct2 = new Container(stack, 'MyTestContainer2', { 38 | buildArgs: { 39 | key1: 'val1', 40 | key2: 'val2', 41 | }, 42 | file: 'Dockerfile2', 43 | context: 'Context2', 44 | uri: 'stelligent/mutato', 45 | }); 46 | chai 47 | .expect(construct2.buildCommand) 48 | .to.be.equal( 49 | `docker build --build-arg key1="val1" --build-arg key2="val2" -t stelligent/mutato:latest -t stelligent/mutato:$mutato_opts__git__commit -f Dockerfile2 Context2`, 50 | ); 51 | }); 52 | 53 | it('should ba able to generate a push command without a tag', () => { 54 | const app = new cdk.App(); 55 | const stack = new cdk.Stack(app, 'MyTestStack'); 56 | const construct = new Container(stack, 'MyTestContainer', { 57 | file: 'Dockerfile', 58 | uri: 'stelligent/mutato', 59 | }); 60 | chai.assert.equal(construct.props.uri, 'stelligent/mutato'); 61 | const uri = construct.getImageUri(); 62 | chai 63 | .expect(construct.pushCommand) 64 | .to.be.equal( 65 | `docker push ${uri}:latest && docker push ${uri}:$mutato_opts__git__commit`, 66 | ); 67 | }); 68 | }); 69 | 70 | describe('ECR Tests', () => { 71 | it('should create an ECR repository when no tag is given', () => { 72 | const app = new cdk.App(); 73 | const stack = new cdk.Stack(app, 'MyTestStack'); 74 | new Container(stack, 'MyTestContainer', { 75 | file: 'Dockerfile', 76 | }); 77 | cdkAssert 78 | .expect(stack) 79 | .to(cdkAssert.haveResource('AWS::ECR::Repository')); 80 | }); 81 | 82 | it('should ba able to generate a build command without a tag', () => { 83 | const app = new cdk.App(); 84 | const stack = new cdk.Stack(app, 'MyTestStack'); 85 | const construct = new Container(stack, 'MyTestContainer', { 86 | file: 'Dockerfile', 87 | }); 88 | 89 | const uri1 = construct.getImageUri(); 90 | chai 91 | .expect(construct.buildCommand) 92 | .to.be.equal( 93 | `docker build -t ${uri1}:latest -t ${uri1}:$mutato_opts__git__commit -f Dockerfile .`, 94 | ); 95 | const construct2 = new Container(stack, 'MyTestContainer2', { 96 | buildArgs: { 97 | key1: 'val1', 98 | key2: 'val2', 99 | }, 100 | file: 'Dockerfile2', 101 | context: 'Context2', 102 | }); 103 | const uri2 = construct2.getImageUri(); 104 | chai 105 | .expect(construct2.buildCommand) 106 | .to.be.equal( 107 | `docker build --build-arg key1="val1" --build-arg key2="val2" -t ${uri2}:latest -t ${uri2}:$mutato_opts__git__commit -f Dockerfile2 Context2`, 108 | ); 109 | }); 110 | 111 | it('should ba able to generate a push command without a tag', () => { 112 | const app = new cdk.App(); 113 | const stack = new cdk.Stack(app, 'MyTestStack'); 114 | const construct = new Container(stack, 'MyTestContainer', { 115 | file: 'Dockerfile', 116 | }); 117 | 118 | const uri = construct.getImageUri(); 119 | chai 120 | .expect(construct.pushCommand) 121 | .to.be.equal( 122 | `docker push ${uri}:latest && docker push ${uri}:$mutato_opts__git__commit`, 123 | ); 124 | }); 125 | }); 126 | }); 127 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Mutato 2 | 3 | Help wanted! We'd love your contributions to Mutato. Please review the following 4 | guidelines before contributing. Also, feel free to propose changes to these 5 | guidelines by updating this file and submitting a pull request. 6 | 7 | - [I have a question...](#questions) 8 | - [I found a bug...](#bugs) 9 | - [I have a feature request...](#features) 10 | - [I have a contribution to share...](#process) 11 | 12 | ## Have a Question? 13 | 14 | Please don't open a GitHub issue for questions about how to use mutato, as the 15 | goal is to use issues for managing bugs and feature requests. Issues that are 16 | related to general support will be closed and redirected to our gitter room. 17 | 18 | For all support related questions, please ask the question in our gitter room: 19 | [stelligent/mutato](https://gitter.im/stelligent/mutato). 20 | 21 | ## Found a Bug? 22 | 23 | If you've identified a bug in mutato, please [submit an issue](#issue) to our 24 | GitHub repo: 25 | [stelligent/mutato](https://github.com/stelligent/mutato/issues/new). Please 26 | also feel free to submit a [Pull Request](#pr) with a fix for the bug! 27 | 28 | ## Have a Feature Request? 29 | 30 | All feature requests should start with [submitting an issue](#issue) documenting 31 | the user story and acceptance criteria. Again, feel free to submit a [Pull 32 | Request](#pr) with a proposed implementation of the feature. 33 | 34 | ## Ready to Contribute! 35 | 36 | ### Create an issue 37 | 38 | Before submitting a new issue, please search the issues to make sure there isn't 39 | a similar issue doesn't already exist. 40 | 41 | Assuming no existing issues exist, please ensure you include the following bits 42 | of information when submitting the issue to ensure we can quickly reproduce your 43 | issue: 44 | 45 | - Version of mutato 46 | - Platform (Linux, OS X, Windows) 47 | - The complete `mutato.yml` file used 48 | - The complete command that was executed 49 | - Any output from the command 50 | - Details of the expected results and how they differed from the actual results 51 | 52 | We may have additional questions and will communicate through the GitHub issue, 53 | so please respond back to our questions to help reproduce and resolve the issue 54 | as quickly as possible. 55 | 56 | New issues can be created with in our [GitHub 57 | repo](https://github.com/stelligent/mutato/issues/new). 58 | 59 | ### Pull Requests 60 | 61 | Pull requests should target the `develop` branch. Ensure you have a successful 62 | build for your branch. Please also reference the issue from the description of 63 | the pull request using [special keyword 64 | syntax](https://help.github.com/articles/closing-issues-via-commit-messages/) to 65 | auto close the issue when the PR is merged. For example, include the phrase 66 | `fixes #14` in the PR description to have issue #14 auto close. 67 | 68 | ### Styleguide 69 | 70 | When submitting code, please make every effort to follow existing conventions 71 | and style in order to keep the code as readable as possible. Here are a few 72 | points to keep in mind: 73 | 74 | - Please run `npm run-script lint:check` before committing to ensure code aligns 75 | with go standards. 76 | - Ensure all Classes, and Functions are documented appropriately as for the 77 | [JSDoc](https://devdocs.io/jsdoc/) standard. See the project `README.md` for 78 | these instructions. 79 | - All dependencies must be defined in the `package.json` file and pinned to a 80 | patch range. This is accomplished via a command like `npm install --save-dev 81 | {PKG}` for development dependencies or `npm install {PKG}` otherwise 82 | 83 | Also, consider the original design principles: 84 | 85 | - **Polyglot** - There will be no prescribed language or framework for 86 | developing the microservices. The only requirement will be that the service 87 | will be run inside a container and exposed via an HTTP endpoint. 88 | - **Cloud Provider** - At this point, the tool will assume AWS for the cloud 89 | provider and will not be written in a cloud agnostic manner. However, this 90 | does not preclude refactoring to add support for other providers at a later 91 | time. 92 | - **Declarative** - All resource administration will be handled in a declarative 93 | vs. imperative manner. A file will be used to declared the desired state of 94 | the resources and the tool will simply assert the actual state matches the 95 | desired state. The tool will accomplish this by generating CloudFormation 96 | templates. 97 | - **Stateless** - The tool will not maintain its own state. Rather, it will rely 98 | on the CloudFormation stacks to determine the state of the platform. 99 | - **Secure** - All security will be managed by AWS IAM credentials. No 100 | additional authentication or authorization mechanisms will be introduced. 101 | 102 | ### License 103 | 104 | By contributing your code, you agree to license your contribution under the 105 | terms of the [MIT License](LICENSE). 106 | 107 | All files are released with the MIT license. 108 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@stelligent/mutato", 3 | "version": "2.1.0", 4 | "description": "simplify the declaration and administration of the AWS resources necessary to support microservices.", 5 | "license": "MIT", 6 | "repository": { 7 | "type": "git", 8 | "url": "git+https://github.com/stelligent/mutato.git" 9 | }, 10 | "main": "out/lib/index.js", 11 | "types": "out/lib/index.d.ts", 12 | "bin": { 13 | "mutato": "out/bin/mutato.js" 14 | }, 15 | "scripts": { 16 | "postinstall": "npx patch-package", 17 | "build": "npm-run-all lint:write compile:emit docs:build", 18 | "clean": "git clean -xdff -e node_modules", 19 | "lint": "npm-run-all compile:check lint:check", 20 | "test": "npm-run-all build test:mocha", 21 | "compile:emit": "tsc", 22 | "compile:check": "tsc --noEmit", 23 | "test:mocha": "nyc mocha test/**/*.test.ts 2>npm-test.log", 24 | "lint:check": "eslint --quiet {lib,bin,test}/**/*.ts", 25 | "lint:write": "eslint --quiet --fix {lib,bin,test}/**/*.ts", 26 | "docs:serve": "docsify serve doc/", 27 | "docs:build": "jsdoc2md --files ./lib/**/*.ts --no-cache --configure ./jsdoc2md.json > ./doc/api.md", 28 | "deps:upgrade": "npx ncu -u -x @types/node", 29 | "bootstrap": "npx cdk bootstrap", 30 | "destroy": "npx cdk destroy --verbose Mutato-Pipeline*", 31 | "deploy": "npx cdk deploy --verbose Mutato-Pipeline*", 32 | "synth": "npx cdk synth --verbose -o $(ts-node -e \"console.log(require('./lib/config.ts').config.opts.git.local)\")/dist", 33 | "diff": "npx cdk diff --verbose Mutato-Pipeline*", 34 | "cdk": "npx cdk" 35 | }, 36 | "devDependencies": { 37 | "@aws-cdk/assert": "^1.32.0", 38 | "@babel/cli": "^7.8.4", 39 | "@babel/core": "^7.9.0", 40 | "@babel/plugin-proposal-class-properties": "^7.8.3", 41 | "@babel/plugin-proposal-object-rest-spread": "^7.9.0", 42 | "@babel/preset-env": "^7.9.0", 43 | "@babel/preset-typescript": "^7.9.0", 44 | "@istanbuljs/nyc-config-typescript": "^1.0.1", 45 | "@types/chai": "^4.2.11", 46 | "@types/chai-as-promised": "^7.1.2", 47 | "@types/debug": "^4.1.5", 48 | "@types/lodash": "^4.14.149", 49 | "@types/mocha": "^7.0.2", 50 | "@types/ms": "^0.7.31", 51 | "@types/node": "^12.12.34", 52 | "@types/nunjucks": "^3.1.3", 53 | "@types/parse-github-url": "^1.0.0", 54 | "@types/rc": "^1.1.0", 55 | "@types/traverse": "^0.6.32", 56 | "@types/yaml": "^1.2.0", 57 | "@typescript-eslint/eslint-plugin": "^2.27.0", 58 | "@typescript-eslint/parser": "^2.27.0", 59 | "chai": "^4.2.0", 60 | "chai-as-promised": "^7.1.1", 61 | "choma": "^1.2.1", 62 | "docsify-cli": "^4.4.0", 63 | "eslint": "^6.8.0", 64 | "eslint-config-prettier": "^6.10.1", 65 | "eslint-plugin-jsdoc": "^22.1.0", 66 | "eslint-plugin-prettier": "^3.1.2", 67 | "husky": "^4.2.3", 68 | "jsdoc-babel": "^0.5.0", 69 | "jsdoc-to-markdown": "^5.0.3", 70 | "mocha": "^7.1.1", 71 | "npm-run-all": "^4.1.5", 72 | "nyc": "^15.0.1", 73 | "prettier": "^2.0.4", 74 | "ts-node": "^8.8.2", 75 | "typescript": "^3.8.3" 76 | }, 77 | "dependencies": { 78 | "@aws-cdk/app-delivery": "^1.32.0", 79 | "@aws-cdk/aws-applicationautoscaling": "^1.32.0", 80 | "@aws-cdk/aws-codebuild": "^1.32.0", 81 | "@aws-cdk/aws-codepipeline": "^1.32.0", 82 | "@aws-cdk/aws-codepipeline-actions": "^1.32.0", 83 | "@aws-cdk/aws-dynamodb": "^1.32.0", 84 | "@aws-cdk/aws-ec2": "^1.32.0", 85 | "@aws-cdk/aws-ecr": "^1.32.0", 86 | "@aws-cdk/aws-ecs": "^1.32.0", 87 | "@aws-cdk/aws-ecs-patterns": "^1.32.0", 88 | "@aws-cdk/aws-iam": "^1.32.0", 89 | "@aws-cdk/aws-lambda": "^1.32.0", 90 | "@aws-cdk/aws-rds": "^1.32.0", 91 | "@aws-cdk/aws-s3": "^1.32.0", 92 | "@aws-cdk/aws-s3-assets": "^1.32.0", 93 | "@aws-cdk/aws-sqs": "^1.32.0", 94 | "@aws-cdk/core": "^1.32.0", 95 | "aws-cdk": "^1.32.0", 96 | "debug": "^4.1.1", 97 | "docker-parse-image": "^3.0.1", 98 | "gitignore-globs": "^0.1.1", 99 | "lodash": "^4.17.19", 100 | "ms": "^2.1.2", 101 | "nunjucks": "^3.2.1", 102 | "parse-github-url": "^1.0.2", 103 | "parse-strings-in-object": "^2.0.0", 104 | "patch-package": "^6.2.2", 105 | "rc": "^1.2.8", 106 | "source-map-support": "^0.5.16", 107 | "traverse": "^0.6.6", 108 | "yaml": "^1.8.3" 109 | }, 110 | "mocha": { 111 | "bail": true, 112 | "fullTrace": true, 113 | "recursive": true, 114 | "inlineDiffs": true, 115 | "require": [ 116 | "choma", 117 | "ts-node/register", 118 | "source-map-support/register" 119 | ] 120 | }, 121 | "nyc": { 122 | "extends": "@istanbuljs/nyc-config-typescript", 123 | "reporter": [ 124 | "html", 125 | "text-summary" 126 | ], 127 | "all": false 128 | }, 129 | "prettier": { 130 | "semi": true, 131 | "tabWidth": 2, 132 | "printWidth": 80, 133 | "singleQuote": true, 134 | "trailingComma": "all" 135 | }, 136 | "eslintConfig": { 137 | "root": true, 138 | "parser": "@typescript-eslint/parser", 139 | "parserOptions": { 140 | "project": "tsconfig.json" 141 | }, 142 | "extends": [ 143 | "plugin:jsdoc/recommended", 144 | "plugin:@typescript-eslint/recommended", 145 | "prettier/@typescript-eslint", 146 | "plugin:prettier/recommended" 147 | ], 148 | "rules": { 149 | "jsdoc/require-param-type": 0, 150 | "jsdoc/require-returns-type": 0, 151 | "@typescript-eslint/camelcase": 0 152 | }, 153 | "settings": { 154 | "jsdoc": { 155 | "mode": "typescript" 156 | } 157 | } 158 | }, 159 | "husky": { 160 | "hooks": { 161 | "pre-commit": "tsc --noEmit && lint-staged" 162 | } 163 | }, 164 | "lint-staged": { 165 | "*.{js,ts}": [ 166 | "eslint --fix", 167 | "git add" 168 | ] 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /lib/resources/container.ts: -------------------------------------------------------------------------------- 1 | import * as ecr from '@aws-cdk/aws-ecr'; 2 | import * as cdk from '@aws-cdk/core'; 3 | import * as assert from 'assert'; 4 | import debug from 'debug'; 5 | import _ from 'lodash'; 6 | import { config } from '../config'; 7 | // eslint-disable-next-line @typescript-eslint/no-var-requires 8 | const dockerImageParse = require('docker-parse-image'); 9 | 10 | interface ContainerProps { 11 | /** build time parameters passed to "docker build" */ 12 | buildArgs?: { [key: string]: string }; 13 | /** path to Dockerfile (default: Dockerfile) */ 14 | file?: string; 15 | /** path to build context (default: current working directory) */ 16 | context?: string; 17 | /** image's push URI. leave empty if using AWS ECR */ 18 | uri?: string; 19 | /** optional array of tags to apply if we are building the container */ 20 | tags?: string[]; 21 | } 22 | 23 | /** 24 | * a construct abstracting a single Dockerfile. This class does not participate 25 | * in authentication, building, or pushing the actual image of the container. 26 | */ 27 | export class Container extends cdk.Construct { 28 | public readonly props: ContainerProps; 29 | public readonly needsBuilding: boolean; 30 | public readonly repo?: ecr.Repository; 31 | private readonly _debug: debug.Debugger; 32 | private readonly _ecrRepoName?: string; 33 | 34 | /** 35 | * @hideconstructor 36 | * @param scope CDK scope 37 | * @param id CDK construct id 38 | * @param props CDK construct parameters 39 | */ 40 | constructor(scope: cdk.Construct, id: string, props: ContainerProps) { 41 | super(scope, id); 42 | 43 | this._debug = debug(`mutato:constructs:container:${id}`); 44 | this.props = _.defaults(props, { 45 | buildArgs: {}, 46 | context: '.', 47 | file: '', 48 | uri: '', 49 | tags: [], 50 | }); 51 | 52 | this._debug('creating a container construct with props: %o', this.props); 53 | assert.ok(this.props.context); 54 | assert.ok(_.isString(this.props.uri)); 55 | 56 | if (this.props.file) { 57 | if (this.props.uri) { 58 | this._debug('container is building for Docker Hub'); 59 | assert.ok(config.opts.docker.user && config.opts.docker.pass); 60 | const { tag } = dockerImageParse(this.props.uri); 61 | // if a tag is given in the URI, remove it and add it to "tags" 62 | if (tag) this.props.tags?.push(tag); 63 | this.props.uri = this.props.uri?.replace(`:${tag}`, ''); 64 | } else { 65 | this._debug('container is building for AWS ECR'); 66 | const git = config.getGithubMetaData(); 67 | this._ecrRepoName = `mutato/${git.identifier}`; 68 | this.repo = new ecr.Repository(this, 'repository', { 69 | removalPolicy: cdk.RemovalPolicy.DESTROY, 70 | repositoryName: this._ecrRepoName, 71 | }); 72 | const uri = this.repo.repositoryUri; 73 | this._debug('overriding container uri to: %s', uri); 74 | this.props.uri = uri; 75 | } 76 | } 77 | 78 | assert.ok(this.props.uri); 79 | if (_.isEmpty(this.props.tags)) this.props.tags = ['latest']; 80 | this.props.tags?.push('$mutato_opts__git__commit'); 81 | this.needsBuilding = !!this.props.file; 82 | this._debug('uri: %s, tags: %o', this.getImageUri(this), this._tags); 83 | } 84 | 85 | /** 86 | * @param caller optional construct in a different stack needing to access the 87 | * image URI without referencing the stack that is building the container. 88 | * @param latest whether we should return the image with its latest GIT tag. 89 | * @returns Get the container image's URI for use in ECS. Optionally caller 90 | * can be used to get a portable URI independent of the stack building this 91 | * container with a precondition that caller exists in the same AWS region and 92 | * account. 93 | */ 94 | getImageUri(caller?: cdk.Construct, latest = false): string { 95 | const tag = latest ? `:${config.opts.git.commit}` : ''; 96 | if (caller && this.repo) { 97 | // little hack so we can use this container cross-stacks without circular 98 | // errors thrown by CloudFormation. the build order is guaranteed so this 99 | // is safe here. 100 | const stack = cdk.Stack.of(caller); 101 | const suffix = stack.urlSuffix; 102 | const region = stack.region; 103 | const account = stack.account; 104 | const repo = this._ecrRepoName; 105 | const uri = `${account}.dkr.ecr.${region}.${suffix}/${repo}${tag}`; 106 | return uri; 107 | } else 108 | return this.needsBuilding 109 | ? // Docker Hub images we are building 110 | `${this.props.uri}${tag}` 111 | : // Off the shelf images 112 | (this.props.uri as string); 113 | } 114 | 115 | /** @returns shell command containing "docker login" */ 116 | get loginCommand(): string { 117 | const region = cdk.Stack.of(this).region; 118 | return this.repo 119 | ? `$(aws ecr get-login --no-include-email --region ${region})` 120 | : config.opts.docker.user && config.opts.docker.pass 121 | ? `docker login -u ${config.opts.docker.user} -p ${config.opts.docker.pass}` 122 | : 'echo "skipping docker login (credentials not provided)"'; 123 | } 124 | 125 | /** @returns shell command containing "docker build" */ 126 | get buildCommand(): string { 127 | assert.ok(this.needsBuilding, 'container is not part of the pipeline'); 128 | const buildArgs = _.reduce( 129 | this.props.buildArgs, 130 | (accumulate, value, key) => `${accumulate} --build-arg ${key}="${value}"`, 131 | '', 132 | ).trim(); 133 | const file = this.props.file; 134 | const context = this.props.context; 135 | const tagArgs = this._tags.map((tag) => `-t ${tag}`).join(' '); 136 | return `docker build ${buildArgs} ${tagArgs} -f ${file} ${context}`; 137 | } 138 | 139 | /** @returns shell command containing "docker push" */ 140 | get pushCommand(): string { 141 | assert.ok(this.needsBuilding, 'container is not part of the pipeline'); 142 | return this._tags.map((tag) => `docker push ${tag}`).join(' && '); 143 | } 144 | 145 | private get _tags(): string[] { 146 | const imageUri = this.getImageUri(); 147 | return _.uniq(this.props.tags).map((tag) => `${imageUri}:${tag}`); 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /lib/resources/service.ts: -------------------------------------------------------------------------------- 1 | import * as ecs from '@aws-cdk/aws-ecs'; 2 | import * as sqs from '@aws-cdk/aws-sqs'; 3 | import * as ecsPatterns from '@aws-cdk/aws-ecs-patterns'; 4 | import * as iam from '@aws-cdk/aws-iam'; 5 | import * as cdk from '@aws-cdk/core'; 6 | import * as appAutoScaling from '@aws-cdk/aws-applicationautoscaling'; 7 | import assert from 'assert'; 8 | import debug from 'debug'; 9 | import _ from 'lodash'; 10 | import { Container } from './container'; 11 | import { Network } from './network'; 12 | import { Storage } from './storage'; 13 | import { config } from '../config'; 14 | 15 | enum ServiceProvider { 16 | Fargate = 'fargate', 17 | Classic = 'classic', 18 | FargateTask = 'fargate-task', 19 | ClassicTask = 'classic-task', 20 | FargateQueue = 'fargate-queue', 21 | ClassicQueue = 'classic-queue', 22 | } 23 | 24 | interface ServiceProps { 25 | provider?: ServiceProvider; 26 | container: Container; 27 | storage?: Storage; 28 | network: Network; 29 | rate?: string; 30 | config?: 31 | | ecsPatterns.ScheduledEc2TaskProps 32 | | ecsPatterns.ScheduledFargateTaskProps 33 | | ecsPatterns.QueueProcessingEc2ServiceProps 34 | | ecsPatterns.QueueProcessingFargateServiceProps 35 | | ecsPatterns.ApplicationLoadBalancedEc2ServiceProps 36 | | ecsPatterns.ApplicationLoadBalancedFargateServiceProps; 37 | } 38 | 39 | /** ECS service construct */ 40 | export class Service extends cdk.Construct { 41 | public readonly props: ServiceProps; 42 | public readonly resource: 43 | | ecsPatterns.ScheduledEc2Task 44 | | ecsPatterns.ScheduledFargateTask 45 | | ecsPatterns.QueueProcessingEc2Service 46 | | ecsPatterns.QueueProcessingFargateService 47 | | ecsPatterns.ApplicationLoadBalancedFargateService 48 | | ecsPatterns.ApplicationLoadBalancedEc2Service; 49 | private readonly _debug: debug.Debugger; 50 | 51 | /** 52 | * @hideconstructor 53 | * @param scope CDK construct scope 54 | * @param id CDK construct ID 55 | * @param props service configuration 56 | */ 57 | constructor(scope: cdk.Construct, id: string, props: ServiceProps) { 58 | super(scope, id); 59 | 60 | this._debug = debug(`mutato:constructs:service:${id}`); 61 | this.props = _.defaults(props, { 62 | provider: ServiceProvider.Fargate, 63 | rate: 'rate(1 day)', 64 | }); 65 | 66 | this._debug('creating a service construct with props: %o', this.props); 67 | assert.ok(_.isString(this.props.provider)); 68 | assert.ok(_.isObject(this.props.container)); 69 | assert.ok(_.isObject(this.props.network)); 70 | 71 | const imageUri = this.props.container.getImageUri(this, true /* latest */); 72 | // a unique container to force an ECS cluster update to pull down changes 73 | const containerName = `container-${config.opts.git.commit}-${Date.now()}`; 74 | const ecrPullPolicy = new iam.PolicyStatement({ 75 | effect: iam.Effect.ALLOW, 76 | actions: [ 77 | 'ecr:GetAuthorizationToken', 78 | 'ecr:BatchCheckLayerAvailability', 79 | 'ecr:GetDownloadUrlForLayer', 80 | 'ecr:BatchGetImage', 81 | ], 82 | // TODO fix this and limit the scope 83 | resources: ['*'], 84 | }); 85 | 86 | switch (this.props.provider) { 87 | case ServiceProvider.Fargate: 88 | this.resource = new ecsPatterns.ApplicationLoadBalancedFargateService( 89 | this, 90 | `Fargate`, 91 | { 92 | cluster: this.props.network.cluster, 93 | taskImageOptions: { 94 | image: ecs.ContainerImage.fromRegistry(imageUri), 95 | environment: { MUTATO_CONTAINER_NAME: containerName }, 96 | containerName, 97 | }, 98 | ...(this.props 99 | .config as ecsPatterns.ApplicationLoadBalancedFargateServiceProps), 100 | }, 101 | ); 102 | break; 103 | case ServiceProvider.Classic: 104 | this.resource = new ecsPatterns.ApplicationLoadBalancedEc2Service( 105 | this, 106 | `Classic`, 107 | { 108 | cluster: this.props.network.cluster, 109 | taskImageOptions: { 110 | image: ecs.ContainerImage.fromRegistry(imageUri), 111 | environment: { MUTATO_CONTAINER_NAME: containerName }, 112 | containerName, 113 | }, 114 | ...(this.props 115 | .config as ecsPatterns.ApplicationLoadBalancedEc2ServiceProps), 116 | }, 117 | ); 118 | break; 119 | case ServiceProvider.FargateTask: 120 | assert.ok(this.props.rate, 'CloudWatch rate expression must be set'); 121 | this.resource = new ecsPatterns.ScheduledFargateTask( 122 | this, 123 | `FargateTask`, 124 | { 125 | cluster: this.props.network.cluster, 126 | scheduledFargateTaskImageOptions: { 127 | image: ecs.ContainerImage.fromRegistry(imageUri), 128 | environment: { MUTATO_CONTAINER_NAME: containerName }, 129 | }, 130 | schedule: appAutoScaling.Schedule.expression( 131 | this.props.rate as string, 132 | ), 133 | ...(this.props.config as ecsPatterns.ScheduledFargateTaskProps), 134 | }, 135 | ); 136 | break; 137 | case ServiceProvider.ClassicTask: 138 | assert.ok(this.props.rate, 'CloudWatch rate expression must be set'); 139 | this.resource = new ecsPatterns.ScheduledEc2Task(this, `ClassicTask`, { 140 | cluster: this.props.network.cluster, 141 | scheduledEc2TaskImageOptions: { 142 | image: ecs.ContainerImage.fromRegistry(imageUri), 143 | environment: { MUTATO_CONTAINER_NAME: containerName }, 144 | }, 145 | schedule: appAutoScaling.Schedule.expression( 146 | this.props.rate as string, 147 | ), 148 | ...(this.props.config as ecsPatterns.ScheduledEc2TaskProps), 149 | }); 150 | break; 151 | case ServiceProvider.FargateQueue: 152 | assert.ok(this.props.storage, 'storage must be set'); 153 | assert.ok(this.props.storage?.resource, 'storage resource must be set'); 154 | this.resource = new ecsPatterns.QueueProcessingFargateService( 155 | this, 156 | 'FargateQueue', 157 | { 158 | image: ecs.ContainerImage.fromRegistry(imageUri), 159 | environment: { MUTATO_CONTAINER_NAME: containerName }, 160 | queue: this.props.storage?.resource as sqs.Queue, 161 | cluster: this.props.network.cluster, 162 | ...(this.props 163 | .config as ecsPatterns.QueueProcessingFargateServiceProps), 164 | }, 165 | ); 166 | break; 167 | case ServiceProvider.ClassicQueue: 168 | assert.ok(this.props.storage, 'storage must be set'); 169 | assert.ok(this.props.storage?.resource, 'storage resource must be set'); 170 | this.resource = new ecsPatterns.QueueProcessingEc2Service( 171 | this, 172 | 'ClassicQueue', 173 | { 174 | image: ecs.ContainerImage.fromRegistry(imageUri), 175 | environment: { MUTATO_CONTAINER_NAME: containerName }, 176 | queue: this.props.storage?.resource as sqs.Queue, 177 | cluster: this.props.network.cluster, 178 | ...(this.props 179 | .config as ecsPatterns.QueueProcessingEc2ServiceProps), 180 | }, 181 | ); 182 | break; 183 | default: 184 | assert.fail('storage type not supported'); 185 | } 186 | 187 | this.resource.taskDefinition.addToExecutionRolePolicy(ecrPullPolicy); 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /lib/app.ts: -------------------------------------------------------------------------------- 1 | import * as cicd from '@aws-cdk/app-delivery'; 2 | import * as codeBuild from '@aws-cdk/aws-codebuild'; 3 | import * as codePipeline from '@aws-cdk/aws-codepipeline'; 4 | import * as codePipelineActions from '@aws-cdk/aws-codepipeline-actions'; 5 | import * as s3Assets from '@aws-cdk/aws-s3-assets'; 6 | import * as s3 from '@aws-cdk/aws-s3'; 7 | import * as cdk from '@aws-cdk/core'; 8 | import * as iam from '@aws-cdk/aws-iam'; 9 | import assert from 'assert'; 10 | import debug from 'debug'; 11 | import fs from 'fs'; 12 | import _ from 'lodash'; 13 | import path from 'path'; 14 | import * as Actions from './actions'; 15 | import { config } from './config'; 16 | import { MutatoSpec, Parser } from './parser'; 17 | import { Container } from './resources/container'; 18 | import { Network } from './resources/network'; 19 | import { Service } from './resources/service'; 20 | import { Storage } from './resources/storage'; 21 | import { Database } from './resources/database'; 22 | // eslint-disable-next-line @typescript-eslint/no-var-requires 23 | const toGlob = require('gitignore-globs'); 24 | 25 | /** 26 | * This class holds together a Mutato Pipeline (a Stack) and Mutato Resources (a 27 | * Stack deployed through the Mutato Pipeline stack). This is the entry point to 28 | * all Mutato powered microservice infrastructures. 29 | */ 30 | export class App extends cdk.App { 31 | private readonly _parser = new Parser(); 32 | private readonly _debug = debug('mutato:App'); 33 | private static MUTATO_YML = path.resolve(config.opts.git.local, 'mutato.yml'); 34 | 35 | /** 36 | * initializes this Mutato App from a valid Mutato YAML file 37 | * 38 | * @param file Mutato YAML file path. By default it looks under your 39 | * current working directory for mutato.yml 40 | */ 41 | public async synthesizeFromFile(file = App.MUTATO_YML): Promise { 42 | this._debug('synthesizing Mutato app from file: %s', file); 43 | const yamlString = await fs.promises.readFile(file, { encoding: 'utf8' }); 44 | this.synthesizeFromString(yamlString); 45 | } 46 | 47 | /** 48 | * initializes this Mutato stack from a valid Mutato YAML string 49 | * 50 | * @param string Mutato YAML string 51 | */ 52 | public async synthesizeFromString(string: string): Promise { 53 | this._debug('synthesizing Mutato app from string: %s', string); 54 | const ymlObject = this._parser.parse(string); 55 | await this.synthesizeFromObject(ymlObject as MutatoSpec); 56 | } 57 | 58 | /** 59 | * initializes this Mutato stack from a valid Mutato YAML object (converted to JSON) 60 | * 61 | * @param spec a valid Mutato YAML object 62 | */ 63 | public async synthesizeFromObject(spec: MutatoSpec): Promise { 64 | this._debug('synthesizing Mutato app from object: %o', spec); 65 | assert.ok(_.isObject(spec)); 66 | 67 | const git = config.getGithubMetaData(); 68 | this._debug('git meta data extracted: %o', git); 69 | 70 | const __ = function (name: string): string { 71 | return `${name}-${git.identifier}`; 72 | }; 73 | 74 | this._debug('creating a stack (Mutato Pipeline)'); 75 | const pipelineStack = new cdk.Stack(this, 'MutatoPipeline', { 76 | description: 'pipeline that manages deploy of mutato.yml resources', 77 | stackName: __('Mutato-Pipeline'), 78 | }); 79 | 80 | this._debug('creating a CodePipeline to manage Mutato resources'); 81 | const pipeline = new codePipeline.Pipeline(pipelineStack, __('pipeline'), { 82 | restartExecutionOnUpdate: true, 83 | }); 84 | 85 | this._debug('creating an artifact to store Github source'); 86 | const githubSource = new codePipeline.Artifact('S'); 87 | this._debug('creating an action that pulls source from Github'); 88 | const source = new codePipelineActions.GitHubSourceAction({ 89 | variablesNamespace: 'GH', 90 | actionName: 'GitHub', 91 | output: githubSource, 92 | owner: git.owner, 93 | repo: git.repo, 94 | branch: git.branch, 95 | /** @todo add SSM here to read github token from */ 96 | oauthToken: cdk.SecretValue.plainText(config.opts.git.secret), 97 | }); 98 | this._debug('adding Github action to the pipeline'); 99 | pipeline.addStage({ 100 | stageName: 'Mutato-Source', 101 | actions: [source], 102 | }); 103 | 104 | let variables: { [key: string]: codeBuild.BuildEnvironmentVariable } = {}; 105 | // explanation on WTF is going on here: if "bundle" isn't configured through 106 | // environment variables, that means user is executing mutato outside of the 107 | // CodeBuild environment. in that case, we capture mutato's source into .zip 108 | // and send it to CodeBuild as a CDK asset. CodeBuild won't re-run this code 109 | // since "config.opts.bundle" is defined for it. 110 | if (!process.env.CODEBUILD_BUILD_ID) { 111 | this._debug('running outside of CodeBuild, package up mutato'); 112 | assert.ok(!config.opts.bundle.bucket && !config.opts.bundle.object); 113 | this._debug('freezing the list of env vars to send to CodeBuild'); 114 | const envFile = _.map( 115 | { 116 | ...config.toStringEnvironmentMap(), 117 | ...spec.environmentVariables, 118 | USER: 'root', 119 | DEBUG: 'mutato*', 120 | DEBUG_COLORS: '0', 121 | }, 122 | (v, k) => `export ${k}=${v};`, 123 | ).join('\n'); 124 | this._debug('variables env file: %s', envFile); 125 | await fs.promises.writeFile('.env', envFile, { encoding: 'utf-8' }); 126 | assert.ok(fs.existsSync('.env')); 127 | const bundle = new s3Assets.Asset(pipelineStack, __('mutato-asset'), { 128 | exclude: _.concat('.git', 'mutato.yml', toGlob('.gitignore'), '!.env'), 129 | path: process.cwd(), 130 | }); 131 | variables = config.toBuildEnvironmentMap({ 132 | mutato_opts__bundle__bucket: bundle.s3BucketName, 133 | mutato_opts__bundle__object: bundle.s3ObjectKey, 134 | }); 135 | } else { 136 | // the first time user deploys through their terminal, this causes a loop 137 | // from Synth -> Update -> Synth again because the underlying CFN template 138 | // is changing from using a CDN param (CDK asset) to an inline bucket. but 139 | // the result is the same, therefore it goes out of the loop 140 | assert.ok(config.opts.bundle.bucket && config.opts.bundle.object); 141 | const bundle = `s3://${config.opts.bundle.bucket}/${config.opts.bundle.object}`; 142 | this._debug('running inside CodeBuild, using mutato bundle: %s', bundle); 143 | // so that it is not destroyed when synth stage passes for the first time 144 | s3.Bucket.fromBucketName( 145 | pipelineStack, 146 | __('mutato-bucket'), 147 | config.opts.bundle.bucket, 148 | ); 149 | variables = config.toBuildEnvironmentMap({ 150 | mutato_opts__bundle__bucket: config.opts.bundle.bucket, 151 | mutato_opts__bundle__object: config.opts.bundle.object, 152 | }); 153 | } 154 | 155 | this._debug('creating a CodeBuild project that synthesizes myself'); 156 | const project = new codeBuild.PipelineProject(pipelineStack, 'build', { 157 | environment: { 158 | buildImage: codeBuild.LinuxBuildImage.fromDockerRegistry('node:lts'), 159 | environmentVariables: variables, 160 | }, 161 | buildSpec: codeBuild.BuildSpec.fromObject({ 162 | version: 0.2, 163 | phases: { 164 | build: { 165 | commands: [ 166 | '/bin/bash', 167 | // make sure mutato knows where user's repo is mounted 168 | 'export mutato_opts__git__local=`pwd`', 169 | // install AWS CLI 170 | 'mkdir -p /aws-cli && cd /aws-cli', 171 | 'curl "s3.amazonaws.com/aws-cli/awscli-bundle.zip" -o "awscli-bundle.zip"', 172 | 'unzip awscli-bundle.zip', 173 | './awscli-bundle/install -i /usr/local/aws -b /usr/local/bin/aws', 174 | // create the mutato bundle address 175 | `export BUNDLE="s3://$mutato_opts__bundle__bucket/$mutato_opts__bundle__object"`, 176 | // pull down mutato's bundle used to create this pipeline 177 | 'mkdir -p /mutato && cd /mutato', 178 | 'aws s3 cp "$BUNDLE" .', 179 | 'unzip $(basename "$BUNDLE")', 180 | // prepare the environment 181 | 'chmod +x .env && . ./.env && rm .env', 182 | // do cdk synth, mutato knows about user's repo over env vars 183 | 'npm install && npm run synth', 184 | // show the user what changes they just pushed 185 | 'npm run --silent cdk -- diff || true', 186 | ], 187 | }, 188 | }, 189 | artifacts: { 'base-directory': 'dist', files: '**/*' }, 190 | }), 191 | }); 192 | 193 | // band-aid for admin permission issues during deploy. FIXME 194 | this._debug('granting admin permission to the synthesize build stage'); 195 | project.addToRolePolicy( 196 | new iam.PolicyStatement({ 197 | resources: ['*'], 198 | actions: ['*'], 199 | }), 200 | ); 201 | 202 | this._debug('creating an artifact to store synthesized self'); 203 | const synthesizedApp = new codePipeline.Artifact(); 204 | this._debug('creating an action for the pipeline to actually build self'); 205 | const buildAction = new codePipelineActions.CodeBuildAction({ 206 | actionName: 'CodeBuild', 207 | project, 208 | input: githubSource, 209 | outputs: [synthesizedApp], 210 | environmentVariables: { 211 | mutato_opts__git__commit: { value: source.variables.commitId }, 212 | }, 213 | }); 214 | 215 | this._debug('adding self build action to the pipeline'); 216 | pipeline.addStage({ 217 | stageName: 'Mutato-Synthesize', 218 | actions: [buildAction], 219 | }); 220 | 221 | this._debug('adding a self update stage'); 222 | pipeline.addStage({ 223 | stageName: 'Mutato-Update', 224 | actions: [ 225 | new cicd.PipelineDeployStackAction({ 226 | stack: pipelineStack, 227 | input: synthesizedApp, 228 | adminPermissions: true, 229 | }), 230 | ], 231 | }); 232 | 233 | const containerSpecs = spec.containers; 234 | this._debug('containers specs: %o', containerSpecs); 235 | const containers = _.map(containerSpecs, (containerSpec) => { 236 | const type = _.head(_.keys(containerSpec)) as string; 237 | assert.ok(type === 'docker'); 238 | const prop = _.get(containerSpec, type); 239 | const name = _.get(prop, 'name', 'default'); 240 | const construct = new Container(pipelineStack, name, prop); 241 | return construct; 242 | }) as Container[]; 243 | const queryContainer = ( 244 | nameOrUri: string, 245 | requester: string, 246 | ): Container => { 247 | this._debug('resolving container: %s', nameOrUri); 248 | const container = _.find(containers, (c) => c.node.id === nameOrUri); 249 | return container 250 | ? container 251 | : new Container(pipelineStack, `volatile-${requester}-${nameOrUri}`, { 252 | uri: nameOrUri, 253 | }); 254 | }; 255 | 256 | const actionSpecs = spec.actions; 257 | this._debug('action specs: %o', actionSpecs); 258 | const actions = _.map(actionSpecs, (actionSpec) => { 259 | const type = _.head(_.keys(actionSpec)) as string; 260 | const prop = _.get(actionSpec, type); 261 | const name = _.get(prop, 'name'); 262 | assert.ok(name, 'actions must have a name'); 263 | switch (type) { 264 | case 'docker': 265 | return new Actions.DockerRun({ 266 | name, 267 | ...prop, 268 | pipeline, 269 | source: githubSource, 270 | sourceAction: source, 271 | container: queryContainer(prop.container, name), 272 | }); 273 | case 'codebuild': 274 | return new Actions.CodeBuild({ 275 | name, 276 | ...prop, 277 | pipeline, 278 | source: githubSource, 279 | sourceAction: source, 280 | container: _.isString(prop.container) 281 | ? queryContainer(prop.container, name) 282 | : undefined, 283 | }); 284 | case 'approval': 285 | return new Actions.Approval({ name, ...prop }); 286 | default: 287 | assert.fail(`action type not supported: ${type}`); 288 | } 289 | }); 290 | 291 | this._debug('checking to see if we have any containers to build'); 292 | const pipelineContainers = containers.filter((c) => c.needsBuilding); 293 | if (pipelineContainers.length > 0) { 294 | this._debug('we are building containers, adding its stages'); 295 | const containersStage = pipeline.addStage({ 296 | stageName: 'Mutato-Containers', 297 | }); 298 | 299 | const havePreBuild = !!containerSpecs 300 | .map((c) => _.head(_.values(c))) 301 | .filter((p) => _.get(p, 'events["pre-build"]') && _.get(p, 'file')) 302 | ?.length; 303 | this._debug('container pre build events found: %s', havePreBuild); 304 | let containerPreBuildStage: codePipeline.IStage; 305 | if (havePreBuild) { 306 | containerPreBuildStage = pipeline.addStage({ 307 | stageName: 'Mutato-Containers-Pre-Build', 308 | placement: { 309 | rightBefore: containersStage, 310 | }, 311 | }); 312 | } 313 | 314 | const havePostBuild = !!containerSpecs 315 | .map((c) => _.head(_.values(c))) 316 | .filter((p) => _.get(p, 'events["post-build"]') && _.get(p, 'file')) 317 | ?.length; 318 | this._debug('container post build events found: %s', havePostBuild); 319 | let containerPostBuildStage: codePipeline.IStage; 320 | if (havePostBuild) { 321 | containerPostBuildStage = pipeline.addStage({ 322 | stageName: 'Mutato-Containers-Post-Build', 323 | placement: { 324 | justAfter: containersStage, 325 | }, 326 | }); 327 | } 328 | 329 | pipelineContainers.forEach((container) => { 330 | const events = _.get( 331 | containerSpecs 332 | .map((c) => _.head(_.values(c))) 333 | .find((c) => _.get(c, 'name', 'default') === container.node.id) || 334 | {}, 335 | 'events', 336 | {}, 337 | ); 338 | 339 | const preBuildEventSpecs = _.get(events, 'pre-build') as string[]; 340 | const preBuildEvents = 341 | (_.isString(preBuildEventSpecs) 342 | ? [preBuildEventSpecs] 343 | : preBuildEventSpecs) || []; 344 | preBuildEvents 345 | .map((ev) => 346 | actions.find((actionFactory) => actionFactory.name === ev), 347 | ) 348 | .forEach((actionFactory) => 349 | containerPreBuildStage?.addAction( 350 | actionFactory?.action( 351 | `${container.node.id}-pre-build`, 352 | ) as codePipeline.IAction, 353 | ), 354 | ); 355 | 356 | containersStage.addAction( 357 | new Actions.DockerBuild({ 358 | name: `build-${container.node.id}`, 359 | source: githubSource, 360 | sourceAction: source, 361 | container, 362 | pipeline, 363 | }).action(container.node.id), 364 | ); 365 | 366 | const postBuildEventSpecs = _.get(events, 'post-build') as string[]; 367 | const postBuildEvents = 368 | (_.isString(postBuildEventSpecs) 369 | ? [postBuildEventSpecs] 370 | : postBuildEventSpecs) || []; 371 | postBuildEvents 372 | .map((ev) => 373 | actions.find((actionFactory) => actionFactory.name === ev), 374 | ) 375 | .forEach((actionFactory) => 376 | containerPostBuildStage?.addAction( 377 | actionFactory?.action( 378 | `${container.node.id}-post-build`, 379 | ) as codePipeline.IAction, 380 | ), 381 | ); 382 | }); 383 | } 384 | 385 | Array.from(spec.environments.keys()).forEach((envName) => { 386 | const resources = spec.environments.get(envName); 387 | if (resources?.length === 1) { 388 | this._debug('environment is empty, skipping it: %s', envName); 389 | return; 390 | } 391 | const queryConstruct = (type: string): object[] => 392 | _.filter( 393 | (resources?.filter( 394 | (c) => _.head(_.keys(c)) === type, 395 | ) as object[]).map((c) => _.get(c, type)), 396 | ); 397 | const environment = _.head(queryConstruct('environment')); 398 | this._debug('creating environment: %s / %o', envName, environment); 399 | 400 | this._debug('creating a stack (Mutato Resources)'); 401 | const envStack = new cdk.Stack(this, `MutatoResources-${envName}`, { 402 | description: `application resources for environment: ${envName}`, 403 | stackName: __(`Mutato-App-${envName}`), 404 | }); 405 | 406 | const networkSpecs = queryConstruct('network'); 407 | assert.ok(networkSpecs.length <= 1); 408 | const networkProp = _.head(networkSpecs); 409 | const networkName = `network-${envName}`; 410 | const networkConstruct = new Network(envStack, networkName, networkProp); 411 | 412 | const storages = queryConstruct('storage').map((props) => { 413 | const storageName = _.get(props, 'name', `storage-${envName}`); 414 | return new Storage(envStack, storageName, props); 415 | }); 416 | 417 | const databases = queryConstruct('database').map((props) => { 418 | const databaseName = _.get(props, 'name', `database-${envName}`); 419 | return new Database(envStack, databaseName, { 420 | ...props, 421 | network: networkConstruct, 422 | }); 423 | }); 424 | 425 | queryConstruct('service').forEach((props) => { 426 | const serviceName = _.get(props, 'name', `service-${envName}`); 427 | const containerNameOrUri = _.get(props, 'container', 'default'); 428 | const service = new Service(envStack, serviceName, { 429 | ...props, 430 | network: networkConstruct, 431 | container: queryContainer(containerNameOrUri, serviceName), 432 | }); 433 | storages.forEach((storage) => storage.grantAccess(service)); 434 | databases.forEach((database) => database.grantAccess(service)); 435 | }); 436 | 437 | this._debug('adding environment deploy stage'); 438 | const deployStage = pipeline.addStage({ 439 | stageName: `Mutato-${envName}-Deploy`, 440 | actions: [ 441 | new cicd.PipelineDeployStackAction({ 442 | stack: envStack, 443 | input: synthesizedApp, 444 | adminPermissions: true, 445 | }), 446 | ], 447 | }); 448 | 449 | const havePreDeploy = !!_.get(environment, 'events["pre-deploy"]'); 450 | if (havePreDeploy) { 451 | const preDeployEventSpecs = _.get( 452 | environment, 453 | 'events["pre-deploy"]', 454 | ) as string[]; 455 | const preDeployEvents = 456 | (_.isString(preDeployEventSpecs) 457 | ? [preDeployEventSpecs] 458 | : preDeployEventSpecs) || []; 459 | pipeline.addStage({ 460 | stageName: `Mutato-${envName}-Pre-Deploy`, 461 | placement: { rightBefore: deployStage }, 462 | actions: preDeployEvents.map( 463 | (ev) => 464 | actions 465 | .find((actionFactory) => actionFactory.name === ev) 466 | ?.action(`${envName}-pre-deploy`) as codePipeline.IAction, 467 | ), 468 | }); 469 | } 470 | 471 | const havePostDeploy = !!_.get(environment, 'events["post-deploy"]'); 472 | if (havePostDeploy) { 473 | const postDeployEventSpecs = _.get( 474 | environment, 475 | 'events["post-deploy"]', 476 | ) as string[]; 477 | const postDeployEvents = 478 | (_.isString(postDeployEventSpecs) 479 | ? [postDeployEventSpecs] 480 | : postDeployEventSpecs) || []; 481 | pipeline.addStage({ 482 | stageName: `Mutato-${envName}-Post-Deploy`, 483 | placement: { justAfter: deployStage }, 484 | actions: postDeployEvents.map( 485 | (ev) => 486 | actions 487 | .find((actionFactory) => actionFactory.name === ev) 488 | ?.action(`${envName}-post-deploy`) as codePipeline.IAction, 489 | ), 490 | }); 491 | } 492 | }); 493 | } 494 | } 495 | --------------------------------------------------------------------------------