Ensure read write many first
This commit is contained in:
@@ -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