├── logs └── .gitkeep ├── repos └── .gitkeep ├── .nvmrc ├── .prettierignore ├── .dockerignore ├── renovate.json ├── logo.png ├── .gitignore ├── .vscode ├── extensions.json ├── tasks.json ├── settings.json └── launch.json ├── Makefile ├── .prettierrc ├── src ├── types.d.ts ├── constants.ts ├── stats.ts ├── daemon.ts ├── error.ts ├── app │ ├── log-details.tsx │ ├── index.tsx │ ├── log.tsx │ ├── util.ts │ ├── app-shell.tsx │ ├── local-images.tsx │ └── debug.tsx ├── logger.ts ├── config.ts ├── middlewares.ts ├── image-runner.ts ├── builder.ts ├── index.ts └── api.ts ├── test ├── __mocks__ │ └── nodegit.js ├── api.test.ts └── logger.test.ts ├── tsconfig.json ├── Dockerfile ├── .circleci └── config.yml ├── package.json └── README.md /logs/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /repos/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v12.16.3 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .git 3 | logs 4 | repos 5 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ "config:base" ] 3 | } 4 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Automattic/dserve/HEAD/logo.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | yarn-error.log 3 | build 4 | logs/* 5 | repos/* 6 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "eg2.tslint", 4 | "streetsidesoftware.code-spell-checker" 5 | ] 6 | } -------------------------------------------------------------------------------- /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 -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /src/types.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'docker-parse-image' { 2 | export default function parse( 3 | imgName: string 4 | ): { 5 | registry: string; 6 | namespace: string; 7 | repository: string; 8 | tag: string; 9 | }; 10 | } 11 | -------------------------------------------------------------------------------- /test/__mocks__/nodegit.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Since nodegit is a native module, it gives jest all kinds of hell. 3 | * This makes it so that it never actually gets used in any test...whatsoever 4 | */ 5 | module.exports = jest.fn(); 6 | module.exports.enableThreadSafety = jest.fn(); -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const ONE_SECOND = 1000; 2 | export const ONE_MINUTE = 60 * ONE_SECOND; 3 | export const FIVE_MINUTES = 5 * ONE_MINUTE; 4 | export const TEN_MINUTES = 10 * ONE_MINUTE; 5 | export const CONTAINER_EXPIRY_TIME = 60 * ONE_MINUTE; 6 | 7 | export const START_TIME = Date.now(); 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es6", 5 | "noImplicitAny": true, 6 | "removeComments": true, 7 | "sourceMap": true, 8 | "outDir": "build", 9 | "jsx": "react", 10 | "lib": [ "es7", "dom" ], 11 | "esModuleInterop": true 12 | }, 13 | "include": [ "src/*" ] 14 | } 15 | -------------------------------------------------------------------------------- /.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 | } -------------------------------------------------------------------------------- /.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 | } -------------------------------------------------------------------------------- /src/stats.ts: -------------------------------------------------------------------------------- 1 | import { StatsD } from 'hot-shots'; 2 | 3 | const statsd = new StatsD( { 4 | host: process.env.STATSD_HOST || 'localhost', 5 | port: +process.env.STATSD_PORT || 8125, 6 | } ); 7 | 8 | export function increment( stat: string ) { 9 | statsd.increment( `dserve.${ stat }` ); 10 | } 11 | 12 | export function decrement( stat: string ) { 13 | statsd.decrement( `dserve.${ stat }` ); 14 | } 15 | 16 | export function gauge( stat: string, value: number ) { 17 | statsd.gauge( `dserve.${ stat }`, value ); 18 | } 19 | 20 | export function timing( stat: string, value: number ) { 21 | statsd.timing( `dserve.${ stat }`, value ); 22 | } 23 | -------------------------------------------------------------------------------- /src/daemon.ts: -------------------------------------------------------------------------------- 1 | import forever from 'forever-monitor'; 2 | 3 | import { l } from './logger'; 4 | 5 | const child = new forever.Monitor( 'build/index.js', { 6 | watch: false, 7 | silent: false, 8 | max: Infinity, 9 | minUptime: 2000, 10 | } ); 11 | 12 | child.on( 'error', err => { 13 | l.error( { err }, 'forever: Error during run' ); 14 | } ); 15 | 16 | child.on( 'restart', () => { 17 | l.info( 'forever: Restarting' ); 18 | } ); 19 | 20 | child.on( 'exit:code', ( code, signal ) => { 21 | l.info( { code, signal }, 'forever: exited child', code, signal ); 22 | } ); 23 | 24 | child.on( 'exit', ( child, spinning ) => { 25 | l.info( { child, spinning }, 'forever: really exited', child, spinning ); 26 | } ); 27 | 28 | child.on( 'stop', childData => { 29 | l.info( { data: childData }, 'forever: child stopping' ); 30 | } ); 31 | 32 | child.start(); 33 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /src/error.ts: -------------------------------------------------------------------------------- 1 | 2 | export class ContainerError extends Error { 3 | containerName: string; 4 | constructor(containerName:string, message:string) { 5 | super(message); 6 | this.containerName = containerName; 7 | Error.captureStackTrace(this, ContainerError); 8 | } 9 | } 10 | 11 | export class ImageError extends Error { 12 | imageName: string; 13 | constructor(imageName:string, message:string) { 14 | super(message); 15 | this.imageName = imageName; 16 | Error.captureStackTrace(this, ImageError); 17 | } 18 | } 19 | 20 | export class ImageNotFound extends ImageError { 21 | constructor(name:string) { 22 | super(name, "Docker image not found"); 23 | } 24 | } 25 | 26 | export class InvalidImage extends ImageError { 27 | constructor(name:string) { 28 | super(name, "Image is invalid"); 29 | } 30 | } 31 | 32 | export class InvalidRegistry extends Error { 33 | registry: string; 34 | constructor(registry:string) { 35 | super("Docker registry is invalid"); 36 | this.registry = registry; 37 | Error.captureStackTrace(this, InvalidRegistry); 38 | } 39 | } 40 | 41 | 42 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /src/app/log-details.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { humanTimeSpan } from './util'; 3 | 4 | const interestingDetails = new Set( [ 5 | 'buildConcurrency', 6 | 'buildImageTime', 7 | 'checkoutTime', 8 | 'cloneTime', 9 | 'code', 10 | 'commitHash', 11 | 'containerId', 12 | 'data', 13 | 'err', 14 | 'error', 15 | 'freePort', 16 | 'imageName', 17 | 'reason', 18 | 'signal', 19 | 'success', 20 | ] ); 21 | 22 | const serialize = ( value: any, key: string ) => { 23 | switch ( key ) { 24 | case 'buildImageTime': 25 | case 'checkoutTime': 26 | case 'cloneTime': 27 | return humanTimeSpan( +value ); 28 | case 'commitHash': 29 | return ( 30 | 31 | 32 | { value.substr( 0, 8 ) } 33 | {' '} 34 | (github) 35 | 36 | ); 37 | default: 38 | return typeof value === 'object' ? JSON.stringify( value, null, 2 ) : value.toString(); 39 | } 40 | }; 41 | 42 | const LogDetails = ( { data, details }: any ) => { 43 | details = details || interestingDetails; 44 | const detailsToShow = new Map(); 45 | for ( let detail of details ) { 46 | if ( data[ detail ] ) { 47 | detailsToShow.set( detail, data[ detail ] ); 48 | } 49 | } 50 | if ( detailsToShow.size === 0 ) { 51 | return null; 52 | } 53 | return ( 54 |
55 | { Array.from( detailsToShow.entries() ).map( ( [ key, value ] ) => ( 56 |
57 | 					{ key }: { serialize( value, key ) }
58 | 				
59 | ) ) } 60 |
61 | ); 62 | }; 63 | 64 | export default LogDetails; 65 | -------------------------------------------------------------------------------- /src/app/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOMServer from 'react-dom/server'; 3 | import { ONE_SECOND } from '../constants'; 4 | 5 | import { Shell } from './app-shell'; 6 | import stripAnsi from 'strip-ansi'; 7 | import LogDetails from './log-details'; 8 | 9 | const BUILD_LOG_DETAILS = new Set( [ 'error', 'err' ] ); 10 | 11 | class BuildLog extends React.Component< { log: string } > { 12 | render() { 13 | const { log } = this.props; 14 | const formattedLog = log 15 | .trim() 16 | .split( '\n' ) 17 | .map( str => { 18 | try { 19 | const line = JSON.parse( str ); 20 | return [ line, `Time=${ line.time } | ${ line.msg }` ]; 21 | } catch ( err ) { 22 | return [ {}, '' ]; 23 | } 24 | } ) 25 | .map( ( [ data, str ], i ) => ( 26 |
  • 27 | { stripAnsi( str ) } 28 | 29 |
  • 30 | ) ); 31 | return
      { formattedLog }
    ; 32 | } 33 | } 34 | 35 | const App = ( { buildLog, message, startedServerAt }: RenderContext ) => ( 36 | 37 |
    41 | .dserve-toolbar a { 42 | transition: background 200ms ease-in; 43 | } 44 | 45 | @keyframes progress-bar-animation { 46 | 0% { background-position: 400px 50px; } 47 | 100% { } 48 | } 49 | 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/logger.ts: -------------------------------------------------------------------------------- 1 | import bunyan from 'bunyan'; 2 | import _ from 'lodash'; 3 | import { Writable } from 'stream'; 4 | 5 | import { CommitHash, getImageName } from './api'; 6 | import { getLogPath } from './builder'; 7 | import { config } from './config'; 8 | 9 | export const ringbuffer = new bunyan.RingBuffer( { limit: 1000 } ); 10 | 11 | const dserveLogger = bunyan.createLogger( { 12 | name: 'dserve', 13 | streams: [ 14 | { 15 | type: 'rotating-file', 16 | path: './logs/log.txt', 17 | period: '1d', 18 | count: 7, 19 | }, 20 | { 21 | level: bunyan.DEBUG, 22 | type: 'raw', 23 | stream: ringbuffer, 24 | }, 25 | { 26 | stream: process.stdout, 27 | level: bunyan.DEBUG, 28 | }, 29 | ], 30 | serializers: bunyan.stdSerializers, // allows one to use err, req, and res as special keys 31 | src: true, 32 | } ); 33 | 34 | /* super convenient name */ 35 | export const l = dserveLogger; 36 | 37 | /** 38 | * Creates a child logger that outputs to the build directory and 39 | * outputs errors to the console. 40 | * 41 | * @param commitHash - hash to make logger for 42 | */ 43 | export function getLoggerForBuild( commitHash: CommitHash ) { 44 | const path = getLogPath( commitHash ); 45 | const logger = dserveLogger.child( { 46 | streams: [ 47 | { 48 | type: 'file', 49 | path, 50 | }, 51 | { 52 | stream: process.stdout, 53 | level: bunyan.INFO, 54 | }, 55 | ], 56 | commitHash, 57 | imageName: getImageName( commitHash ), 58 | } ); 59 | 60 | // we want it to be a child so that 61 | // it inherits al the same properties as the parent 62 | // except we don't want any of the parents streams 63 | // so this line removes them all 64 | // @ts-ignore this needs to be fixed with proper typing 65 | ( ( logger as any ) as Logger ).streams = _.filter( ( logger as any ).streams, { path } ); 66 | 67 | return logger; 68 | } 69 | 70 | type Logger = { streams: Array< { stream: Writable; type: string } > }; 71 | export function closeLogger( logger: Logger ) { 72 | logger.streams.forEach( stream => { 73 | stream.stream.end(); 74 | } ); 75 | } 76 | -------------------------------------------------------------------------------- /test/api.test.ts: -------------------------------------------------------------------------------- 1 | import { getExpiredContainers, getImageName, state } from '../src/api'; 2 | 3 | import { CONTAINER_EXPIRY_TIME } from '../src/constants'; 4 | 5 | describe( 'api', () => { 6 | describe( 'getExpiredContainers', () => { 7 | const RealNow = Date.now; 8 | const fakeNow = RealNow() + 24 * 60 * 1000; 9 | Date.now = () => fakeNow; 10 | 11 | afterAll( () => { 12 | Date.now = RealNow; 13 | } ); 14 | 15 | const EXPIRED_TIME = Date.now() - CONTAINER_EXPIRY_TIME - 1; 16 | const GOOD_TIME = Date.now() - CONTAINER_EXPIRY_TIME + 1; 17 | const images = [ 18 | { Image: getImageName( '1' ), Id: 1, Created: EXPIRED_TIME / 1000, Names: [ '/foo' ] }, 19 | { Image: getImageName( '2' ), Id: 1, Created: EXPIRED_TIME / 1000, Names: [ '/bar' ] }, 20 | ]; 21 | 22 | afterEach( () => { 23 | state.accesses = new Map(); 24 | state.containers = new Map(); 25 | } ); 26 | 27 | beforeEach( () => { 28 | state.containers = new Map( images.map( image => [ image.Image, { ...image } ] ) as any ); 29 | } ); 30 | 31 | test( 'returns nothing for empty list of containers', () => { 32 | state.containers = new Map(); 33 | expect( getExpiredContainers() ).toEqual( [] ); 34 | } ); 35 | 36 | test( 'returns the whole list if everything is expired', () => { 37 | expect( getExpiredContainers() ).toEqual( images ); 38 | } ); 39 | 40 | test.only( 'returns empty list if everything was accessed before expiry', () => { 41 | state.accesses.set( 'foo', GOOD_TIME ); 42 | state.accesses.set( 'bar', GOOD_TIME ); 43 | 44 | expect( getExpiredContainers() ).toEqual( [] ); 45 | } ); 46 | 47 | test( 'returns list of only images that have not expired', () => { 48 | state.accesses.set( 'foo', Date.now() ); 49 | 50 | expect( getExpiredContainers() ).toEqual( [ state.containers.get( getImageName( '2' ) ) ] ); 51 | } ); 52 | 53 | test( 'young images are not returned, regardless of access time', () => { 54 | state.containers.get( getImageName( '1' ) ).Created = Date.now() / 1000; 55 | 56 | expect( getExpiredContainers() ).toEqual( [ state.containers.get( getImageName( '2' ) ) ] ); 57 | } ); 58 | } ); 59 | } ); 60 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import Dockerode from 'dockerode'; 2 | import { DockerRepository, RunEnv } from './api'; 3 | 4 | type Readonly< T > = { readonly [ P in keyof T ]: T[ P ] }; 5 | type AppConfig = Readonly< { 6 | build: BuildConfig; 7 | repo: RepoConfig; 8 | envs: EnvsConfig; 9 | allowedDockerRepositories: AllowedDockerRepositories; 10 | allowedLabels: AllowedLabels; 11 | proxyRetry: number; 12 | } >; 13 | 14 | type BuildConfig = Readonly< { 15 | containerCreateOptions?: Dockerode.ContainerCreateOptions; 16 | exposedPort: number; 17 | logFilename: string; 18 | tagPrefix: string; 19 | } >; 20 | 21 | type RepoConfig = Readonly< { 22 | project: string; 23 | } >; 24 | 25 | type EnvsConfig = Readonly< RunEnv[] >; 26 | 27 | type AllowedDockerRepositories = Readonly< DockerRepository[] >; 28 | 29 | type AllowedLabels = Readonly< Record< string, string > >; 30 | 31 | export const config: AppConfig = { 32 | build: { 33 | containerCreateOptions: {}, 34 | exposedPort: 3000, 35 | logFilename: 'dserve-build-log.txt', 36 | tagPrefix: 'dserve-wpcalypso', 37 | }, 38 | 39 | repo: { 40 | project: 'Automattic/wp-calypso', 41 | }, 42 | 43 | envs: [ 'calypso', 'jetpack', 'a8c-for-agencies', 'dashboard' ], 44 | 45 | allowedDockerRepositories: [ 'registry.a8c.com' ], 46 | 47 | allowedLabels: { 48 | 'com.a8c.target': 'calypso-live', 49 | }, 50 | 51 | // When the proxy to the container fails with a ECONNRESET error, retry this number 52 | // of times. 53 | proxyRetry: 3, 54 | }; 55 | 56 | export function envContainerConfig( environment: RunEnv ): Dockerode.ContainerCreateOptions { 57 | switch ( environment ) { 58 | case 'calypso': 59 | default: 60 | return { 61 | Env: [ 'NODE_ENV=wpcalypso', 'CALYPSO_ENV=wpcalypso' ], 62 | }; 63 | case 'jetpack': 64 | return { 65 | Env: [ 'NODE_ENV=jetpack-cloud-horizon', 'CALYPSO_ENV=jetpack-cloud-horizon' ], 66 | }; 67 | case 'a8c-for-agencies': 68 | return { 69 | Env: [ 'NODE_ENV=a8c-for-agencies-horizon', 'CALYPSO_ENV=a8c-for-agencies-horizon' ], 70 | }; 71 | case 'dashboard': 72 | return { 73 | Env: [ 'NODE_ENV=dashboard-horizon', 'CALYPSO_ENV=dashboard-horizon' ], 74 | }; 75 | 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /test/logger.test.ts: -------------------------------------------------------------------------------- 1 | jest.mock( 'bunyan', () => ( { 2 | createLogger: jest.fn( () => ( { 3 | info: () => {}, 4 | warn: () => {}, 5 | error: () => {}, 6 | } ) ), 7 | RingBuffer: jest.fn(), 8 | } ) ); 9 | 10 | describe( 'logger', () => { 11 | let logger: any; 12 | let createLogger: any; 13 | beforeEach( () => { 14 | jest.clearAllMocks(); 15 | jest.resetModules(); 16 | logger = { 17 | info: jest.fn(), 18 | warn: jest.fn(), 19 | error: jest.fn(), 20 | child: jest.fn( options => ( { streams: options.streams } ) ), 21 | }; 22 | createLogger = () => logger; 23 | } ); 24 | 25 | describe( '#closeLogger', () => { 26 | test( 'should close all of the streams in a logger logger', () => {} ); 27 | } ); 28 | 29 | describe( 'l', () => { 30 | test( 'should only make one base logger', () => { 31 | const bunyan = require( 'bunyan' ); 32 | const { l, closeLogger, getLoggerForBuild } = require( '../src/logger' ); 33 | expect( bunyan.createLogger.mock.calls.length ).toBe( 1 ); 34 | expect( bunyan.createLogger.mock.instances.length ).toBe( 1 ); 35 | } ); 36 | 37 | test( 'l should call the underlying loggers info and error functions', () => { 38 | jest.setMock( 'bunyan', { createLogger, RingBuffer: jest.fn() } ); 39 | const { l, closeLogger, getLoggerForBuild } = require( '../src/logger' ); 40 | 41 | l.info( 'testLog' ); 42 | l.warn( 'testLog' ); 43 | l.error( 'testLog' ); 44 | 45 | expect( logger.info.mock.calls.length ).toBe( 1 ); 46 | expect( logger.warn.mock.calls.length ).toBe( 1 ); 47 | expect( logger.error.mock.calls.length ).toBe( 1 ); 48 | } ); 49 | } ); 50 | 51 | describe( '#getLoggerForBuild', () => { 52 | test( 'should make a child logger', () => { 53 | const bunyan = require( 'bunyan' ); 54 | const { l, closeLogger, getLoggerForBuild } = require( '../src/logger' ); 55 | getLoggerForBuild( 'build-hash' ); 56 | expect( logger.child.mock.calls.length ).toBe( 1 ); 57 | } ); 58 | 59 | test( 'should write to a file at the correct path', () => { 60 | const bunyan = require( 'bunyan' ); 61 | const { l, closeLogger, getLoggerForBuild } = require( '../src/logger' ); 62 | const childLogger = getLoggerForBuild( 'build-hash' ); 63 | expect( childLogger.streams ).toEqual( [ 64 | { 65 | path: expect.stringContaining( 66 | 'dserve-build-Automattic-wp-calypso-build-hash/dserve-build-log.txt' 67 | ), 68 | type: 'file', 69 | }, 70 | ] ); 71 | } ); 72 | } ); 73 | } ); 74 | -------------------------------------------------------------------------------- /src/app/log.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOMServer from 'react-dom/server'; 3 | 4 | import { Shell } from './app-shell'; 5 | import { errorClass, humanRelativeTime } from './util'; 6 | import { ONE_MINUTE } from '../constants'; 7 | import LogDetails from './log-details'; 8 | 9 | const Log = ( { log, startedServerAt }: RenderContext ) => { 10 | return ( 11 | 12 |