Ensure read write many first

This commit is contained in:
Nikola Jokic
2026-04-22 22:55:49 +02:00
parent 6ecda1d8ea
commit 4972708208
11 changed files with 889 additions and 648 deletions
+55
View File
@@ -0,0 +1,55 @@
# ADR 0135: RWX volume strategy and RWO affinity fallback
**Date:** 22 April 2026
**Status**: Accepted
## Context
The Kubernetes hook implementation for GitHub Actions runners requires access to the runner's working directory (`_work`) within the dynamically created job pods. This shared access is typically managed via Persistent Volume Claims (PVCs).
Regardless of the storage strategy, job pods are always constrained to run on the same node as the runner pod to ensure consistent access to the local environment and state. The choice of volume access mode determines operational flexibility and multi-pod access capability rather than pod placement.
Depending on the storage provider and cluster configuration, operators may choose between `ReadWriteMany` (RWX) or `ReadWriteOnce` (RWO) access modes. RWX is preferred because it allows multiple pods to access the volume simultaneously, providing greater operational flexibility for future scaling or monitoring scenarios. RWO restricts volume access to a single pod at a time, locking the volume to that pod's specific node.
## Decision
We have decided to establish `ReadWriteMany` (RWX) as the preferred storage strategy for the Kubernetes hook. While job pods remain pinned to the runner's node, RWX provides superior operational flexibility by allowing multiple pods (such as sidecars or auxiliary tools) to access the same volume without storage-imposed locking constraints.
For environments where RWX is unavailable or undesirable, we support a `ReadWriteOnce` (RWO) fallback strategy. This fallback is implemented using node affinity to ensure that job pods are scheduled onto the same node as the runner pod that holds the RWO volume.
### Operational Guidance
1. **Preferred Model (RWX):** Operators should configure the runner with a PVC supporting `ReadWriteMany`.
2. **Fallback Model (RWO):** If using `ReadWriteOnce`, operators must enable the Kubernetes scheduler integration by setting `ACTIONS_RUNNER_USE_KUBE_SCHEDULER=true`.
3. **Node Selection:** When scheduler integration is enabled, the hook applies a `requiredDuringSchedulingIgnoredDuringExecution` node affinity targeting the runner's current node (`kubernetes.io/hostname`).
4. **Implementation Details:**
- The hook determines the node name via `getCurrentNodeName()` and applies affinity in `packages/k8s/src/k8s/index.ts` (lines 101, 165).
- The scheduler behavior is toggled by the `ACTIONS_RUNNER_USE_KUBE_SCHEDULER` environment variable, as defined in `packages/k8s/src/k8s/utils.ts` (line 16).
- The PVC claim name defaults to `${ACTIONS_RUNNER_POD_NAME}-work` unless overridden by `ACTIONS_RUNNER_CLAIM_NAME` (`packages/k8s/src/hooks/constants.ts`, lines 27-33).
### Non-Recommendations
We explicitly do **not** recommend the use of `spec.nodeName` for operator-driven scheduling. While the hook uses `nodeName` as a legacy fallback when `ACTIONS_RUNNER_USE_KUBE_SCHEDULER` is not set to `true` (`packages/k8s/src/k8s/index.ts`, lines 103, 167), this bypasses the Kubernetes scheduler and can lead to scheduling failures or resource imbalances. Operators should always prefer the affinity-based approach for RWO volumes.
## Alternatives
- **nodeName Bypass:** Directly setting `nodeName` bypasses the scheduler entirely. This was rejected as a recommendation because it prevents the scheduler from accounting for taints, tolerations, and resource pressure.
- **Local Volumes:** Using local volumes tied to specific nodes. This is a subset of the RWO fallback and is supported via the affinity mechanism.
## Consequences
- **Flexibility:** RWX users benefit from the ability to have multiple pods access the volume simultaneously, simplifying future operational extensions.
- **Node Coupling:** All users are coupled to the node where the runner pod is running. The hook ensures job pods are scheduled on the same node to maintain workspace integrity.
- **Configuration:** Operators must be aware of the `ACTIONS_RUNNER_USE_KUBE_SCHEDULER` toggle when moving from RWX to RWO. This toggle controls whether the hook uses `nodeName` (bypassing the scheduler) or node affinity (using the scheduler) to pin the pod to the runner's node.
## Migration Guidance
Operators migrating from an RWO setup that relied on default `nodeName` behavior to a more robust affinity-based setup should:
1. Ensure the runner pod has the `ACTIONS_RUNNER_USE_KUBE_SCHEDULER` environment variable set to `true`.
2. Verify that the runner's ServiceAccount has the necessary permissions to list pods (to determine its own node).
## Non-Goals
- This ADR does not recommend `nodeName` as a primary or secondary configuration path for operators.
- This ADR does not dictate specific storage providers (e.g., EBS vs. EFS vs. Azure Files), but rather the access mode strategy.
+21
View File
@@ -30,6 +30,27 @@ rules:
- The `ACTIONS_RUNNER_REQUIRE_JOB_CONTAINER` env should be set to true to prevent the runner from running any jobs outside of a container - The `ACTIONS_RUNNER_REQUIRE_JOB_CONTAINER` env should be set to true to prevent the runner from running any jobs outside of a container
- The runner pod should map a persistent volume claim into the `_work` directory - The runner pod should map a persistent volume claim into the `_work` directory
- The `ACTIONS_RUNNER_CLAIM_NAME` env should be set to the persistent volume claim that contains the runner's working directory, otherwise it defaults to `${ACTIONS_RUNNER_POD_NAME}-work` - The `ACTIONS_RUNNER_CLAIM_NAME` env should be set to the persistent volume claim that contains the runner's working directory, otherwise it defaults to `${ACTIONS_RUNNER_POD_NAME}-work`
- The `ACTIONS_RUNNER_USE_KUBE_SCHEDULER` env can be set to `true` to enable the Kubernetes scheduler for job pods. When set to `true`, the hook uses `nodeAffinity` to ensure job pods are scheduled correctly (essential for `ReadWriteOnce` volumes). If not set, the hook defaults to a legacy mode where job pods are pinned to the same node as the runner pod using `nodeName`.
## Storage Guidance
The K8s hooks require a shared volume between the runner pod and the job pods to share the workspace and other internal directories.
### RWX (Recommended)
The preferred way to configure storage is using a `ReadWriteMany` (RWX) Persistent Volume Claim. While job pods are always pinned to the runner's node, RWX provides better operational flexibility by allowing multiple pods to access the same workspace simultaneously.
To migrate from RWO to RWX:
1. Provision a new `ReadWriteMany` StorageClass if one is not available.
2. Update your PVC definition to use `accessModes: [ReadWriteMany]`.
3. Set `ACTIONS_RUNNER_USE_KUBE_SCHEDULER=true` to enable the scheduler-based node pinning (via affinity) instead of the default `nodeName` pinning.
### RWO Fallback (Affinity-based)
If `ReadWriteMany` storage is not available, you can use `ReadWriteOnce` (RWO) storage. In this mode, all job pods must be scheduled on the same node as the runner pod that owns the PVC.
To enable this safely:
1. Ensure `ACTIONS_RUNNER_USE_KUBE_SCHEDULER` is set to `true`.
2. The hooks will automatically add a `nodeAffinity` to the job pods, ensuring they are scheduled on the same node as the runner pod (`kubernetes.io/hostname` match).
> **Note:** We do not recommend manually setting `nodeName` in the pod template, as the hooks handle node placement automatically via affinity when the scheduler is enabled.
- Some actions runner env's are expected to be set. These are set automatically by the runner. - Some actions runner env's are expected to be set. These are set automatically by the runner.
- `RUNNER_WORKSPACE` is expected to be set to the workspace of the runner - `RUNNER_WORKSPACE` is expected to be set to the workspace of the runner
- `GITHUB_WORKSPACE` is expected to be set to the workspace of the job - `GITHUB_WORKSPACE` is expected to be set to the workspace of the job
+29 -48
View File
@@ -1,4 +1,5 @@
import * as core from '@actions/core' import * as core from '@actions/core'
import * as io from '@actions/io'
import * as k8s from '@kubernetes/client-node' import * as k8s from '@kubernetes/client-node'
import { import {
JobContainerInfo, JobContainerInfo,
@@ -7,33 +8,26 @@ import {
writeToResponseFile, writeToResponseFile,
ServiceContainerInfo ServiceContainerInfo
} from 'hooklib' } from 'hooklib'
import path from 'path'
import { import {
containerPorts, containerPorts,
createJobPod, createPod,
isPodContainerAlpine, isPodContainerAlpine,
prunePods, prunePods,
waitForPodPhases, waitForPodPhases,
getPrepareJobTimeoutSeconds, getPrepareJobTimeoutSeconds
execCpToPod,
execPodStep
} from '../k8s' } from '../k8s'
import { import {
CONTAINER_VOLUMES, containerVolumes,
DEFAULT_CONTAINER_ENTRY_POINT, DEFAULT_CONTAINER_ENTRY_POINT,
DEFAULT_CONTAINER_ENTRY_POINT_ARGS, DEFAULT_CONTAINER_ENTRY_POINT_ARGS,
generateContainerName, generateContainerName,
mergeContainerWithOptions, mergeContainerWithOptions,
readExtensionFromFile, readExtensionFromFile,
PodPhase, PodPhase,
fixArgs, fixArgs
prepareJobScript
} from '../k8s/utils' } from '../k8s/utils'
import { import { CONTAINER_EXTENSION_PREFIX, JOB_CONTAINER_NAME } from './constants'
CONTAINER_EXTENSION_PREFIX,
getJobPodName,
JOB_CONTAINER_NAME
} from './constants'
import { dirname } from 'path'
export async function prepareJob( export async function prepareJob(
args: PrepareJobArgs, args: PrepareJobArgs,
@@ -46,6 +40,7 @@ export async function prepareJob(
await prunePods() await prunePods()
const extension = readExtensionFromFile() const extension = readExtensionFromFile()
await copyExternalsToRoot()
let container: k8s.V1Container | undefined = undefined let container: k8s.V1Container | undefined = undefined
if (args.container?.image) { if (args.container?.image) {
@@ -75,8 +70,7 @@ export async function prepareJob(
let createdPod: k8s.V1Pod | undefined = undefined let createdPod: k8s.V1Pod | undefined = undefined
try { try {
createdPod = await createJobPod( createdPod = await createPod(
getJobPodName(),
container, container,
services, services,
args.container.registry, args.container.registry,
@@ -96,13 +90,6 @@ export async function prepareJob(
`Job pod created, waiting for it to come online ${createdPod?.metadata?.name}` `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 { try {
await waitForPodPhases( await waitForPodPhases(
createdPod.metadata.name, createdPod.metadata.name,
@@ -115,28 +102,6 @@ export async function prepareJob(
throw new Error(`pod failed to come online with error: ${err}`) 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') core.debug('Job pod is ready for traffic')
let isAlpine = false let isAlpine = false
@@ -180,8 +145,10 @@ function generateResponseFile(
const mainContainerContextPorts: ContextPorts = {} const mainContainerContextPorts: ContextPorts = {}
if (mainContainer?.ports) { if (mainContainer?.ports) {
for (const port of mainContainer.ports) { for (const port of mainContainer.ports) {
mainContainerContextPorts[port.containerPort] = if (port.containerPort && port.hostPort) {
mainContainerContextPorts.hostPort mainContainerContextPorts[port.containerPort.toString()] =
port.hostPort.toString()
}
} }
} }
@@ -217,6 +184,17 @@ function generateResponseFile(
writeToResponseFile(responseFile, JSON.stringify(response)) 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( export function createContainerSpec(
container: JobContainerInfo | ServiceContainerInfo, container: JobContainerInfo | ServiceContainerInfo,
name: string, name: string,
@@ -250,7 +228,7 @@ export function createContainerSpec(
container['environmentVariables'] || {} container['environmentVariables'] || {}
)) { )) {
if (value && key !== 'HOME') { if (value && key !== 'HOME') {
podContainer.env.push({ name: key, value }) podContainer.env.push({ name: key, value: value as string })
} }
} }
@@ -266,7 +244,10 @@ export function createContainerSpec(
}) })
} }
podContainer.volumeMounts = CONTAINER_VOLUMES podContainer.volumeMounts = containerVolumes(
container['userMountVolumes'],
jobContainer
)
if (!extension) { if (!extension) {
return podContainer return podContainer
+92 -91
View File
@@ -1,31 +1,23 @@
import * as core from '@actions/core' import * as core from '@actions/core'
import * as fs from 'fs'
import * as k8s from '@kubernetes/client-node' import * as k8s from '@kubernetes/client-node'
import { RunContainerStepArgs } from 'hooklib' import { RunContainerStepArgs } from 'hooklib'
import { dirname } from 'path'
import { import {
createContainerStepPod, createJob,
deletePod, createSecretForEnvs,
execCpFromPod, getContainerJobPodName,
execCpToPod, getPodLogs,
execPodStep, getPodStatus,
getPrepareJobTimeoutSeconds, waitForJobToComplete,
waitForPodPhases waitForPodPhases
} from '../k8s' } from '../k8s'
import { import {
CONTAINER_VOLUMES, containerVolumes,
fixArgs,
mergeContainerWithOptions, mergeContainerWithOptions,
PodPhase, PodPhase,
readExtensionFromFile, readExtensionFromFile
DEFAULT_CONTAINER_ENTRY_POINT_ARGS,
writeContainerStepScript
} from '../k8s/utils' } from '../k8s/utils'
import { import { JOB_CONTAINER_EXTENSION_NAME, JOB_CONTAINER_NAME } from './constants'
getJobPodName,
getStepPodName,
JOB_CONTAINER_EXTENSION_NAME,
JOB_CONTAINER_NAME
} from './constants'
export async function runContainerStep( export async function runContainerStep(
stepContainer: RunContainerStepArgs stepContainer: RunContainerStepArgs
@@ -34,109 +26,118 @@ export async function runContainerStep(
throw new Error('Building container actions is not currently supported') throw new Error('Building container actions is not currently supported')
} }
if (!stepContainer.entryPoint) { let secretName: string | undefined = undefined
throw new Error( if (stepContainer.environmentVariables) {
'failed to start the container since the entrypoint is overwritten' try {
) const envs = JSON.parse(
} JSON.stringify(stepContainer.environmentVariables)
)
const envs = stepContainer.environmentVariables || {} envs['GITHUB_ACTIONS'] = 'true'
envs['GITHUB_ACTIONS'] = 'true' if (!('CI' in envs)) {
if (!('CI' in envs)) { envs.CI = 'true'
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}`)
}
} }
const extension = readExtensionFromFile() const extension = readExtensionFromFile()
const container = createContainerSpec(stepContainer, extension) core.debug(`Created secret ${secretName} for container job envs`)
const container = createContainerSpec(stepContainer, secretName, extension)
let pod: k8s.V1Pod let job: k8s.V1Job
try { try {
pod = await createContainerStepPod(getStepPodName(), container, extension) job = await createJob(container, extension)
} catch (err) { } catch (err) {
core.debug(`createJob failed: ${JSON.stringify(err)}`) core.debug(`createJob failed: ${JSON.stringify(err)}`)
const message = (err as any)?.response?.body?.message || err const message = (err as any)?.response?.body?.message || err
throw new Error(`failed to run script step: ${message}`) throw new Error(`failed to run script step: ${message}`)
} }
if (!pod.metadata?.name) { if (!job.metadata?.name) {
throw new Error( throw new Error(
`Expected job ${JSON.stringify( `Expected job ${JSON.stringify(
pod job
)} to have correctly set the metadata.name` )} to have correctly set the metadata.name`
) )
} }
const podName = pod.metadata.name core.debug(`Job created, waiting for pod to start: ${job.metadata?.name}`)
let podName: string
try { try {
await waitForPodPhases( podName = await getContainerJobPodName(job.metadata.name)
podName, } catch (err) {
new Set([PodPhase.RUNNING]), core.debug(`getContainerJobPodName failed: ${JSON.stringify(err)}`)
new Set([PodPhase.PENDING, PodPhase.UNKNOWN]), const message = (err as any)?.response?.body?.message || err
getPrepareJobTimeoutSeconds() throw new Error(`failed to get container job pod name: ${message}`)
)
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(
['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}`)
})
} }
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)
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
)}`
)
return 1
}
const exitCode =
status.containerStatuses[status.containerStatuses.length - 1].state
?.terminated?.exitCode
return Number(exitCode) || 1
} }
function createContainerSpec( function createContainerSpec(
container: RunContainerStepArgs, container: RunContainerStepArgs,
secretName?: string,
extension?: k8s.V1PodTemplateSpec extension?: k8s.V1PodTemplateSpec
): k8s.V1Container { ): k8s.V1Container {
const podContainer = new k8s.V1Container() const podContainer = new k8s.V1Container()
podContainer.name = JOB_CONTAINER_NAME podContainer.name = JOB_CONTAINER_NAME
podContainer.image = container.image podContainer.image = container.image
podContainer.workingDir = '/__w' podContainer.workingDir = container.workingDirectory
podContainer.command = ['tail'] podContainer.command = container.entryPoint
podContainer.args = DEFAULT_CONTAINER_ENTRY_POINT_ARGS ? [container.entryPoint]
: undefined
podContainer.args = container.entryPointArgs?.length
? fixArgs(container.entryPointArgs)
: undefined
podContainer.volumeMounts = CONTAINER_VOLUMES if (secretName) {
podContainer.envFrom = [
{
secretRef: {
name: secretName,
optional: false
}
}
]
}
podContainer.volumeMounts = containerVolumes(undefined, false, true)
if (!extension) { if (!extension) {
return podContainer return podContainer
+6 -75
View File
@@ -2,19 +2,17 @@
import * as fs from 'fs' import * as fs from 'fs'
import * as core from '@actions/core' import * as core from '@actions/core'
import { RunScriptStepArgs } from 'hooklib' import { RunScriptStepArgs } from 'hooklib'
import { execCpFromPod, execCpToPod, execPodStep } from '../k8s' import { execPodStep } from '../k8s'
import { writeRunScript, sleep, listDirAllCommand } from '../k8s/utils' import { writeEntryPointScript } from '../k8s/utils'
import { JOB_CONTAINER_NAME } from './constants' import { JOB_CONTAINER_NAME } from './constants'
import { dirname } from 'path'
import * as shlex from 'shlex'
export async function runScriptStep( export async function runScriptStep(
args: RunScriptStepArgs, args: RunScriptStepArgs,
state state,
responseFile?
): Promise<void> { ): Promise<void> {
// Write the entrypoint first. This will be later coppied to the workflow pod
const { entryPoint, entryPointArgs, environmentVariables } = args const { entryPoint, entryPointArgs, environmentVariables } = args
const { containerPath, runnerPath } = writeRunScript( const { containerPath, runnerPath } = writeEntryPointScript(
args.workingDirectory, args.workingDirectory,
entryPoint, entryPoint,
entryPointArgs, entryPointArgs,
@@ -22,56 +20,6 @@ export async function runScriptStep(
environmentVariables environmentVariables
) )
const workdir = dirname(process.env.RUNNER_WORKSPACE as string)
const runnerTemp = `${workdir}/_temp`
const containerTemp = '/__w/_temp'
const containerTempSrc = '/__w/_temp_pre'
// Ensure base and staging dirs exist before copying
await execPodStep(
[
'sh',
'-c',
'mkdir -p /__w && mkdir -p /__w/_temp && mkdir -p /__w/_temp_pre'
],
state.jobPod,
JOB_CONTAINER_NAME
)
await execCpToPod(state.jobPod, runnerTemp, containerTempSrc)
// Copy GitHub directories from temp to /github
// Merge strategy:
// - Overwrite files in _runner_file_commands
// - Append files not already present elsewhere
const mergeCommands = [
'set -e',
'mkdir -p /__w/_temp /__w/_temp_pre',
'SRC=/__w/_temp_pre',
'DST=/__w/_temp',
// Overwrite _runner_file_commands
'cp -a "$SRC/_runner_file_commands/." "$DST/_runner_file_commands"',
`find "$SRC" -type f ! -path "*/_runner_file_commands/*" -exec sh -c '
rel="\${1#$2/}"
target="$3/$rel"
mkdir -p "$(dirname "$target")"
cp -a "$1" "$target"
' _ {} "$SRC" "$DST" \\;`,
// Remove _temp_pre after merging
'rm -rf /__w/_temp_pre'
]
try {
await execPodStep(
['sh', '-c', mergeCommands.join(' && ')],
state.jobPod,
JOB_CONTAINER_NAME
)
} catch (err) {
core.debug(`Failed to merge temp directories: ${JSON.stringify(err)}`)
const message = (err as any)?.response?.body?.message || err
throw new Error(`failed to merge temp dirs: ${message}`)
}
// Execute the entrypoint script
args.entryPoint = 'sh' args.entryPoint = 'sh'
args.entryPointArgs = ['-e', containerPath] args.entryPointArgs = ['-e', containerPath]
try { try {
@@ -85,23 +33,6 @@ export async function runScriptStep(
const message = (err as any)?.response?.body?.message || err const message = (err as any)?.response?.body?.message || err
throw new Error(`failed to run script step: ${message}`) throw new Error(`failed to run script step: ${message}`)
} finally { } finally {
try { fs.rmSync(runnerPath)
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}/_runner_file_commands`,
`${workdir}/_temp`
)
} catch (error) {
core.warning('Failed to copy _temp from pod')
} }
} }
+130 -405
View File
@@ -1,14 +1,13 @@
import * as core from '@actions/core' import * as core from '@actions/core'
import * as path from 'path'
import { spawn } from 'child_process'
import * as k8s from '@kubernetes/client-node' import * as k8s from '@kubernetes/client-node'
import tar from 'tar-fs'
import * as stream from 'stream' import * as stream from 'stream'
import { WritableStreamBuffer } from 'stream-buffers'
import { createHash } from 'crypto'
import type { ContainerInfo, Registry } from 'hooklib' import type { ContainerInfo, Registry } from 'hooklib'
import { import {
getJobPodName,
getRunnerPodName,
getSecretName, getSecretName,
getStepPodName,
getVolumeClaimName,
JOB_CONTAINER_NAME, JOB_CONTAINER_NAME,
RunnerInstanceLabel RunnerInstanceLabel
} from '../hooks/constants' } from '../hooks/constants'
@@ -16,14 +15,9 @@ import {
PodPhase, PodPhase,
mergePodSpecWithOptions, mergePodSpecWithOptions,
mergeObjectMeta, mergeObjectMeta,
fixArgs, useKubeScheduler,
listDirAllCommand, fixArgs
sleep,
EXTERNALS_VOLUME_NAME,
GITHUB_VOLUME_NAME,
WORK_VOLUME
} from './utils' } from './utils'
import * as shlex from 'shlex'
const kc = new k8s.KubeConfig() const kc = new k8s.KubeConfig()
@@ -35,6 +29,8 @@ const k8sAuthorizationV1Api = kc.makeApiClient(k8s.AuthorizationV1Api)
const DEFAULT_WAIT_FOR_POD_TIME_SECONDS = 10 * 60 // 10 min const DEFAULT_WAIT_FOR_POD_TIME_SECONDS = 10 * 60 // 10 min
export const POD_VOLUME_NAME = 'work'
export const requiredPermissions = [ export const requiredPermissions = [
{ {
group: '', group: '',
@@ -54,6 +50,12 @@ export const requiredPermissions = [
resource: 'pods', resource: 'pods',
subresource: 'log' subresource: 'log'
}, },
{
group: 'batch',
verbs: ['get', 'list', 'create', 'delete'],
resource: 'jobs',
subresource: ''
},
{ {
group: '', group: '',
verbs: ['create', 'delete', 'get', 'list'], verbs: ['create', 'delete', 'get', 'list'],
@@ -62,8 +64,7 @@ export const requiredPermissions = [
} }
] ]
export async function createJobPod( export async function createPod(
name: string,
jobContainer?: k8s.V1Container, jobContainer?: k8s.V1Container,
services?: k8s.V1Container[], services?: k8s.V1Container[],
registry?: Registry, registry?: Registry,
@@ -83,7 +84,7 @@ export async function createJobPod(
appPod.kind = 'Pod' appPod.kind = 'Pod'
appPod.metadata = new k8s.V1ObjectMeta() appPod.metadata = new k8s.V1ObjectMeta()
appPod.metadata.name = name appPod.metadata.name = getJobPodName()
const instanceLabel = new RunnerInstanceLabel() const instanceLabel = new RunnerInstanceLabel()
appPod.metadata.labels = { appPod.metadata.labels = {
@@ -93,68 +94,19 @@ export async function createJobPod(
appPod.spec = new k8s.V1PodSpec() appPod.spec = new k8s.V1PodSpec()
appPod.spec.containers = containers appPod.spec.containers = containers
appPod.spec.securityContext = {
fsGroup: 1001
}
// Extract working directory from GITHUB_WORKSPACE
// GITHUB_WORKSPACE is like /__w/repo-name/repo-name
const githubWorkspace = process.env.GITHUB_WORKSPACE
const workingDirPath = githubWorkspace?.split('/').slice(-2).join('/') ?? ''
const initCommands = [
'mkdir -p /mnt/externals',
'mkdir -p /mnt/work',
'mkdir -p /mnt/github',
'mv /home/runner/externals/* /mnt/externals/'
]
if (workingDirPath) {
initCommands.push(`mkdir -p /mnt/work/${workingDirPath}`)
}
appPod.spec.initContainers = [
{
name: 'fs-init',
image:
process.env.ACTIONS_RUNNER_IMAGE ||
'ghcr.io/actions/actions-runner:latest',
command: ['sh', '-c', initCommands.join(' && ')],
securityContext: {
runAsGroup: 1001,
runAsUser: 1001
},
volumeMounts: [
{
name: EXTERNALS_VOLUME_NAME,
mountPath: '/mnt/externals'
},
{
name: WORK_VOLUME,
mountPath: '/mnt/work'
},
{
name: GITHUB_VOLUME_NAME,
mountPath: '/mnt/github'
}
]
}
]
appPod.spec.restartPolicy = 'Never' 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 = [ appPod.spec.volumes = [
{ {
name: EXTERNALS_VOLUME_NAME, name: POD_VOLUME_NAME,
emptyDir: {} persistentVolumeClaim: { claimName }
},
{
name: GITHUB_VOLUME_NAME,
emptyDir: {}
},
{
name: WORK_VOLUME,
emptyDir: {}
} }
] ]
@@ -182,62 +134,90 @@ export async function createJobPod(
}) })
} }
export async function createContainerStepPod( export async function createJob(
name: string,
container: k8s.V1Container, container: k8s.V1Container,
extension?: k8s.V1PodTemplateSpec extension?: k8s.V1PodTemplateSpec
): Promise<k8s.V1Pod> { ): Promise<k8s.V1Job> {
const appPod = new k8s.V1Pod() const runnerInstanceLabel = new RunnerInstanceLabel()
appPod.apiVersion = 'v1' const job = new k8s.V1Job()
appPod.kind = 'Pod' 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.metadata = new k8s.V1ObjectMeta() job.spec = new k8s.V1JobSpec()
appPod.metadata.name = name job.spec.ttlSecondsAfterFinished = 300
job.spec.backoffLimit = 0
job.spec.template = new k8s.V1PodTemplateSpec()
const instanceLabel = new RunnerInstanceLabel() job.spec.template.spec = new k8s.V1PodSpec()
appPod.metadata.labels = { job.spec.template.metadata = new k8s.V1ObjectMeta()
[instanceLabel.key]: instanceLabel.value 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
} }
appPod.metadata.annotations = {}
appPod.spec = new k8s.V1PodSpec() const claimName = getVolumeClaimName()
appPod.spec.containers = [container] job.spec.template.spec.volumes = [
appPod.spec.restartPolicy = 'Never'
appPod.spec.volumes = [
{ {
name: EXTERNALS_VOLUME_NAME, name: POD_VOLUME_NAME,
emptyDir: {} persistentVolumeClaim: { claimName }
},
{
name: GITHUB_VOLUME_NAME,
emptyDir: {}
},
{
name: WORK_VOLUME,
emptyDir: {}
} }
] ]
if (extension?.metadata) { if (extension) {
mergeObjectMeta(appPod, extension.metadata) if (extension.metadata) {
mergeObjectMeta(job, extension.metadata)
mergeObjectMeta(job.spec.template, extension.metadata)
}
if (extension.spec) {
mergePodSpecWithOptions(job.spec.template.spec, extension.spec)
}
} }
if (extension?.spec) { return await k8sBatchV1Api.createNamespacedJob({
mergePodSpecWithOptions(appPod.spec, extension.spec)
}
return await k8sApi.createNamespacedPod({
namespace: namespace(), namespace: namespace(),
body: appPod body: job
}) })
} }
export async function deletePod(name: string): Promise<void> { 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(podName: string): Promise<void> {
await k8sApi.deleteNamespacedPod({ await k8sApi.deleteNamespacedPod({
name, name: podName,
namespace: namespace(), namespace: namespace(),
gracePeriodSeconds: 0 gracePeriodSeconds: 0
}) })
@@ -264,7 +244,6 @@ export async function execPodStep(
stdin ?? null, stdin ?? null,
false /* tty */, false /* tty */,
resp => { resp => {
core.debug(`execPodStep response: ${JSON.stringify(resp)}`)
if (resp.status === 'Success') { if (resp.status === 'Success') {
resolve(resp.code || 0) resolve(resp.code || 0)
} else { } else {
@@ -282,290 +261,6 @@ export async function execPodStep(
}) })
} }
export async function execCalculateOutputHashSorted(
podName: string,
containerName: string,
command: string[]
): Promise<string> {
const exec = new k8s.Exec(kc)
let output = ''
const outputWriter = new stream.Writable({
write(chunk, _enc, cb) {
try {
output += chunk.toString('utf8')
cb()
} catch (e) {
cb(e as Error)
}
}
})
await new Promise<void>((resolve, reject) => {
exec
.exec(
namespace(),
podName,
containerName,
command,
outputWriter, // capture stdout
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))
})
outputWriter.end()
// Sort lines for consistent ordering across platforms
const sortedOutput =
output
.split('\n')
.filter(line => line.length > 0)
.sort()
.join('\n') + '\n'
const hash = createHash('sha256')
hash.update(sortedOutput)
return hash.digest('hex')
}
export async function localCalculateOutputHashSorted(
commands: string[]
): Promise<string> {
return await new Promise<string>((resolve, reject) => {
const child = spawn(commands[0], commands.slice(1), {
stdio: ['ignore', 'pipe', 'ignore']
})
let output = ''
child.stdout.on('data', chunk => {
output += chunk.toString('utf8')
})
child.on('error', reject)
child.on('close', (code: number) => {
if (code === 0) {
// Sort lines for consistent ordering across distributions/platforms
const sortedOutput =
output
.split('\n')
.filter(line => line.length > 0)
.sort()
.join('\n') + '\n'
const hash = createHash('sha256')
hash.update(sortedOutput)
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)
// Use tar to extract with --no-same-owner to avoid ownership issues.
// Then use find to fix permissions. The -m flag helps but we also need to fix permissions after.
const command = [
'sh',
'-c',
`tar xf - --no-same-owner -C ${shlex.quote(containerPath)} 2>/dev/null; ` +
`find ${shlex.quote(containerPath)} -type f -exec chmod u+rw {} \\; 2>/dev/null; ` +
`find ${shlex.quote(containerPath)} -type d -exec chmod u+rwx {} \\; 2>/dev/null`
]
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 execCpToPod - status: ${status.status}, 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)
}
}
let attempts = 15
const delay = 1000
for (let i = 0; i < attempts; i++) {
try {
const want = await localCalculateOutputHashSorted([
'sh',
'-c',
listDirAllCommand(runnerPath)
])
const got = await execCalculateOutputHashSorted(
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}`
)
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 want = await execCalculateOutputHashSorted(
podName,
JOB_CONTAINER_NAME,
['sh', '-c', listDirAllCommand(containerPath)]
)
const got = await localCalculateOutputHashSorted([
'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> { export async function waitForJobToComplete(jobName: string): Promise<void> {
const backOffManager = new BackOffManager() const backOffManager = new BackOffManager()
while (true) { while (true) {
@@ -649,9 +344,9 @@ export async function createSecretForEnvs(envs: {
return secretName return secretName
} }
export async function deleteSecret(name: string): Promise<void> { export async function deleteSecret(secretName: string): Promise<void> {
await k8sApi.deleteNamespacedSecret({ await k8sApi.deleteNamespacedSecret({
name, name: secretName,
namespace: namespace() namespace: namespace()
}) })
} }
@@ -667,8 +362,7 @@ export async function pruneSecrets(): Promise<void> {
await Promise.all( await Promise.all(
secretList.items.map( secretList.items.map(
async secret => secret => secret.metadata?.name && deleteSecret(secret.metadata.name)
secret.metadata?.name && (await deleteSecret(secret.metadata.name))
) )
) )
} }
@@ -784,9 +478,7 @@ export async function prunePods(): Promise<void> {
} }
await Promise.all( await Promise.all(
podList.items.map( podList.items.map(pod => pod.metadata?.name && deletePod(pod.metadata.name))
async pod => pod.metadata?.name && (await deletePod(pod.metadata.name))
)
) )
} }
@@ -831,12 +523,12 @@ export async function isPodContainerAlpine(
[ [
'sh', 'sh',
'-c', '-c',
`[ $(cat /etc/*release* | grep -i -e "^ID=*alpine*" -c) != 0 ] || exit 1` `'[ $(cat /etc/*release* | grep -i -e "^ID=*alpine*" -c) != 0 ] || exit 1'`
], ],
podName, podName,
containerName containerName
) )
} catch { } catch (err) {
isAlpine = false isAlpine = false
} }
@@ -857,6 +549,39 @@ export function namespace(): string {
return context.namespace return context.namespace
} }
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
}
class BackOffManager { class BackOffManager {
private backOffSeconds = 1 private backOffSeconds = 1
totalTime = 0 totalTime = 0
+124 -29
View File
@@ -6,6 +6,8 @@ import { v1 as uuidv4 } from 'uuid'
import { CONTAINER_EXTENSION_PREFIX } from '../hooks/constants' import { CONTAINER_EXTENSION_PREFIX } from '../hooks/constants'
import * as shlex from 'shlex' import * as shlex from 'shlex'
import { Mount } from 'hooklib' import { Mount } from 'hooklib'
import * as path from 'path'
import { POD_VOLUME_NAME } from './index'
export const DEFAULT_CONTAINER_ENTRY_POINT_ARGS = [`-f`, `/dev/null`] export const DEFAULT_CONTAINER_ENTRY_POINT_ARGS = [`-f`, `/dev/null`]
export const DEFAULT_CONTAINER_ENTRY_POINT = 'tail' export const DEFAULT_CONTAINER_ENTRY_POINT = 'tail'
@@ -13,24 +15,98 @@ export const DEFAULT_CONTAINER_ENTRY_POINT = 'tail'
export const ENV_HOOK_TEMPLATE_PATH = 'ACTIONS_RUNNER_CONTAINER_HOOK_TEMPLATE' export const ENV_HOOK_TEMPLATE_PATH = 'ACTIONS_RUNNER_CONTAINER_HOOK_TEMPLATE'
export const ENV_USE_KUBE_SCHEDULER = 'ACTIONS_RUNNER_USE_KUBE_SCHEDULER' export const ENV_USE_KUBE_SCHEDULER = 'ACTIONS_RUNNER_USE_KUBE_SCHEDULER'
export const EXTERNALS_VOLUME_NAME = 'externals' export function containerVolumes(
export const GITHUB_VOLUME_NAME = 'github' userMountVolumes: Mount[] = [],
export const WORK_VOLUME = 'work' jobContainer = true,
containerAction = false
): k8s.V1VolumeMount[] {
const mounts: k8s.V1VolumeMount[] = [
{
name: POD_VOLUME_NAME,
mountPath: '/__w'
}
]
export const CONTAINER_VOLUMES: k8s.V1VolumeMount[] = [ const workspacePath = process.env.GITHUB_WORKSPACE as string
{ if (containerAction) {
name: EXTERNALS_VOLUME_NAME, const i = workspacePath.lastIndexOf('_work/')
mountPath: '/__e' const workspaceRelativePath = workspacePath.slice(i + '_work/'.length)
}, mounts.push(
{ {
name: WORK_VOLUME, name: POD_VOLUME_NAME,
mountPath: '/__w' mountPath: '/github/workspace',
}, subPath: workspaceRelativePath
{ },
name: GITHUB_VOLUME_NAME, {
mountPath: '/github' 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
} }
]
if (!jobContainer) {
return mounts
}
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'
)
}
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 prepareJobScript(userVolumeMounts: Mount[]): { export function prepareJobScript(userVolumeMounts: Mount[]): {
containerPath: string containerPath: string
@@ -54,6 +130,38 @@ mkdir -p ${mountDirs}
} }
} }
export function writeEntryPointScript(
workingDirectory: string,
entryPoint: string,
entryPointArgs?: string[],
prependPath?: string[],
environmentVariables?: { [key: string]: string }
): { containerPath: string; runnerPath: string } {
let exportPath = ''
if (prependPath?.length) {
const prepend =
typeof prependPath === 'string' ? prependPath : prependPath.join(':')
exportPath = `export PATH=${prepend}:$PATH`
}
const environmentPrefix = scriptEnv(environmentVariables)
const content = `#!/bin/sh -l
${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 writeRunScript( export function writeRunScript(
workingDirectory: string, workingDirectory: string,
entryPoint: string, entryPoint: string,
@@ -288,18 +396,5 @@ function mergeLists<T>(base?: T[], from?: T[]): T[] {
} }
export function fixArgs(args: string[]): string[] { export function fixArgs(args: string[]): string[] {
// Preserve shell command strings passed via `sh -c` without re-tokenizing.
// Retokenizing would split the script into multiple args, breaking `sh -c`.
if (args.length >= 2 && args[0] === 'sh' && args[1] === '-c') {
return args
}
return shlex.split(args.join(' ')) 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 . -type f -not -path '*/_runner_hook_responses*' -exec stat -c '%s %n' {} \\;`
}
+143
View File
@@ -0,0 +1,143 @@
import * as fs from 'fs'
import { cleanupJob } from '../src/hooks'
import { prepareJob } from '../src/hooks/prepare-job'
import { TestHelper } from './test-setup'
import { getPodByName } from '../src/k8s'
import { ENV_USE_KUBE_SCHEDULER } from '../src/k8s/utils'
jest.useRealTimers()
let testHelper: TestHelper
let prepareJobData: any
let prepareJobOutputFilePath: string
describe('RWO Affinity Behavior (Scheduler Mode)', () => {
beforeEach(async () => {
testHelper = new TestHelper()
await testHelper.initialize()
prepareJobData = testHelper.getPrepareJobDefinition()
prepareJobOutputFilePath = testHelper.createFile('prepare-job-output.json')
})
afterEach(async () => {
await cleanupJob()
await testHelper.cleanup()
delete process.env[ENV_USE_KUBE_SCHEDULER]
})
it('should add nodeAffinity with hostname selector when scheduler mode is enabled', async () => {
process.env[ENV_USE_KUBE_SCHEDULER] = 'true'
await prepareJob(prepareJobData.args, prepareJobOutputFilePath)
const content = JSON.parse(
fs.readFileSync(prepareJobOutputFilePath).toString()
)
const pod = await getPodByName(content.state.jobPod)
expect(pod.spec?.affinity).toBeDefined()
expect(pod.spec?.affinity?.nodeAffinity).toBeDefined()
const nodeAffinity = pod.spec?.affinity?.nodeAffinity
expect(
nodeAffinity?.requiredDuringSchedulingIgnoredDuringExecution
).toBeDefined()
const nodeSelectorTerms =
nodeAffinity?.requiredDuringSchedulingIgnoredDuringExecution
?.nodeSelectorTerms
expect(nodeSelectorTerms).toBeDefined()
expect(nodeSelectorTerms?.length).toBeGreaterThan(0)
const matchExpressions = nodeSelectorTerms?.[0].matchExpressions
expect(matchExpressions).toBeDefined()
expect(matchExpressions?.length).toBeGreaterThan(0)
const hostnameExpression = matchExpressions?.[0]
expect(hostnameExpression?.key).toBe('kubernetes.io/hostname')
expect(hostnameExpression?.operator).toBe('In')
expect(hostnameExpression?.values).toBeDefined()
expect(hostnameExpression?.values?.length).toBeGreaterThan(0)
expect(hostnameExpression?.values?.[0]).toBeTruthy()
})
it('should NOT add nodeAffinity when scheduler mode is disabled', async () => {
process.env[ENV_USE_KUBE_SCHEDULER] = 'false'
await prepareJob(prepareJobData.args, prepareJobOutputFilePath)
const content = JSON.parse(
fs.readFileSync(prepareJobOutputFilePath).toString()
)
const pod = await getPodByName(content.state.jobPod)
if (pod.spec?.affinity) {
expect(pod.spec.affinity.nodeAffinity).toBeUndefined()
}
expect(pod.spec?.nodeName).toBeDefined()
})
it('should fail assertion if affinity block is missing when scheduler mode is enabled', async () => {
process.env[ENV_USE_KUBE_SCHEDULER] = 'true'
await prepareJob(prepareJobData.args, prepareJobOutputFilePath)
const content = JSON.parse(
fs.readFileSync(prepareJobOutputFilePath).toString()
)
const pod = await getPodByName(content.state.jobPod)
expect(pod.spec?.affinity).toBeDefined()
expect(pod.spec?.affinity?.nodeAffinity).toBeDefined()
expect(
pod.spec?.affinity?.nodeAffinity
?.requiredDuringSchedulingIgnoredDuringExecution
).toBeDefined()
const nodeSelectorTerms =
pod.spec?.affinity?.nodeAffinity
?.requiredDuringSchedulingIgnoredDuringExecution?.nodeSelectorTerms
expect(nodeSelectorTerms?.[0]?.matchExpressions?.[0]?.key).toBe(
'kubernetes.io/hostname'
)
expect(nodeSelectorTerms?.[0]?.matchExpressions?.[0]?.operator).toBe('In')
expect(
nodeSelectorTerms?.[0]?.matchExpressions?.[0]?.values?.length
).toBeGreaterThan(0)
})
it('should use correct node name from runner pod in affinity values', async () => {
process.env[ENV_USE_KUBE_SCHEDULER] = 'true'
const runnerPodName = process.env.ACTIONS_RUNNER_POD_NAME
await prepareJob(prepareJobData.args, prepareJobOutputFilePath)
const content = JSON.parse(
fs.readFileSync(prepareJobOutputFilePath).toString()
)
const jobPod = await getPodByName(content.state.jobPod)
const runnerPod = await getPodByName(runnerPodName!)
const affinityValues =
jobPod.spec?.affinity?.nodeAffinity
?.requiredDuringSchedulingIgnoredDuringExecution?.nodeSelectorTerms?.[0]
?.matchExpressions?.[0]?.values
expect(affinityValues).toBeDefined()
expect(affinityValues?.length).toBeGreaterThan(0)
if (runnerPod.spec?.nodeName) {
expect(affinityValues).toContain(runnerPod.spec.nodeName)
}
})
})
@@ -0,0 +1,28 @@
import {
isRWXTestEnabled,
getRWXStorageClass,
RWX_SKIP_MESSAGE
} from './test-setup'
describe('RWX Test Contract Demo', () => {
const describeOrSkip = isRWXTestEnabled() ? describe : describe.skip
describeOrSkip('RWX volume tests', () => {
it('should use RWX storage class when enabled', () => {
const storageClass = getRWXStorageClass()
expect(storageClass).toBeDefined()
expect(typeof storageClass).toBe('string')
})
it('should verify both env vars are required', () => {
expect(process.env.ACTIONS_RUNNER_K8S_TEST_ENABLE_RWX).toBe('true')
expect(
process.env.ACTIONS_RUNNER_K8S_TEST_RWX_STORAGE_CLASS
).toBeDefined()
})
})
if (!isRWXTestEnabled()) {
it(RWX_SKIP_MESSAGE, () => {})
}
})
+119
View File
@@ -0,0 +1,119 @@
import * as k8s from '@kubernetes/client-node'
import * as fs from 'fs'
import { cleanupJob, prepareJob, runScriptStep } from '../src/hooks'
import {
TestHelper,
isRWXTestEnabled,
getRWXStorageClass,
RWX_SKIP_MESSAGE
} from './test-setup'
import { RunScriptStepArgs } from 'hooklib'
jest.useRealTimers()
const kc = new k8s.KubeConfig()
kc.loadFromDefault()
const k8sApi = kc.makeApiClient(k8s.CoreV1Api)
describe('RWX Volume Tests', () => {
const describeOrSkip = isRWXTestEnabled() ? describe : describe.skip
describeOrSkip('RWX volume integration', () => {
let testHelper: TestHelper
let rwxPvcName: string
let prepareJobData: any
let prepareJobOutputFilePath: string
beforeEach(async () => {
testHelper = new TestHelper()
await testHelper.initialize()
const podName = process.env.ACTIONS_RUNNER_POD_NAME
rwxPvcName = `${podName}-work-rwx`
const volumeClaim: k8s.V1PersistentVolumeClaim = {
metadata: {
name: rwxPvcName
},
spec: {
accessModes: ['ReadWriteMany'],
volumeMode: 'Filesystem',
storageClassName: getRWXStorageClass(),
resources: {
requests: {
storage: '1Gi'
}
}
}
}
await k8sApi.createNamespacedPersistentVolumeClaim({
namespace: 'default',
body: volumeClaim
})
process.env.ACTIONS_RUNNER_CLAIM_NAME = rwxPvcName
prepareJobData = testHelper.getPrepareJobDefinition()
prepareJobOutputFilePath = testHelper.createFile(
'prepare-job-output.json'
)
})
afterAll(async () => {
if (rwxPvcName) {
try {
await k8sApi.deleteNamespacedPersistentVolumeClaim({
name: rwxPvcName,
namespace: 'default'
})
} catch {
// Ignore cleanup errors - PVC may not exist
}
}
})
afterEach(async () => {
await testHelper.cleanup()
})
it('should successfully run hook flow with RWX volume', async () => {
await expect(
prepareJob(prepareJobData.args, prepareJobOutputFilePath)
).resolves.not.toThrow()
const prepareJobOutputJson = fs.readFileSync(prepareJobOutputFilePath)
const prepareJobOutputData = JSON.parse(prepareJobOutputJson.toString())
const scriptStepData = testHelper.getRunScriptStepDefinition()
await expect(
runScriptStep(
scriptStepData.args as RunScriptStepArgs,
prepareJobOutputData.state
)
).resolves.not.toThrow()
await expect(cleanupJob()).resolves.not.toThrow()
})
it('should verify RWX PVC was created with correct access mode', async () => {
const pvc = await k8sApi.readNamespacedPersistentVolumeClaim({
name: rwxPvcName,
namespace: 'default'
})
expect(pvc.spec?.accessModes).toContain('ReadWriteMany')
expect(pvc.spec?.storageClassName).toBe(getRWXStorageClass())
expect(pvc.spec?.volumeMode).toBe('Filesystem')
})
it('should verify RWX claim name is set correctly', () => {
expect(process.env.ACTIONS_RUNNER_CLAIM_NAME).toBe(rwxPvcName)
})
})
if (!isRWXTestEnabled()) {
it(RWX_SKIP_MESSAGE, () => {})
}
})
+142
View File
@@ -9,6 +9,7 @@ const kc = new k8s.KubeConfig()
kc.loadFromDefault() kc.loadFromDefault()
const k8sApi = kc.makeApiClient(k8s.CoreV1Api) const k8sApi = kc.makeApiClient(k8s.CoreV1Api)
const k8sStorageApi = kc.makeApiClient(k8s.StorageV1Api)
export class TestHelper { export class TestHelper {
private tempDirPath: string private tempDirPath: string
@@ -46,6 +47,7 @@ export class TestHelper {
await this.cleanupK8sResources() await this.cleanupK8sResources()
try { try {
await this.createTestVolume()
await this.createTestJobPod() await this.createTestJobPod()
} catch (e) { } catch (e) {
console.log(e) console.log(e)
@@ -62,6 +64,24 @@ export class TestHelper {
} }
async cleanupK8sResources(): Promise<void> { async cleanupK8sResources(): Promise<void> {
await k8sApi
.deleteNamespacedPersistentVolumeClaim({
name: `${this.podName}-work`,
namespace: 'default',
gracePeriodSeconds: 0
})
.catch((e: k8s.ApiException<any>) => {
if (e.code !== 404) {
console.error(JSON.stringify(e))
}
})
await k8sApi
.deletePersistentVolume({ name: `${this.podName}-pv` })
.catch((e: k8s.ApiException<any>) => {
if (e.code !== 404) {
console.error(JSON.stringify(e))
}
})
await k8sApi await k8sApi
.deleteNamespacedPod({ .deleteNamespacedPod({
name: this.podName, name: this.podName,
@@ -84,6 +104,14 @@ export class TestHelper {
console.error(JSON.stringify(e)) console.error(JSON.stringify(e))
} }
}) })
await k8sStorageApi
.deleteStorageClass({ name: `${this.podName}-storage` })
.catch((e: k8s.ApiException<any>) => {
if (e.code !== 404) {
console.error(JSON.stringify(e))
}
})
} }
createFile(fileName?: string): string { createFile(fileName?: string): string {
const filePath = `${this.tempDirPath}/${fileName || uuidv4()}` const filePath = `${this.tempDirPath}/${fileName || uuidv4()}`
@@ -120,6 +148,58 @@ export class TestHelper {
await k8sApi.createNamespacedPod({ namespace: 'default', body: pod }) await k8sApi.createNamespacedPod({ namespace: 'default', body: pod })
} }
async createTestVolume(): Promise<void> {
const storageClassName = `${this.podName}-storage`
const sc: k8s.V1StorageClass = {
metadata: {
name: storageClassName
},
provisioner: 'kubernetes.io/no-provisioner',
volumeBindingMode: 'Immediate'
}
await k8sStorageApi.createStorageClass({ body: sc })
const volume: k8s.V1PersistentVolume = {
metadata: {
name: `${this.podName}-pv`
},
spec: {
storageClassName,
capacity: {
storage: '2Gi'
},
volumeMode: 'Filesystem',
accessModes: ['ReadWriteOnce'],
hostPath: {
path: `${this.tempDirPath}/_work`
}
}
}
await k8sApi.createPersistentVolume({ body: volume })
const volumeClaim: k8s.V1PersistentVolumeClaim = {
metadata: {
name: `${this.podName}-work`
},
spec: {
accessModes: ['ReadWriteOnce'],
volumeMode: 'Filesystem',
storageClassName,
volumeName: `${this.podName}-pv`,
resources: {
requests: {
storage: '1Gi'
}
}
}
}
await k8sApi.createNamespacedPersistentVolumeClaim({
namespace: 'default',
body: volumeClaim
})
}
getPrepareJobDefinition(): HookData { getPrepareJobDefinition(): HookData {
const prepareJob = JSON.parse( const prepareJob = JSON.parse(
fs.readFileSync( fs.readFileSync(
@@ -163,3 +243,65 @@ export class TestHelper {
return runContainerStep return runContainerStep
} }
} }
/**
* RWX Test Contract:
*
* Tests requiring ReadWriteMany (RWX) volumes MUST be gated by TWO environment variables:
* 1. ACTIONS_RUNNER_K8S_TEST_ENABLE_RWX=true (explicit opt-in)
* 2. ACTIONS_RUNNER_K8S_TEST_RWX_STORAGE_CLASS=<name> (storage class that supports RWX)
*
* If either variable is missing or ACTIONS_RUNNER_K8S_TEST_ENABLE_RWX is not "true",
* the test MUST be skipped with the exact message defined in this contract.
*
* This contract ensures:
* - RWX tests do not fail on clusters without RWX provisioners
* - Test requirements are explicit and documented
* - RWO affinity tests remain independent and always runnable
* - Skip behavior is deterministic (no dynamic cluster probing)
*
* Usage example:
* ```typescript
* import { isRWXTestEnabled, getRWXStorageClass, RWX_SKIP_MESSAGE } from './test-setup'
*
* describe('RWX Test Suite', () => {
* const describeOrSkip = isRWXTestEnabled() ? describe : describe.skip
*
* describeOrSkip('RWX volume tests', () => {
* it('should test RWX functionality', async () => {
* const storageClass = getRWXStorageClass()
* // ... test code using storageClass
* })
* })
*
* if (!isRWXTestEnabled()) {
* it(RWX_SKIP_MESSAGE, () => {})
* }
* })
* ```
*/
/**
* Checks if RWX tests should run based on environment variables.
* @returns true if both ACTIONS_RUNNER_K8S_TEST_ENABLE_RWX=true and ACTIONS_RUNNER_K8S_TEST_RWX_STORAGE_CLASS are set
*/
export function isRWXTestEnabled(): boolean {
const enabled = process.env.ACTIONS_RUNNER_K8S_TEST_ENABLE_RWX === 'true'
const storageClass = process.env.ACTIONS_RUNNER_K8S_TEST_RWX_STORAGE_CLASS
return enabled && !!storageClass
}
/**
* Gets the RWX storage class name from environment variable.
* @returns The storage class name, or undefined if not set
*/
export function getRWXStorageClass(): string | undefined {
return process.env.ACTIONS_RUNNER_K8S_TEST_RWX_STORAGE_CLASS
}
/**
* Skip message constant - DO NOT MODIFY
* This exact message must be used when skipping RWX tests
*/
export const RWX_SKIP_MESSAGE =
'RWX tests skipped: set ACTIONS_RUNNER_K8S_TEST_ENABLE_RWX=true and ACTIONS_RUNNER_K8S_TEST_RWX_STORAGE_CLASS'