Compare commits

..
Author SHA1 Message Date
Nikola JokicandSisyphus 9e78b267f6 refactor(k8s): invert scheduler env var to make affinity default
Change ACTIONS_RUNNER_USE_KUBE_SCHEDULER to ACTIONS_RUNNER_DISABLE_KUBE_SCHEDULER
to make affinity-based scheduling the first-class (default) implementation.

Breaking Change:
- OLD: Set ACTIONS_RUNNER_USE_KUBE_SCHEDULER=true to enable affinity (opt-in)
- NEW: Affinity is enabled by default, set ACTIONS_RUNNER_DISABLE_KUBE_SCHEDULER=true to disable (opt-out)

Code changes:
- utils.ts: Rename constant and invert useKubeScheduler() logic
- rwo-affinity-test.ts: Update tests to verify default affinity behavior
- ADR 0135: Update to reflect opt-out model
- README: Update guidance to reflect default behavior

Co-authored-by: Sisyphus <[email protected]>
2026-04-23 00:23:47 +02:00
Nikola JokicandSisyphus 51a0a891f6 docs(adr,k8s): clarify RWX enables free scheduling, RWO requires affinity
- RWX volumes allow job pods to be scheduled on any cluster node
- RWO volumes require affinity to pin job pods to runner's node
- Remove ACTIONS_RUNNER_USE_KUBE_SCHEDULER from RWX migration steps
- Emphasize resource utilization benefits of RWX free scheduling

Co-authored-by: Sisyphus <[email protected]>
2026-04-23 00:09:05 +02:00
Nikola Jokic 5d337ff96e lint fmt 2026-04-22 22:59:40 +02:00
Nikola Jokic 4972708208 Ensure read write many first 2026-04-22 22:55:49 +02:00
tfhddd 6ecda1d8ea fix: Resolves the issue of inconsistent hash calculations between runner and workflow files. (#316)
CodeQL / Analyze (javascript) (push) Has been cancelled
2026-02-13 22:47:15 +01:00
11 changed files with 886 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).
The choice of storage strategy significantly impacts pod scheduling. While ReadWriteOnce (RWO) volumes require job pods to be co-located on the same node as the runner pod, ReadWriteMany (RWX) volumes allow job pods to be scheduled freely across the cluster.
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 the Kubernetes scheduler to place job pods on any available node, improving resource utilization and cluster flexibility. RWO restricts volume access to a single node at a time, requiring all pods using the volume to be pinned to that specific node.
## Decision
We have decided to establish `ReadWriteMany` (RWX) as the preferred storage strategy for the Kubernetes hook. RWX provides superior operational flexibility by enabling free scheduling of job pods across the cluster, as the shared volume is accessible from any node. This decoupling of job pods from the runner's node allows for better resource distribution and reduces the risk of node-level resource exhaustion.
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`, the Kubernetes scheduler integration is enabled by default. Operators can optionally disable it by setting `ACTIONS_RUNNER_DISABLE_KUBE_SCHEDULER=true` (not recommended).
3. **Node Selection:** By default, 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 is enabled by default. Setting `ACTIONS_RUNNER_DISABLE_KUBE_SCHEDULER=true` disables it, 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_DISABLE_KUBE_SCHEDULER` is 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 prefer the default 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 schedule job pods on any node in the cluster, maximizing resource utilization.
- **Node Coupling:** RWO users remain coupled to the node where the runner pod is running. The hook ensures job pods are scheduled on the same node via affinity (enabled by default) to maintain workspace integrity.
- **Configuration:** Operators using RWO can rely on the default affinity-based scheduling. Setting `ACTIONS_RUNNER_DISABLE_KUBE_SCHEDULER=true` will fall back to legacy `nodeName` pinning (not recommended). RWX configurations do not require any special configuration for basic operation.
## Migration Guidance
Operators migrating from an RWO setup that relied on legacy `nodeName` behavior can continue by setting `ACTIONS_RUNNER_DISABLE_KUBE_SCHEDULER=true`, but should migrate to the default affinity-based scheduling for better scheduler integration:
1. Remove the `ACTIONS_RUNNER_DISABLE_KUBE_SCHEDULER` environment variable to use the default affinity-based scheduling.
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`
- By default, the hook uses the Kubernetes scheduler with `nodeAffinity` to ensure job pods are scheduled correctly (essential for `ReadWriteOnce` volumes). Setting `ACTIONS_RUNNER_DISABLE_KUBE_SCHEDULER=true` will fall back to legacy `nodeName` pinning (not recommended).
## 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. RWX allows the Kubernetes scheduler to place job pods on any node in the cluster, maximizing resource availability and flexibility.
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. No additional environment variables are needed - affinity-based scheduling is the default.
### 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. The default affinity-based scheduling works automatically. Do not set `ACTIONS_RUNNER_DISABLE_KUBE_SCHEDULER`.
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 })
} }
} }
@@ -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
+82 -81
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 {
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( await waitForPodPhases(
podName, podName,
new Set([PodPhase.RUNNING]), new Set([
new Set([PodPhase.PENDING, PodPhase.UNKNOWN]), PodPhase.COMPLETED,
getPrepareJobTimeoutSeconds() PodPhase.RUNNING,
PodPhase.SUCCEEDED,
PodPhase.FAILED
]),
new Set([PodPhase.PENDING, PodPhase.UNKNOWN])
) )
core.debug('Container step is running or complete, pulling logs')
const runnerWorkspace = dirname(process.env.RUNNER_WORKSPACE as string) await getPodLogs(podName, JOB_CONTAINER_NAME)
const githubWorkspace = process.env.GITHUB_WORKSPACE as string
const parts = githubWorkspace.split('/').slice(-2) core.debug('Waiting for container job to complete')
if (parts.length !== 2) { await waitForJobToComplete(job.metadata.name)
throw new Error(`Invalid github workspace directory: ${githubWorkspace}`)
const status = await getPodStatus(podName)
if (status?.phase === 'Succeeded') {
return 0
} }
const relativeWorkspace = parts.join('/') if (!status?.containerStatuses?.length) {
core.error(
core.debug( `Can't determine container status from response: ${JSON.stringify(
`Copying files from pod ${getJobPodName()} to ${runnerWorkspace}/${relativeWorkspace}` status
)}`
) )
await execCpFromPod(getJobPodName(), `/__w`, `${runnerWorkspace}`) return 1
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}`)
})
} }
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 -403
View File
@@ -1,29 +1,22 @@
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,
JOB_CONTAINER_NAME, getStepPodName,
getVolumeClaimName,
RunnerInstanceLabel RunnerInstanceLabel
} from '../hooks/constants' } from '../hooks/constants'
import { 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 +28,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 +49,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 +63,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 +83,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 +93,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 +133,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 +243,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 +260,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 +343,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()
}) })
} }
@@ -668,7 +362,7 @@ export async function pruneSecrets(): Promise<void> {
await Promise.all( await Promise.all(
secretList.items.map( secretList.items.map(
async secret => async secret =>
secret.metadata?.name && (await deleteSecret(secret.metadata.name)) secret.metadata?.name && deleteSecret(secret.metadata.name)
) )
) )
} }
@@ -785,7 +479,7 @@ export async function prunePods(): Promise<void> {
await Promise.all( await Promise.all(
podList.items.map( podList.items.map(
async pod => pod.metadata?.name && (await deletePod(pod.metadata.name)) async pod => pod.metadata?.name && deletePod(pod.metadata.name)
) )
) )
} }
@@ -831,7 +525,7 @@ 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
@@ -857,6 +551,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
@@ -925,7 +652,7 @@ export function containerPorts(
return ports return ports
} }
export async function getPodByName(name): Promise<k8s.V1Pod> { export async function getPodByName(name: string): Promise<k8s.V1Pod> {
return await k8sApi.readNamespacedPod({ return await k8sApi.readNamespacedPod({
name, name,
namespace: namespace() namespace: namespace()
+125 -29
View File
@@ -6,32 +6,109 @@ 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'
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_DISABLE_KUBE_SCHEDULER =
'ACTIONS_RUNNER_DISABLE_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
export const CONTAINER_VOLUMES: k8s.V1VolumeMount[] = [ ): k8s.V1VolumeMount[] {
const mounts: k8s.V1VolumeMount[] = [
{ {
name: EXTERNALS_VOLUME_NAME, name: POD_VOLUME_NAME,
mountPath: '/__e'
},
{
name: WORK_VOLUME,
mountPath: '/__w' mountPath: '/__w'
},
{
name: GITHUB_VOLUME_NAME,
mountPath: '/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
}
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
runnerPath: string runnerPath: string
@@ -54,6 +131,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,
@@ -266,7 +375,7 @@ export function readExtensionFromFile(): k8s.V1PodTemplateSpec | undefined {
} }
export function useKubeScheduler(): boolean { export function useKubeScheduler(): boolean {
return process.env[ENV_USE_KUBE_SCHEDULER] === 'true' return process.env[ENV_DISABLE_KUBE_SCHEDULER] !== 'true'
} }
export enum PodPhase { export enum PodPhase {
@@ -288,18 +397,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 . -not -path '*/_runner_hook_responses*' -exec stat -c '%s %n' {} \\;`
}
+137
View File
@@ -0,0 +1,137 @@
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_DISABLE_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_DISABLE_KUBE_SCHEDULER]
})
it('should add nodeAffinity with hostname selector by default', async () => {
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_DISABLE_KUBE_SCHEDULER] = 'true'
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 by default', async () => {
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 by default', async () => {
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'