mirror of
https://github.com/actions/runner-container-hooks.git
synced 2026-01-13 22:24:01 +08:00
* fix: calculate hash again after failure The hash from the source is calculated only once. The source hash is checked with the destination hash, but if the destination hash does not match, the destination match is calculated again. The problem is that if the source hash is incorrect, the check will keep failing because the source hash is never re-calculated. Now, in the event that the hashes do not match, the hash of the source and the destination are calculated again. * fix: use size instead of block size Previously the %b parameter was used with stat. This displays the block size of the file. We noticed that in some cases the block size of the source and the destination file could be slightly different. Since the source and target run in different containers, they can have different block sizes defined. If the block size did not match, the hash would also not match, even if the file content would be exactly the same. With this change, the block size is no longer used. Instead the actual size in bytes of the file is listed.
301 lines
8.5 KiB
TypeScript
301 lines
8.5 KiB
TypeScript
import * as k8s from '@kubernetes/client-node'
|
|
import * as fs from 'fs'
|
|
import * as yaml from 'js-yaml'
|
|
import * as core from '@actions/core'
|
|
import { v1 as uuidv4 } from 'uuid'
|
|
import { CONTAINER_EXTENSION_PREFIX } from '../hooks/constants'
|
|
import * as shlex from 'shlex'
|
|
import { Mount } from 'hooklib'
|
|
|
|
export const DEFAULT_CONTAINER_ENTRY_POINT_ARGS = [`-f`, `/dev/null`]
|
|
export const DEFAULT_CONTAINER_ENTRY_POINT = 'tail'
|
|
|
|
export const ENV_HOOK_TEMPLATE_PATH = 'ACTIONS_RUNNER_CONTAINER_HOOK_TEMPLATE'
|
|
export const ENV_USE_KUBE_SCHEDULER = 'ACTIONS_RUNNER_USE_KUBE_SCHEDULER'
|
|
|
|
export const EXTERNALS_VOLUME_NAME = 'externals'
|
|
export const GITHUB_VOLUME_NAME = 'github'
|
|
export const WORK_VOLUME = 'work'
|
|
|
|
export const CONTAINER_VOLUMES: k8s.V1VolumeMount[] = [
|
|
{
|
|
name: EXTERNALS_VOLUME_NAME,
|
|
mountPath: '/__e'
|
|
},
|
|
{
|
|
name: WORK_VOLUME,
|
|
mountPath: '/__w'
|
|
},
|
|
{
|
|
name: GITHUB_VOLUME_NAME,
|
|
mountPath: '/github'
|
|
}
|
|
]
|
|
|
|
export function prepareJobScript(userVolumeMounts: Mount[]): {
|
|
containerPath: string
|
|
runnerPath: string
|
|
} {
|
|
let mountDirs = userVolumeMounts.map(m => m.targetVolumePath).join(' ')
|
|
|
|
const content = `#!/bin/sh -l
|
|
set -e
|
|
cp -R /__w/_temp/_github_home /github/home
|
|
cp -R /__w/_temp/_github_workflow /github/workflow
|
|
mkdir -p ${mountDirs}
|
|
`
|
|
|
|
const filename = `${uuidv4()}.sh`
|
|
const entryPointPath = `${process.env.RUNNER_TEMP}/${filename}`
|
|
fs.writeFileSync(entryPointPath, content)
|
|
return {
|
|
containerPath: `/__w/_temp/${filename}`,
|
|
runnerPath: entryPointPath
|
|
}
|
|
}
|
|
|
|
export function writeRunScript(
|
|
workingDirectory: string,
|
|
entryPoint: string,
|
|
entryPointArgs?: string[],
|
|
prependPath?: string[],
|
|
environmentVariables?: { [key: string]: string }
|
|
): { containerPath: string; runnerPath: string } {
|
|
let exportPath = ''
|
|
if (prependPath?.length) {
|
|
// 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 prepend =
|
|
typeof prependPath === 'string' ? prependPath : prependPath.join(':')
|
|
exportPath = `export PATH=${prepend}:$PATH`
|
|
}
|
|
|
|
let environmentPrefix = scriptEnv(environmentVariables)
|
|
|
|
const content = `#!/bin/sh -l
|
|
set -e
|
|
rm "$0" # remove script after running
|
|
${exportPath}
|
|
cd ${workingDirectory} && \
|
|
exec ${environmentPrefix} ${entryPoint} ${
|
|
entryPointArgs?.length ? entryPointArgs.join(' ') : ''
|
|
}
|
|
`
|
|
const filename = `${uuidv4()}.sh`
|
|
const entryPointPath = `${process.env.RUNNER_TEMP}/${filename}`
|
|
fs.writeFileSync(entryPointPath, content)
|
|
return {
|
|
containerPath: `/__w/_temp/${filename}`,
|
|
runnerPath: entryPointPath
|
|
}
|
|
}
|
|
|
|
export function writeContainerStepScript(
|
|
dst: string,
|
|
workingDirectory: string,
|
|
entryPoint: string,
|
|
entryPointArgs?: string[],
|
|
environmentVariables?: { [key: string]: string }
|
|
): { containerPath: string; runnerPath: string } {
|
|
let environmentPrefix = scriptEnv(environmentVariables)
|
|
|
|
const parts = workingDirectory.split('/').slice(-2)
|
|
if (parts.length !== 2) {
|
|
throw new Error(`Invalid working directory: ${workingDirectory}`)
|
|
}
|
|
|
|
const content = `#!/bin/sh -l
|
|
rm "$0" # remove script after running
|
|
mv /__w/_temp/_github_home /github/home && \
|
|
mv /__w/_temp/_github_workflow /github/workflow && \
|
|
mv /__w/_temp/_runner_file_commands /github/file_commands || true && \
|
|
mv /__w/${parts.join('/')}/ /github/workspace && \
|
|
cd /github/workspace && \
|
|
exec ${environmentPrefix} ${entryPoint} ${
|
|
entryPointArgs?.length ? entryPointArgs.join(' ') : ''
|
|
}
|
|
`
|
|
const filename = `${uuidv4()}.sh`
|
|
const entryPointPath = `${dst}/${filename}`
|
|
core.debug(`Writing container step script to ${entryPointPath}`)
|
|
fs.writeFileSync(entryPointPath, content)
|
|
return {
|
|
containerPath: `/__w/_temp/${filename}`,
|
|
runnerPath: entryPointPath
|
|
}
|
|
}
|
|
|
|
function scriptEnv(envs?: { [key: string]: string }): string {
|
|
if (!envs || !Object.entries(envs).length) {
|
|
return ''
|
|
}
|
|
const envBuffer: string[] = []
|
|
for (const [key, value] of Object.entries(envs)) {
|
|
if (
|
|
key.includes(`=`) ||
|
|
key.includes(`'`) ||
|
|
key.includes(`"`) ||
|
|
key.includes(`$`)
|
|
) {
|
|
throw new Error(
|
|
`environment key ${key} is invalid - the key must not contain =, $, ', or "`
|
|
)
|
|
}
|
|
envBuffer.push(
|
|
`"${key}=${value
|
|
.replace(/\\/g, '\\\\')
|
|
.replace(/"/g, '\\"')
|
|
.replace(/\$/g, '\\$')
|
|
.replace(/`/g, '\\`')}"`
|
|
)
|
|
}
|
|
|
|
if (!envBuffer?.length) {
|
|
return ''
|
|
}
|
|
|
|
return `env ${envBuffer.join(' ')} `
|
|
}
|
|
|
|
export function generateContainerName(image: string): string {
|
|
const nameWithTag = image.split('/').pop()
|
|
const name = nameWithTag?.split(':')[0]
|
|
|
|
if (!name) {
|
|
throw new Error(`Image definition '${image}' is invalid`)
|
|
}
|
|
|
|
return name
|
|
}
|
|
|
|
// Overwrite or append based on container options
|
|
//
|
|
// Keep in mind, envs and volumes could be passed as fields in container definition
|
|
// so default volume mounts and envs are appended first, and then create options are used
|
|
// to append more values
|
|
//
|
|
// Rest of the fields are just applied
|
|
// For example, container.createOptions.container.image is going to overwrite container.image field
|
|
export function mergeContainerWithOptions(
|
|
base: k8s.V1Container,
|
|
from: k8s.V1Container
|
|
): void {
|
|
for (const [key, value] of Object.entries(from)) {
|
|
if (key === 'name') {
|
|
if (value !== CONTAINER_EXTENSION_PREFIX + base.name) {
|
|
core.warning("Skipping name override: name can't be overwritten")
|
|
}
|
|
continue
|
|
} else if (key === 'image') {
|
|
core.warning("Skipping image override: image can't be overwritten")
|
|
continue
|
|
} else if (key === 'env') {
|
|
const envs = value as k8s.V1EnvVar[]
|
|
base.env = mergeLists(base.env, envs)
|
|
} else if (key === 'volumeMounts' && value) {
|
|
const volumeMounts = value as k8s.V1VolumeMount[]
|
|
base.volumeMounts = mergeLists(base.volumeMounts, volumeMounts)
|
|
} else if (key === 'ports' && value) {
|
|
const ports = value as k8s.V1ContainerPort[]
|
|
base.ports = mergeLists(base.ports, ports)
|
|
} else {
|
|
base[key] = value
|
|
}
|
|
}
|
|
}
|
|
|
|
export function mergePodSpecWithOptions(
|
|
base: k8s.V1PodSpec,
|
|
from: k8s.V1PodSpec
|
|
): void {
|
|
for (const [key, value] of Object.entries(from)) {
|
|
if (key === 'containers') {
|
|
base.containers.push(
|
|
...from.containers.filter(
|
|
e => !e.name?.startsWith(CONTAINER_EXTENSION_PREFIX)
|
|
)
|
|
)
|
|
} else if (key === 'volumes' && value) {
|
|
const volumes = value as k8s.V1Volume[]
|
|
base.volumes = mergeLists(base.volumes, volumes)
|
|
} else {
|
|
base[key] = value
|
|
}
|
|
}
|
|
}
|
|
|
|
export function mergeObjectMeta(
|
|
base: { metadata?: k8s.V1ObjectMeta },
|
|
from: k8s.V1ObjectMeta
|
|
): void {
|
|
if (!base.metadata?.labels || !base.metadata?.annotations) {
|
|
throw new Error(
|
|
"Can't merge metadata: base.metadata or base.annotations field is undefined"
|
|
)
|
|
}
|
|
if (from?.labels) {
|
|
for (const [key, value] of Object.entries(from.labels)) {
|
|
if (base.metadata?.labels?.[key]) {
|
|
core.warning(`Label ${key} is already defined and will be overwritten`)
|
|
}
|
|
base.metadata.labels[key] = value
|
|
}
|
|
}
|
|
|
|
if (from?.annotations) {
|
|
for (const [key, value] of Object.entries(from.annotations)) {
|
|
if (base.metadata?.annotations?.[key]) {
|
|
core.warning(
|
|
`Annotation ${key} is already defined and will be overwritten`
|
|
)
|
|
}
|
|
base.metadata.annotations[key] = value
|
|
}
|
|
}
|
|
}
|
|
|
|
export function readExtensionFromFile(): k8s.V1PodTemplateSpec | undefined {
|
|
const filePath = process.env[ENV_HOOK_TEMPLATE_PATH]
|
|
if (!filePath) {
|
|
return undefined
|
|
}
|
|
const doc = yaml.load(fs.readFileSync(filePath, 'utf8'))
|
|
if (!doc || typeof doc !== 'object') {
|
|
throw new Error(`Failed to parse ${filePath}`)
|
|
}
|
|
return doc as k8s.V1PodTemplateSpec
|
|
}
|
|
|
|
export function useKubeScheduler(): boolean {
|
|
return process.env[ENV_USE_KUBE_SCHEDULER] === 'true'
|
|
}
|
|
|
|
export enum PodPhase {
|
|
PENDING = 'Pending',
|
|
RUNNING = 'Running',
|
|
SUCCEEDED = 'Succeeded',
|
|
FAILED = 'Failed',
|
|
UNKNOWN = 'Unknown',
|
|
COMPLETED = 'Completed'
|
|
}
|
|
|
|
function mergeLists<T>(base?: T[], from?: T[]): T[] {
|
|
const b: T[] = base || []
|
|
if (!from?.length) {
|
|
return b
|
|
}
|
|
b.push(...from)
|
|
return b
|
|
}
|
|
|
|
export function fixArgs(args: string[]): string[] {
|
|
return shlex.split(args.join(' '))
|
|
}
|
|
|
|
export async function sleep(ms: number): Promise<void> {
|
|
return new Promise(resolve => setTimeout(resolve, ms))
|
|
}
|
|
|
|
export function listDirAllCommand(dir: string): string {
|
|
return `cd ${shlex.quote(dir)} && find . -not -path '*/_runner_hook_responses*' -exec stat -c '%s %n' {} \\;`
|
|
}
|