├── .circleci └── config.yml ├── .dockerignore ├── .gitignore ├── .nvmrc ├── .prettierignore ├── .prettierrc ├── .vscode ├── extensions.json ├── launch.json ├── settings.json └── tasks.json ├── Dockerfile ├── Makefile ├── README.md ├── logo.png ├── logs └── .gitkeep ├── package.json ├── renovate.json ├── repos └── .gitkeep ├── src ├── api.ts ├── app │ ├── app-shell.tsx │ ├── debug.tsx │ ├── index.tsx │ ├── local-images.tsx │ ├── log-details.tsx │ ├── log.tsx │ └── util.ts ├── builder.ts ├── config.ts ├── constants.ts ├── daemon.ts ├── error.ts ├── image-runner.ts ├── index.ts ├── logger.ts ├── middlewares.ts ├── stats.ts └── types.d.ts ├── test ├── __mocks__ │ └── nodegit.js ├── api.test.ts └── logger.test.ts ├── tsconfig.json └── yarn.lock /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Javascript Node CircleCI 2.0 configuration file 2 | # 3 | # Check https://circleci.com/docs/2.0/language-javascript/ for more details 4 | # 5 | version: 2 6 | jobs: 7 | build: 8 | docker: 9 | - image: circleci/node:10 10 | 11 | working_directory: ~/repo 12 | 13 | steps: 14 | - checkout 15 | 16 | # Download and cache dependencies 17 | - restore_cache: 18 | key: v2-dependencies-{{ checksum "yarn.lock" }} 19 | 20 | - run: 21 | name: Install node dependencies 22 | command: | 23 | if [ ! -d node_modules ]; then 24 | yarn 25 | fi 26 | 27 | - save_cache: 28 | key: v2-dependencies-{{ checksum "yarn.lock" }} 29 | paths: 30 | - node_modules 31 | 32 | # Ensure TypeScript builds 33 | - run: yarn run build-ts 34 | 35 | # run tests! 36 | - run: yarn test 37 | 38 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .git 3 | logs 4 | repos 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | yarn-error.log 3 | build 4 | logs/* 5 | repos/* 6 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v12.16.3 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | useTabs: true 2 | tabWidth: 2 3 | printWidth: 100 4 | singleQuote: true 5 | trailingComma: es5 6 | bracketSpacing: true 7 | parenSpacing: true 8 | jsxBracketSameLine: false 9 | semi: true 10 | arrowParens: avoid 11 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "eg2.tslint", 4 | "streetsidesoftware.code-spell-checker" 5 | ] 6 | } -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible Node.js debug attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Launch", 9 | "request": "launch", 10 | "runtimeArgs": [ 11 | "run", 12 | "serve:debug" 13 | ], 14 | "runtimeExecutable": "yarn", 15 | "skipFiles": [ 16 | "/**" 17 | ], 18 | "outFiles": [ 19 | "${workspaceFolder}/build/**/*.js", 20 | "!**/node_modules/**" 21 | ], 22 | "preLaunchTask": "npm: build-ts", 23 | "type": "pwa-node", 24 | "outputCapture": "std" 25 | }, 26 | { 27 | "request": "launch", 28 | "name": "Run tests", 29 | "program": "${workspaceFolder}/node_modules/.bin/jest", 30 | "skipFiles": [ 31 | "/**" 32 | ], 33 | "args": [ 34 | "--runInBand", 35 | "${file}" 36 | ], 37 | "type": "pwa-node", 38 | } 39 | ] 40 | } 41 | 42 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | // Place your settings in this file to overwrite default and user settings. 2 | { 3 | "search.exclude": { 4 | "**/node_modules": true, 5 | "**/bower_components": true, 6 | "**/dist": true 7 | }, 8 | "typescript.referencesCodeLens.enabled": true, 9 | "tslint.ignoreDefinitionFiles": false, 10 | "tslint.autoFixOnSave": true, 11 | "tslint.exclude": "**/node_modules/**/*" 12 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=733558 3 | // for the documentation about the tasks.json format 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "type": "npm", 8 | "script": "build", 9 | "group": { 10 | "kind": "build", 11 | "isDefault": true 12 | } 13 | } 14 | ] 15 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # THIS DOES NOT CURRENTLY WORK 2 | # Docker for Mac and Docker for Linux have different networking configuration requirements for making dserve work 3 | # On Mac all of docker runs in a vm, in Linux it can kind-of run on the host. 4 | # 5 | # We need the dserve container to share a localhost with the host in order to proxy to various other containers 6 | 7 | from node:alpine 8 | LABEL maintainer="Automattic" 9 | 10 | # All for installing dependencies of nodegit 11 | RUN apk update && \ 12 | apk upgrade && \ 13 | apk add git libgit2-dev && \ 14 | apk add python tzdata pkgconfig build-base && \ 15 | yarn install --production nodegit 16 | 17 | # install rest of dependencies 18 | COPY package.json yarn.lock tsconfig.json ./ 19 | RUN yarn --production 20 | 21 | COPY src ./src 22 | RUN mkdir logs 23 | RUN mkdir repos 24 | 25 | RUN yarn build-ts 26 | 27 | CMD yarn serve:forever 28 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | build: 2 | docker build -t dserve . 3 | 4 | run: 5 | docker run -it --rm -p 80:3000 -v /var/run/docker.sock:/var/run/docker.sock dserve 6 | 7 | .PHONY: build -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # dserve 2 | 3 | [![CircleCI](https://circleci.com/gh/Automattic/dserve/tree/master.svg?style=svg&circle-token=061a56710d3d75a9251ff74141b1c758a0790461)](https://circleci.com/gh/Automattic/dserve/tree/master) 4 | 5 | 6 | 7 | A development server for serving branches of your docker-based web application an on-demand basis. 8 | 9 | It can build images for any hash, run containers, stop containers that haven't been accessed in a 10 | while, proxy requests to the right container based on query params, etc. 11 | 12 | ## Install 13 | 14 | ```bash 15 | git clone git@github.com:Automattic/dserve.git 16 | cd dserve 17 | nvm use 18 | yarn 19 | yarn start 20 | ``` 21 | 22 | ## Use 23 | 24 | You will need to modify your hosts file to include the following line: 25 | 26 | ``` 27 | 127.0.0.1 calypso.localhost 28 | ``` 29 | 30 | Then you may either specify a branch or a hash to load. 31 | 32 | 1. branch: calypso.localhost:3000?branch={branchName} 33 | 2. hash: calypso.localhost:3000?hash={commitHash} 34 | 35 | ## Source Code Overview 36 | 37 | At the end of the day, dserve is node express server written in typescript. It can trigger docker image 38 | builds and deletions, start and stop containers, and a few other tricks. 39 | 40 | Here is an example flow of what happens when requesting a never-requested-before commit sha: 41 | 42 | 1. User tries to access `https://calypso.live?hash=hash`. 43 | 2. dserve will query the local fs and docker daemon to determine the status of the corresponding image. It will discover that `hash` has never been requested before and needs to build an image for it. Therefore it will add the hash to the build queue and send the user a screen saying "starting a build for requested hash". 44 | 3. If the branch or hash exist, dserve will redirect the user to https://hash-$hash.calypso.live, isolating the build to a subdomain 45 | 4. Internally dserve checks the build queue very frequently and will initate a build within seconds. The build takes places within its own temporary directory in a place like: `/tmp/dserve-calyspo-hash/repo` and logs will be stored in `/tmp/dserve-calypso-hash/dserve-build-log.txt`. 46 | 5. When a user requests the branch while the build is happening, dserve will recognize that the build is in progress and show the user the build's status. 47 | 6. Finally when the build completes, the next time a user requests the branch they will see: "starting container, this page will refresh in a couple of seconds". 48 | 49 | **index.ts**: this acts as the entry point for dserve. it sets up the server, initializes the routes, and contains the request handling for each incoming route. 50 | 51 | **middlewares.ts** this file contains all of the middlewares that dserve uses. 52 | 53 | 1. _redirectHashFromQueryStringToSubdomain_: This middleware will look for a branch or hash in the query string and redirect to a corresponding subdomain matching the commit hash. 54 | 2. _determineCommitHash_: Every request to dserve needs to be associated with a commit hash or else it cannot be fulfilled. this middleware will attach a `commitHash` to the express request based on the subdomain. 55 | 3. _session_: Standard session middleware so that each request doesn't need to specify a hash with a query param. 56 | 57 | **api.ts**: Contains all of the code that interfaces with external things like the fs, docker, or github. there are two kinds of entities that exist in this file, those that periodically update data and the other is helper functions for things that need to be done on-demand. 58 | 59 | _periodically repeating_: updating the git branches to commit hash mapping, updating which docker images are available from the local docker server, stopping unused containers. 60 | 61 | _on-demand helper functions_: this includes functionality for recording how recently a commitHash was accessed, a helper for proxying a request to the right container, helpers for checking the progress/state of a a commit, etc. 62 | 63 | **builder.ts**: Contains all of the code for building the docker images for a specific commit hash. This includes making build queue and rate limiting dserve to N builds at a time. 64 | 65 | **logger.ts**: Exports a couple key items around loggers including the application logger and a getter for configuring a specific logger per-docker build of commits. 66 | 67 | ## Operations 68 | 69 | Are you in a situation where you are suddently tasked with maintaining dserve even though you didn't write it? 70 | Once the flood of mixed feelings towards the original authors settles, it'd be a good idea to read this section. 71 | You probably want to know how to do things like, deploy new code, debug issues, and e2e test dserve locally. 72 | Here goes nothing: 73 | 74 | **deploying** 75 | This GitHub repo is polled every 15 minutes. If there have been updates to the repo, then the latest sha is deployed. 76 | Thats it. Merge, and it'll be deployed. In a high severity situation where dserve is broken, you'll want to make sure you time your attempts to fix it _before_ the next 15 minute mark. 77 | 78 | **debugging** 79 | dserve has a couple helpful urls for debugging issues for times when you don't have ssh access. 80 | Note that any time you see `branch=${branchName}` you can subsitute `hash=${sha}`. 81 | 82 | - Application Log: https://calypso.live/log 83 | - List of local Docker images: https://calypso.live/localimages 84 | - Delete a build directory: add reset=1 as a query param like so https://calypso.live?branch=${branchName}&reset=1 85 | - Build Status: https://calypso.live/status?branch=${branchName} 86 | 87 | **e2e test locally** 88 | 89 | 1. start up dserve with `yarn start` 90 | 2. try to access a branch that you've never built before by going to localhost:3000?branch=${branchName}. After a successful build you should be proxied to calypso 91 | 3. try to access an already built branch (by looking at the result of `docker images` you can find repo-tags with the right sha to specify). After a succesfful build you should be proxied to that branch's version of calypso. 92 | 4. you might need access to the private Docker registry: PCYsg-stw-p2 93 | 94 | **fixing errors** 95 | 96 | - Docker connection error ("connect ENOENT /var/run/docker.sock"): You can easily fix this by running [sudo ln -s ~/.docker/run/docker.sock /var/run/docker.sock](https://github.com/lando/lando/issues/3533#issuecomment-1464252377) 97 | - Error when building image: Make sure the image of the branch you are trying to build is available. 98 | 99 | **things that have broken in the past** 100 | 101 | 1. We were running an older version of docker that had `buildCache` issues. disabling the build cache (as a setting in the `buildImage` function) until we could upgrade docker versions solved the issue 102 | 2. The Docker Daemon ran into problems: there was one instance where builds seemed to just hang randomly and there was no obvious cause. all builds had failed. Systems restarting the docker daemon solved the issue. 103 | 3. Double slash branches: there is an interesting property of git with respect to how branches get stored on the local filesystem. Each slash in a branchname actually means that it occupies a nested folder. That means if locally you have a branch named `thing/thing2` then you _cannot_ pull down a remote branch with the name `thing`. The reason the remote repo was capable of having branch `thing/thing2` is because `thing` had already been deleted in its repo. The fix here is to always run a `git prune` when pulling down new branches which automatically deletes the appropriates local branches that no longer exist in the remote repo. 104 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Automattic/dserve/60171cfd935c9633c0ef91c2da65cf5abf3a7055/logo.png -------------------------------------------------------------------------------- /logs/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Automattic/dserve/60171cfd935c9633c0ef91c2da65cf5abf3a7055/logs/.gitkeep -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "calypso-docker-branches", 3 | "version": "0.1.0", 4 | "description": "", 5 | "main": "src/index.js", 6 | "devDependencies": { 7 | "prettier": "npm:wp-prettier@1.18.2" 8 | }, 9 | "dependencies": { 10 | "@types/bunyan": "^1.8.4", 11 | "@types/dockerode": "^2.5.5", 12 | "@types/express": "^4.16.0", 13 | "@types/express-session": "^1.15.10", 14 | "@types/forever-monitor": "^1.7.3", 15 | "@types/fs-extra": "^8.0.0", 16 | "@types/http-proxy": "^1.16.2", 17 | "@types/jest": "^24.0.0", 18 | "@types/lodash": "^4.14.116", 19 | "@types/node": "^10.12.2", 20 | "@types/node-fetch": "^2.1.2", 21 | "@types/nodegit": "^0.26.2", 22 | "@types/react": "^16.4.8", 23 | "@types/react-dom": "^16.0.7", 24 | "@types/strip-ansi": "^3.0.0", 25 | "@types/striptags": "^3.1.1", 26 | "@types/tar-fs": "^1.16.1", 27 | "@types/useragent": "^2.1.1", 28 | "bunyan": "^1.8.12", 29 | "docker-parse-image": "^3.0.1", 30 | "dockerode": "^3.0.0", 31 | "express": "^4.16.3", 32 | "express-session": "^1.15.6", 33 | "forever-monitor": "^1.7.1", 34 | "fs-extra": "^8.0.0", 35 | "get-port": "^5.1.1", 36 | "hot-shots": "^6.3.0", 37 | "http-proxy": "^1.17.0", 38 | "jest": "^24.0.0", 39 | "lodash": "^4.17.10", 40 | "node-fetch": "^2.2.1", 41 | "nodegit": "^0.26.5", 42 | "react": "^16.4.2", 43 | "react-dom": "^16.4.2", 44 | "strip-ansi": "^5.0.0", 45 | "striptags": "^3.1.1", 46 | "tar-fs": "^2.0.0", 47 | "ts-jest": "^24.0.0", 48 | "typescript": "^3.1.6", 49 | "useragent": "^2.3.0" 50 | }, 51 | "scripts": { 52 | "test": "jest --testURL=http://localhost/", 53 | "start": "yarn run build-ts && yarn run serve", 54 | "serve": "node build/index.js", 55 | "serve:debug": "node build/index.js | bunyan", 56 | "serve:forever": "node build/daemon.js", 57 | "build-ts": "tsc", 58 | "watch-ts": "tsc -w", 59 | "reformat-files": "./node_modules/.bin/prettier --write \"**/*.{ts,tsx,json}\"" 60 | }, 61 | "repository": { 62 | "type": "git", 63 | "url": "https://github.com/Automattic/dserve" 64 | }, 65 | "author": "samouri", 66 | "license": "MIT", 67 | "jest": { 68 | "transform": { 69 | "^.+\\.tsx?$": "ts-jest" 70 | }, 71 | "testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$", 72 | "moduleFileExtensions": [ 73 | "ts", 74 | "tsx", 75 | "js", 76 | "jsx", 77 | "json", 78 | "node" 79 | ] 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ "config:base" ] 3 | } 4 | -------------------------------------------------------------------------------- /repos/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Automattic/dserve/60171cfd935c9633c0ef91c2da65cf5abf3a7055/repos/.gitkeep -------------------------------------------------------------------------------- /src/api.ts: -------------------------------------------------------------------------------- 1 | import httpProxy from 'http-proxy'; 2 | import Docker, { ImageInfo } from 'dockerode'; 3 | import _ from 'lodash'; 4 | import getPort from 'get-port'; 5 | import git from 'nodegit'; 6 | import fs from 'fs-extra'; 7 | import path from 'path'; 8 | import { promisify } from 'util'; 9 | import { ContainerInfo } from 'dockerode'; 10 | 11 | import { config, envContainerConfig } from './config'; 12 | import { l } from './logger'; 13 | import { pendingHashes } from './builder'; 14 | import { exec } from 'child_process'; 15 | 16 | import { CONTAINER_EXPIRY_TIME } from './constants'; 17 | import { timing } from './stats'; 18 | import { ContainerError, ImageError } from './error'; 19 | 20 | type APIState = { 21 | accesses: Map< ContainerName, number >; 22 | branchHashes: Map< CommitHash, BranchName >; 23 | containers: Map< string, Docker.ContainerInfo >; 24 | localImages: Map< ImageName, Docker.ImageInfo >; 25 | pullingImages: Map< ImageName, Promise< DockerodeStream > >; 26 | remoteBranches: Map< BranchName, CommitHash >; 27 | startingContainers: Map< CommitHash, Promise< ContainerInfo > >; 28 | }; 29 | 30 | export const state: APIState = { 31 | accesses: new Map(), 32 | branchHashes: new Map(), 33 | containers: new Map(), 34 | localImages: new Map(), 35 | pullingImages: new Map(), 36 | remoteBranches: new Map(), 37 | startingContainers: new Map(), 38 | }; 39 | 40 | export const docker = new Docker(); 41 | 42 | // types 43 | export type NotFound = Error; 44 | export type CommitHash = string; 45 | export type BranchName = string; 46 | export type PortNumber = number; 47 | export type ImageStatus = 'NoImage' | 'Inactive' | PortNumber; 48 | export type RunEnv = string; 49 | export type DockerRepository = string; 50 | export type ImageName = string; 51 | export type ContainerName = string; 52 | export type DockerodeStream = any; 53 | export type ContainerSearchOptions = { 54 | image?: ImageName; 55 | env?: RunEnv; 56 | status?: string; 57 | id?: string; 58 | name?: string; 59 | sanitizedName?: string; 60 | }; 61 | 62 | export const getImageName = ( hash: CommitHash ) => `${ config.build.tagPrefix }:${ hash }`; 63 | export const extractCommitFromImage = ( imageName: string ): CommitHash => { 64 | const [ prefix, sha ] = imageName.split( ':' ); 65 | if ( prefix !== config.build.tagPrefix ) { 66 | return null; 67 | } 68 | return sha; 69 | }; 70 | 71 | export const extractEnvironmentFromImage = ( image: ContainerInfo ): RunEnv => { 72 | return image.Labels.calypsoEnvironment || undefined; 73 | }; 74 | 75 | /** 76 | * Polls the local Docker daemon to fetch an updated list of images 77 | * 78 | * It saves them in the Map `stcate.localImages`, indexed by tag name. If an image has more than 79 | * one tag it will appear multiple times in the map. 80 | */ 81 | export async function refreshLocalImages() { 82 | const images = await docker.listImages(); 83 | state.localImages = new Map( 84 | images.reduce( 85 | ( acc, image ) => [ 86 | ...acc, 87 | ...( image.RepoTags || [] ).map( tag => [ tag, image ] as [ ImageName, ImageInfo ] ), 88 | ], 89 | [] 90 | ) 91 | ); 92 | } 93 | 94 | /** 95 | * Returns the list of images built by dserve 96 | */ 97 | export function getLocalImages() { 98 | return new Map( 99 | Array.from( state.localImages.entries() ).filter( ( [ imageName ] ) => 100 | imageName.startsWith( config.build.tagPrefix ) 101 | ) 102 | ); 103 | } 104 | 105 | /** 106 | * Returns the list of all images 107 | */ 108 | export function getAllImages() { 109 | return state.localImages; 110 | } 111 | 112 | export function getAllContainers() { 113 | return state.containers; 114 | } 115 | 116 | export function isValidImage( imageInfo: ImageInfo ) { 117 | if ( ! imageInfo.Labels ) return false; 118 | 119 | for ( const [ label, value ] of Object.entries( config.allowedLabels ) ) { 120 | if ( imageInfo.Labels[ label ] !== value ) return false; 121 | } 122 | return true; 123 | } 124 | 125 | export async function hasHashLocally( hash: CommitHash ): Promise< boolean > { 126 | return getLocalImages().has( getImageName( hash ) ); 127 | } 128 | 129 | export async function deleteImage( hash: CommitHash ) { 130 | l.info( { commitHash: hash }, 'attempting to remove image for hash' ); 131 | 132 | const runningContainer = state.containers.get( getImageName( hash ) ); 133 | if ( runningContainer ) { 134 | await docker.getContainer( runningContainer.Id ).stop(); 135 | } 136 | 137 | let img; 138 | try { 139 | img = docker.getImage( getImageName( hash ) ); 140 | if ( ! img ) { 141 | l.info( 142 | { commitHash: hash }, 143 | 'did not have an image locally with name' + getImageName( hash ) 144 | ); 145 | return; 146 | } 147 | } catch ( err ) { 148 | l.info( 149 | { commitHash: hash, err }, 150 | 'error trying to find image locally with name' + getImageName( hash ) 151 | ); 152 | return; 153 | } 154 | 155 | try { 156 | await img.remove( { force: true } ); 157 | l.info( { commitHash: hash }, 'succesfully removed image' ); 158 | } catch ( err ) { 159 | l.error( { err, commitHash: hash }, 'failed to remove image' ); 160 | } 161 | } 162 | export async function startContainer( commitHash: CommitHash, env: RunEnv ) { 163 | //l.info( { commitHash }, `Request to start a container for ${ commitHash }` ); 164 | const image = getImageName( commitHash ); 165 | const containerId = `${ env }:${ image }`; 166 | 167 | // do we have an existing container? 168 | const existingContainer = getRunningContainerForHash( commitHash, env ); 169 | if ( existingContainer ) { 170 | l.info( 171 | { commitHash, containerId: existingContainer.Id }, 172 | `Found a running container for ${ commitHash }` 173 | ); 174 | return Promise.resolve( existingContainer ); 175 | } 176 | 177 | // are we starting one already? 178 | if ( state.startingContainers.has( containerId ) ) { 179 | //l.info( { commitHash }, `Already starting a container for ${ commitHash }` ); 180 | return state.startingContainers.get( containerId ); 181 | } 182 | 183 | async function start( 184 | image: string, 185 | commitHash: CommitHash, 186 | env: RunEnv 187 | ): Promise< ContainerInfo > { 188 | // ok, try to start one 189 | let freePort: number; 190 | try { 191 | freePort = await getPort(); 192 | } catch ( err ) { 193 | l.error( 194 | { err, image, commitHash }, 195 | `Error while attempting to find a free port for ${ image }` 196 | ); 197 | throw err; 198 | } 199 | 200 | const exposedPort = `${ config.build.exposedPort }/tcp`; 201 | const dockerPromise = new Promise( ( resolve, reject ) => { 202 | let runError: any; 203 | 204 | l.info( { image, commitHash }, `Starting a container for ${ commitHash }` ); 205 | 206 | docker.run( 207 | image, 208 | [], 209 | process.stdout, 210 | { 211 | ...config.build.containerCreateOptions, 212 | ...envContainerConfig( env ), 213 | ExposedPorts: { [ exposedPort ]: {} }, 214 | PortBindings: { [ exposedPort ]: [ { HostPort: freePort.toString() } ] }, 215 | Tty: false, 216 | Labels: { 217 | calypsoEnvironment: env, 218 | }, 219 | }, 220 | err => { 221 | runError = err; 222 | } 223 | ); 224 | 225 | // run will never callback for calypso when things work as intended. 226 | // wait 5 seconds. If we don't see an error by then, assume run worked and resolve 227 | setTimeout( () => { 228 | if ( runError ) { 229 | reject( { error: runError, freePort } ); 230 | } else { 231 | resolve( { freePort } ); 232 | } 233 | }, 5000 ); 234 | } ); 235 | return dockerPromise.then( 236 | ( { success, freePort } ) => { 237 | l.info( 238 | { image, freePort, commitHash }, 239 | `Successfully started container for ${ image } on ${ freePort }` 240 | ); 241 | return refreshContainers().then( () => getRunningContainerForHash( commitHash ) ); 242 | }, 243 | ( { error, freePort } ) => { 244 | l.error( 245 | { image, freePort, error, commitHash }, 246 | `Failed starting container for ${ image } on ${ freePort }` 247 | ); 248 | throw error; 249 | } 250 | ); 251 | } 252 | 253 | const startPromise = start( image, commitHash, env ); 254 | 255 | state.startingContainers.set( containerId, startPromise ); 256 | startPromise.then( 257 | s => { 258 | state.startingContainers.delete( containerId ); 259 | return s; 260 | }, 261 | err => { 262 | state.startingContainers.delete( containerId ); 263 | throw err; 264 | } 265 | ); 266 | return startPromise; 267 | } 268 | 269 | export async function refreshContainers() { 270 | const containers = await docker.listContainers( { all: true } ); 271 | state.containers = new Map( 272 | containers.map( container => [ container.Id, container ] as [ string, ContainerInfo ] ) 273 | ); 274 | } 275 | 276 | export function getRunningContainerForHash( hash: CommitHash, env?: RunEnv ): ContainerInfo | null { 277 | const image = getImageName( hash ); 278 | return Array.from( state.containers.values() ).find( 279 | ci => 280 | ci.Image === image && 281 | ci.State === 'running' && 282 | ( ! env || env === extractEnvironmentFromImage( ci ) ) 283 | ); 284 | } 285 | 286 | export function isContainerRunning( hash: CommitHash, env?: RunEnv ): boolean { 287 | return !! getRunningContainerForHash( hash, env ); 288 | } 289 | 290 | export function getPortForContainer( hash: CommitHash, env: RunEnv ): number | boolean { 291 | const container = getRunningContainerForHash( hash, env ); 292 | 293 | if ( ! container ) { 294 | return false; 295 | } 296 | 297 | const ports = container.Ports; 298 | 299 | return ports.length > 0 ? ports[ 0 ].PublicPort : false; 300 | } 301 | 302 | async function getRemoteBranches(): Promise< Map< string, string > > { 303 | const repoDir = path.join( __dirname, '../repos' ); 304 | const calypsoDir = path.join( repoDir, 'wp-calypso' ); 305 | let repo: git.Repository; 306 | 307 | const start = Date.now(); 308 | 309 | try { 310 | if ( ! ( await fs.pathExists( repoDir ) ) ) { 311 | await fs.mkdir( repoDir ); 312 | } 313 | if ( ! ( await fs.pathExists( calypsoDir ) ) ) { 314 | repo = await git.Clone.clone( `https://github.com/${ config.repo.project }`, calypsoDir ); 315 | } else { 316 | repo = await git.Repository.open( calypsoDir ); 317 | } 318 | 319 | // this code here is all for retrieving origin 320 | // and then pruning out old branches 321 | const origin: git.Remote = await repo.getRemote( 'origin' ); 322 | await origin.connect( git.Enums.DIRECTION.FETCH, {} ); 323 | await origin.download( null ); 324 | const pruneError = origin.prune( new git.RemoteCallbacks() ); 325 | if ( pruneError ) { 326 | throw new Error( `invoking remote prune returned error code: ${ pruneError }` ); 327 | } 328 | // 329 | await repo.fetchAll(); 330 | } catch ( err ) { 331 | l.error( { err }, 'Could not fetch repo to update branches list' ); 332 | } 333 | 334 | if ( ! repo ) { 335 | l.error( 'Something went very wrong while trying to refresh branches' ); 336 | } 337 | 338 | timing( 'git.refresh', Date.now() - start ); 339 | 340 | try { 341 | const branchesReferences = ( await repo.getReferences() ).filter( 342 | ( x: git.Reference ) => x.isBranch 343 | ); 344 | 345 | const branchToCommitHashMap: Map< string, string > = new Map( 346 | branchesReferences.map( reference => { 347 | const name = reference.shorthand().replace( 'origin/', '' ); 348 | const commitHash = reference.target().tostrS(); 349 | 350 | return [ name, commitHash ] as [ string, CommitHash ]; 351 | } ) 352 | ); 353 | 354 | // gc the repo if no builds are running 355 | if ( pendingHashes.size === 0 ) { 356 | try { 357 | await promisify( exec )( 'git gc', { 358 | cwd: calypsoDir, 359 | } ); 360 | } catch ( err ) { 361 | l.error( { err }, 'git gc failed' ); 362 | } 363 | } 364 | 365 | return branchToCommitHashMap; 366 | } catch ( err ) { 367 | l.error( 368 | { err, repository: config.repo.project }, 369 | 'Error creating branchName --> commitSha map' 370 | ); 371 | return; 372 | } 373 | } 374 | 375 | let refreshingPromise: Promise< any > = null; 376 | export async function refreshRemoteBranches() { 377 | if ( refreshingPromise ) { 378 | return refreshingPromise; 379 | } 380 | 381 | refreshingPromise = ( async () => { 382 | const branches = await getRemoteBranches(); 383 | 384 | if ( branches ) { 385 | state.branchHashes = new Map( 386 | Array.from( branches ).map( ( [ a, b ] ) => [ b, a ] as [ CommitHash, BranchName ] ) 387 | ); 388 | 389 | state.remoteBranches = branches; 390 | } 391 | } )(); 392 | 393 | function letItGo() { 394 | refreshingPromise = null; 395 | } 396 | 397 | refreshingPromise.then( letItGo, letItGo ); // errors never bothered me anyway 398 | 399 | return refreshingPromise; 400 | } 401 | 402 | export function getBranchHashes() { 403 | return state.branchHashes; 404 | } 405 | 406 | export function getKnownBranches() { 407 | return state.remoteBranches; 408 | } 409 | 410 | export function getCommitHashForBranch( branch: BranchName ): CommitHash | undefined { 411 | return state.remoteBranches.get( branch ); 412 | } 413 | 414 | export function touchCommit( hash: CommitHash, env?: RunEnv ) { 415 | const container = getRunningContainerForHash( hash, env ); 416 | if ( ! container ) throw `Running container for commit ${ hash } not found}`; 417 | 418 | const name = getContainerName( container ); 419 | touchContainer( name ); 420 | } 421 | 422 | export function touchContainer( name: ContainerName ) { 423 | state.accesses.set( name, Date.now() ); 424 | } 425 | 426 | export function getContainerAccessTime( name: ContainerName ): number | undefined { 427 | return state.accesses.get( name ); 428 | } 429 | 430 | /* 431 | * Get all currently running containers that have expired. 432 | * Expired means have not been accessed in EXPIRED_DURATION 433 | */ 434 | export function getExpiredContainers() { 435 | // Filter off containers that are still valid 436 | return Array.from( state.containers.values() ).filter( ( container: ContainerInfo ) => { 437 | if ( container.State === 'dead' || container.State === 'created' ) { 438 | // ignore dead and just created containers 439 | return false; 440 | } 441 | 442 | if ( container.State === 'exited' ) { 443 | // these are done, remove 'em 444 | return true; 445 | } 446 | 447 | const createdAgo = Date.now() - container.Created * 1000; 448 | const lastAccessed = getContainerAccessTime( getContainerName( container ) ); 449 | 450 | return ( 451 | createdAgo > CONTAINER_EXPIRY_TIME && 452 | ( _.isUndefined( lastAccessed ) || Date.now() - lastAccessed > CONTAINER_EXPIRY_TIME ) 453 | ); 454 | } ); 455 | } 456 | 457 | // stop any container that hasn't been accessed within ten minutes 458 | export async function cleanupExpiredContainers() { 459 | await refreshContainers(); 460 | const expiredContainers = getExpiredContainers(); 461 | for ( let container of expiredContainers ) { 462 | const containerName = getContainerName( container ) 463 | 464 | const log = l.child({ 465 | containerId: container.Id, 466 | containerName 467 | }) 468 | 469 | log.info( 'Cleaning up stale container' ); 470 | 471 | if ( container.State === 'running' ) { 472 | try { 473 | await docker.getContainer( container.Id ).stop(); 474 | log.info( `Successfully stopped container` ); 475 | } catch ( err ) { 476 | log.error( err, 'Failed to stop container' ); 477 | } 478 | } 479 | try { 480 | await docker.getContainer( container.Id ).remove(); 481 | log.info(`Successfully removed container` ); 482 | } catch ( err ) { 483 | log.error( err, 'Failed to remove container' ); 484 | } 485 | } 486 | refreshContainers(); 487 | } 488 | 489 | const proxy = httpProxy.createProxyServer( {} ); // See (†) 490 | export async function proxyRequestToHash( req: any, res: any ) { 491 | const { commitHash, runEnv } = req.session; 492 | let port = await getPortForContainer( commitHash, runEnv ); 493 | 494 | if ( ! port ) { 495 | l.info( { port, commitHash, runEnv }, `Could not find port for commitHash` ); 496 | res.send( 'Error setting up port!' ); 497 | res.end(); 498 | return; 499 | } 500 | 501 | touchCommit( commitHash, runEnv ); 502 | proxy.web( req, res, { target: `http://localhost:${ port }` }, err => { 503 | if ( err && ( err as any ).code === 'ECONNRESET' ) { 504 | return; 505 | } 506 | l.info( { err, req, res, commitHash }, 'unexpected error occured while proxying' ); 507 | } ); 508 | } 509 | 510 | export function getContainerName( container: ContainerInfo ) { 511 | // The first character is a `/`, skip it 512 | return container.Names[ 0 ].substring( 1 ); 513 | } 514 | 515 | export function findContainer( { 516 | id, 517 | image, 518 | env, 519 | status, 520 | name, 521 | sanitizedName, 522 | }: ContainerSearchOptions ) { 523 | return Array.from( state.containers.values() ).find( container => { 524 | if ( image && ( container.Image !== image && container.ImageID !== image ) ) return false; 525 | if ( env && container.Labels[ 'calypsoEnvironment' ] !== env ) return false; 526 | if ( status && container.Status !== status ) return false; 527 | if ( id && container.Id !== id ) return false; 528 | // In the Docker internal list, names start with `/` 529 | if ( name && ! container.Names.includes( '/' + name ) ) return false; 530 | 531 | // Sanitized name is the URL friendly version of the container's name (`_` got replaced with `-`) 532 | if ( 533 | sanitizedName && 534 | ! container.Names.map( name => name.replace( /_/g, '-' ).substr( 1 ) ).includes( 535 | sanitizedName 536 | ) 537 | ) { 538 | return false; 539 | } 540 | 541 | return true; 542 | } ); 543 | } 544 | 545 | export async function proxyRequestToContainer( req: any, res: any, container: ContainerInfo ) { 546 | // In the Docker internal list, names start with `/` 547 | const containerName = getContainerName( container ); 548 | 549 | if ( ! container.Ports[ 0 ] ) { 550 | l.info( { containerName }, `Could not find port for container` ); 551 | throw new Error( `Could not find port for container ${ containerName }` ); 552 | } 553 | const port = container.Ports[ 0 ].PublicPort; 554 | 555 | let retryCounter = config.proxyRetry; 556 | const proxyToContainer = () => 557 | proxy.web( req, res, { target: `http://localhost:${ port }` }, errorHandler ); 558 | 559 | const errorHandler = ( err: any ) => { 560 | if ( err && err.code === 'ECONNRESET' ) { 561 | retryCounter--; 562 | if ( retryCounter > 0 ) { 563 | l.warn( { err, containerName }, `ECONNRESET error occured while proxying, retrying (${retryCounter} retries left)` ); 564 | setTimeout( proxyToContainer, 1000 ); 565 | return 566 | } else { 567 | l.error( { err, containerName }, 'ECONNRESET error occured while proxying, no more retries left' ); 568 | } 569 | } 570 | 571 | throw new Error( 'unexpected error occured while proxying' ); 572 | }; 573 | touchContainer( containerName ); 574 | proxyToContainer(); 575 | } 576 | 577 | /** 578 | * Pulls an image. Calls onProgress() when there is an update, resolves the returned promise 579 | * when the image is pulled 580 | */ 581 | export async function pullImage( imageName: ImageName, onProgress: ( data: any ) => void ):Promise { 582 | // Store the stream in memory, so other requets can "join" and listen for the progress 583 | if ( ! state.pullingImages.has( imageName ) ) { 584 | const stream = docker.pull( imageName, {} ) as Promise< DockerodeStream >; 585 | state.pullingImages.set( imageName, stream ); 586 | } 587 | 588 | const stream = state.pullingImages.get( imageName ); 589 | return new Promise( async ( resolve, reject ) => { 590 | try { 591 | docker.modem.followProgress( 592 | await stream, 593 | ( err: any ) => { 594 | state.pullingImages.delete( imageName ); 595 | if ( err ) reject( err ); 596 | else resolve(); 597 | }, 598 | onProgress 599 | ); 600 | } catch( err ) { 601 | reject(err); 602 | } 603 | } ); 604 | } 605 | 606 | /** 607 | * Asks a container nicely to stop, waits for 10 seconds and then obliterates it 608 | */ 609 | export async function deleteContainer( containerInfo: ContainerInfo ) { 610 | const container = docker.getContainer( containerInfo.Id ); 611 | if ( containerInfo.State === 'running' ) { 612 | await container.stop( { t: 10 } ); 613 | } 614 | await container.remove( { force: true } ); 615 | await refreshContainers(); 616 | } 617 | 618 | /** 619 | * Creates a container 620 | * 621 | * createContainer is async, but we don't keep a list of container being creates to ensure atomicity for a few reasons: 622 | * 623 | * - Creating container is quite fast (a few ms), so the chances of collisions are quite low 624 | * - Even if we get two requests with the same image+env at the same time, creating two separate containers for the same 625 | * image is ok. Each one will get a different URL, and if one of them is not used it will get eventually cleaned up. 626 | */ 627 | export async function createContainer( imageName: ImageName, env: RunEnv ) { 628 | const exposedPort = `${ config.build.exposedPort }/tcp`; 629 | 630 | let freePort: number; 631 | try { 632 | freePort = await getPort(); 633 | } catch ( err ) { 634 | throw new ImageError(imageName, `Error while attempting to find a free port: ${err.message}`); 635 | } 636 | 637 | try { 638 | const container = await docker.createContainer( { 639 | ...config.build.containerCreateOptions, 640 | ...envContainerConfig( env ), 641 | Image: imageName, 642 | ExposedPorts: { [ exposedPort ]: {} }, 643 | HostConfig: { 644 | PortBindings: { [ exposedPort ]: [ { HostPort: freePort.toString() } ] }, 645 | }, 646 | Labels: { 647 | calypsoEnvironment: env, 648 | }, 649 | } ); 650 | l.info( { imageName }, `Successfully created container for ${ imageName }` ); 651 | await refreshContainers(); 652 | 653 | // Returns a ContainerInfo for the created container, in order to avoid exposing a real Container object. 654 | return findContainer( { 655 | id: container.id, 656 | } ); 657 | } catch ( error ) { 658 | throw new ImageError(imageName, `Failed creating container: ${error.message}`); 659 | } 660 | } 661 | 662 | /** 663 | * Starts a container that was dormant (either never started, or stopped) 664 | */ 665 | export async function reviveContainer( containerInfo: ContainerInfo ) { 666 | const containerName = getContainerName( containerInfo ); 667 | const container = docker.getContainer( containerInfo.Id ); 668 | 669 | // Try to start the container. Due concurrent requests the container 670 | // maybe alredy be starting at this point anyway. 671 | try { 672 | await container.start(); 673 | l.info( { containerName }, `Successfully started container` ); 674 | } catch(error) { 675 | if (error.statusCode === 304) { 676 | l.info( { containerName }, `Container already started` ); 677 | } else { 678 | throw new ContainerError(containerName, `Failed reviving ${containerInfo.State} container: ${error.message}`); 679 | } 680 | } 681 | 682 | // Refresh intenerl info about container and return the updated info for this container 683 | await refreshContainers(); 684 | return findContainer( { id: container.id } ); 685 | } 686 | -------------------------------------------------------------------------------- /src/app/app-shell.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { start } from 'repl'; 3 | import { humanRelativeTime } from './util'; 4 | import { pendingHashes, buildQueue } from '../builder'; 5 | 6 | class Reloader extends React.Component< { milliseconds: number } > { 7 | render() { 8 | return ( 9 |
13 | setTimeout(() => window.location.reload(), ${ this.props.milliseconds }); 14 | 15 | `, 16 | } } 17 | /> 18 | ); 19 | } 20 | } 21 | 22 | export const Shell = ( { refreshInterval, startedServerAt, showReset = false, children }: any ) => ( 23 | 24 | 25 | { 'number' === typeof refreshInterval && } 26 | 50 | `, 51 | } } 52 | /> 53 |
54 | 			{ buildLog &&  }
55 | 			{ message && 

{ message }

} 56 |
57 | 58 | ); 59 | 60 | type RenderContext = { buildLog?: string; message?: string; startedServerAt: Date }; 61 | export default function renderApp( renderContext: RenderContext ) { 62 | return ReactDOMServer.renderToStaticNodeStream( ); 63 | } 64 | -------------------------------------------------------------------------------- /src/app/local-images.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOMServer from 'react-dom/server'; 3 | import Docker from 'dockerode'; 4 | import { config } from '../config'; 5 | 6 | import { Shell } from './app-shell'; 7 | import { humanSize, humanRelativeTime } from './util'; 8 | import { BranchName, CommitHash } from '../api'; 9 | import { ONE_MINUTE, ONE_SECOND } from '../constants'; 10 | 11 | const LocalImages = ( { 12 | branchHashes, 13 | knownBranches, 14 | localImages, 15 | startedServerAt, 16 | }: RenderContext ) => ( 17 | 0 ? ONE_MINUTE : 5 * ONE_SECOND } 19 | startedServerAt={ startedServerAt } 20 | > 21 |