2022-06-02 15:53:11 -04:00
import * as core from '@actions/core'
import * as fs from 'fs'
import {
ContainerInfo ,
2022-06-03 06:52:06 -07:00
Registry ,
2022-06-02 15:53:11 -04:00
RunContainerStepArgs ,
2022-06-08 13:20:54 +02:00
ServiceContainerInfo
2022-06-02 15:53:11 -04:00
} from 'hooklib/lib'
2022-06-03 15:10:14 +02:00
import * as path from 'path'
2022-06-02 15:53:11 -04:00
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 < ContainerMetadata > {
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 ) {
2022-06-08 15:02:40 +02:00
for ( const [ key ] of Object . entries ( args . environmentVariables ) ) {
2025-04-17 12:08:32 +02:00
dockerArgs . push ( '-e' , key )
2022-06-02 15:53:11 -04:00
}
}
2025-04-17 12:08:32 +02:00
dockerArgs . push ( '-e' , 'GITHUB_ACTIONS=true' )
// Use same behavior as the runner https://github.com/actions/runner/blob/27d9c886ab9a45e0013cb462529ac85d581f8c41/src/Runner.Worker/Container/DockerCommandManager.cs#L150
if ( ! ( 'CI' in ( args . environmentVariables ? ? { } ) ) ) {
dockerArgs . push ( '-e' , 'CI=true' )
}
2022-06-02 15:53:11 -04:00
const mountVolumes = [
. . . ( args . userMountVolumes || [ ] ) ,
2022-06-08 13:20:54 +02:00
. . . ( args . systemMountVolumes || [ ] )
2022-06-02 15:53:11 -04:00
]
for ( const mountVolume of mountVolumes ) {
dockerArgs . push (
2025-07-29 18:12:01 +09:00
` -v= ${ mountVolume . sourceVolumePath } : ${ mountVolume . targetVolumePath } ${
mountVolume . readOnly ? ':ro' : ''
} `
2022-06-02 15:53:11 -04:00
)
}
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 )
}
}
2022-06-13 17:13:47 +02:00
const id = (
await runDockerCommand ( dockerArgs , { env : args.environmentVariables } )
) . trim ( )
2022-06-02 15:53:11 -04:00
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 < void > {
2023-06-30 15:03:01 +02:00
const dockerArgs : string [ ] = [ ]
2022-06-02 15:53:11 -04:00
if ( configLocation ) {
dockerArgs . push ( '--config' )
dockerArgs . push ( configLocation )
}
2023-06-30 15:03:01 +02:00
dockerArgs . push ( 'pull' )
2022-06-02 15:53:11 -04:00
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 < void > {
const dockerArgs : string [ ] = [ 'start' ]
dockerArgs . push ( ` ${ id } ` )
await runDockerCommand ( dockerArgs )
}
export async function containerStop ( id : string | string [ ] ) : Promise < void > {
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 < void > {
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 < void > {
2022-06-03 14:10:15 +02:00
if ( ! args . dockerfile ) {
2022-06-03 15:10:14 +02:00
throw new Error ( "Container build expects 'args.dockerfile' to be set" )
2022-06-03 14:10:15 +02:00
}
2022-06-02 15:53:11 -04:00
const dockerArgs : string [ ] = [ 'build' ]
dockerArgs . push ( '-t' , tag )
2022-06-03 14:10:15 +02:00
dockerArgs . push ( '-f' , args . dockerfile )
dockerArgs . push ( getBuildContext ( args . dockerfile ) )
2022-06-10 12:45:59 +02:00
2022-06-02 15:53:11 -04:00
await runDockerCommand ( dockerArgs , {
2022-06-03 14:10:15 +02:00
workingDir : getWorkingDir ( args . dockerfile )
2022-06-02 15:53:11 -04:00
} )
}
2022-06-03 14:10:15 +02:00
function getBuildContext ( dockerfilePath : string ) : string {
2022-06-03 15:10:14 +02:00
return path . dirname ( dockerfilePath )
2022-06-03 14:10:15 +02:00
}
function getWorkingDir ( dockerfilePath : string ) : string {
const workspace = env . GITHUB_WORKSPACE as string
let workingDir = workspace
if ( ! dockerfilePath ? . includes ( workspace ) ) {
// This is container action
const pathSplit = dockerfilePath . split ( '/' )
const actionIndex = pathSplit ? . findIndex ( d = > d === '_actions' )
if ( actionIndex ) {
const actionSubdirectoryDepth = 3 // handle + repo + [branch | tag]
pathSplit . splice ( actionIndex + actionSubdirectoryDepth + 1 )
workingDir = pathSplit . join ( '/' )
}
}
return workingDir
}
2022-06-02 15:53:11 -04:00
export async function containerLogs ( id : string ) : Promise < void > {
const dockerArgs : string [ ] = [ 'logs' ]
dockerArgs . push ( '--details' )
dockerArgs . push ( id )
await runDockerCommand ( dockerArgs )
}
export async function containerNetworkRemove ( network : string ) : Promise < void > {
const dockerArgs : string [ ] = [ 'network' ]
dockerArgs . push ( 'rm' )
dockerArgs . push ( network )
await runDockerCommand ( dockerArgs )
}
2022-06-03 15:15:19 +02:00
export async function containerNetworkPrune ( ) : Promise < void > {
const dockerArgs = [
'network' ,
'prune' ,
2022-06-03 16:01:24 +02:00
'--force' ,
2022-06-03 15:15:19 +02:00
'--filter' ,
` label= ${ getRunnerLabel ( ) } `
]
await runDockerCommand ( dockerArgs )
}
2022-06-02 15:53:11 -04:00
export async function containerPrune ( ) : Promise < void > {
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 < ContainerHealth > {
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 < void > {
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 < string [ ] > {
const dockerArgs = [ 'port' , id ]
const portMappings = ( await runDockerCommand ( dockerArgs ) ) . trim ( )
2022-06-08 16:49:44 +02:00
return portMappings . split ( '\n' ) . filter ( p = > ! ! p )
2022-06-02 15:53:11 -04:00
}
2022-06-16 15:44:40 +02:00
export async function getContainerEnvValue (
id : string ,
name : string
) : Promise < string > {
const dockerArgs = [
'inspect' ,
` --format='{{range $ index, $ value := .Config.Env}}{{if eq (index (split $ value "=") 0) " ${ name } "}}{{index (split $ value "=") 1}}{{end}}{{end}}' ` ,
id
]
const value = ( await runDockerCommand ( dockerArgs ) ) . trim ( )
const lines = value . split ( '\n' )
return lines . length ? lines [ 0 ] . replace ( /^'/ , '' ) . replace ( /'$/ , '' ) : ''
}
2022-06-03 06:52:06 -07:00
export async function registryLogin ( registry? : Registry ) : Promise < string > {
if ( ! registry ) {
2022-06-02 15:53:11 -04:00
return ''
}
const credentials = {
2022-06-03 06:52:06 -07:00
username : registry.username ,
password : registry.password
2022-06-02 15:53:11 -04:00
}
const configLocation = ` ${ env . RUNNER_TEMP } /.docker_ ${ uuidv4 ( ) } `
fs . mkdirSync ( configLocation )
try {
2022-06-03 06:52:06 -07:00
await dockerLogin ( configLocation , registry . serverUrl , credentials )
2022-06-02 15:53:11 -04:00
} catch ( error ) {
fs . rmdirSync ( configLocation , { recursive : true } )
throw error
}
return configLocation
}
export async function registryLogout ( configLocation : string ) : Promise < void > {
if ( configLocation ) {
await dockerLogout ( configLocation )
fs . rmdirSync ( configLocation , { recursive : true } )
}
}
async function dockerLogin (
configLocation : string ,
registry : string ,
2022-06-03 06:52:06 -07:00
credentials : { username? : string ; password? : string }
2022-06-02 15:53:11 -04:00
) : Promise < void > {
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 < void > {
const dockerArgs = [ '--config' , configLocation , 'logout' ]
await runDockerCommand ( dockerArgs )
}
export async function containerExecStep (
args ,
containerId : string
) : Promise < void > {
const dockerArgs : string [ ] = [ 'exec' , '-i' ]
dockerArgs . push ( ` --workdir= ${ args . workingDirectory } ` )
2022-06-08 15:02:40 +02:00
for ( const [ key ] of Object . entries ( args [ 'environmentVariables' ] ) ) {
2022-06-02 15:53:11 -04:00
dockerArgs . push ( '-e' )
2022-06-08 17:37:43 +02:00
dockerArgs . push ( key )
2022-06-02 15:53:11 -04:00
}
2022-06-08 13:28:46 +02:00
if ( args . prependPath ? . length ) {
2022-06-16 15:44:40 +02:00
// TODO: remove compatibility with typeof prependPath === 'string' as we bump to next major version, the hooks will lose PrependPath compat with runners 2.293.0 and older
const prependPath =
typeof args . prependPath === 'string'
? args.prependPath
: args.prependPath.join ( ':' )
dockerArgs . push (
'-e' ,
` PATH= ${ prependPath } : ${ await getContainerEnvValue ( containerId , 'PATH' ) } `
)
2022-06-08 13:28:46 +02:00
}
2022-06-02 15:53:11 -04:00
dockerArgs . push ( containerId )
dockerArgs . push ( args . entryPoint )
for ( const entryPointArg of args . entryPointArgs ) {
dockerArgs . push ( entryPointArg )
}
2022-06-13 17:13:47 +02:00
await runDockerCommand ( dockerArgs , { env : args.environmentVariables } )
2022-06-02 15:53:11 -04:00
}
export async function containerRun (
args : RunContainerStepArgs ,
name : string ,
2022-06-03 14:10:15 +02:00
network? : string
2022-06-02 15:53:11 -04:00
) : Promise < void > {
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 ( ) } ` )
2022-06-03 14:10:15 +02:00
if ( network ) {
dockerArgs . push ( ` --network= ${ network } ` )
}
2022-06-02 15:53:11 -04:00
if ( args . createOptions ) {
dockerArgs . push ( . . . args . createOptions . split ( ' ' ) )
}
if ( args . environmentVariables ) {
2022-06-13 17:13:47 +02:00
for ( const [ key ] of Object . entries ( args . environmentVariables ) ) {
2025-04-17 12:08:32 +02:00
dockerArgs . push ( '-e' , key )
2022-06-02 15:53:11 -04:00
}
}
2025-04-17 12:08:32 +02:00
dockerArgs . push ( '-e' , 'GITHUB_ACTIONS=true' )
// Use same behavior as the runner https://github.com/actions/runner/blob/27d9c886ab9a45e0013cb462529ac85d581f8c41/src/Runner.Worker/Container/DockerCommandManager.cs#L150
if ( ! ( 'CI' in ( args . environmentVariables ? ? { } ) ) ) {
dockerArgs . push ( '-e' , 'CI=true' )
}
2022-06-02 15:53:11 -04:00
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 ) {
2022-12-08 08:09:51 +01:00
if ( ! entryPointArg ) {
continue
}
2022-06-02 15:53:11 -04:00
dockerArgs . push ( entryPointArg )
}
}
2022-06-13 17:13:47 +02:00
await runDockerCommand ( dockerArgs , { env : args.environmentVariables } )
2022-06-02 15:53:11 -04:00
}
export async function isContainerAlpine ( containerId : string ) : Promise < boolean > {
const dockerArgs : string [ ] = [
'exec' ,
containerId ,
'sh' ,
'-c' ,
2023-11-20 15:09:36 +01:00
` '[ $ (cat /etc/*release* | grep -i -e "^ID=*alpine*" -c) != 0 ] || exit 1' `
2022-06-02 15:53:11 -04:00
]
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
}