import * as core from '@actions/core' import * as fs from 'fs' import { ContainerInfo, JobContainerInfo, RunContainerStepArgs, ServiceContainerInfo, StepContainerInfo } from 'hooklib/lib' import path from 'path' import { env } from 'process' import { v4 as uuidv4 } from 'uuid' import { runDockerCommand, RunDockerCommandOptions } from '../utils' import { getRunnerLabel } from './constants' export async function createContainer( args: ContainerInfo, name: string, network: string ): Promise { if (!args.image) { throw new Error('Image was expected') } const dockerArgs: string[] = ['create'] dockerArgs.push(`--label=${getRunnerLabel()}`) dockerArgs.push(`--network=${network}`) if ((args as ServiceContainerInfo)?.contextName) { dockerArgs.push( `--network-alias=${(args as ServiceContainerInfo)?.contextName}` ) } dockerArgs.push('--name', name) if (args?.portMappings?.length) { for (const portMapping of args.portMappings) { dockerArgs.push('-p', portMapping) } } if (args.createOptions) { dockerArgs.push(...args.createOptions.split(' ')) } if (args.environmentVariables) { for (const [key] of Object.entries(args.environmentVariables)) { dockerArgs.push('-e') dockerArgs.push(`"${key}"`) } } const mountVolumes = [ ...(args.userMountVolumes || []), ...((args as JobContainerInfo | StepContainerInfo).systemMountVolumes || []) ] for (const mountVolume of mountVolumes) { dockerArgs.push( `-v=${mountVolume.sourceVolumePath}:${mountVolume.targetVolumePath}` ) } if (args.entryPoint) { dockerArgs.push(`--entrypoint`) dockerArgs.push(args.entryPoint) } dockerArgs.push(args.image) if (args.entryPointArgs) { for (const entryPointArg of args.entryPointArgs) { dockerArgs.push(entryPointArg) } } const id = (await runDockerCommand(dockerArgs)).trim() if (!id) { throw new Error('Could not read id from docker command') } const response: ContainerMetadata = { id, image: args.image } if (network) { response.network = network } response.ports = [] if ((args as ServiceContainerInfo).contextName) { response['contextName'] = (args as ServiceContainerInfo).contextName } return response } export async function containerPull( image: string, configLocation: string ): Promise { const dockerArgs: string[] = ['pull'] if (configLocation) { dockerArgs.push('--config') dockerArgs.push(configLocation) } dockerArgs.push(image) for (let i = 0; i < 3; i++) { try { await runDockerCommand(dockerArgs) return } catch { core.info(`docker pull failed on attempt: ${i + 1}`) } } throw new Error('Exiting docker pull after 3 failed attempts') } export async function containerStart(id: string): Promise { const dockerArgs: string[] = ['start'] dockerArgs.push(`${id}`) await runDockerCommand(dockerArgs) } export async function containerStop(id: string | string[]): Promise { const dockerArgs: string[] = ['stop'] if (Array.isArray(id)) { for (const v of id) { dockerArgs.push(v) } } else { dockerArgs.push(id) } await runDockerCommand(dockerArgs) } export async function containerRemove(id: string | string[]): Promise { const dockerArgs: string[] = ['rm'] dockerArgs.push('--force') if (Array.isArray(id)) { for (const v of id) { dockerArgs.push(v) } } else { dockerArgs.push(id) } await runDockerCommand(dockerArgs) } export async function containerBuild( args: RunContainerStepArgs, tag: string ): Promise { const context = path.dirname(`${env.GITHUB_WORKSPACE}/${args.dockerfile}`) const dockerArgs: string[] = ['build'] dockerArgs.push('-t', tag) dockerArgs.push('-f', `${env.GITHUB_WORKSPACE}/${args.dockerfile}`) dockerArgs.push(context) // TODO: figure out build working directory await runDockerCommand(dockerArgs, { workingDir: args['buildWorkingDirectory'] }) } export async function containerLogs(id: string): Promise { const dockerArgs: string[] = ['logs'] dockerArgs.push('--details') dockerArgs.push(id) await runDockerCommand(dockerArgs) } export async function containerNetworkRemove(network: string): Promise { const dockerArgs: string[] = ['network'] dockerArgs.push('rm') dockerArgs.push(network) await runDockerCommand(dockerArgs) } export async function containerNetworkPrune(): Promise { const dockerArgs = [ 'network', 'prune', '--force', '--filter', `label=${getRunnerLabel()}` ] await runDockerCommand(dockerArgs) } export async function containerPrune(): Promise { const dockerPSArgs: string[] = [ 'ps', '--all', '--quiet', '--no-trunc', '--filter', `label=${getRunnerLabel()}` ] const res = (await runDockerCommand(dockerPSArgs)).trim() if (res) { await containerRemove(res.split('\n')) } } async function containerHealthStatus(id: string): Promise { const dockerArgs = [ 'inspect', '--format="{{if .Config.Healthcheck}}{{print .State.Health.Status}}{{end}}"', id ] const result = (await runDockerCommand(dockerArgs)).trim().replace(/"/g, '') if ( result === ContainerHealth.Healthy || result === ContainerHealth.Starting || result === ContainerHealth.Unhealthy ) { return result } return ContainerHealth.None } export async function healthCheck({ id, image }: ContainerMetadata): Promise { let health = await containerHealthStatus(id) if (health === ContainerHealth.None) { core.info( `Healthcheck is not set for container ${image}, considered as ${ContainerHealth.Healthy}` ) return } let tries = 1 while (health === ContainerHealth.Starting && tries < 13) { const backOffSeconds = Math.pow(2, tries) core.info( `Container '${image}' is '${health}', retry in ${backOffSeconds} seconds` ) await new Promise(resolve => setTimeout(resolve, 1000 * backOffSeconds)) tries++ health = await containerHealthStatus(id) } if (health !== ContainerHealth.Healthy) { throw new String( `Container '${image}' is unhealthy with status '${health}'` ) } } export async function containerPorts(id: string): Promise { const dockerArgs = ['port', id] const portMappings = (await runDockerCommand(dockerArgs)).trim() return portMappings.split('\n') } export async function registryLogin(args): Promise { if (!args.registry) { return '' } const credentials = { username: args.registry.username, password: args.registry.password } const configLocation = `${env.RUNNER_TEMP}/.docker_${uuidv4()}` fs.mkdirSync(configLocation) try { await dockerLogin(configLocation, args.registry.serverUrl, credentials) } catch (error) { fs.rmdirSync(configLocation, { recursive: true }) throw error } return configLocation } export async function registryLogout(configLocation: string): Promise { if (configLocation) { await dockerLogout(configLocation) fs.rmdirSync(configLocation, { recursive: true }) } } async function dockerLogin( configLocation: string, registry: string, credentials: { username: string; password: string } ): Promise { const credentialsArgs = credentials.username && credentials.password ? ['-u', credentials.username, '--password-stdin'] : [] const dockerArgs = [ '--config', configLocation, 'login', ...credentialsArgs, registry ] const options: RunDockerCommandOptions = credentials.username && credentials.password ? { input: Buffer.from(credentials.password, 'utf-8') } : {} await runDockerCommand(dockerArgs, options) } async function dockerLogout(configLocation: string): Promise { const dockerArgs = ['--config', configLocation, 'logout'] await runDockerCommand(dockerArgs) } export async function containerExecStep( args, containerId: string ): Promise { const dockerArgs: string[] = ['exec', '-i'] dockerArgs.push(`--workdir=${args.workingDirectory}`) for (const [key] of Object.entries(args['environmentVariables'])) { dockerArgs.push('-e') dockerArgs.push(`"${key}"`) } // Todo figure out prepend path and update it here // (we need to pass path in as -e Path={fullpath}) where {fullpath is the prepend path added to the current containers path} dockerArgs.push(containerId) dockerArgs.push(args.entryPoint) for (const entryPointArg of args.entryPointArgs) { dockerArgs.push(entryPointArg) } await runDockerCommand(dockerArgs) } export async function containerRun( args: RunContainerStepArgs, name: string, network: string ): Promise { if (!args.image) { throw new Error('expected image to be set') } const dockerArgs: string[] = ['run', '--rm'] dockerArgs.push('--name', name) dockerArgs.push(`--workdir=${args.workingDirectory}`) dockerArgs.push(`--label=${getRunnerLabel()}`) dockerArgs.push(`--network=${network}`) if (args.createOptions) { dockerArgs.push(...args.createOptions.split(' ')) } if (args.environmentVariables) { for (const [key, value] of Object.entries(args.environmentVariables)) { // Pass in this way to avoid printing secrets env[key] = value ?? undefined dockerArgs.push('-e') dockerArgs.push(key) } } const mountVolumes = [ ...(args.userMountVolumes || []), ...(args.systemMountVolumes || []) ] for (const mountVolume of mountVolumes) { dockerArgs.push(`-v`) dockerArgs.push( `${mountVolume.sourceVolumePath}:${mountVolume.targetVolumePath}${ mountVolume.readOnly ? ':ro' : '' }` ) } if (args['entryPoint']) { dockerArgs.push(`--entrypoint`) dockerArgs.push(args['entryPoint']) } dockerArgs.push(args.image) if (args.entryPointArgs) { for (const entryPointArg of args.entryPointArgs) { dockerArgs.push(entryPointArg) } } await runDockerCommand(dockerArgs) } export async function isContainerAlpine(containerId: string): Promise { const dockerArgs: string[] = [ 'exec', containerId, 'sh', '-c', "[ $(cat /etc/*release* | grep -i -e '^ID=*alpine*' -c) != 0 ] || exit 1" ] try { await runDockerCommand(dockerArgs) return true } catch { return false } } enum ContainerHealth { Starting = 'starting', Healthy = 'healthy', Unhealthy = 'unhealthy', None = 'none' } export interface ContainerMetadata { id: string image: string network?: string ports?: string[] contextName?: string }