Skip to content

Commit c47c78e

Browse files
test(k8s): add RWX and RWO affinity coverage
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <[email protected]>
1 parent ec61990 commit c47c78e

4 files changed

Lines changed: 352 additions & 0 deletions

File tree

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import * as fs from 'fs'
2+
import { cleanupJob } from '../src/hooks'
3+
import { prepareJob } from '../src/hooks/prepare-job'
4+
import { TestHelper } from './test-setup'
5+
import { getPodByName } from '../src/k8s'
6+
import { ENV_USE_KUBE_SCHEDULER } from '../src/k8s/utils'
7+
8+
jest.useRealTimers()
9+
10+
let testHelper: TestHelper
11+
let prepareJobData: any
12+
let prepareJobOutputFilePath: string
13+
14+
describe('RWO Affinity Behavior (Scheduler Mode)', () => {
15+
beforeEach(async () => {
16+
testHelper = new TestHelper()
17+
await testHelper.initialize()
18+
prepareJobData = testHelper.getPrepareJobDefinition()
19+
prepareJobOutputFilePath = testHelper.createFile('prepare-job-output.json')
20+
})
21+
22+
afterEach(async () => {
23+
await cleanupJob()
24+
await testHelper.cleanup()
25+
delete process.env[ENV_USE_KUBE_SCHEDULER]
26+
})
27+
28+
it('should add nodeAffinity with hostname selector when scheduler mode is enabled', async () => {
29+
process.env[ENV_USE_KUBE_SCHEDULER] = 'true'
30+
31+
await prepareJob(prepareJobData.args, prepareJobOutputFilePath)
32+
33+
const content = JSON.parse(
34+
fs.readFileSync(prepareJobOutputFilePath).toString()
35+
)
36+
37+
const pod = await getPodByName(content.state.jobPod)
38+
39+
expect(pod.spec?.affinity).toBeDefined()
40+
expect(pod.spec?.affinity?.nodeAffinity).toBeDefined()
41+
42+
const nodeAffinity = pod.spec?.affinity?.nodeAffinity
43+
expect(
44+
nodeAffinity?.requiredDuringSchedulingIgnoredDuringExecution
45+
).toBeDefined()
46+
47+
const nodeSelectorTerms =
48+
nodeAffinity?.requiredDuringSchedulingIgnoredDuringExecution
49+
?.nodeSelectorTerms
50+
51+
expect(nodeSelectorTerms).toBeDefined()
52+
expect(nodeSelectorTerms?.length).toBeGreaterThan(0)
53+
54+
const matchExpressions = nodeSelectorTerms?.[0].matchExpressions
55+
expect(matchExpressions).toBeDefined()
56+
expect(matchExpressions?.length).toBeGreaterThan(0)
57+
58+
const hostnameExpression = matchExpressions?.[0]
59+
expect(hostnameExpression?.key).toBe('kubernetes.io/hostname')
60+
expect(hostnameExpression?.operator).toBe('In')
61+
62+
expect(hostnameExpression?.values).toBeDefined()
63+
expect(hostnameExpression?.values?.length).toBeGreaterThan(0)
64+
expect(hostnameExpression?.values?.[0]).toBeTruthy()
65+
})
66+
67+
it('should NOT add nodeAffinity when scheduler mode is disabled', async () => {
68+
process.env[ENV_USE_KUBE_SCHEDULER] = 'false'
69+
70+
await prepareJob(prepareJobData.args, prepareJobOutputFilePath)
71+
72+
const content = JSON.parse(
73+
fs.readFileSync(prepareJobOutputFilePath).toString()
74+
)
75+
76+
const pod = await getPodByName(content.state.jobPod)
77+
78+
if (pod.spec?.affinity) {
79+
expect(pod.spec.affinity.nodeAffinity).toBeUndefined()
80+
}
81+
82+
expect(pod.spec?.nodeName).toBeDefined()
83+
})
84+
85+
it('should fail assertion if affinity block is missing when scheduler mode is enabled', async () => {
86+
process.env[ENV_USE_KUBE_SCHEDULER] = 'true'
87+
88+
await prepareJob(prepareJobData.args, prepareJobOutputFilePath)
89+
90+
const content = JSON.parse(
91+
fs.readFileSync(prepareJobOutputFilePath).toString()
92+
)
93+
94+
const pod = await getPodByName(content.state.jobPod)
95+
96+
expect(pod.spec?.affinity).toBeDefined()
97+
expect(pod.spec?.affinity?.nodeAffinity).toBeDefined()
98+
expect(
99+
pod.spec?.affinity?.nodeAffinity
100+
?.requiredDuringSchedulingIgnoredDuringExecution
101+
).toBeDefined()
102+
103+
const nodeSelectorTerms =
104+
pod.spec?.affinity?.nodeAffinity
105+
?.requiredDuringSchedulingIgnoredDuringExecution?.nodeSelectorTerms
106+
107+
expect(nodeSelectorTerms?.[0]?.matchExpressions?.[0]?.key).toBe(
108+
'kubernetes.io/hostname'
109+
)
110+
expect(nodeSelectorTerms?.[0]?.matchExpressions?.[0]?.operator).toBe('In')
111+
expect(
112+
nodeSelectorTerms?.[0]?.matchExpressions?.[0]?.values?.length
113+
).toBeGreaterThan(0)
114+
})
115+
116+
it('should use correct node name from runner pod in affinity values', async () => {
117+
process.env[ENV_USE_KUBE_SCHEDULER] = 'true'
118+
119+
const runnerPodName = process.env.ACTIONS_RUNNER_POD_NAME
120+
121+
await prepareJob(prepareJobData.args, prepareJobOutputFilePath)
122+
123+
const content = JSON.parse(
124+
fs.readFileSync(prepareJobOutputFilePath).toString()
125+
)
126+
127+
const jobPod = await getPodByName(content.state.jobPod)
128+
129+
const runnerPod = await getPodByName(runnerPodName!)
130+
131+
const affinityValues =
132+
jobPod.spec?.affinity?.nodeAffinity
133+
?.requiredDuringSchedulingIgnoredDuringExecution?.nodeSelectorTerms?.[0]
134+
?.matchExpressions?.[0]?.values
135+
136+
expect(affinityValues).toBeDefined()
137+
expect(affinityValues?.length).toBeGreaterThan(0)
138+
139+
if (runnerPod.spec?.nodeName) {
140+
expect(affinityValues).toContain(runnerPod.spec.nodeName)
141+
}
142+
})
143+
})
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import {
2+
isRWXTestEnabled,
3+
getRWXStorageClass,
4+
RWX_SKIP_MESSAGE
5+
} from './test-setup'
6+
7+
describe('RWX Test Contract Demo', () => {
8+
const describeOrSkip = isRWXTestEnabled() ? describe : describe.skip
9+
10+
describeOrSkip('RWX volume tests', () => {
11+
it('should use RWX storage class when enabled', () => {
12+
const storageClass = getRWXStorageClass()
13+
expect(storageClass).toBeDefined()
14+
expect(typeof storageClass).toBe('string')
15+
})
16+
17+
it('should verify both env vars are required', () => {
18+
expect(process.env.ACTIONS_RUNNER_K8S_TEST_ENABLE_RWX).toBe('true')
19+
expect(
20+
process.env.ACTIONS_RUNNER_K8S_TEST_RWX_STORAGE_CLASS
21+
).toBeDefined()
22+
})
23+
})
24+
25+
if (!isRWXTestEnabled()) {
26+
it(RWX_SKIP_MESSAGE, () => {})
27+
}
28+
})
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import * as k8s from '@kubernetes/client-node'
2+
import * as fs from 'fs'
3+
import { cleanupJob, prepareJob, runScriptStep } from '../src/hooks'
4+
import {
5+
TestHelper,
6+
isRWXTestEnabled,
7+
getRWXStorageClass,
8+
RWX_SKIP_MESSAGE
9+
} from './test-setup'
10+
import { RunScriptStepArgs } from 'hooklib'
11+
12+
jest.useRealTimers()
13+
14+
const kc = new k8s.KubeConfig()
15+
kc.loadFromDefault()
16+
const k8sApi = kc.makeApiClient(k8s.CoreV1Api)
17+
18+
describe('RWX Volume Tests', () => {
19+
const describeOrSkip = isRWXTestEnabled() ? describe : describe.skip
20+
21+
describeOrSkip('RWX volume integration', () => {
22+
let testHelper: TestHelper
23+
let rwxPvcName: string
24+
let prepareJobData: any
25+
let prepareJobOutputFilePath: string
26+
27+
beforeEach(async () => {
28+
testHelper = new TestHelper()
29+
await testHelper.initialize()
30+
31+
const podName = process.env.ACTIONS_RUNNER_POD_NAME
32+
rwxPvcName = `${podName}-work-rwx`
33+
34+
const volumeClaim: k8s.V1PersistentVolumeClaim = {
35+
metadata: {
36+
name: rwxPvcName
37+
},
38+
spec: {
39+
accessModes: ['ReadWriteMany'],
40+
volumeMode: 'Filesystem',
41+
storageClassName: getRWXStorageClass(),
42+
resources: {
43+
requests: {
44+
storage: '1Gi'
45+
}
46+
}
47+
}
48+
}
49+
50+
await k8sApi.createNamespacedPersistentVolumeClaim({
51+
namespace: 'default',
52+
body: volumeClaim
53+
})
54+
55+
process.env.ACTIONS_RUNNER_CLAIM_NAME = rwxPvcName
56+
57+
prepareJobData = testHelper.getPrepareJobDefinition()
58+
prepareJobOutputFilePath = testHelper.createFile(
59+
'prepare-job-output.json'
60+
)
61+
})
62+
63+
afterAll(async () => {
64+
if (rwxPvcName) {
65+
try {
66+
await k8sApi.deleteNamespacedPersistentVolumeClaim({
67+
name: rwxPvcName,
68+
namespace: 'default'
69+
})
70+
} catch {
71+
// Ignore cleanup errors - PVC may not exist
72+
}
73+
}
74+
})
75+
76+
afterEach(async () => {
77+
await testHelper.cleanup()
78+
})
79+
80+
it('should successfully run hook flow with RWX volume', async () => {
81+
await expect(
82+
prepareJob(prepareJobData.args, prepareJobOutputFilePath)
83+
).resolves.not.toThrow()
84+
85+
const prepareJobOutputJson = fs.readFileSync(prepareJobOutputFilePath)
86+
const prepareJobOutputData = JSON.parse(prepareJobOutputJson.toString())
87+
88+
const scriptStepData = testHelper.getRunScriptStepDefinition()
89+
90+
await expect(
91+
runScriptStep(
92+
scriptStepData.args as RunScriptStepArgs,
93+
prepareJobOutputData.state
94+
)
95+
).resolves.not.toThrow()
96+
97+
await expect(cleanupJob()).resolves.not.toThrow()
98+
})
99+
100+
it('should verify RWX PVC was created with correct access mode', async () => {
101+
const pvc = await k8sApi.readNamespacedPersistentVolumeClaim({
102+
name: rwxPvcName,
103+
namespace: 'default'
104+
})
105+
106+
expect(pvc.spec?.accessModes).toContain('ReadWriteMany')
107+
expect(pvc.spec?.storageClassName).toBe(getRWXStorageClass())
108+
expect(pvc.spec?.volumeMode).toBe('Filesystem')
109+
})
110+
111+
it('should verify RWX claim name is set correctly', () => {
112+
expect(process.env.ACTIONS_RUNNER_CLAIM_NAME).toBe(rwxPvcName)
113+
})
114+
})
115+
116+
if (!isRWXTestEnabled()) {
117+
it(RWX_SKIP_MESSAGE, () => {})
118+
}
119+
})

packages/k8s/tests/test-setup.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,3 +243,65 @@ export class TestHelper {
243243
return runContainerStep
244244
}
245245
}
246+
247+
/**
248+
* RWX Test Contract:
249+
*
250+
* Tests requiring ReadWriteMany (RWX) volumes MUST be gated by TWO environment variables:
251+
* 1. ACTIONS_RUNNER_K8S_TEST_ENABLE_RWX=true (explicit opt-in)
252+
* 2. ACTIONS_RUNNER_K8S_TEST_RWX_STORAGE_CLASS=<name> (storage class that supports RWX)
253+
*
254+
* If either variable is missing or ACTIONS_RUNNER_K8S_TEST_ENABLE_RWX is not "true",
255+
* the test MUST be skipped with the exact message defined in this contract.
256+
*
257+
* This contract ensures:
258+
* - RWX tests do not fail on clusters without RWX provisioners
259+
* - Test requirements are explicit and documented
260+
* - RWO affinity tests remain independent and always runnable
261+
* - Skip behavior is deterministic (no dynamic cluster probing)
262+
*
263+
* Usage example:
264+
* ```typescript
265+
* import { isRWXTestEnabled, getRWXStorageClass, RWX_SKIP_MESSAGE } from './test-setup'
266+
*
267+
* describe('RWX Test Suite', () => {
268+
* const describeOrSkip = isRWXTestEnabled() ? describe : describe.skip
269+
*
270+
* describeOrSkip('RWX volume tests', () => {
271+
* it('should test RWX functionality', async () => {
272+
* const storageClass = getRWXStorageClass()
273+
* // ... test code using storageClass
274+
* })
275+
* })
276+
*
277+
* if (!isRWXTestEnabled()) {
278+
* it(RWX_SKIP_MESSAGE, () => {})
279+
* }
280+
* })
281+
* ```
282+
*/
283+
284+
/**
285+
* Checks if RWX tests should run based on environment variables.
286+
* @returns true if both ACTIONS_RUNNER_K8S_TEST_ENABLE_RWX=true and ACTIONS_RUNNER_K8S_TEST_RWX_STORAGE_CLASS are set
287+
*/
288+
export function isRWXTestEnabled(): boolean {
289+
const enabled = process.env.ACTIONS_RUNNER_K8S_TEST_ENABLE_RWX === 'true'
290+
const storageClass = process.env.ACTIONS_RUNNER_K8S_TEST_RWX_STORAGE_CLASS
291+
return enabled && !!storageClass
292+
}
293+
294+
/**
295+
* Gets the RWX storage class name from environment variable.
296+
* @returns The storage class name, or undefined if not set
297+
*/
298+
export function getRWXStorageClass(): string | undefined {
299+
return process.env.ACTIONS_RUNNER_K8S_TEST_RWX_STORAGE_CLASS
300+
}
301+
302+
/**
303+
* Skip message constant - DO NOT MODIFY
304+
* This exact message must be used when skipping RWX tests
305+
*/
306+
export const RWX_SKIP_MESSAGE =
307+
'RWX tests skipped: set ACTIONS_RUNNER_K8S_TEST_ENABLE_RWX=true and ACTIONS_RUNNER_K8S_TEST_RWX_STORAGE_CLASS'

0 commit comments

Comments
 (0)