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
}