mirror of
https://github.com/actions/runner-container-hooks.git
synced 2026-01-16 15:55:43 +08:00
Remove dependency on the runner's volume (#244)
* bump actions * experiment using init container to prepare working environment * rm script before continuing * fix * Update packages/k8s/src/hooks/run-script-step.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * leverage exec stat instead of printf * npm update * document the new constraint --------- Co-authored-by: DenisPalnitsky <DenisPalnitsky@users.noreply.github.com>
This commit is contained in:
@@ -1,5 +1,4 @@
|
||||
import * as core from '@actions/core'
|
||||
import * as io from '@actions/io'
|
||||
import * as k8s from '@kubernetes/client-node'
|
||||
import {
|
||||
JobContainerInfo,
|
||||
@@ -8,26 +7,33 @@ import {
|
||||
writeToResponseFile,
|
||||
ServiceContainerInfo
|
||||
} from 'hooklib'
|
||||
import path from 'path'
|
||||
import {
|
||||
containerPorts,
|
||||
createPod,
|
||||
createJobPod,
|
||||
isPodContainerAlpine,
|
||||
prunePods,
|
||||
waitForPodPhases,
|
||||
getPrepareJobTimeoutSeconds
|
||||
getPrepareJobTimeoutSeconds,
|
||||
execCpToPod,
|
||||
execPodStep
|
||||
} from '../k8s'
|
||||
import {
|
||||
containerVolumes,
|
||||
CONTAINER_VOLUMES,
|
||||
DEFAULT_CONTAINER_ENTRY_POINT,
|
||||
DEFAULT_CONTAINER_ENTRY_POINT_ARGS,
|
||||
generateContainerName,
|
||||
mergeContainerWithOptions,
|
||||
readExtensionFromFile,
|
||||
PodPhase,
|
||||
fixArgs
|
||||
fixArgs,
|
||||
prepareJobScript
|
||||
} from '../k8s/utils'
|
||||
import { CONTAINER_EXTENSION_PREFIX, JOB_CONTAINER_NAME } from './constants'
|
||||
import {
|
||||
CONTAINER_EXTENSION_PREFIX,
|
||||
getJobPodName,
|
||||
JOB_CONTAINER_NAME
|
||||
} from './constants'
|
||||
import { dirname } from 'path'
|
||||
|
||||
export async function prepareJob(
|
||||
args: PrepareJobArgs,
|
||||
@@ -40,11 +46,9 @@ export async function prepareJob(
|
||||
await prunePods()
|
||||
|
||||
const extension = readExtensionFromFile()
|
||||
await copyExternalsToRoot()
|
||||
|
||||
let container: k8s.V1Container | undefined = undefined
|
||||
if (args.container?.image) {
|
||||
core.debug(`Using image '${args.container.image}' for job image`)
|
||||
container = createContainerSpec(
|
||||
args.container,
|
||||
JOB_CONTAINER_NAME,
|
||||
@@ -56,7 +60,6 @@ export async function prepareJob(
|
||||
let services: k8s.V1Container[] = []
|
||||
if (args.services?.length) {
|
||||
services = args.services.map(service => {
|
||||
core.debug(`Adding service '${service.image}' to pod definition`)
|
||||
return createContainerSpec(
|
||||
service,
|
||||
generateContainerName(service.image),
|
||||
@@ -72,7 +75,8 @@ export async function prepareJob(
|
||||
|
||||
let createdPod: k8s.V1Pod | undefined = undefined
|
||||
try {
|
||||
createdPod = await createPod(
|
||||
createdPod = await createJobPod(
|
||||
getJobPodName(),
|
||||
container,
|
||||
services,
|
||||
args.container.registry,
|
||||
@@ -92,6 +96,13 @@ export async function prepareJob(
|
||||
`Job pod created, waiting for it to come online ${createdPod?.metadata?.name}`
|
||||
)
|
||||
|
||||
const runnerWorkspace = dirname(process.env.RUNNER_WORKSPACE as string)
|
||||
|
||||
let prepareScript: { containerPath: string; runnerPath: string } | undefined
|
||||
if (args.container?.userMountVolumes?.length) {
|
||||
prepareScript = prepareJobScript(args.container.userMountVolumes || [])
|
||||
}
|
||||
|
||||
try {
|
||||
await waitForPodPhases(
|
||||
createdPod.metadata.name,
|
||||
@@ -104,6 +115,28 @@ export async function prepareJob(
|
||||
throw new Error(`pod failed to come online with error: ${err}`)
|
||||
}
|
||||
|
||||
await execCpToPod(createdPod.metadata.name, runnerWorkspace, '/__w')
|
||||
|
||||
if (prepareScript) {
|
||||
await execPodStep(
|
||||
['sh', '-e', prepareScript.containerPath],
|
||||
createdPod.metadata.name,
|
||||
JOB_CONTAINER_NAME
|
||||
)
|
||||
|
||||
const promises: Promise<void>[] = []
|
||||
for (const vol of args?.container?.userMountVolumes || []) {
|
||||
promises.push(
|
||||
execCpToPod(
|
||||
createdPod.metadata.name,
|
||||
vol.sourceVolumePath,
|
||||
vol.targetVolumePath
|
||||
)
|
||||
)
|
||||
}
|
||||
await Promise.all(promises)
|
||||
}
|
||||
|
||||
core.debug('Job pod is ready for traffic')
|
||||
|
||||
let isAlpine = false
|
||||
@@ -127,7 +160,7 @@ function generateResponseFile(
|
||||
responseFile: string,
|
||||
args: PrepareJobArgs,
|
||||
appPod: k8s.V1Pod,
|
||||
isAlpine
|
||||
isAlpine: boolean
|
||||
): void {
|
||||
if (!appPod.metadata?.name) {
|
||||
throw new Error('app pod must have metadata.name specified')
|
||||
@@ -184,17 +217,6 @@ function generateResponseFile(
|
||||
writeToResponseFile(responseFile, JSON.stringify(response))
|
||||
}
|
||||
|
||||
async function copyExternalsToRoot(): Promise<void> {
|
||||
const workspace = process.env['RUNNER_WORKSPACE']
|
||||
if (workspace) {
|
||||
await io.cp(
|
||||
path.join(workspace, '../../externals'),
|
||||
path.join(workspace, '../externals'),
|
||||
{ force: true, recursive: true, copySourceDirectory: false }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function createContainerSpec(
|
||||
container: JobContainerInfo | ServiceContainerInfo,
|
||||
name: string,
|
||||
@@ -244,10 +266,7 @@ export function createContainerSpec(
|
||||
})
|
||||
}
|
||||
|
||||
podContainer.volumeMounts = containerVolumes(
|
||||
container.userMountVolumes,
|
||||
jobContainer
|
||||
)
|
||||
podContainer.volumeMounts = CONTAINER_VOLUMES
|
||||
|
||||
if (!extension) {
|
||||
return podContainer
|
||||
|
||||
@@ -1,23 +1,31 @@
|
||||
import * as core from '@actions/core'
|
||||
import * as fs from 'fs'
|
||||
import * as k8s from '@kubernetes/client-node'
|
||||
import { RunContainerStepArgs } from 'hooklib'
|
||||
import { dirname } from 'path'
|
||||
import {
|
||||
createJob,
|
||||
createSecretForEnvs,
|
||||
getContainerJobPodName,
|
||||
getPodLogs,
|
||||
getPodStatus,
|
||||
waitForJobToComplete,
|
||||
createContainerStepPod,
|
||||
deletePod,
|
||||
execCpFromPod,
|
||||
execCpToPod,
|
||||
execPodStep,
|
||||
getPrepareJobTimeoutSeconds,
|
||||
waitForPodPhases
|
||||
} from '../k8s'
|
||||
import {
|
||||
containerVolumes,
|
||||
fixArgs,
|
||||
CONTAINER_VOLUMES,
|
||||
mergeContainerWithOptions,
|
||||
PodPhase,
|
||||
readExtensionFromFile
|
||||
readExtensionFromFile,
|
||||
DEFAULT_CONTAINER_ENTRY_POINT_ARGS,
|
||||
writeContainerStepScript
|
||||
} from '../k8s/utils'
|
||||
import { JOB_CONTAINER_EXTENSION_NAME, JOB_CONTAINER_NAME } from './constants'
|
||||
import {
|
||||
getJobPodName,
|
||||
getStepPodName,
|
||||
JOB_CONTAINER_EXTENSION_NAME,
|
||||
JOB_CONTAINER_NAME
|
||||
} from './constants'
|
||||
|
||||
export async function runContainerStep(
|
||||
stepContainer: RunContainerStepArgs
|
||||
@@ -26,119 +34,109 @@ export async function runContainerStep(
|
||||
throw new Error('Building container actions is not currently supported')
|
||||
}
|
||||
|
||||
let secretName: string | undefined = undefined
|
||||
if (stepContainer.environmentVariables) {
|
||||
try {
|
||||
const envs = JSON.parse(
|
||||
JSON.stringify(stepContainer.environmentVariables)
|
||||
)
|
||||
envs['GITHUB_ACTIONS'] = 'true'
|
||||
if (!('CI' in envs)) {
|
||||
envs.CI = 'true'
|
||||
}
|
||||
secretName = await createSecretForEnvs(envs)
|
||||
} catch (err) {
|
||||
core.debug(`createSecretForEnvs failed: ${JSON.stringify(err)}`)
|
||||
const message = (err as any)?.response?.body?.message || err
|
||||
throw new Error(`failed to create script environment: ${message}`)
|
||||
}
|
||||
if (!stepContainer.entryPoint) {
|
||||
throw new Error(
|
||||
'failed to start the container since the entrypoint is overwritten'
|
||||
)
|
||||
}
|
||||
|
||||
const envs = stepContainer.environmentVariables || {}
|
||||
envs['GITHUB_ACTIONS'] = 'true'
|
||||
if (!('CI' in envs)) {
|
||||
envs.CI = 'true'
|
||||
}
|
||||
|
||||
const extension = readExtensionFromFile()
|
||||
|
||||
core.debug(`Created secret ${secretName} for container job envs`)
|
||||
const container = createContainerSpec(stepContainer, secretName, extension)
|
||||
const container = createContainerSpec(stepContainer, extension)
|
||||
|
||||
let job: k8s.V1Job
|
||||
let pod: k8s.V1Pod
|
||||
try {
|
||||
job = await createJob(container, extension)
|
||||
pod = await createContainerStepPod(getStepPodName(), container, extension)
|
||||
} catch (err) {
|
||||
core.debug(`createJob failed: ${JSON.stringify(err)}`)
|
||||
const message = (err as any)?.response?.body?.message || err
|
||||
throw new Error(`failed to run script step: ${message}`)
|
||||
}
|
||||
|
||||
if (!job.metadata?.name) {
|
||||
if (!pod.metadata?.name) {
|
||||
throw new Error(
|
||||
`Expected job ${JSON.stringify(
|
||||
job
|
||||
pod
|
||||
)} to have correctly set the metadata.name`
|
||||
)
|
||||
}
|
||||
core.debug(`Job created, waiting for pod to start: ${job.metadata?.name}`)
|
||||
const podName = pod.metadata.name
|
||||
|
||||
let podName: string
|
||||
try {
|
||||
podName = await getContainerJobPodName(job.metadata.name)
|
||||
} catch (err) {
|
||||
core.debug(`getContainerJobPodName failed: ${JSON.stringify(err)}`)
|
||||
const message = (err as any)?.response?.body?.message || err
|
||||
throw new Error(`failed to get container job pod name: ${message}`)
|
||||
}
|
||||
|
||||
await waitForPodPhases(
|
||||
podName,
|
||||
new Set([
|
||||
PodPhase.COMPLETED,
|
||||
PodPhase.RUNNING,
|
||||
PodPhase.SUCCEEDED,
|
||||
PodPhase.FAILED
|
||||
]),
|
||||
new Set([PodPhase.PENDING, PodPhase.UNKNOWN])
|
||||
)
|
||||
core.debug('Container step is running or complete, pulling logs')
|
||||
|
||||
await getPodLogs(podName, JOB_CONTAINER_NAME)
|
||||
|
||||
core.debug('Waiting for container job to complete')
|
||||
await waitForJobToComplete(job.metadata.name)
|
||||
|
||||
// pod has failed so pull the status code from the container
|
||||
const status = await getPodStatus(podName)
|
||||
if (status?.phase === 'Succeeded') {
|
||||
return 0
|
||||
}
|
||||
if (!status?.containerStatuses?.length) {
|
||||
core.error(
|
||||
`Can't determine container status from response: ${JSON.stringify(
|
||||
status
|
||||
)}`
|
||||
await waitForPodPhases(
|
||||
podName,
|
||||
new Set([PodPhase.RUNNING]),
|
||||
new Set([PodPhase.PENDING, PodPhase.UNKNOWN]),
|
||||
getPrepareJobTimeoutSeconds()
|
||||
)
|
||||
return 1
|
||||
|
||||
const runnerWorkspace = dirname(process.env.RUNNER_WORKSPACE as string)
|
||||
const githubWorkspace = process.env.GITHUB_WORKSPACE as string
|
||||
const parts = githubWorkspace.split('/').slice(-2)
|
||||
if (parts.length !== 2) {
|
||||
throw new Error(`Invalid github workspace directory: ${githubWorkspace}`)
|
||||
}
|
||||
const relativeWorkspace = parts.join('/')
|
||||
|
||||
core.debug(
|
||||
`Copying files from pod ${getJobPodName()} to ${runnerWorkspace}/${relativeWorkspace}`
|
||||
)
|
||||
await execCpFromPod(getJobPodName(), `/__w`, `${runnerWorkspace}`)
|
||||
|
||||
const { containerPath, runnerPath } = writeContainerStepScript(
|
||||
`${runnerWorkspace}/__w/_temp`,
|
||||
githubWorkspace,
|
||||
stepContainer.entryPoint,
|
||||
stepContainer.entryPointArgs,
|
||||
envs
|
||||
)
|
||||
|
||||
await execCpToPod(podName, `${runnerWorkspace}/__w`, '/__w')
|
||||
|
||||
fs.rmSync(`${runnerWorkspace}/__w`, { recursive: true, force: true })
|
||||
|
||||
try {
|
||||
core.debug(`Executing container step script in pod ${podName}`)
|
||||
return await execPodStep(
|
||||
['/__e/sh', '-e', containerPath],
|
||||
pod.metadata.name,
|
||||
JOB_CONTAINER_NAME
|
||||
)
|
||||
} catch (err) {
|
||||
core.debug(`execPodStep failed: ${JSON.stringify(err)}`)
|
||||
const message = (err as any)?.response?.body?.message || err
|
||||
throw new Error(`failed to run script step: ${message}`)
|
||||
} finally {
|
||||
fs.rmSync(runnerPath, { force: true })
|
||||
}
|
||||
} catch (error) {
|
||||
core.error(`Failed to run container step: ${error}`)
|
||||
throw error
|
||||
} finally {
|
||||
await deletePod(podName).catch(err => {
|
||||
core.error(`Failed to delete step pod ${podName}: ${err}`)
|
||||
})
|
||||
}
|
||||
const exitCode =
|
||||
status.containerStatuses[status.containerStatuses.length - 1].state
|
||||
?.terminated?.exitCode
|
||||
return Number(exitCode) || 1
|
||||
}
|
||||
|
||||
function createContainerSpec(
|
||||
container: RunContainerStepArgs,
|
||||
secretName?: string,
|
||||
extension?: k8s.V1PodTemplateSpec
|
||||
): k8s.V1Container {
|
||||
const podContainer = new k8s.V1Container()
|
||||
podContainer.name = JOB_CONTAINER_NAME
|
||||
podContainer.image = container.image
|
||||
podContainer.workingDir = container.workingDirectory
|
||||
podContainer.command = container.entryPoint
|
||||
? [container.entryPoint]
|
||||
: undefined
|
||||
podContainer.args = container.entryPointArgs?.length
|
||||
? fixArgs(container.entryPointArgs)
|
||||
: undefined
|
||||
podContainer.workingDir = '/__w'
|
||||
podContainer.command = ['/__e/tail']
|
||||
podContainer.args = DEFAULT_CONTAINER_ENTRY_POINT_ARGS
|
||||
|
||||
if (secretName) {
|
||||
podContainer.envFrom = [
|
||||
{
|
||||
secretRef: {
|
||||
name: secretName,
|
||||
optional: false
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
podContainer.volumeMounts = containerVolumes(undefined, false, true)
|
||||
podContainer.volumeMounts = CONTAINER_VOLUMES
|
||||
|
||||
if (!extension) {
|
||||
return podContainer
|
||||
|
||||
@@ -2,17 +2,18 @@
|
||||
import * as fs from 'fs'
|
||||
import * as core from '@actions/core'
|
||||
import { RunScriptStepArgs } from 'hooklib'
|
||||
import { execPodStep } from '../k8s'
|
||||
import { writeEntryPointScript } from '../k8s/utils'
|
||||
import { execCpFromPod, execCpToPod, execPodStep } from '../k8s'
|
||||
import { writeRunScript, sleep, listDirAllCommand } from '../k8s/utils'
|
||||
import { JOB_CONTAINER_NAME } from './constants'
|
||||
import { dirname } from 'path'
|
||||
|
||||
export async function runScriptStep(
|
||||
args: RunScriptStepArgs,
|
||||
state,
|
||||
responseFile
|
||||
state
|
||||
): Promise<void> {
|
||||
// Write the entrypoint first. This will be later coppied to the workflow pod
|
||||
const { entryPoint, entryPointArgs, environmentVariables } = args
|
||||
const { containerPath, runnerPath } = writeEntryPointScript(
|
||||
const { containerPath, runnerPath } = writeRunScript(
|
||||
args.workingDirectory,
|
||||
entryPoint,
|
||||
entryPointArgs,
|
||||
@@ -20,6 +21,12 @@ export async function runScriptStep(
|
||||
environmentVariables
|
||||
)
|
||||
|
||||
const workdir = dirname(process.env.RUNNER_WORKSPACE as string)
|
||||
const containerTemp = '/__w/_temp'
|
||||
const runnerTemp = `${workdir}/_temp`
|
||||
await execCpToPod(state.jobPod, runnerTemp, containerTemp)
|
||||
|
||||
// Execute the entrypoint script
|
||||
args.entryPoint = 'sh'
|
||||
args.entryPointArgs = ['-e', containerPath]
|
||||
try {
|
||||
@@ -33,6 +40,19 @@ export async function runScriptStep(
|
||||
const message = (err as any)?.response?.body?.message || err
|
||||
throw new Error(`failed to run script step: ${message}`)
|
||||
} finally {
|
||||
fs.rmSync(runnerPath)
|
||||
try {
|
||||
fs.rmSync(runnerPath, { force: true })
|
||||
} catch (removeErr) {
|
||||
core.debug(`Failed to remove file ${runnerPath}: ${removeErr}`)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
core.debug(
|
||||
`Copying from job pod '${state.jobPod}' ${containerTemp} to ${runnerTemp}`
|
||||
)
|
||||
await execCpFromPod(state.jobPod, containerTemp, workdir)
|
||||
} catch (error) {
|
||||
core.warning('Failed to copy _temp from pod')
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user