mirror of
https://github.com/actions/runner-container-hooks.git
synced 2025-12-15 00:58:12 +00:00
418 lines
10 KiB
TypeScript
418 lines
10 KiB
TypeScript
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<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) {
|
|
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<void> {
|
|
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<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> {
|
|
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<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)
|
|
}
|
|
|
|
export async function containerNetworkPrune(): Promise<void> {
|
|
const dockerArgs = [
|
|
'network',
|
|
'prune',
|
|
'--force',
|
|
'--filter',
|
|
`label=${getRunnerLabel()}`
|
|
]
|
|
|
|
await runDockerCommand(dockerArgs)
|
|
}
|
|
|
|
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()
|
|
return portMappings.split('\n')
|
|
}
|
|
|
|
export async function registryLogin(args): Promise<string> {
|
|
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<void> {
|
|
if (configLocation) {
|
|
await dockerLogout(configLocation)
|
|
fs.rmdirSync(configLocation, { recursive: true })
|
|
}
|
|
}
|
|
|
|
async function dockerLogin(
|
|
configLocation: string,
|
|
registry: string,
|
|
credentials: { username: string; password: string }
|
|
): 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}`)
|
|
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<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()}`)
|
|
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<boolean> {
|
|
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
|
|
}
|