mirror of
https://github.com/actions/runner-container-hooks.git
synced 2025-12-12 07:36:43 +00: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:
@@ -4,9 +4,6 @@ metadata:
|
||||
labels:
|
||||
labeled-by: "extension"
|
||||
spec:
|
||||
securityContext:
|
||||
runAsUser: 1000
|
||||
runAsGroup: 3000
|
||||
restartPolicy: Never
|
||||
containers:
|
||||
- name: $job # overwrites job container
|
||||
|
||||
3630
package-lock.json
generated
3630
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -22,9 +22,6 @@ rules:
|
||||
- apiGroups: [""]
|
||||
resources: ["pods/log"]
|
||||
verbs: ["get", "list", "watch",]
|
||||
- apiGroups: ["batch"]
|
||||
resources: ["jobs"]
|
||||
verbs: ["get", "list", "create", "delete"]
|
||||
- apiGroups: [""]
|
||||
resources: ["secrets"]
|
||||
verbs: ["get", "list", "create", "delete"]
|
||||
@@ -43,3 +40,4 @@ rules:
|
||||
- Building container actions from a dockerfile is not supported at this time
|
||||
- Container actions will not have access to the services network or job container network
|
||||
- Docker [create options](https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idcontaineroptions) are not supported
|
||||
- Container actions will have to specify the entrypoint, since the default entrypoint will be overridden to run the commands from the workflow.
|
||||
|
||||
5793
packages/k8s/package-lock.json
generated
5793
packages/k8s/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -20,17 +20,18 @@
|
||||
"hooklib": "file:../hooklib",
|
||||
"js-yaml": "^4.1.0",
|
||||
"shlex": "^3.0.0",
|
||||
"tar-fs": "^3.1.0",
|
||||
"uuid": "^11.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.25.2",
|
||||
"@babel/preset-env": "^7.25.4",
|
||||
"@babel/core": "^7.28.3",
|
||||
"@babel/preset-env": "^7.28.3",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^24.0.14",
|
||||
"@types/node": "^24.3.0",
|
||||
"@vercel/ncc": "^0.38.3",
|
||||
"babel-jest": "^30.0.4",
|
||||
"jest": "^30.0.4",
|
||||
"ts-jest": "^29.4.0",
|
||||
"typescript": "^5.8.3"
|
||||
"babel-jest": "^30.1.1",
|
||||
"jest": "^30.1.1",
|
||||
"ts-jest": "^29.4.1",
|
||||
"typescript": "^5.9.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ async function run(): Promise<void> {
|
||||
await cleanupJob()
|
||||
return process.exit(0)
|
||||
case Command.RunScriptStep:
|
||||
await runScriptStep(args as RunScriptStepArgs, state, null)
|
||||
await runScriptStep(args as RunScriptStepArgs, state)
|
||||
return process.exit(0)
|
||||
case Command.RunContainerStep:
|
||||
exitCode = await runContainerStep(args as RunContainerStepArgs)
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
import * as core from '@actions/core'
|
||||
import * as path from 'path'
|
||||
import { spawn } from 'child_process'
|
||||
import * as k8s from '@kubernetes/client-node'
|
||||
import tar from 'tar-fs'
|
||||
import * as stream from 'stream'
|
||||
import { WritableStreamBuffer } from 'stream-buffers'
|
||||
import { createHash } from 'crypto'
|
||||
import type { ContainerInfo, Registry } from 'hooklib'
|
||||
import {
|
||||
getJobPodName,
|
||||
getRunnerPodName,
|
||||
getSecretName,
|
||||
getStepPodName,
|
||||
getVolumeClaimName,
|
||||
JOB_CONTAINER_NAME,
|
||||
RunnerInstanceLabel
|
||||
} from '../hooks/constants'
|
||||
import {
|
||||
PodPhase,
|
||||
mergePodSpecWithOptions,
|
||||
mergeObjectMeta,
|
||||
useKubeScheduler,
|
||||
fixArgs
|
||||
fixArgs,
|
||||
listDirAllCommand,
|
||||
sleep,
|
||||
EXTERNALS_VOLUME_NAME,
|
||||
GITHUB_VOLUME_NAME
|
||||
} from './utils'
|
||||
|
||||
const kc = new k8s.KubeConfig()
|
||||
@@ -28,8 +33,6 @@ const k8sAuthorizationV1Api = kc.makeApiClient(k8s.AuthorizationV1Api)
|
||||
|
||||
const DEFAULT_WAIT_FOR_POD_TIME_SECONDS = 10 * 60 // 10 min
|
||||
|
||||
export const POD_VOLUME_NAME = 'work'
|
||||
|
||||
export const requiredPermissions = [
|
||||
{
|
||||
group: '',
|
||||
@@ -49,12 +52,6 @@ export const requiredPermissions = [
|
||||
resource: 'pods',
|
||||
subresource: 'log'
|
||||
},
|
||||
{
|
||||
group: 'batch',
|
||||
verbs: ['get', 'list', 'create', 'delete'],
|
||||
resource: 'jobs',
|
||||
subresource: ''
|
||||
},
|
||||
{
|
||||
group: '',
|
||||
verbs: ['create', 'delete', 'get', 'list'],
|
||||
@@ -63,7 +60,8 @@ export const requiredPermissions = [
|
||||
}
|
||||
]
|
||||
|
||||
export async function createPod(
|
||||
export async function createJobPod(
|
||||
name: string,
|
||||
jobContainer?: k8s.V1Container,
|
||||
services?: k8s.V1Container[],
|
||||
registry?: Registry,
|
||||
@@ -83,7 +81,7 @@ export async function createPod(
|
||||
appPod.kind = 'Pod'
|
||||
|
||||
appPod.metadata = new k8s.V1ObjectMeta()
|
||||
appPod.metadata.name = getJobPodName()
|
||||
appPod.metadata.name = name
|
||||
|
||||
const instanceLabel = new RunnerInstanceLabel()
|
||||
appPod.metadata.labels = {
|
||||
@@ -93,19 +91,36 @@ export async function createPod(
|
||||
|
||||
appPod.spec = new k8s.V1PodSpec()
|
||||
appPod.spec.containers = containers
|
||||
appPod.spec.initContainers = [
|
||||
{
|
||||
name: 'fs-init',
|
||||
image:
|
||||
process.env.ACTIONS_RUNNER_IMAGE ||
|
||||
'ghcr.io/actions/actions-runner:latest',
|
||||
command: ['sh', '-c', 'sudo mv /home/runner/externals/* /mnt/externals'],
|
||||
securityContext: {
|
||||
runAsGroup: 1001,
|
||||
runAsUser: 1001
|
||||
},
|
||||
volumeMounts: [
|
||||
{
|
||||
name: EXTERNALS_VOLUME_NAME,
|
||||
mountPath: '/mnt/externals'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
appPod.spec.restartPolicy = 'Never'
|
||||
|
||||
const nodeName = await getCurrentNodeName()
|
||||
if (useKubeScheduler()) {
|
||||
appPod.spec.affinity = await getPodAffinity(nodeName)
|
||||
} else {
|
||||
appPod.spec.nodeName = nodeName
|
||||
}
|
||||
const claimName = getVolumeClaimName()
|
||||
appPod.spec.volumes = [
|
||||
{
|
||||
name: 'work',
|
||||
persistentVolumeClaim: { claimName }
|
||||
name: EXTERNALS_VOLUME_NAME,
|
||||
emptyDir: {}
|
||||
},
|
||||
{
|
||||
name: GITHUB_VOLUME_NAME,
|
||||
emptyDir: {}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -133,88 +148,82 @@ export async function createPod(
|
||||
})
|
||||
}
|
||||
|
||||
export async function createJob(
|
||||
export async function createContainerStepPod(
|
||||
name: string,
|
||||
container: k8s.V1Container,
|
||||
extension?: k8s.V1PodTemplateSpec
|
||||
): Promise<k8s.V1Job> {
|
||||
const runnerInstanceLabel = new RunnerInstanceLabel()
|
||||
): Promise<k8s.V1Pod> {
|
||||
const appPod = new k8s.V1Pod()
|
||||
|
||||
const job = new k8s.V1Job()
|
||||
job.apiVersion = 'batch/v1'
|
||||
job.kind = 'Job'
|
||||
job.metadata = new k8s.V1ObjectMeta()
|
||||
job.metadata.name = getStepPodName()
|
||||
job.metadata.labels = { [runnerInstanceLabel.key]: runnerInstanceLabel.value }
|
||||
job.metadata.annotations = {}
|
||||
appPod.apiVersion = 'v1'
|
||||
appPod.kind = 'Pod'
|
||||
|
||||
job.spec = new k8s.V1JobSpec()
|
||||
job.spec.ttlSecondsAfterFinished = 300
|
||||
job.spec.backoffLimit = 0
|
||||
job.spec.template = new k8s.V1PodTemplateSpec()
|
||||
appPod.metadata = new k8s.V1ObjectMeta()
|
||||
appPod.metadata.name = name
|
||||
|
||||
job.spec.template.spec = new k8s.V1PodSpec()
|
||||
job.spec.template.metadata = new k8s.V1ObjectMeta()
|
||||
job.spec.template.metadata.labels = {}
|
||||
job.spec.template.metadata.annotations = {}
|
||||
job.spec.template.spec.containers = [container]
|
||||
job.spec.template.spec.restartPolicy = 'Never'
|
||||
|
||||
const nodeName = await getCurrentNodeName()
|
||||
if (useKubeScheduler()) {
|
||||
job.spec.template.spec.affinity = await getPodAffinity(nodeName)
|
||||
} else {
|
||||
job.spec.template.spec.nodeName = nodeName
|
||||
const instanceLabel = new RunnerInstanceLabel()
|
||||
appPod.metadata.labels = {
|
||||
[instanceLabel.key]: instanceLabel.value
|
||||
}
|
||||
appPod.metadata.annotations = {}
|
||||
|
||||
const claimName = getVolumeClaimName()
|
||||
job.spec.template.spec.volumes = [
|
||||
appPod.spec = new k8s.V1PodSpec()
|
||||
appPod.spec.containers = [container]
|
||||
appPod.spec.initContainers = [
|
||||
{
|
||||
name: 'work',
|
||||
persistentVolumeClaim: { claimName }
|
||||
name: 'fs-init',
|
||||
image:
|
||||
process.env.ACTIONS_RUNNER_IMAGE ||
|
||||
'ghcr.io/actions/actions-runner:latest',
|
||||
command: [
|
||||
'bash',
|
||||
'-c',
|
||||
`sudo cp $(which sh) /mnt/externals/sh \
|
||||
&& sudo cp $(which tail) /mnt/externals/tail \
|
||||
&& sudo cp $(which env) /mnt/externals/env \
|
||||
&& sudo chmod -R 777 /mnt/externals`
|
||||
],
|
||||
securityContext: {
|
||||
runAsGroup: 1001,
|
||||
runAsUser: 1001,
|
||||
privileged: true
|
||||
},
|
||||
volumeMounts: [
|
||||
{
|
||||
name: EXTERNALS_VOLUME_NAME,
|
||||
mountPath: '/mnt/externals'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
if (extension) {
|
||||
if (extension.metadata) {
|
||||
// apply metadata both to the job and the pod created by the job
|
||||
mergeObjectMeta(job, extension.metadata)
|
||||
mergeObjectMeta(job.spec.template, extension.metadata)
|
||||
}
|
||||
if (extension.spec) {
|
||||
mergePodSpecWithOptions(job.spec.template.spec, extension.spec)
|
||||
appPod.spec.restartPolicy = 'Never'
|
||||
|
||||
appPod.spec.volumes = [
|
||||
{
|
||||
name: EXTERNALS_VOLUME_NAME,
|
||||
emptyDir: {}
|
||||
},
|
||||
{
|
||||
name: GITHUB_VOLUME_NAME,
|
||||
emptyDir: {}
|
||||
}
|
||||
]
|
||||
|
||||
if (extension?.metadata) {
|
||||
mergeObjectMeta(appPod, extension.metadata)
|
||||
}
|
||||
|
||||
return await k8sBatchV1Api.createNamespacedJob({
|
||||
if (extension?.spec) {
|
||||
mergePodSpecWithOptions(appPod.spec, extension.spec)
|
||||
}
|
||||
|
||||
return await k8sApi.createNamespacedPod({
|
||||
namespace: namespace(),
|
||||
body: job
|
||||
body: appPod
|
||||
})
|
||||
}
|
||||
|
||||
export async function getContainerJobPodName(jobName: string): Promise<string> {
|
||||
const selector = `job-name=${jobName}`
|
||||
const backOffManager = new BackOffManager(60)
|
||||
while (true) {
|
||||
const podList = await k8sApi.listNamespacedPod({
|
||||
namespace: namespace(),
|
||||
labelSelector: selector,
|
||||
limit: 1
|
||||
})
|
||||
|
||||
if (!podList.items?.length) {
|
||||
await backOffManager.backOff()
|
||||
continue
|
||||
}
|
||||
|
||||
if (!podList.items[0].metadata?.name) {
|
||||
throw new Error(
|
||||
`Failed to determine the name of the pod for job ${jobName}`
|
||||
)
|
||||
}
|
||||
return podList.items[0].metadata.name
|
||||
}
|
||||
}
|
||||
|
||||
export async function deletePod(name: string): Promise<void> {
|
||||
await k8sApi.deleteNamespacedPod({
|
||||
name,
|
||||
@@ -228,11 +237,11 @@ export async function execPodStep(
|
||||
podName: string,
|
||||
containerName: string,
|
||||
stdin?: stream.Readable
|
||||
): Promise<void> {
|
||||
): Promise<number> {
|
||||
const exec = new k8s.Exec(kc)
|
||||
|
||||
command = fixArgs(command)
|
||||
// Exec returns a websocket. If websocket fails, we should reject the promise. Otherwise, websocket will call a callback. Since at that point, websocket is not failing, we can safely resolve or reject the promise.
|
||||
await new Promise(function (resolve, reject) {
|
||||
return await new Promise(function (resolve, reject) {
|
||||
exec
|
||||
.exec(
|
||||
namespace(),
|
||||
@@ -244,9 +253,9 @@ export async function execPodStep(
|
||||
stdin ?? null,
|
||||
false /* tty */,
|
||||
resp => {
|
||||
// kube.exec returns an error if exit code is not 0, but we can't actually get the exit code
|
||||
core.debug(`execPodStep response: ${JSON.stringify(resp)}`)
|
||||
if (resp.status === 'Success') {
|
||||
resolve(resp.code)
|
||||
resolve(resp.code || 0)
|
||||
} else {
|
||||
core.debug(
|
||||
JSON.stringify({
|
||||
@@ -254,15 +263,271 @@ export async function execPodStep(
|
||||
details: resp?.details
|
||||
})
|
||||
)
|
||||
reject(resp?.message)
|
||||
reject(new Error(resp?.message || 'execPodStep failed'))
|
||||
}
|
||||
}
|
||||
)
|
||||
// If exec.exec fails, explicitly reject the outer promise
|
||||
.catch(e => reject(e))
|
||||
})
|
||||
}
|
||||
|
||||
export async function execCalculateOutputHash(
|
||||
podName: string,
|
||||
containerName: string,
|
||||
command: string[]
|
||||
): Promise<string> {
|
||||
const exec = new k8s.Exec(kc)
|
||||
|
||||
// Create a writable stream that updates a SHA-256 hash with stdout data
|
||||
const hash = createHash('sha256')
|
||||
const hashWriter = new stream.Writable({
|
||||
write(chunk, _enc, cb) {
|
||||
try {
|
||||
hash.update(chunk.toString('utf8') as Buffer)
|
||||
cb()
|
||||
} catch (e) {
|
||||
cb(e as Error)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
exec
|
||||
.exec(
|
||||
namespace(),
|
||||
podName,
|
||||
containerName,
|
||||
command,
|
||||
hashWriter, // capture stdout for hashing
|
||||
process.stderr,
|
||||
null,
|
||||
false /* tty */,
|
||||
resp => {
|
||||
core.debug(`internalExecOutput response: ${JSON.stringify(resp)}`)
|
||||
if (resp.status === 'Success') {
|
||||
resolve()
|
||||
} else {
|
||||
core.debug(
|
||||
JSON.stringify({
|
||||
message: resp?.message,
|
||||
details: resp?.details
|
||||
})
|
||||
)
|
||||
reject(new Error(resp?.message || 'internalExecOutput failed'))
|
||||
}
|
||||
}
|
||||
)
|
||||
.catch(e => reject(e))
|
||||
})
|
||||
|
||||
// finalize hash and return digest
|
||||
hashWriter.end()
|
||||
|
||||
return hash.digest('hex')
|
||||
}
|
||||
|
||||
export async function localCalculateOutputHash(
|
||||
commands: string[]
|
||||
): Promise<string> {
|
||||
return await new Promise<string>((resolve, reject) => {
|
||||
const hash = createHash('sha256')
|
||||
const child = spawn(commands[0], commands.slice(1), {
|
||||
stdio: ['ignore', 'pipe', 'ignore']
|
||||
})
|
||||
|
||||
child.stdout.on('data', chunk => {
|
||||
hash.update(chunk)
|
||||
})
|
||||
child.on('error', reject)
|
||||
child.on('close', (code: number) => {
|
||||
if (code === 0) {
|
||||
resolve(hash.digest('hex'))
|
||||
} else {
|
||||
reject(new Error(`child process exited with code ${code}`))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export async function execCpToPod(
|
||||
podName: string,
|
||||
runnerPath: string,
|
||||
containerPath: string
|
||||
): Promise<void> {
|
||||
core.debug(`Copying ${runnerPath} to pod ${podName} at ${containerPath}`)
|
||||
|
||||
let attempt = 0
|
||||
while (true) {
|
||||
try {
|
||||
const exec = new k8s.Exec(kc)
|
||||
const command = ['tar', 'xf', '-', '-C', containerPath]
|
||||
const readStream = tar.pack(runnerPath)
|
||||
const errStream = new WritableStreamBuffer()
|
||||
await new Promise((resolve, reject) => {
|
||||
exec
|
||||
.exec(
|
||||
namespace(),
|
||||
podName,
|
||||
JOB_CONTAINER_NAME,
|
||||
command,
|
||||
null,
|
||||
errStream,
|
||||
readStream,
|
||||
false,
|
||||
async status => {
|
||||
if (errStream.size()) {
|
||||
reject(
|
||||
new Error(
|
||||
`Error from cpFromPod - details: \n ${errStream.getContentsAsString()}`
|
||||
)
|
||||
)
|
||||
}
|
||||
resolve(status)
|
||||
}
|
||||
)
|
||||
.catch(e => reject(e))
|
||||
})
|
||||
break
|
||||
} catch (error) {
|
||||
core.debug(`cpToPod: Attempt ${attempt + 1} failed: ${error}`)
|
||||
attempt++
|
||||
if (attempt >= 30) {
|
||||
throw new Error(
|
||||
`cpToPod failed after ${attempt} attempts: ${JSON.stringify(error)}`
|
||||
)
|
||||
}
|
||||
await sleep(1000)
|
||||
}
|
||||
}
|
||||
|
||||
const want = await localCalculateOutputHash([
|
||||
'sh',
|
||||
'-c',
|
||||
listDirAllCommand(runnerPath)
|
||||
])
|
||||
|
||||
let attempts = 15
|
||||
const delay = 1000
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
try {
|
||||
const got = await execCalculateOutputHash(podName, JOB_CONTAINER_NAME, [
|
||||
'sh',
|
||||
'-c',
|
||||
listDirAllCommand(containerPath)
|
||||
])
|
||||
|
||||
if (got !== want) {
|
||||
core.debug(
|
||||
`The hash of the directory does not match the expected value; want='${want}' got='${got}'`
|
||||
)
|
||||
await sleep(delay)
|
||||
continue
|
||||
}
|
||||
|
||||
break
|
||||
} catch (error) {
|
||||
core.debug(`Attempt ${i + 1} failed: ${error}`)
|
||||
await sleep(delay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function execCpFromPod(
|
||||
podName: string,
|
||||
containerPath: string,
|
||||
parentRunnerPath: string
|
||||
): Promise<void> {
|
||||
const targetRunnerPath = `${parentRunnerPath}/${path.basename(containerPath)}`
|
||||
core.debug(
|
||||
`Copying from pod ${podName} ${containerPath} to ${targetRunnerPath}`
|
||||
)
|
||||
const want = await execCalculateOutputHash(podName, JOB_CONTAINER_NAME, [
|
||||
'sh',
|
||||
'-c',
|
||||
listDirAllCommand(containerPath)
|
||||
])
|
||||
|
||||
let attempt = 0
|
||||
while (true) {
|
||||
try {
|
||||
// make temporary directory
|
||||
const exec = new k8s.Exec(kc)
|
||||
const containerPaths = containerPath.split('/')
|
||||
const dirname = containerPaths.pop() as string
|
||||
const command = [
|
||||
'tar',
|
||||
'cf',
|
||||
'-',
|
||||
'-C',
|
||||
containerPaths.join('/') || '/',
|
||||
dirname
|
||||
]
|
||||
const writerStream = tar.extract(parentRunnerPath)
|
||||
const errStream = new WritableStreamBuffer()
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
exec
|
||||
.exec(
|
||||
namespace(),
|
||||
podName,
|
||||
JOB_CONTAINER_NAME,
|
||||
command,
|
||||
writerStream,
|
||||
errStream,
|
||||
null,
|
||||
false,
|
||||
async status => {
|
||||
if (errStream.size()) {
|
||||
reject(
|
||||
new Error(
|
||||
`Error from cpFromPod - details: \n ${errStream.getContentsAsString()}`
|
||||
)
|
||||
)
|
||||
}
|
||||
resolve(status)
|
||||
}
|
||||
)
|
||||
.catch(e => reject(e))
|
||||
})
|
||||
break
|
||||
} catch (error) {
|
||||
core.debug(`Attempt ${attempt + 1} failed: ${error}`)
|
||||
attempt++
|
||||
if (attempt >= 30) {
|
||||
throw new Error(
|
||||
`execCpFromPod failed after ${attempt} attempts: ${JSON.stringify(error)}`
|
||||
)
|
||||
}
|
||||
await sleep(1000)
|
||||
}
|
||||
}
|
||||
|
||||
let attempts = 15
|
||||
const delay = 1000
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
try {
|
||||
const got = await localCalculateOutputHash([
|
||||
'sh',
|
||||
'-c',
|
||||
listDirAllCommand(targetRunnerPath)
|
||||
])
|
||||
|
||||
if (got !== want) {
|
||||
core.debug(
|
||||
`The hash of the directory does not match the expected value; want='${want}' got='${got}'`
|
||||
)
|
||||
await sleep(delay)
|
||||
continue
|
||||
}
|
||||
|
||||
break
|
||||
} catch (error) {
|
||||
core.debug(`Attempt ${i + 1} failed: ${error}`)
|
||||
await sleep(delay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForJobToComplete(jobName: string): Promise<void> {
|
||||
const backOffManager = new BackOffManager()
|
||||
while (true) {
|
||||
@@ -540,39 +805,6 @@ export async function isPodContainerAlpine(
|
||||
return isAlpine
|
||||
}
|
||||
|
||||
async function getCurrentNodeName(): Promise<string> {
|
||||
const resp = await k8sApi.readNamespacedPod({
|
||||
name: getRunnerPodName(),
|
||||
namespace: namespace()
|
||||
})
|
||||
|
||||
const nodeName = resp.spec?.nodeName
|
||||
if (!nodeName) {
|
||||
throw new Error('Failed to determine node name')
|
||||
}
|
||||
return nodeName
|
||||
}
|
||||
|
||||
async function getPodAffinity(nodeName: string): Promise<k8s.V1Affinity> {
|
||||
const affinity = new k8s.V1Affinity()
|
||||
affinity.nodeAffinity = new k8s.V1NodeAffinity()
|
||||
affinity.nodeAffinity.requiredDuringSchedulingIgnoredDuringExecution =
|
||||
new k8s.V1NodeSelector()
|
||||
affinity.nodeAffinity.requiredDuringSchedulingIgnoredDuringExecution.nodeSelectorTerms =
|
||||
[
|
||||
{
|
||||
matchExpressions: [
|
||||
{
|
||||
key: 'kubernetes.io/hostname',
|
||||
operator: 'In',
|
||||
values: [nodeName]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
return affinity
|
||||
}
|
||||
|
||||
export function namespace(): string {
|
||||
if (process.env['ACTIONS_RUNNER_KUBERNETES_NAMESPACE']) {
|
||||
return process.env['ACTIONS_RUNNER_KUBERNETES_NAMESPACE']
|
||||
|
||||
@@ -2,12 +2,10 @@ 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 { Mount } from 'hooklib'
|
||||
import * as path from 'path'
|
||||
import { v1 as uuidv4 } from 'uuid'
|
||||
import { POD_VOLUME_NAME } from './index'
|
||||
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'
|
||||
@@ -15,101 +13,43 @@ 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 function containerVolumes(
|
||||
userMountVolumes: Mount[] = [],
|
||||
jobContainer = true,
|
||||
containerAction = false
|
||||
): k8s.V1VolumeMount[] {
|
||||
const mounts: k8s.V1VolumeMount[] = [
|
||||
{
|
||||
name: POD_VOLUME_NAME,
|
||||
mountPath: '/__w'
|
||||
}
|
||||
]
|
||||
export const EXTERNALS_VOLUME_NAME = 'externals'
|
||||
export const GITHUB_VOLUME_NAME = 'github'
|
||||
|
||||
const workspacePath = process.env.GITHUB_WORKSPACE as string
|
||||
if (containerAction) {
|
||||
const i = workspacePath.lastIndexOf('_work/')
|
||||
const workspaceRelativePath = workspacePath.slice(i + '_work/'.length)
|
||||
mounts.push(
|
||||
{
|
||||
name: POD_VOLUME_NAME,
|
||||
mountPath: '/github/workspace',
|
||||
subPath: workspaceRelativePath
|
||||
},
|
||||
{
|
||||
name: POD_VOLUME_NAME,
|
||||
mountPath: '/github/file_commands',
|
||||
subPath: '_temp/_runner_file_commands'
|
||||
},
|
||||
{
|
||||
name: POD_VOLUME_NAME,
|
||||
mountPath: '/github/home',
|
||||
subPath: '_temp/_github_home'
|
||||
},
|
||||
{
|
||||
name: POD_VOLUME_NAME,
|
||||
mountPath: '/github/workflow',
|
||||
subPath: '_temp/_github_workflow'
|
||||
}
|
||||
)
|
||||
return mounts
|
||||
export const CONTAINER_VOLUMES: k8s.V1VolumeMount[] = [
|
||||
{
|
||||
name: EXTERNALS_VOLUME_NAME,
|
||||
mountPath: '/__e'
|
||||
},
|
||||
{
|
||||
name: GITHUB_VOLUME_NAME,
|
||||
mountPath: '/github'
|
||||
}
|
||||
]
|
||||
|
||||
if (!jobContainer) {
|
||||
return mounts
|
||||
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
|
||||
}
|
||||
|
||||
mounts.push(
|
||||
{
|
||||
name: POD_VOLUME_NAME,
|
||||
mountPath: '/__e',
|
||||
subPath: 'externals'
|
||||
},
|
||||
{
|
||||
name: POD_VOLUME_NAME,
|
||||
mountPath: '/github/home',
|
||||
subPath: '_temp/_github_home'
|
||||
},
|
||||
{
|
||||
name: POD_VOLUME_NAME,
|
||||
mountPath: '/github/workflow',
|
||||
subPath: '_temp/_github_workflow'
|
||||
}
|
||||
)
|
||||
|
||||
if (!userMountVolumes?.length) {
|
||||
return mounts
|
||||
}
|
||||
|
||||
for (const userVolume of userMountVolumes) {
|
||||
let sourceVolumePath = ''
|
||||
if (path.isAbsolute(userVolume.sourceVolumePath)) {
|
||||
if (!userVolume.sourceVolumePath.startsWith(workspacePath)) {
|
||||
throw new Error(
|
||||
'Volume mounts outside of the work folder are not supported'
|
||||
)
|
||||
}
|
||||
// source volume path should be relative path
|
||||
sourceVolumePath = userVolume.sourceVolumePath.slice(
|
||||
workspacePath.length + 1
|
||||
)
|
||||
} else {
|
||||
sourceVolumePath = userVolume.sourceVolumePath
|
||||
}
|
||||
|
||||
mounts.push({
|
||||
name: POD_VOLUME_NAME,
|
||||
mountPath: userVolume.targetVolumePath,
|
||||
subPath: sourceVolumePath,
|
||||
readOnly: userVolume.readOnly
|
||||
})
|
||||
}
|
||||
|
||||
return mounts
|
||||
}
|
||||
|
||||
export function writeEntryPointScript(
|
||||
export function writeRunScript(
|
||||
workingDirectory: string,
|
||||
entryPoint: string,
|
||||
entryPointArgs?: string[],
|
||||
@@ -123,33 +63,12 @@ export function writeEntryPointScript(
|
||||
typeof prependPath === 'string' ? prependPath : prependPath.join(':')
|
||||
exportPath = `export PATH=${prepend}:$PATH`
|
||||
}
|
||||
let environmentPrefix = ''
|
||||
|
||||
if (environmentVariables && Object.entries(environmentVariables).length) {
|
||||
const envBuffer: string[] = []
|
||||
for (const [key, value] of Object.entries(environmentVariables)) {
|
||||
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, '\\`')}"`
|
||||
)
|
||||
}
|
||||
environmentPrefix = `env ${envBuffer.join(' ')} `
|
||||
}
|
||||
let environmentPrefix = scriptEnv(environmentVariables)
|
||||
|
||||
const content = `#!/bin/sh -l
|
||||
set -e
|
||||
rm "$0" # remove script after running
|
||||
${exportPath}
|
||||
cd ${workingDirectory} && \
|
||||
exec ${environmentPrefix} ${entryPoint} ${
|
||||
@@ -165,6 +84,73 @@ exec ${environmentPrefix} ${entryPoint} ${
|
||||
}
|
||||
}
|
||||
|
||||
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 && \
|
||||
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]
|
||||
@@ -299,3 +285,11 @@ function mergeLists<T>(base?: T[], from?: T[]): T[] {
|
||||
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 '%b %n' {} \\;`
|
||||
}
|
||||
|
||||
@@ -39,8 +39,7 @@ describe('e2e', () => {
|
||||
await expect(
|
||||
runScriptStep(
|
||||
scriptStepData.args as RunScriptStepArgs,
|
||||
prepareJobOutputData.state,
|
||||
null
|
||||
prepareJobOutputData.state
|
||||
)
|
||||
).resolves.not.toThrow()
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import * as fs from 'fs'
|
||||
import { containerPorts, POD_VOLUME_NAME } from '../src/k8s'
|
||||
import { containerPorts } from '../src/k8s'
|
||||
import {
|
||||
containerVolumes,
|
||||
generateContainerName,
|
||||
writeEntryPointScript,
|
||||
writeRunScript,
|
||||
mergePodSpecWithOptions,
|
||||
mergeContainerWithOptions,
|
||||
readExtensionFromFile,
|
||||
@@ -27,91 +26,55 @@ describe('k8s utils', () => {
|
||||
|
||||
it('should not throw', () => {
|
||||
expect(() =>
|
||||
writeEntryPointScript(
|
||||
'/test',
|
||||
'sh',
|
||||
['-e', 'script.sh'],
|
||||
['/prepend/path'],
|
||||
{
|
||||
SOME_ENV: 'SOME_VALUE'
|
||||
}
|
||||
)
|
||||
writeRunScript('/test', 'sh', ['-e', 'script.sh'], ['/prepend/path'], {
|
||||
SOME_ENV: 'SOME_VALUE'
|
||||
})
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
it('should throw if RUNNER_TEMP is not set', () => {
|
||||
delete process.env.RUNNER_TEMP
|
||||
expect(() =>
|
||||
writeEntryPointScript(
|
||||
'/test',
|
||||
'sh',
|
||||
['-e', 'script.sh'],
|
||||
['/prepend/path'],
|
||||
{
|
||||
SOME_ENV: 'SOME_VALUE'
|
||||
}
|
||||
)
|
||||
writeRunScript('/test', 'sh', ['-e', 'script.sh'], ['/prepend/path'], {
|
||||
SOME_ENV: 'SOME_VALUE'
|
||||
})
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
it('should throw if environment variable name contains double quote', () => {
|
||||
expect(() =>
|
||||
writeEntryPointScript(
|
||||
'/test',
|
||||
'sh',
|
||||
['-e', 'script.sh'],
|
||||
['/prepend/path'],
|
||||
{
|
||||
'SOME"_ENV': 'SOME_VALUE'
|
||||
}
|
||||
)
|
||||
writeRunScript('/test', 'sh', ['-e', 'script.sh'], ['/prepend/path'], {
|
||||
'SOME"_ENV': 'SOME_VALUE'
|
||||
})
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
it('should throw if environment variable name contains =', () => {
|
||||
expect(() =>
|
||||
writeEntryPointScript(
|
||||
'/test',
|
||||
'sh',
|
||||
['-e', 'script.sh'],
|
||||
['/prepend/path'],
|
||||
{
|
||||
'SOME=ENV': 'SOME_VALUE'
|
||||
}
|
||||
)
|
||||
writeRunScript('/test', 'sh', ['-e', 'script.sh'], ['/prepend/path'], {
|
||||
'SOME=ENV': 'SOME_VALUE'
|
||||
})
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
it('should throw if environment variable name contains single quote', () => {
|
||||
expect(() =>
|
||||
writeEntryPointScript(
|
||||
'/test',
|
||||
'sh',
|
||||
['-e', 'script.sh'],
|
||||
['/prepend/path'],
|
||||
{
|
||||
"SOME'_ENV": 'SOME_VALUE'
|
||||
}
|
||||
)
|
||||
writeRunScript('/test', 'sh', ['-e', 'script.sh'], ['/prepend/path'], {
|
||||
"SOME'_ENV": 'SOME_VALUE'
|
||||
})
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
it('should throw if environment variable name contains dollar', () => {
|
||||
expect(() =>
|
||||
writeEntryPointScript(
|
||||
'/test',
|
||||
'sh',
|
||||
['-e', 'script.sh'],
|
||||
['/prepend/path'],
|
||||
{
|
||||
SOME_$_ENV: 'SOME_VALUE'
|
||||
}
|
||||
)
|
||||
writeRunScript('/test', 'sh', ['-e', 'script.sh'], ['/prepend/path'], {
|
||||
SOME_$_ENV: 'SOME_VALUE'
|
||||
})
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
it('should escape double quote, dollar and backslash in environment variable values', () => {
|
||||
const { runnerPath } = writeEntryPointScript(
|
||||
const { runnerPath } = writeRunScript(
|
||||
'/test',
|
||||
'sh',
|
||||
['-e', 'script.sh'],
|
||||
@@ -130,7 +93,7 @@ describe('k8s utils', () => {
|
||||
})
|
||||
|
||||
it('should return object with containerPath and runnerPath', () => {
|
||||
const { containerPath, runnerPath } = writeEntryPointScript(
|
||||
const { containerPath, runnerPath } = writeRunScript(
|
||||
'/test',
|
||||
'sh',
|
||||
['-e', 'script.sh'],
|
||||
@@ -145,7 +108,7 @@ describe('k8s utils', () => {
|
||||
})
|
||||
|
||||
it('should write entrypoint path and the file should exist', () => {
|
||||
const { runnerPath } = writeEntryPointScript(
|
||||
const { runnerPath } = writeRunScript(
|
||||
'/test',
|
||||
'sh',
|
||||
['-e', 'script.sh'],
|
||||
@@ -168,90 +131,6 @@ describe('k8s utils', () => {
|
||||
await testHelper.cleanup()
|
||||
})
|
||||
|
||||
it('should throw if container action and GITHUB_WORKSPACE env is not set', () => {
|
||||
delete process.env.GITHUB_WORKSPACE
|
||||
expect(() => containerVolumes([], true, true)).toThrow()
|
||||
expect(() => containerVolumes([], false, true)).toThrow()
|
||||
})
|
||||
|
||||
it('should always have work mount', () => {
|
||||
let volumes = containerVolumes([], true, true)
|
||||
expect(volumes.find(e => e.mountPath === '/__w')).toBeTruthy()
|
||||
volumes = containerVolumes([], true, false)
|
||||
expect(volumes.find(e => e.mountPath === '/__w')).toBeTruthy()
|
||||
volumes = containerVolumes([], false, true)
|
||||
expect(volumes.find(e => e.mountPath === '/__w')).toBeTruthy()
|
||||
volumes = containerVolumes([], false, false)
|
||||
expect(volumes.find(e => e.mountPath === '/__w')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should always have /github/workflow mount if working on container job or container action', () => {
|
||||
let volumes = containerVolumes([], true, true)
|
||||
expect(volumes.find(e => e.mountPath === '/github/workflow')).toBeTruthy()
|
||||
volumes = containerVolumes([], true, false)
|
||||
expect(volumes.find(e => e.mountPath === '/github/workflow')).toBeTruthy()
|
||||
volumes = containerVolumes([], false, true)
|
||||
expect(volumes.find(e => e.mountPath === '/github/workflow')).toBeTruthy()
|
||||
|
||||
volumes = containerVolumes([], false, false)
|
||||
expect(
|
||||
volumes.find(e => e.mountPath === '/github/workflow')
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should have container action volumes', () => {
|
||||
let volumes = containerVolumes([], true, true)
|
||||
let workspace = volumes.find(e => e.mountPath === '/github/workspace')
|
||||
let fileCommands = volumes.find(
|
||||
e => e.mountPath === '/github/file_commands'
|
||||
)
|
||||
expect(workspace).toBeTruthy()
|
||||
expect(workspace?.subPath).toBe('repo/repo')
|
||||
expect(fileCommands).toBeTruthy()
|
||||
expect(fileCommands?.subPath).toBe('_temp/_runner_file_commands')
|
||||
|
||||
volumes = containerVolumes([], false, true)
|
||||
workspace = volumes.find(e => e.mountPath === '/github/workspace')
|
||||
fileCommands = volumes.find(e => e.mountPath === '/github/file_commands')
|
||||
expect(workspace).toBeTruthy()
|
||||
expect(workspace?.subPath).toBe('repo/repo')
|
||||
expect(fileCommands).toBeTruthy()
|
||||
expect(fileCommands?.subPath).toBe('_temp/_runner_file_commands')
|
||||
})
|
||||
|
||||
it('should have externals, github home mounts if job container', () => {
|
||||
const volumes = containerVolumes()
|
||||
expect(volumes.find(e => e.mountPath === '/__e')).toBeTruthy()
|
||||
expect(volumes.find(e => e.mountPath === '/github/home')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should throw if user volume source volume path is not in workspace', () => {
|
||||
expect(() =>
|
||||
containerVolumes(
|
||||
[
|
||||
{
|
||||
sourceVolumePath: '/outside/of/workdir',
|
||||
targetVolumePath: '/some/target/path',
|
||||
readOnly: false
|
||||
}
|
||||
],
|
||||
true,
|
||||
false
|
||||
)
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
it(`all volumes should have name ${POD_VOLUME_NAME}`, () => {
|
||||
let volumes = containerVolumes([], true, true)
|
||||
expect(volumes.every(e => e.name === POD_VOLUME_NAME)).toBeTruthy()
|
||||
volumes = containerVolumes([], true, false)
|
||||
expect(volumes.every(e => e.name === POD_VOLUME_NAME)).toBeTruthy()
|
||||
volumes = containerVolumes([], false, true)
|
||||
expect(volumes.every(e => e.name === POD_VOLUME_NAME)).toBeTruthy()
|
||||
volumes = containerVolumes([], false, false)
|
||||
expect(volumes.every(e => e.name === POD_VOLUME_NAME)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should parse container ports', () => {
|
||||
const tt = [
|
||||
{
|
||||
|
||||
@@ -3,12 +3,8 @@ import * as path from 'path'
|
||||
import { cleanupJob } from '../src/hooks'
|
||||
import { createContainerSpec, prepareJob } from '../src/hooks/prepare-job'
|
||||
import { TestHelper } from './test-setup'
|
||||
import {
|
||||
ENV_HOOK_TEMPLATE_PATH,
|
||||
ENV_USE_KUBE_SCHEDULER,
|
||||
generateContainerName
|
||||
} from '../src/k8s/utils'
|
||||
import { getPodByName } from '../src/k8s'
|
||||
import { ENV_HOOK_TEMPLATE_PATH, generateContainerName } from '../src/k8s/utils'
|
||||
import { execPodStep, getPodByName } from '../src/k8s'
|
||||
import { V1Container } from '@kubernetes/client-node'
|
||||
import { JOB_CONTAINER_NAME } from '../src/hooks/constants'
|
||||
|
||||
@@ -45,19 +41,38 @@ describe('Prepare job', () => {
|
||||
})
|
||||
|
||||
it('should prepare job with absolute path for userVolumeMount', async () => {
|
||||
const userVolumeMount = path.join(
|
||||
process.env.GITHUB_WORKSPACE as string,
|
||||
'myvolume'
|
||||
)
|
||||
fs.mkdirSync(userVolumeMount)
|
||||
fs.writeFileSync(path.join(userVolumeMount, 'file.txt'), 'hello')
|
||||
prepareJobData.args.container.userMountVolumes = [
|
||||
{
|
||||
sourceVolumePath: path.join(
|
||||
process.env.GITHUB_WORKSPACE as string,
|
||||
'/myvolume'
|
||||
),
|
||||
targetVolumePath: '/volume_mount',
|
||||
sourceVolumePath: userVolumeMount,
|
||||
targetVolumePath: '/__w/myvolume',
|
||||
readOnly: false
|
||||
}
|
||||
]
|
||||
await expect(
|
||||
prepareJob(prepareJobData.args, prepareJobOutputFilePath)
|
||||
).resolves.not.toThrow()
|
||||
|
||||
const content = JSON.parse(
|
||||
fs.readFileSync(prepareJobOutputFilePath).toString()
|
||||
)
|
||||
|
||||
await execPodStep(
|
||||
[
|
||||
'sh',
|
||||
'-c',
|
||||
'\'[ "$(cat /__w/myvolume/file.txt)" = "hello" ] || exit 5\''
|
||||
],
|
||||
content!.state!.jobPod,
|
||||
JOB_CONTAINER_NAME
|
||||
).then(output => {
|
||||
expect(output).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
it('should prepare job with envs CI and GITHUB_ACTIONS', async () => {
|
||||
@@ -108,19 +123,6 @@ describe('Prepare job', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('should throw an exception if the user volume mount is absolute path outside of GITHUB_WORKSPACE', async () => {
|
||||
prepareJobData.args.container.userMountVolumes = [
|
||||
{
|
||||
sourceVolumePath: '/somewhere/not/in/gh-workspace',
|
||||
targetVolumePath: '/containermount',
|
||||
readOnly: false
|
||||
}
|
||||
]
|
||||
await expect(
|
||||
prepareJob(prepareJobData.args, prepareJobOutputFilePath)
|
||||
).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('should not run prepare job without the job container', async () => {
|
||||
prepareJobData.args.container = undefined
|
||||
await expect(
|
||||
@@ -166,8 +168,7 @@ describe('Prepare job', () => {
|
||||
|
||||
expect(got.metadata?.annotations?.['annotated-by']).toBe('extension')
|
||||
expect(got.metadata?.labels?.['labeled-by']).toBe('extension')
|
||||
expect(got.spec?.securityContext?.runAsUser).toBe(1000)
|
||||
expect(got.spec?.securityContext?.runAsGroup).toBe(3000)
|
||||
expect(got.spec?.restartPolicy).toBe('Never')
|
||||
|
||||
// job container
|
||||
expect(got.spec?.containers[0].name).toBe(JOB_CONTAINER_NAME)
|
||||
@@ -217,17 +218,6 @@ describe('Prepare job', () => {
|
||||
expect(content.context.services.length).toBe(1)
|
||||
})
|
||||
|
||||
it('should not throw exception using kube scheduler', async () => {
|
||||
// only for ReadWriteMany volumes or single node cluster
|
||||
process.env[ENV_USE_KUBE_SCHEDULER] = 'true'
|
||||
|
||||
await expect(
|
||||
prepareJob(prepareJobData.args, prepareJobOutputFilePath)
|
||||
).resolves.not.toThrow()
|
||||
|
||||
delete process.env[ENV_USE_KUBE_SCHEDULER]
|
||||
})
|
||||
|
||||
test.each([undefined, null, []])(
|
||||
'should not throw exception when portMapping=%p',
|
||||
async pm => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { runContainerStep } from '../src/hooks'
|
||||
import { prepareJob, runContainerStep } from '../src/hooks'
|
||||
import { TestHelper } from './test-setup'
|
||||
import { ENV_HOOK_TEMPLATE_PATH } from '../src/k8s/utils'
|
||||
import * as fs from 'fs'
|
||||
@@ -10,11 +10,16 @@ jest.useRealTimers()
|
||||
let testHelper: TestHelper
|
||||
|
||||
let runContainerStepData: any
|
||||
let prepareJobData: any
|
||||
let prepareJobOutputFilePath: string
|
||||
|
||||
describe('Run container step', () => {
|
||||
beforeEach(async () => {
|
||||
testHelper = new TestHelper()
|
||||
await testHelper.initialize()
|
||||
prepareJobData = testHelper.getPrepareJobDefinition()
|
||||
prepareJobOutputFilePath = testHelper.createFile('prepare-job-output.json')
|
||||
await prepareJob(prepareJobData.args, prepareJobOutputFilePath)
|
||||
runContainerStepData = testHelper.getRunContainerStepDefinition()
|
||||
})
|
||||
|
||||
@@ -22,11 +27,6 @@ describe('Run container step', () => {
|
||||
await testHelper.cleanup()
|
||||
})
|
||||
|
||||
it('should not throw', async () => {
|
||||
const exitCode = await runContainerStep(runContainerStepData.args)
|
||||
expect(exitCode).toBe(0)
|
||||
})
|
||||
|
||||
it('should run pod with extensions applied', async () => {
|
||||
const extension = {
|
||||
metadata: {
|
||||
@@ -42,7 +42,7 @@ describe('Run container step', () => {
|
||||
{
|
||||
name: JOB_CONTAINER_EXTENSION_NAME,
|
||||
command: ['sh'],
|
||||
args: ['-c', 'echo test']
|
||||
args: ['-c', 'sleep 10000']
|
||||
},
|
||||
{
|
||||
name: 'side-container',
|
||||
@@ -51,11 +51,7 @@ describe('Run container step', () => {
|
||||
args: ['-c', 'echo test']
|
||||
}
|
||||
],
|
||||
restartPolicy: 'Never',
|
||||
securityContext: {
|
||||
runAsUser: 1000,
|
||||
runAsGroup: 3000
|
||||
}
|
||||
restartPolicy: 'Never'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as fs from 'fs'
|
||||
import { cleanupJob, prepareJob, runScriptStep } from '../src/hooks'
|
||||
import { TestHelper } from './test-setup'
|
||||
import { PrepareJobArgs } from 'hooklib'
|
||||
import { PrepareJobArgs, RunScriptStepArgs } from 'hooklib'
|
||||
|
||||
jest.useRealTimers()
|
||||
|
||||
@@ -9,7 +9,9 @@ let testHelper: TestHelper
|
||||
|
||||
let prepareJobOutputData: any
|
||||
|
||||
let runScriptStepDefinition
|
||||
let runScriptStepDefinition: {
|
||||
args: RunScriptStepArgs
|
||||
}
|
||||
|
||||
describe('Run script step', () => {
|
||||
beforeEach(async () => {
|
||||
@@ -20,7 +22,9 @@ describe('Run script step', () => {
|
||||
)
|
||||
|
||||
const prepareJobData = testHelper.getPrepareJobDefinition()
|
||||
runScriptStepDefinition = testHelper.getRunScriptStepDefinition()
|
||||
runScriptStepDefinition = testHelper.getRunScriptStepDefinition() as {
|
||||
args: RunScriptStepArgs
|
||||
}
|
||||
|
||||
await prepareJob(
|
||||
prepareJobData.args as PrepareJobArgs,
|
||||
@@ -41,22 +45,14 @@ describe('Run script step', () => {
|
||||
|
||||
it('should not throw an exception', async () => {
|
||||
await expect(
|
||||
runScriptStep(
|
||||
runScriptStepDefinition.args,
|
||||
prepareJobOutputData.state,
|
||||
null
|
||||
)
|
||||
runScriptStep(runScriptStepDefinition.args, prepareJobOutputData.state)
|
||||
).resolves.not.toThrow()
|
||||
})
|
||||
|
||||
it('should fail if the working directory does not exist', async () => {
|
||||
runScriptStepDefinition.args.workingDirectory = '/foo/bar'
|
||||
await expect(
|
||||
runScriptStep(
|
||||
runScriptStepDefinition.args,
|
||||
prepareJobOutputData.state,
|
||||
null
|
||||
)
|
||||
runScriptStep(runScriptStepDefinition.args, prepareJobOutputData.state)
|
||||
).rejects.toThrow()
|
||||
})
|
||||
|
||||
@@ -68,16 +64,12 @@ describe('Run script step', () => {
|
||||
"'if [[ -z $NODE_ENV ]]; then exit 1; fi'"
|
||||
]
|
||||
await expect(
|
||||
runScriptStep(
|
||||
runScriptStepDefinition.args,
|
||||
prepareJobOutputData.state,
|
||||
null
|
||||
)
|
||||
runScriptStep(runScriptStepDefinition.args, prepareJobOutputData.state)
|
||||
).resolves.not.toThrow()
|
||||
})
|
||||
|
||||
it('Should have path variable changed in container with prepend path string', async () => {
|
||||
runScriptStepDefinition.args.prependPath = '/some/path'
|
||||
runScriptStepDefinition.args.prependPath = ['/some/path']
|
||||
runScriptStepDefinition.args.entryPoint = '/bin/bash'
|
||||
runScriptStepDefinition.args.entryPointArgs = [
|
||||
'-c',
|
||||
@@ -85,11 +77,7 @@ describe('Run script step', () => {
|
||||
]
|
||||
|
||||
await expect(
|
||||
runScriptStep(
|
||||
runScriptStepDefinition.args,
|
||||
prepareJobOutputData.state,
|
||||
null
|
||||
)
|
||||
runScriptStep(runScriptStepDefinition.args, prepareJobOutputData.state)
|
||||
).resolves.not.toThrow()
|
||||
})
|
||||
|
||||
@@ -107,11 +95,7 @@ describe('Run script step', () => {
|
||||
]
|
||||
|
||||
await expect(
|
||||
runScriptStep(
|
||||
runScriptStepDefinition.args,
|
||||
prepareJobOutputData.state,
|
||||
null
|
||||
)
|
||||
runScriptStep(runScriptStepDefinition.args, prepareJobOutputData.state)
|
||||
).resolves.not.toThrow()
|
||||
})
|
||||
|
||||
@@ -126,11 +110,7 @@ describe('Run script step', () => {
|
||||
]
|
||||
|
||||
await expect(
|
||||
runScriptStep(
|
||||
runScriptStepDefinition.args,
|
||||
prepareJobOutputData.state,
|
||||
null
|
||||
)
|
||||
runScriptStep(runScriptStepDefinition.args, prepareJobOutputData.state)
|
||||
).resolves.not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,35 +9,43 @@ const kc = new k8s.KubeConfig()
|
||||
kc.loadFromDefault()
|
||||
|
||||
const k8sApi = kc.makeApiClient(k8s.CoreV1Api)
|
||||
const k8sStorageApi = kc.makeApiClient(k8s.StorageV1Api)
|
||||
|
||||
export class TestHelper {
|
||||
private tempDirPath: string
|
||||
private podName: string
|
||||
private runnerWorkdir: string
|
||||
private runnerTemp: string
|
||||
|
||||
constructor() {
|
||||
this.tempDirPath = `${__dirname}/_temp/runner`
|
||||
this.runnerWorkdir = `${this.tempDirPath}/_work`
|
||||
this.runnerTemp = `${this.tempDirPath}/_work/_temp`
|
||||
this.podName = uuidv4().replace(/-/g, '')
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
process.env['ACTIONS_RUNNER_POD_NAME'] = `${this.podName}`
|
||||
process.env['RUNNER_WORKSPACE'] = `${this.tempDirPath}/_work/repo`
|
||||
process.env['RUNNER_TEMP'] = `${this.tempDirPath}/_work/_temp`
|
||||
process.env['GITHUB_WORKSPACE'] = `${this.tempDirPath}/_work/repo/repo`
|
||||
process.env['RUNNER_WORKSPACE'] = `${this.runnerWorkdir}/repo`
|
||||
process.env['RUNNER_TEMP'] = `${this.runnerTemp}`
|
||||
process.env['GITHUB_WORKSPACE'] = `${this.runnerWorkdir}/repo/repo`
|
||||
process.env['ACTIONS_RUNNER_KUBERNETES_NAMESPACE'] = 'default'
|
||||
|
||||
fs.mkdirSync(`${this.tempDirPath}/_work/repo/repo`, { recursive: true })
|
||||
fs.mkdirSync(`${this.runnerWorkdir}/repo/repo`, { recursive: true })
|
||||
fs.mkdirSync(`${this.tempDirPath}/externals`, { recursive: true })
|
||||
fs.mkdirSync(process.env.RUNNER_TEMP, { recursive: true })
|
||||
fs.mkdirSync(this.runnerTemp, { recursive: true })
|
||||
fs.mkdirSync(`${this.runnerTemp}/_github_workflow`, { recursive: true })
|
||||
fs.mkdirSync(`${this.runnerTemp}/_github_home`, { recursive: true })
|
||||
fs.mkdirSync(`${this.runnerTemp}/_runner_file_commands`, {
|
||||
recursive: true
|
||||
})
|
||||
|
||||
fs.copyFileSync(
|
||||
path.resolve(`${__dirname}/../../../examples/example-script.sh`),
|
||||
`${process.env.RUNNER_TEMP}/example-script.sh`
|
||||
`${this.runnerTemp}/example-script.sh`
|
||||
)
|
||||
|
||||
await this.cleanupK8sResources()
|
||||
try {
|
||||
await this.createTestVolume()
|
||||
await this.createTestJobPod()
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
@@ -54,33 +62,16 @@ export class TestHelper {
|
||||
}
|
||||
|
||||
async cleanupK8sResources(): Promise<void> {
|
||||
await k8sApi
|
||||
.deleteNamespacedPersistentVolumeClaim({
|
||||
name: `${this.podName}-work`,
|
||||
namespace: 'default',
|
||||
gracePeriodSeconds: 0
|
||||
})
|
||||
.catch(e => {
|
||||
console.error(e)
|
||||
})
|
||||
await k8sApi
|
||||
.deletePersistentVolume({ name: `${this.podName}-pv` })
|
||||
.catch(e => {
|
||||
console.error(e)
|
||||
})
|
||||
await k8sStorageApi
|
||||
.deleteStorageClass({ name: 'local-storage' })
|
||||
.catch(e => {
|
||||
console.error(e)
|
||||
})
|
||||
await k8sApi
|
||||
.deleteNamespacedPod({
|
||||
name: this.podName,
|
||||
namespace: 'default',
|
||||
gracePeriodSeconds: 0
|
||||
})
|
||||
.catch(e => {
|
||||
console.error(e)
|
||||
.catch((e: k8s.ApiException<any>) => {
|
||||
if (e.code !== 404) {
|
||||
console.error(JSON.stringify(e))
|
||||
}
|
||||
})
|
||||
await k8sApi
|
||||
.deleteNamespacedPod({
|
||||
@@ -88,8 +79,10 @@ export class TestHelper {
|
||||
namespace: 'default',
|
||||
gracePeriodSeconds: 0
|
||||
})
|
||||
.catch(e => {
|
||||
console.error(e)
|
||||
.catch((e: k8s.ApiException<any>) => {
|
||||
if (e.code !== 404) {
|
||||
console.error(JSON.stringify(e))
|
||||
}
|
||||
})
|
||||
}
|
||||
createFile(fileName?: string): string {
|
||||
@@ -105,8 +98,8 @@ export class TestHelper {
|
||||
|
||||
async createTestJobPod(): Promise<void> {
|
||||
const container = {
|
||||
name: 'nginx',
|
||||
image: 'nginx:latest',
|
||||
name: 'runner',
|
||||
image: 'ghcr.io/actions/actions-runner:latest',
|
||||
imagePullPolicy: 'IfNotPresent'
|
||||
} as k8s.V1Container
|
||||
|
||||
@@ -116,61 +109,17 @@ export class TestHelper {
|
||||
},
|
||||
spec: {
|
||||
restartPolicy: 'Never',
|
||||
containers: [container]
|
||||
containers: [container],
|
||||
securityContext: {
|
||||
runAsUser: 1001,
|
||||
runAsGroup: 1001,
|
||||
fsGroup: 1001
|
||||
}
|
||||
}
|
||||
} as k8s.V1Pod
|
||||
await k8sApi.createNamespacedPod({ namespace: 'default', body: pod })
|
||||
}
|
||||
|
||||
async createTestVolume(): Promise<void> {
|
||||
var sc: k8s.V1StorageClass = {
|
||||
metadata: {
|
||||
name: 'local-storage'
|
||||
},
|
||||
provisioner: 'kubernetes.io/no-provisioner',
|
||||
volumeBindingMode: 'Immediate'
|
||||
}
|
||||
await k8sStorageApi.createStorageClass({ body: sc })
|
||||
|
||||
var volume: k8s.V1PersistentVolume = {
|
||||
metadata: {
|
||||
name: `${this.podName}-pv`
|
||||
},
|
||||
spec: {
|
||||
storageClassName: 'local-storage',
|
||||
capacity: {
|
||||
storage: '2Gi'
|
||||
},
|
||||
volumeMode: 'Filesystem',
|
||||
accessModes: ['ReadWriteOnce'],
|
||||
hostPath: {
|
||||
path: `${this.tempDirPath}/_work`
|
||||
}
|
||||
}
|
||||
}
|
||||
await k8sApi.createPersistentVolume({ body: volume })
|
||||
var volumeClaim: k8s.V1PersistentVolumeClaim = {
|
||||
metadata: {
|
||||
name: `${this.podName}-work`
|
||||
},
|
||||
spec: {
|
||||
accessModes: ['ReadWriteOnce'],
|
||||
volumeMode: 'Filesystem',
|
||||
storageClassName: 'local-storage',
|
||||
volumeName: `${this.podName}-pv`,
|
||||
resources: {
|
||||
requests: {
|
||||
storage: '1Gi'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
await k8sApi.createNamespacedPersistentVolumeClaim({
|
||||
namespace: 'default',
|
||||
body: volumeClaim
|
||||
})
|
||||
}
|
||||
|
||||
getPrepareJobDefinition(): HookData {
|
||||
const prepareJob = JSON.parse(
|
||||
fs.readFileSync(
|
||||
|
||||
Reference in New Issue
Block a user