import * as _ from 'lodash' ; import * as path from 'path' ; import * as stream from 'stream' ; import * as EventStream from 'event-stream' ; import * as getRawBody from 'raw-body' ; import maybeGunzip = require ( 'gunzip-maybe' ) ; import * as tarStream from 'tar-stream' ; import * as tarFs from 'tar-fs' ; import { parse as parseDockerfile , CommandEntry } from 'docker-file-parser' ; import { getTerminalEnvVars , OVERRIDES_DIR } from '../terminal/terminal-env-overrides' ; import { getDeferred } from '../../util/promise' ; import { DOCKER_HOST_HOSTNAME } from './docker-commands' ; const HTTP_TOOLKIT_INJECTED_PATH = '/http-toolkit-injections' ; const HTTP_TOOLKIT_INJECTED_OVERRIDES_PATH = path . posix . join ( HTTP_TOOLKIT_INJECTED_PATH , 'overrides' ) ; const HTTP_TOOLKIT_INJECTED_CA_PATH = path . posix . join ( HTTP_TOOLKIT_INJECTED_PATH , 'ca.pem' ) ; const HTTP_TOOLKIT_CONTEXT_PATH = '/.http-toolkit-injections' ; const HTTP_TOOLKIT_CONTEXT_OVERRIDES_PATH = path . posix . join ( HTTP_TOOLKIT_CONTEXT_PATH , 'overrides' ) ; const HTTP_TOOLKIT_CONTEXT_CA_PATH = path . posix . join ( HTTP_TOOLKIT_CONTEXT_PATH , 'ca.pem' ) ; export const DOCKER_BUILD_LABEL = 'tech.httptoolkit.docker.build-proxy' ; /**
 * Take a build context stream, and transform it to inject into the build itself via
 * the Dockerfile. Supports gzipped & raw tarballs.
 */ export function injectIntoBuildStream ( dockerfileName , buildStream , config ) { const extractionStream = tarStream . extract ( ) ; const repackStream = tarStream . pack ( ) ; let commandsAddedToDockerfile = getDeferred < number > ( ) ; const dockerfileConfig = { ... config , envVars : getTerminalEnvVars ( config . proxyPort , { certPath : HTTP_TOOLKIT_INJECTED_CA_PATH } , 'runtime-inherit' , { httpToolkitIp : DOCKER_HOST_HOSTNAME , overridePath : HTTP_TOOLKIT_INJECTED_OVERRIDES_PATH , targetPlatform : 'linux' } ) } ; extractionStream . on ( 'entry' , async ( headers , entryStream , next ) => { if ( headers . name === dockerfileName ) { const originalDockerfile = ( await getRawBody ( entryStream ) ) . toString ( 'utf-8' ) ; const dockerfileTransformation = injectIntoDockerfile ( originalDockerfile , dockerfileConfig ) ; commandsAddedToDockerfile . resolve ( dockerfileTransformation . commandsAdded ) ; repackStream . entry ( _ . omit ( headers , 'size' ) , dockerfileTransformation . dockerfile , next ) ; } else { entryStream . pipe ( repackStream . entry ( headers , next ) ) ; } } ) ; extractionStream . on ( 'finish' , async ( ) => { repackStream . entry ( { name : HTTP_TOOLKIT_CONTEXT_CA_PATH } , config . certContent ) ; await packOverrideFiles ( repackStream ) ; repackStream . finalize ( ) ; } ) ; buildStream . pipe ( maybeGunzip ( ) ) . pipe ( extractionStream ) ; return { injectedStream : repackStream , totalCommandsAddedPromise : Promise . race < number > ( [ commandsAddedToDockerfile . promise , new Promise ( ( resolve ) => extractionStream . on ( 'finish' , resolve ) ) . then ( ( ) => 0 ) ] ) } ; } function packOverrideFiles ( existingPackStream ) { return new Promise < tarStream . Pack > ( ( resolve ) => { tarFs . pack ( OVERRIDES_DIR , { pack : existingPackStream , map : ( fileHeader ) => { fileHeader . name = path . posix . join ( HTTP_TOOLKIT_CONTEXT_OVERRIDES_PATH , fileHeader . name ) ; fileHeader . uid = 0 ; fileHeader . gid = 0 ; fileHeader . mode = parseInt ( '555' , 8 ) ; return fileHeader ; } , finalize : false , finish : resolve } ) ; } ) ; } type DockerCommand = Pick < CommandEntry , 'name' | 'args' > & { raw ? : string ; } ; export function injectIntoDockerfile ( dockerfileContents , config ) { const dockerCommands = parseDockerfile ( dockerfileContents , { includeComments : true } ) ; const fromCommandIndexes = dockerCommands . map ( ( command , index ) => command . name === 'FROM' ? index : - 1 ) . filter ( ( index ) => index !== - 1 ) ; const injectionCommands = [ { name : 'LABEL' , args : [ `${ DOCKER_BUILD_LABEL }=started-${ config . proxyPort }` ] } , { name : 'COPY' , args : [ HTTP_TOOLKIT_CONTEXT_PATH , HTTP_TOOLKIT_INJECTED_PATH ] } , ... ( ! _ . isEmpty ( config . envVars ) ? [ { name : 'ENV' , args : config . envVars } ] : [ ] ) , { name : 'LABEL' , args : [ `${ DOCKER_BUILD_LABEL }=${ config . proxyPort }` ] } ] ; fromCommandIndexes . reverse ( ) . forEach ( ( fromIndex ) => { dockerCommands . splice ( fromIndex + 1 , 0 , ... injectionCommands ) ; } ) ; const commandsAdded = fromCommandIndexes . length * injectionCommands . length ; return { dockerfile : generateDockerfileFromCommands ( dockerCommands ) , commandsAdded } ; } function generateDockerfileFromCommands ( commands ) { return commands . map ( command => { if ( command . raw ) { return command . raw ; } else if ( command . name === 'COMMENT' ) { return command . args as string ; } else { return `${ command . name } ${ argsToString ( command . args , command . name ) }` ; } } ) . join ( '\n' ) ; } const SPACE_SEPARATED_ARRAY_COMMANDS = [ 'ARG' , 'EXPOSE' , 'LABEL' ] ; function argsToString ( args , commandName ) { if ( _ . isArray ( args ) ) { let ret = '' ; if ( args [ 0 ] != null && args [ 0 ] . startsWith ( '--' ) ) { ret += args [ 0 ] + ' ' ; args = args . slice ( 1 ) ; } if ( SPACE_SEPARATED_ARRAY_COMMANDS . includes ( commandName ) ) { return ret + args . join ( ' ' ) ; } return ret + '["' + ( args as string [ ] ) . join ( '","' ) + '"]' ; } else if ( _ . isObject ( args ) ) { return _ . map ( args , ( value , key ) => { const escapedValue = JSON . stringify ( value ) ; return `${ key }=${ escapedValue }` ; } ) . join ( ' ' ) ; } else { return args as string ; } } ; const END_OF_STEP_REGEX = /^ ---\u003e [a-z0-9]+\n$/ ; /**
 * Takes a response stream of a Docker build (i.e. build output) and transforms it to simplify all the
 * HTTP Toolkit interception noise down to a single clear line, and normalize the step count text
 * to match.
 */ export function getBuildOutputPipeline ( extraDockerCommandCount ) { let outputToHide = 'none' ; let totalStepCount = - 1 ; let step = 0 ; return EventStream . pipeline ( EventStream . split ( ) , EventStream . mapSync ( ( rawLine ) => { if ( ! rawLine ?. toString ( ) ) return rawLine ; const data = JSON . parse ( rawLine . toString ( ) ) as { "stream" ? : string ; } ; if ( isStepLine ( data . stream ) && totalStepCount === - 1 ) { totalStepCount = getTotalSteps ( data . stream ) - extraDockerCommandCount ; } if ( data . stream ?. includes ( `LABEL ${ DOCKER_BUILD_LABEL }=started` ) ) { outputToHide = 'all' ; return JSON . stringify ( { stream : " *** Enabling HTTP Toolkit interception ***\n" } ) ; } else if ( outputToHide === 'all' ) { if ( data . stream ?. includes ( `LABEL ${ DOCKER_BUILD_LABEL }=` ) ) { outputToHide = 'until-next' ; } return "" ; } else if ( outputToHide === 'until-next' && data . stream ) { if ( ! data . stream . match ( END_OF_STEP_REGEX ) ) { return "" ; } outputToHide = 'none' ; } if ( isStepLine ( data . stream ) ) { step += 1 ; data . stream = replaceSteps ( data . stream , step , totalStepCount ) ; } return JSON . stringify ( data ) ; } ) ) ; } const stepLineRegex = /^step\s\d+\/(\d+)\s?:/i ; const isStepLine = ( str ) : str is string => ! ! str ?. match ( stepLineRegex ) ; const getTotalSteps = ( stepLine ) => { const stepLineMatch = stepLineRegex . exec ( stepLine ) ! ; if ( ! stepLineMatch ) throw new Error ( 'Unexpected non-Step X line in Docker output' ) ; return parseInt ( stepLineMatch [ 1 ] , 10 ) ; } ; const replaceSteps = ( stepLine , stepCount , totalSteps ) => { return stepLine . replace ( stepLineRegex , `Step ${ stepCount }/${ totalSteps } :` ) ; } ;