Ensure read write many first
This commit is contained in:
@@ -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.
|
||||
@@ -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 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_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.
|
||||
- `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
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as core from '@actions/core'
|
||||
import * as io from '@actions/io'
|
||||
import * as k8s from '@kubernetes/client-node'
|
||||
import {
|
||||
JobContainerInfo,
|
||||
@@ -7,33 +8,26 @@ import {
|
||||
writeToResponseFile,
|
||||
ServiceContainerInfo
|
||||
} from 'hooklib'
|
||||
import path from 'path'
|
||||
import {
|
||||
containerPorts,
|
||||
createJobPod,
|
||||
createPod,
|
||||
isPodContainerAlpine,
|
||||
prunePods,
|
||||
waitForPodPhases,
|
||||
getPrepareJobTimeoutSeconds,
|
||||
execCpToPod,
|
||||
execPodStep
|
||||
getPrepareJobTimeoutSeconds
|
||||
} from '../k8s'
|
||||
import {
|
||||
CONTAINER_VOLUMES,
|
||||
containerVolumes,
|
||||
DEFAULT_CONTAINER_ENTRY_POINT,
|
||||
DEFAULT_CONTAINER_ENTRY_POINT_ARGS,
|
||||
generateContainerName,
|
||||
mergeContainerWithOptions,
|
||||
readExtensionFromFile,
|
||||
PodPhase,
|
||||
fixArgs,
|
||||
prepareJobScript
|
||||
fixArgs
|
||||
} from '../k8s/utils'
|
||||
import {
|
||||
CONTAINER_EXTENSION_PREFIX,
|
||||
getJobPodName,
|
||||
JOB_CONTAINER_NAME
|
||||
} from './constants'
|
||||
import { dirname } from 'path'
|
||||
import { CONTAINER_EXTENSION_PREFIX, JOB_CONTAINER_NAME } from './constants'
|
||||
|
||||
export async function prepareJob(
|
||||
args: PrepareJobArgs,
|
||||
@@ -46,6 +40,7 @@ export async function prepareJob(
|
||||
await prunePods()
|
||||
|
||||
const extension = readExtensionFromFile()
|
||||
await copyExternalsToRoot()
|
||||
|
||||
let container: k8s.V1Container | undefined = undefined
|
||||
if (args.container?.image) {
|
||||
@@ -75,8 +70,7 @@ export async function prepareJob(
|
||||
|
||||
let createdPod: k8s.V1Pod | undefined = undefined
|
||||
try {
|
||||
createdPod = await createJobPod(
|
||||
getJobPodName(),
|
||||
createdPod = await createPod(
|
||||
container,
|
||||
services,
|
||||
args.container.registry,
|
||||
@@ -96,13 +90,6 @@ export async function prepareJob(
|
||||
`Job pod created, waiting for it to come online ${createdPod?.metadata?.name}`
|
||||
)
|
||||
|
||||
const runnerWorkspace = dirname(process.env.RUNNER_WORKSPACE as string)
|
||||
|
||||
let prepareScript: { containerPath: string; runnerPath: string } | undefined
|
||||
if (args.container?.userMountVolumes?.length) {
|
||||
prepareScript = prepareJobScript(args.container.userMountVolumes || [])
|
||||
}
|
||||
|
||||
try {
|
||||
await waitForPodPhases(
|
||||
createdPod.metadata.name,
|
||||
@@ -115,28 +102,6 @@ export async function prepareJob(
|
||||
throw new Error(`pod failed to come online with error: ${err}`)
|
||||
}
|
||||
|
||||
await execCpToPod(createdPod.metadata.name, runnerWorkspace, '/__w')
|
||||
|
||||
if (prepareScript) {
|
||||
await execPodStep(
|
||||
['sh', '-e', prepareScript.containerPath],
|
||||
createdPod.metadata.name,
|
||||
JOB_CONTAINER_NAME
|
||||
)
|
||||
|
||||
const promises: Promise<void>[] = []
|
||||
for (const vol of args?.container?.userMountVolumes || []) {
|
||||
promises.push(
|
||||
execCpToPod(
|
||||
createdPod.metadata.name,
|
||||
vol.sourceVolumePath,
|
||||
vol.targetVolumePath
|
||||
)
|
||||
)
|
||||
}
|
||||
await Promise.all(promises)
|
||||
}
|
||||
|
||||
core.debug('Job pod is ready for traffic')
|
||||
|
||||
let isAlpine = false
|
||||
@@ -180,8 +145,10 @@ function generateResponseFile(
|
||||
const mainContainerContextPorts: ContextPorts = {}
|
||||
if (mainContainer?.ports) {
|
||||
for (const port of mainContainer.ports) {
|
||||
mainContainerContextPorts[port.containerPort] =
|
||||
mainContainerContextPorts.hostPort
|
||||
if (port.containerPort && port.hostPort) {
|
||||
mainContainerContextPorts[port.containerPort.toString()] =
|
||||
port.hostPort.toString()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,6 +184,17 @@ function generateResponseFile(
|
||||
writeToResponseFile(responseFile, JSON.stringify(response))
|
||||
}
|
||||
|
||||
async function copyExternalsToRoot(): Promise<void> {
|
||||
const workspace = process.env['RUNNER_WORKSPACE']
|
||||
if (workspace) {
|
||||
await io.cp(
|
||||
path.join(workspace, '../../externals'),
|
||||
path.join(workspace, '../externals'),
|
||||
{ force: true, recursive: true, copySourceDirectory: false }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function createContainerSpec(
|
||||
container: JobContainerInfo | ServiceContainerInfo,
|
||||
name: string,
|
||||
@@ -250,7 +228,7 @@ export function createContainerSpec(
|
||||
container['environmentVariables'] || {}
|
||||
)) {
|
||||
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) {
|
||||
return podContainer
|
||||
|
||||
@@ -1,31 +1,23 @@
|
||||
import * as core from '@actions/core'
|
||||
import * as fs from 'fs'
|
||||
import * as k8s from '@kubernetes/client-node'
|
||||
import { RunContainerStepArgs } from 'hooklib'
|
||||
import { dirname } from 'path'
|
||||
import {
|
||||
createContainerStepPod,
|
||||
deletePod,
|
||||
execCpFromPod,
|
||||
execCpToPod,
|
||||
execPodStep,
|
||||
getPrepareJobTimeoutSeconds,
|
||||
createJob,
|
||||
createSecretForEnvs,
|
||||
getContainerJobPodName,
|
||||
getPodLogs,
|
||||
getPodStatus,
|
||||
waitForJobToComplete,
|
||||
waitForPodPhases
|
||||
} from '../k8s'
|
||||
import {
|
||||
CONTAINER_VOLUMES,
|
||||
containerVolumes,
|
||||
fixArgs,
|
||||
mergeContainerWithOptions,
|
||||
PodPhase,
|
||||
readExtensionFromFile,
|
||||
DEFAULT_CONTAINER_ENTRY_POINT_ARGS,
|
||||
writeContainerStepScript
|
||||
readExtensionFromFile
|
||||
} from '../k8s/utils'
|
||||
import {
|
||||
getJobPodName,
|
||||
getStepPodName,
|
||||
JOB_CONTAINER_EXTENSION_NAME,
|
||||
JOB_CONTAINER_NAME
|
||||
} from './constants'
|
||||
import { JOB_CONTAINER_EXTENSION_NAME, JOB_CONTAINER_NAME } from './constants'
|
||||
|
||||
export async function runContainerStep(
|
||||
stepContainer: RunContainerStepArgs
|
||||
@@ -34,109 +26,118 @@ export async function runContainerStep(
|
||||
throw new Error('Building container actions is not currently supported')
|
||||
}
|
||||
|
||||
if (!stepContainer.entryPoint) {
|
||||
throw new Error(
|
||||
'failed to start the container since the entrypoint is overwritten'
|
||||
)
|
||||
}
|
||||
|
||||
const envs = stepContainer.environmentVariables || {}
|
||||
envs['GITHUB_ACTIONS'] = 'true'
|
||||
if (!('CI' in envs)) {
|
||||
envs.CI = 'true'
|
||||
let secretName: string | undefined = undefined
|
||||
if (stepContainer.environmentVariables) {
|
||||
try {
|
||||
const envs = JSON.parse(
|
||||
JSON.stringify(stepContainer.environmentVariables)
|
||||
)
|
||||
envs['GITHUB_ACTIONS'] = 'true'
|
||||
if (!('CI' in envs)) {
|
||||
envs.CI = 'true'
|
||||
}
|
||||
secretName = await createSecretForEnvs(envs)
|
||||
} catch (err) {
|
||||
core.debug(`createSecretForEnvs failed: ${JSON.stringify(err)}`)
|
||||
const message = (err as any)?.response?.body?.message || err
|
||||
throw new Error(`failed to create script environment: ${message}`)
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
pod = await createContainerStepPod(getStepPodName(), container, extension)
|
||||
job = await createJob(container, extension)
|
||||
} catch (err) {
|
||||
core.debug(`createJob failed: ${JSON.stringify(err)}`)
|
||||
const message = (err as any)?.response?.body?.message || err
|
||||
throw new Error(`failed to run script step: ${message}`)
|
||||
}
|
||||
|
||||
if (!pod.metadata?.name) {
|
||||
if (!job.metadata?.name) {
|
||||
throw new Error(
|
||||
`Expected job ${JSON.stringify(
|
||||
pod
|
||||
job
|
||||
)} 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 {
|
||||
await waitForPodPhases(
|
||||
podName,
|
||||
new Set([PodPhase.RUNNING]),
|
||||
new Set([PodPhase.PENDING, PodPhase.UNKNOWN]),
|
||||
getPrepareJobTimeoutSeconds()
|
||||
)
|
||||
|
||||
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}`)
|
||||
})
|
||||
podName = await getContainerJobPodName(job.metadata.name)
|
||||
} catch (err) {
|
||||
core.debug(`getContainerJobPodName failed: ${JSON.stringify(err)}`)
|
||||
const message = (err as any)?.response?.body?.message || err
|
||||
throw new Error(`failed to get container job pod name: ${message}`)
|
||||
}
|
||||
|
||||
await waitForPodPhases(
|
||||
podName,
|
||||
new Set([
|
||||
PodPhase.COMPLETED,
|
||||
PodPhase.RUNNING,
|
||||
PodPhase.SUCCEEDED,
|
||||
PodPhase.FAILED
|
||||
]),
|
||||
new Set([PodPhase.PENDING, PodPhase.UNKNOWN])
|
||||
)
|
||||
core.debug('Container step is running or complete, pulling logs')
|
||||
|
||||
await getPodLogs(podName, JOB_CONTAINER_NAME)
|
||||
|
||||
core.debug('Waiting for container job to complete')
|
||||
await waitForJobToComplete(job.metadata.name)
|
||||
|
||||
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(
|
||||
container: RunContainerStepArgs,
|
||||
secretName?: string,
|
||||
extension?: k8s.V1PodTemplateSpec
|
||||
): k8s.V1Container {
|
||||
const podContainer = new k8s.V1Container()
|
||||
podContainer.name = JOB_CONTAINER_NAME
|
||||
podContainer.image = container.image
|
||||
podContainer.workingDir = '/__w'
|
||||
podContainer.command = ['tail']
|
||||
podContainer.args = DEFAULT_CONTAINER_ENTRY_POINT_ARGS
|
||||
podContainer.workingDir = container.workingDirectory
|
||||
podContainer.command = container.entryPoint
|
||||
? [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) {
|
||||
return podContainer
|
||||
|
||||
@@ -2,19 +2,17 @@
|
||||
import * as fs from 'fs'
|
||||
import * as core from '@actions/core'
|
||||
import { RunScriptStepArgs } from 'hooklib'
|
||||
import { execCpFromPod, execCpToPod, execPodStep } from '../k8s'
|
||||
import { writeRunScript, sleep, listDirAllCommand } from '../k8s/utils'
|
||||
import { execPodStep } from '../k8s'
|
||||
import { writeEntryPointScript } from '../k8s/utils'
|
||||
import { JOB_CONTAINER_NAME } from './constants'
|
||||
import { dirname } from 'path'
|
||||
import * as shlex from 'shlex'
|
||||
|
||||
export async function runScriptStep(
|
||||
args: RunScriptStepArgs,
|
||||
state
|
||||
state,
|
||||
responseFile?
|
||||
): Promise<void> {
|
||||
// Write the entrypoint first. This will be later coppied to the workflow pod
|
||||
const { entryPoint, entryPointArgs, environmentVariables } = args
|
||||
const { containerPath, runnerPath } = writeRunScript(
|
||||
const { containerPath, runnerPath } = writeEntryPointScript(
|
||||
args.workingDirectory,
|
||||
entryPoint,
|
||||
entryPointArgs,
|
||||
@@ -22,56 +20,6 @@ export async function runScriptStep(
|
||||
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.entryPointArgs = ['-e', containerPath]
|
||||
try {
|
||||
@@ -85,23 +33,6 @@ export async function runScriptStep(
|
||||
const message = (err as any)?.response?.body?.message || err
|
||||
throw new Error(`failed to run script step: ${message}`)
|
||||
} finally {
|
||||
try {
|
||||
fs.rmSync(runnerPath, { force: true })
|
||||
} catch (removeErr) {
|
||||
core.debug(`Failed to remove file ${runnerPath}: ${removeErr}`)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
core.debug(
|
||||
`Copying from job pod '${state.jobPod}' ${containerTemp} to ${runnerTemp}`
|
||||
)
|
||||
await execCpFromPod(
|
||||
state.jobPod,
|
||||
`${containerTemp}/_runner_file_commands`,
|
||||
`${workdir}/_temp`
|
||||
)
|
||||
} catch (error) {
|
||||
core.warning('Failed to copy _temp from pod')
|
||||
fs.rmSync(runnerPath)
|
||||
}
|
||||
}
|
||||
|
||||
+130
-405
@@ -1,14 +1,13 @@
|
||||
import * as core from '@actions/core'
|
||||
import * as path from 'path'
|
||||
import { spawn } from 'child_process'
|
||||
import * as k8s from '@kubernetes/client-node'
|
||||
import tar from 'tar-fs'
|
||||
import * as stream from 'stream'
|
||||
import { WritableStreamBuffer } from 'stream-buffers'
|
||||
import { createHash } from 'crypto'
|
||||
import type { ContainerInfo, Registry } from 'hooklib'
|
||||
import {
|
||||
getJobPodName,
|
||||
getRunnerPodName,
|
||||
getSecretName,
|
||||
getStepPodName,
|
||||
getVolumeClaimName,
|
||||
JOB_CONTAINER_NAME,
|
||||
RunnerInstanceLabel
|
||||
} from '../hooks/constants'
|
||||
@@ -16,14 +15,9 @@ import {
|
||||
PodPhase,
|
||||
mergePodSpecWithOptions,
|
||||
mergeObjectMeta,
|
||||
fixArgs,
|
||||
listDirAllCommand,
|
||||
sleep,
|
||||
EXTERNALS_VOLUME_NAME,
|
||||
GITHUB_VOLUME_NAME,
|
||||
WORK_VOLUME
|
||||
useKubeScheduler,
|
||||
fixArgs
|
||||
} from './utils'
|
||||
import * as shlex from 'shlex'
|
||||
|
||||
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
|
||||
|
||||
export const POD_VOLUME_NAME = 'work'
|
||||
|
||||
export const requiredPermissions = [
|
||||
{
|
||||
group: '',
|
||||
@@ -54,6 +50,12 @@ export const requiredPermissions = [
|
||||
resource: 'pods',
|
||||
subresource: 'log'
|
||||
},
|
||||
{
|
||||
group: 'batch',
|
||||
verbs: ['get', 'list', 'create', 'delete'],
|
||||
resource: 'jobs',
|
||||
subresource: ''
|
||||
},
|
||||
{
|
||||
group: '',
|
||||
verbs: ['create', 'delete', 'get', 'list'],
|
||||
@@ -62,8 +64,7 @@ export const requiredPermissions = [
|
||||
}
|
||||
]
|
||||
|
||||
export async function createJobPod(
|
||||
name: string,
|
||||
export async function createPod(
|
||||
jobContainer?: k8s.V1Container,
|
||||
services?: k8s.V1Container[],
|
||||
registry?: Registry,
|
||||
@@ -83,7 +84,7 @@ export async function createJobPod(
|
||||
appPod.kind = 'Pod'
|
||||
|
||||
appPod.metadata = new k8s.V1ObjectMeta()
|
||||
appPod.metadata.name = name
|
||||
appPod.metadata.name = getJobPodName()
|
||||
|
||||
const instanceLabel = new RunnerInstanceLabel()
|
||||
appPod.metadata.labels = {
|
||||
@@ -93,68 +94,19 @@ export async function createJobPod(
|
||||
|
||||
appPod.spec = new k8s.V1PodSpec()
|
||||
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'
|
||||
|
||||
const nodeName = await getCurrentNodeName()
|
||||
if (useKubeScheduler()) {
|
||||
appPod.spec.affinity = await getPodAffinity(nodeName)
|
||||
} else {
|
||||
appPod.spec.nodeName = nodeName
|
||||
}
|
||||
const claimName = getVolumeClaimName()
|
||||
appPod.spec.volumes = [
|
||||
{
|
||||
name: EXTERNALS_VOLUME_NAME,
|
||||
emptyDir: {}
|
||||
},
|
||||
{
|
||||
name: GITHUB_VOLUME_NAME,
|
||||
emptyDir: {}
|
||||
},
|
||||
{
|
||||
name: WORK_VOLUME,
|
||||
emptyDir: {}
|
||||
name: POD_VOLUME_NAME,
|
||||
persistentVolumeClaim: { claimName }
|
||||
}
|
||||
]
|
||||
|
||||
@@ -182,62 +134,90 @@ export async function createJobPod(
|
||||
})
|
||||
}
|
||||
|
||||
export async function createContainerStepPod(
|
||||
name: string,
|
||||
export async function createJob(
|
||||
container: k8s.V1Container,
|
||||
extension?: k8s.V1PodTemplateSpec
|
||||
): Promise<k8s.V1Pod> {
|
||||
const appPod = new k8s.V1Pod()
|
||||
): Promise<k8s.V1Job> {
|
||||
const runnerInstanceLabel = new RunnerInstanceLabel()
|
||||
|
||||
appPod.apiVersion = 'v1'
|
||||
appPod.kind = 'Pod'
|
||||
const job = new k8s.V1Job()
|
||||
job.apiVersion = 'batch/v1'
|
||||
job.kind = 'Job'
|
||||
job.metadata = new k8s.V1ObjectMeta()
|
||||
job.metadata.name = getStepPodName()
|
||||
job.metadata.labels = { [runnerInstanceLabel.key]: runnerInstanceLabel.value }
|
||||
job.metadata.annotations = {}
|
||||
|
||||
appPod.metadata = new k8s.V1ObjectMeta()
|
||||
appPod.metadata.name = name
|
||||
job.spec = new k8s.V1JobSpec()
|
||||
job.spec.ttlSecondsAfterFinished = 300
|
||||
job.spec.backoffLimit = 0
|
||||
job.spec.template = new k8s.V1PodTemplateSpec()
|
||||
|
||||
const instanceLabel = new RunnerInstanceLabel()
|
||||
appPod.metadata.labels = {
|
||||
[instanceLabel.key]: instanceLabel.value
|
||||
job.spec.template.spec = new k8s.V1PodSpec()
|
||||
job.spec.template.metadata = new k8s.V1ObjectMeta()
|
||||
job.spec.template.metadata.labels = {}
|
||||
job.spec.template.metadata.annotations = {}
|
||||
job.spec.template.spec.containers = [container]
|
||||
job.spec.template.spec.restartPolicy = 'Never'
|
||||
|
||||
const nodeName = await getCurrentNodeName()
|
||||
if (useKubeScheduler()) {
|
||||
job.spec.template.spec.affinity = await getPodAffinity(nodeName)
|
||||
} else {
|
||||
job.spec.template.spec.nodeName = nodeName
|
||||
}
|
||||
appPod.metadata.annotations = {}
|
||||
|
||||
appPod.spec = new k8s.V1PodSpec()
|
||||
appPod.spec.containers = [container]
|
||||
|
||||
appPod.spec.restartPolicy = 'Never'
|
||||
|
||||
appPod.spec.volumes = [
|
||||
const claimName = getVolumeClaimName()
|
||||
job.spec.template.spec.volumes = [
|
||||
{
|
||||
name: EXTERNALS_VOLUME_NAME,
|
||||
emptyDir: {}
|
||||
},
|
||||
{
|
||||
name: GITHUB_VOLUME_NAME,
|
||||
emptyDir: {}
|
||||
},
|
||||
{
|
||||
name: WORK_VOLUME,
|
||||
emptyDir: {}
|
||||
name: POD_VOLUME_NAME,
|
||||
persistentVolumeClaim: { claimName }
|
||||
}
|
||||
]
|
||||
|
||||
if (extension?.metadata) {
|
||||
mergeObjectMeta(appPod, extension.metadata)
|
||||
if (extension) {
|
||||
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) {
|
||||
mergePodSpecWithOptions(appPod.spec, extension.spec)
|
||||
}
|
||||
|
||||
return await k8sApi.createNamespacedPod({
|
||||
return await k8sBatchV1Api.createNamespacedJob({
|
||||
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({
|
||||
name,
|
||||
name: podName,
|
||||
namespace: namespace(),
|
||||
gracePeriodSeconds: 0
|
||||
})
|
||||
@@ -264,7 +244,6 @@ export async function execPodStep(
|
||||
stdin ?? null,
|
||||
false /* tty */,
|
||||
resp => {
|
||||
core.debug(`execPodStep response: ${JSON.stringify(resp)}`)
|
||||
if (resp.status === 'Success') {
|
||||
resolve(resp.code || 0)
|
||||
} 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> {
|
||||
const backOffManager = new BackOffManager()
|
||||
while (true) {
|
||||
@@ -649,9 +344,9 @@ export async function createSecretForEnvs(envs: {
|
||||
return secretName
|
||||
}
|
||||
|
||||
export async function deleteSecret(name: string): Promise<void> {
|
||||
export async function deleteSecret(secretName: string): Promise<void> {
|
||||
await k8sApi.deleteNamespacedSecret({
|
||||
name,
|
||||
name: secretName,
|
||||
namespace: namespace()
|
||||
})
|
||||
}
|
||||
@@ -667,8 +362,7 @@ export async function pruneSecrets(): Promise<void> {
|
||||
|
||||
await Promise.all(
|
||||
secretList.items.map(
|
||||
async secret =>
|
||||
secret.metadata?.name && (await deleteSecret(secret.metadata.name))
|
||||
secret => secret.metadata?.name && deleteSecret(secret.metadata.name)
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -784,9 +478,7 @@ export async function prunePods(): Promise<void> {
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
podList.items.map(
|
||||
async pod => pod.metadata?.name && (await deletePod(pod.metadata.name))
|
||||
)
|
||||
podList.items.map(pod => pod.metadata?.name && deletePod(pod.metadata.name))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -831,12 +523,12 @@ export async function isPodContainerAlpine(
|
||||
[
|
||||
'sh',
|
||||
'-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,
|
||||
containerName
|
||||
)
|
||||
} catch {
|
||||
} catch (err) {
|
||||
isAlpine = false
|
||||
}
|
||||
|
||||
@@ -857,6 +549,39 @@ export function namespace(): string {
|
||||
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 {
|
||||
private backOffSeconds = 1
|
||||
totalTime = 0
|
||||
|
||||
+124
-29
@@ -6,6 +6,8 @@ import { v1 as uuidv4 } from 'uuid'
|
||||
import { CONTAINER_EXTENSION_PREFIX } from '../hooks/constants'
|
||||
import * as shlex from 'shlex'
|
||||
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 = '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_USE_KUBE_SCHEDULER = 'ACTIONS_RUNNER_USE_KUBE_SCHEDULER'
|
||||
|
||||
export const EXTERNALS_VOLUME_NAME = 'externals'
|
||||
export const GITHUB_VOLUME_NAME = 'github'
|
||||
export const WORK_VOLUME = 'work'
|
||||
export function containerVolumes(
|
||||
userMountVolumes: Mount[] = [],
|
||||
jobContainer = true,
|
||||
containerAction = false
|
||||
): k8s.V1VolumeMount[] {
|
||||
const mounts: k8s.V1VolumeMount[] = [
|
||||
{
|
||||
name: POD_VOLUME_NAME,
|
||||
mountPath: '/__w'
|
||||
}
|
||||
]
|
||||
|
||||
export const CONTAINER_VOLUMES: k8s.V1VolumeMount[] = [
|
||||
{
|
||||
name: EXTERNALS_VOLUME_NAME,
|
||||
mountPath: '/__e'
|
||||
},
|
||||
{
|
||||
name: WORK_VOLUME,
|
||||
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[]): {
|
||||
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(
|
||||
workingDirectory: string,
|
||||
entryPoint: string,
|
||||
@@ -288,18 +396,5 @@ function mergeLists<T>(base?: T[], from?: T[]): T[] {
|
||||
}
|
||||
|
||||
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(' '))
|
||||
}
|
||||
|
||||
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' {} \\;`
|
||||
}
|
||||
|
||||
@@ -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, () => {})
|
||||
}
|
||||
})
|
||||
@@ -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, () => {})
|
||||
}
|
||||
})
|
||||
@@ -9,6 +9,7 @@ const kc = new k8s.KubeConfig()
|
||||
kc.loadFromDefault()
|
||||
|
||||
const k8sApi = kc.makeApiClient(k8s.CoreV1Api)
|
||||
const k8sStorageApi = kc.makeApiClient(k8s.StorageV1Api)
|
||||
|
||||
export class TestHelper {
|
||||
private tempDirPath: string
|
||||
@@ -46,6 +47,7 @@ export class TestHelper {
|
||||
|
||||
await this.cleanupK8sResources()
|
||||
try {
|
||||
await this.createTestVolume()
|
||||
await this.createTestJobPod()
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
@@ -62,6 +64,24 @@ export class TestHelper {
|
||||
}
|
||||
|
||||
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
|
||||
.deleteNamespacedPod({
|
||||
name: this.podName,
|
||||
@@ -84,6 +104,14 @@ export class TestHelper {
|
||||
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 {
|
||||
const filePath = `${this.tempDirPath}/${fileName || uuidv4()}`
|
||||
@@ -120,6 +148,58 @@ export class TestHelper {
|
||||
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 {
|
||||
const prepareJob = JSON.parse(
|
||||
fs.readFileSync(
|
||||
@@ -163,3 +243,65 @@ export class TestHelper {
|
||||
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'
|
||||
|
||||
Reference in New Issue
Block a user