308 lines
8.7 KiB
TypeScript
308 lines
8.7 KiB
TypeScript
import * as k8s from '@kubernetes/client-node'
|
|
import * as fs from 'fs'
|
|
import { HookData } from 'hooklib/lib'
|
|
import * as path from 'path'
|
|
import { v4 as uuidv4 } from 'uuid'
|
|
|
|
const kc = new k8s.KubeConfig()
|
|
|
|
kc.loadFromDefault()
|
|
|
|
const k8sApi = kc.makeApiClient(k8s.CoreV1Api)
|
|
const k8sStorageApi = kc.makeApiClient(k8s.StorageV1Api)
|
|
|
|
export class TestHelper {
|
|
private tempDirPath: string
|
|
private podName: string
|
|
private runnerWorkdir: string
|
|
private runnerTemp: string
|
|
|
|
constructor() {
|
|
this.tempDirPath = `${__dirname}/_temp/runner`
|
|
this.runnerWorkdir = `${this.tempDirPath}/_work`
|
|
this.runnerTemp = `${this.tempDirPath}/_work/_temp`
|
|
this.podName = uuidv4().replace(/-/g, '')
|
|
}
|
|
|
|
async initialize(): Promise<void> {
|
|
process.env['ACTIONS_RUNNER_POD_NAME'] = `${this.podName}`
|
|
process.env['RUNNER_WORKSPACE'] = `${this.runnerWorkdir}/repo`
|
|
process.env['RUNNER_TEMP'] = `${this.runnerTemp}`
|
|
process.env['GITHUB_WORKSPACE'] = `${this.runnerWorkdir}/repo/repo`
|
|
process.env['ACTIONS_RUNNER_KUBERNETES_NAMESPACE'] = 'default'
|
|
|
|
fs.mkdirSync(`${this.runnerWorkdir}/repo/repo`, { recursive: true })
|
|
fs.mkdirSync(`${this.tempDirPath}/externals`, { recursive: true })
|
|
fs.mkdirSync(this.runnerTemp, { recursive: true })
|
|
fs.mkdirSync(`${this.runnerTemp}/_github_workflow`, { recursive: true })
|
|
fs.mkdirSync(`${this.runnerTemp}/_github_home`, { recursive: true })
|
|
fs.mkdirSync(`${this.runnerTemp}/_runner_file_commands`, {
|
|
recursive: true
|
|
})
|
|
|
|
fs.copyFileSync(
|
|
path.resolve(`${__dirname}/../../../examples/example-script.sh`),
|
|
`${this.runnerTemp}/example-script.sh`
|
|
)
|
|
|
|
await this.cleanupK8sResources()
|
|
try {
|
|
await this.createTestVolume()
|
|
await this.createTestJobPod()
|
|
} catch (e) {
|
|
console.log(e)
|
|
}
|
|
}
|
|
|
|
async cleanup(): Promise<void> {
|
|
try {
|
|
await this.cleanupK8sResources()
|
|
fs.rmSync(this.tempDirPath, { recursive: true })
|
|
} catch {
|
|
// Ignore errors during cleanup
|
|
}
|
|
}
|
|
|
|
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,
|
|
namespace: 'default',
|
|
gracePeriodSeconds: 0
|
|
})
|
|
.catch((e: k8s.ApiException<any>) => {
|
|
if (e.code !== 404) {
|
|
console.error(JSON.stringify(e))
|
|
}
|
|
})
|
|
await k8sApi
|
|
.deleteNamespacedPod({
|
|
name: `${this.podName}-workflow`,
|
|
namespace: 'default',
|
|
gracePeriodSeconds: 0
|
|
})
|
|
.catch((e: k8s.ApiException<any>) => {
|
|
if (e.code !== 404) {
|
|
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()}`
|
|
fs.writeFileSync(filePath, '')
|
|
return filePath
|
|
}
|
|
|
|
removeFile(fileName: string): void {
|
|
const filePath = `${this.tempDirPath}/${fileName}`
|
|
fs.rmSync(filePath)
|
|
}
|
|
|
|
async createTestJobPod(): Promise<void> {
|
|
const container = {
|
|
name: 'runner',
|
|
image: 'ghcr.io/actions/actions-runner:latest',
|
|
imagePullPolicy: 'IfNotPresent'
|
|
} as k8s.V1Container
|
|
|
|
const pod: k8s.V1Pod = {
|
|
metadata: {
|
|
name: this.podName
|
|
},
|
|
spec: {
|
|
restartPolicy: 'Never',
|
|
containers: [container],
|
|
securityContext: {
|
|
runAsUser: 1001,
|
|
runAsGroup: 1001,
|
|
fsGroup: 1001
|
|
}
|
|
}
|
|
} as k8s.V1Pod
|
|
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(
|
|
path.resolve(__dirname + '/../../../examples/prepare-job.json'),
|
|
'utf8'
|
|
)
|
|
)
|
|
|
|
prepareJob.args.container.userMountVolumes = undefined
|
|
prepareJob.args.container.registry = null
|
|
prepareJob.args.services.forEach(s => {
|
|
s.registry = null
|
|
})
|
|
|
|
return prepareJob
|
|
}
|
|
|
|
getRunScriptStepDefinition(): HookData {
|
|
const runScriptStep = JSON.parse(
|
|
fs.readFileSync(
|
|
path.resolve(__dirname + '/../../../examples/run-script-step.json'),
|
|
'utf8'
|
|
)
|
|
)
|
|
|
|
runScriptStep.args.entryPointArgs[1] = `/__w/_temp/example-script.sh`
|
|
return runScriptStep
|
|
}
|
|
|
|
getRunContainerStepDefinition(): HookData {
|
|
const runContainerStep = JSON.parse(
|
|
fs.readFileSync(
|
|
path.resolve(__dirname + '/../../../examples/run-container-step.json'),
|
|
'utf8'
|
|
)
|
|
)
|
|
|
|
runContainerStep.args.entryPointArgs[1] = `/__w/_temp/example-script.sh`
|
|
runContainerStep.args.userMountVolumes = undefined
|
|
runContainerStep.args.registry = null
|
|
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'
|