diff --git a/packages/k8s/src/hooks/constants.ts b/packages/k8s/src/hooks/constants.ts index 3803531..111f936 100644 --- a/packages/k8s/src/hooks/constants.ts +++ b/packages/k8s/src/hooks/constants.ts @@ -39,8 +39,8 @@ export function getSecretName(): string { )}-secret-${uuidv4().substring(0, STEP_POD_NAME_SUFFIX_LENGTH)}` } -const MAX_POD_NAME_LENGTH = 63 -const STEP_POD_NAME_SUFFIX_LENGTH = 8 +export const MAX_POD_NAME_LENGTH = 63 +export const STEP_POD_NAME_SUFFIX_LENGTH = 8 export const JOB_CONTAINER_NAME = 'job' export class RunnerInstanceLabel { diff --git a/packages/k8s/src/k8s/utils.ts b/packages/k8s/src/k8s/utils.ts index 9bdf5ec..d3072b4 100644 --- a/packages/k8s/src/k8s/utils.ts +++ b/packages/k8s/src/k8s/utils.ts @@ -20,18 +20,18 @@ export function containerVolumes( } ] + const workspacePath = process.env.GITHUB_WORKSPACE as string if (containerAction) { - const workspace = process.env.GITHUB_WORKSPACE as string mounts.push( { name: POD_VOLUME_NAME, mountPath: '/github/workspace', - subPath: workspace.substring(workspace.indexOf('work/') + 1) + subPath: workspacePath.substring(workspacePath.indexOf('work/') + 1) }, { name: POD_VOLUME_NAME, mountPath: '/github/file_commands', - subPath: workspace.substring(workspace.indexOf('work/') + 1) + subPath: workspacePath.substring(workspacePath.indexOf('work/') + 1) } ) return mounts @@ -63,7 +63,6 @@ export function containerVolumes( return mounts } - const workspacePath = process.env.GITHUB_WORKSPACE as string for (const userVolume of userMountVolumes) { let sourceVolumePath = '' if (path.isAbsolute(userVolume.sourceVolumePath)) { diff --git a/packages/k8s/tests/cleanup-job-test.ts b/packages/k8s/tests/cleanup-job-test.ts index 0b50a3c..3981cce 100644 --- a/packages/k8s/tests/cleanup-job-test.ts +++ b/packages/k8s/tests/cleanup-job-test.ts @@ -1,4 +1,7 @@ +import * as k8s from '@kubernetes/client-node' import { cleanupJob, prepareJob } from '../src/hooks' +import { RunnerInstanceLabel } from '../src/hooks/constants' +import { namespace } from '../src/k8s' import { TestHelper } from './test-setup' let testHelper: TestHelper @@ -13,10 +16,50 @@ describe('Cleanup Job', () => { ) await prepareJob(prepareJobData.args, prepareJobOutputFilePath) }) - it('should not throw', async () => { - await expect(cleanupJob()).resolves.not.toThrow() - }) + afterEach(async () => { await testHelper.cleanup() }) + + it('should not throw', async () => { + await expect(cleanupJob()).resolves.not.toThrow() + }) + + it('should have no runner linked pods running', async () => { + await cleanupJob() + const kc = new k8s.KubeConfig() + + kc.loadFromDefault() + const k8sApi = kc.makeApiClient(k8s.CoreV1Api) + + const podList = await k8sApi.listNamespacedPod( + namespace(), + undefined, + undefined, + undefined, + undefined, + new RunnerInstanceLabel().toString() + ) + + expect(podList.body.items.length).toBe(0) + }) + + it('should have no runner linked secrets', async () => { + await cleanupJob() + const kc = new k8s.KubeConfig() + + kc.loadFromDefault() + const k8sApi = kc.makeApiClient(k8s.CoreV1Api) + + const secretList = await k8sApi.listNamespacedSecret( + namespace(), + undefined, + undefined, + undefined, + undefined, + new RunnerInstanceLabel().toString() + ) + + expect(secretList.body.items.length).toBe(0) + }) }) diff --git a/packages/k8s/tests/constants-test.ts b/packages/k8s/tests/constants-test.ts new file mode 100644 index 0000000..4fc1eb1 --- /dev/null +++ b/packages/k8s/tests/constants-test.ts @@ -0,0 +1,173 @@ +import { + getJobPodName, + getRunnerPodName, + getSecretName, + getStepPodName, + getVolumeClaimName, + MAX_POD_NAME_LENGTH, + RunnerInstanceLabel, + STEP_POD_NAME_SUFFIX_LENGTH +} from '../src/hooks/constants' + +describe('constants', () => { + describe('runner instance label', () => { + beforeEach(() => { + process.env.ACTIONS_RUNNER_POD_NAME = 'example' + }) + it('should throw if ACTIONS_RUNNER_POD_NAME env is not set', () => { + delete process.env.ACTIONS_RUNNER_POD_NAME + expect(() => new RunnerInstanceLabel()).toThrow() + }) + + it('should have key truthy', () => { + const runnerInstanceLabel = new RunnerInstanceLabel() + expect(typeof runnerInstanceLabel.key).toBe('string') + expect(runnerInstanceLabel.key).toBeTruthy() + expect(runnerInstanceLabel.key.length).toBeGreaterThan(0) + }) + + it('should have value as runner pod name', () => { + const name = process.env.ACTIONS_RUNNER_POD_NAME as string + const runnerInstanceLabel = new RunnerInstanceLabel() + expect(typeof runnerInstanceLabel.value).toBe('string') + expect(runnerInstanceLabel.value).toBe(name) + }) + + it('should have toString combination of key and value', () => { + const runnerInstanceLabel = new RunnerInstanceLabel() + expect(runnerInstanceLabel.toString()).toBe( + `${runnerInstanceLabel.key}=${runnerInstanceLabel.value}` + ) + }) + }) + + describe('getRunnerPodName', () => { + it('should throw if ACTIONS_RUNNER_POD_NAME env is not set', () => { + delete process.env.ACTIONS_RUNNER_POD_NAME + expect(() => getRunnerPodName()).toThrow() + + process.env.ACTIONS_RUNNER_POD_NAME = '' + expect(() => getRunnerPodName()).toThrow() + }) + + it('should return corrent ACTIONS_RUNNER_POD_NAME name', () => { + const name = 'example' + process.env.ACTIONS_RUNNER_POD_NAME = name + expect(getRunnerPodName()).toBe(name) + }) + }) + + describe('getJobPodName', () => { + it('should throw on getJobPodName if ACTIONS_RUNNER_POD_NAME env is not set', () => { + delete process.env.ACTIONS_RUNNER_POD_NAME + expect(() => getJobPodName()).toThrow() + + process.env.ACTIONS_RUNNER_POD_NAME = '' + expect(() => getRunnerPodName()).toThrow() + }) + + it('should contain suffix -workflow', () => { + const tableTests = [ + { + podName: 'test', + expect: 'test-workflow' + }, + { + // podName.length == 63 + podName: + 'abcdaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + expect: + 'abcdaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-workflow' + } + ] + + for (const tt of tableTests) { + process.env.ACTIONS_RUNNER_POD_NAME = tt.podName + const actual = getJobPodName() + expect(actual).toBe(tt.expect) + } + }) + }) + + describe('getVolumeClaimName', () => { + it('should throw if ACTIONS_RUNNER_POD_NAME env is not set', () => { + delete process.env.ACTIONS_RUNNER_CLAIM_NAME + delete process.env.ACTIONS_RUNNER_POD_NAME + expect(() => getVolumeClaimName()).toThrow() + + process.env.ACTIONS_RUNNER_POD_NAME = '' + expect(() => getVolumeClaimName()).toThrow() + }) + + it('should return ACTIONS_RUNNER_CLAIM_NAME env if set', () => { + const claimName = 'testclaim' + process.env.ACTIONS_RUNNER_CLAIM_NAME = claimName + process.env.ACTIONS_RUNNER_POD_NAME = 'example' + expect(getVolumeClaimName()).toBe(claimName) + }) + + it('should contain suffix -work if ACTIONS_RUNNER_CLAIM_NAME is not set', () => { + delete process.env.ACTIONS_RUNNER_CLAIM_NAME + process.env.ACTIONS_RUNNER_POD_NAME = 'example' + expect(getVolumeClaimName()).toBe('example-work') + }) + }) + + describe('getSecretName', () => { + it('should throw if ACTIONS_RUNNER_POD_NAME env is not set', () => { + delete process.env.ACTIONS_RUNNER_POD_NAME + expect(() => getSecretName()).toThrow() + + process.env.ACTIONS_RUNNER_POD_NAME = '' + expect(() => getSecretName()).toThrow() + }) + + it('should contain suffix -secret- and name trimmed', () => { + const podNames = [ + 'test', + 'abcdaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' + ] + + for (const podName of podNames) { + process.env.ACTIONS_RUNNER_POD_NAME = podName + const actual = getSecretName() + const re = new RegExp( + `${podName.substring( + MAX_POD_NAME_LENGTH - + '-secret-'.length - + STEP_POD_NAME_SUFFIX_LENGTH + )}-secret-[a-z0-9]{8,}` + ) + expect(actual).toMatch(re) + } + }) + }) + + describe('getStepPodName', () => { + it('should throw if ACTIONS_RUNNER_POD_NAME env is not set', () => { + delete process.env.ACTIONS_RUNNER_POD_NAME + expect(() => getStepPodName()).toThrow() + + process.env.ACTIONS_RUNNER_POD_NAME = '' + expect(() => getStepPodName()).toThrow() + }) + + it('should contain suffix -step- and name trimmed', () => { + const podNames = [ + 'test', + 'abcdaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' + ] + + for (const podName of podNames) { + process.env.ACTIONS_RUNNER_POD_NAME = podName + const actual = getStepPodName() + const re = new RegExp( + `${podName.substring( + MAX_POD_NAME_LENGTH - '-step-'.length - STEP_POD_NAME_SUFFIX_LENGTH + )}-step-[a-z0-9]{8,}` + ) + expect(actual).toMatch(re) + } + }) + }) +}) diff --git a/packages/k8s/tests/k8s-utils-test.ts b/packages/k8s/tests/k8s-utils-test.ts new file mode 100644 index 0000000..cf30fda --- /dev/null +++ b/packages/k8s/tests/k8s-utils-test.ts @@ -0,0 +1,153 @@ +import * as fs from 'fs' +import { POD_VOLUME_NAME } from '../src/k8s' +import { containerVolumes, writeEntryPointScript } from '../src/k8s/utils' +import { TestHelper } from './test-setup' + +let testHelper: TestHelper + +describe('k8s utils', () => { + describe('write entrypoint', () => { + beforeEach(async () => { + testHelper = new TestHelper() + await testHelper.initialize() + }) + + afterEach(async () => { + await testHelper.cleanup() + }) + + it('should not throw', () => { + expect(() => + writeEntryPointScript( + '/test', + 'sh', + ['-e', 'script.sh'], + ['/prepend/path'], + { + SOME_ENV: 'SOME_VALUE' + } + ) + ).not.toThrow() + }) + + it('should throw if RUNNER_TEMP is not set', () => { + delete process.env.RUNNER_TEMP + expect(() => + writeEntryPointScript( + '/test', + 'sh', + ['-e', 'script.sh'], + ['/prepend/path'], + { + SOME_ENV: 'SOME_VALUE' + } + ) + ).toThrow() + }) + + it('should return object with containerPath and runnerPath', () => { + const { containerPath, runnerPath } = writeEntryPointScript( + '/test', + 'sh', + ['-e', 'script.sh'], + ['/prepend/path'], + { + SOME_ENV: 'SOME_VALUE' + } + ) + expect(containerPath).toMatch(/\/__w\/_temp\/.*\.sh/) + const re = new RegExp(`${process.env.RUNNER_TEMP}/.*\\.sh`) + expect(runnerPath).toMatch(re) + }) + + it('should write entrypoint path and the file should exist', () => { + const { runnerPath } = writeEntryPointScript( + '/test', + 'sh', + ['-e', 'script.sh'], + ['/prepend/path'], + { + SOME_ENV: 'SOME_VALUE' + } + ) + expect(fs.existsSync(runnerPath)).toBe(true) + }) + }) + + describe('container volumes', () => { + beforeEach(async () => { + testHelper = new TestHelper() + await testHelper.initialize() + }) + + afterEach(async () => { + await testHelper.cleanup() + }) + + it('should throw if container action and GITHUB_WORKSPACE env is not set', () => { + delete process.env.GITHUB_WORKSPACE + expect(() => containerVolumes([], true, true)).toThrow() + expect(() => containerVolumes([], false, true)).toThrow() + }) + + it('should always have work mount', () => { + let volumes = containerVolumes([], true, true) + expect(volumes.find(e => e.mountPath === '/__w')).toBeTruthy() + volumes = containerVolumes([], true, false) + expect(volumes.find(e => e.mountPath === '/__w')).toBeTruthy() + volumes = containerVolumes([], false, true) + expect(volumes.find(e => e.mountPath === '/__w')).toBeTruthy() + volumes = containerVolumes([], false, false) + expect(volumes.find(e => e.mountPath === '/__w')).toBeTruthy() + }) + + it('should have container action volumes', () => { + let volumes = containerVolumes([], true, true) + expect( + volumes.find(e => e.mountPath === '/github/workspace') + ).toBeTruthy() + expect( + volumes.find(e => e.mountPath === '/github/file_commands') + ).toBeTruthy() + volumes = containerVolumes([], false, true) + expect( + volumes.find(e => e.mountPath === '/github/workspace') + ).toBeTruthy() + expect( + volumes.find(e => e.mountPath === '/github/file_commands') + ).toBeTruthy() + }) + + it('should have externals, github home and github workflow mounts if job container', () => { + const volumes = containerVolumes() + expect(volumes.find(e => e.mountPath === '/__e')).toBeTruthy() + expect(volumes.find(e => e.mountPath === '/github/home')).toBeTruthy() + expect(volumes.find(e => e.mountPath === '/github/workflow')).toBeTruthy() + }) + + it('should throw if user volume source volume path is not in workspace', () => { + expect(() => + containerVolumes( + [ + { + sourceVolumePath: '/outside/of/workdir' + } + ], + true, + false + ) + ).toThrow() + }) + + it(`all volumes should have name ${POD_VOLUME_NAME}`, () => { + let volumes = containerVolumes([], true, true) + expect(volumes.every(e => e.name === POD_VOLUME_NAME)).toBeTruthy() + volumes = containerVolumes([], true, false) + expect(volumes.every(e => e.name === POD_VOLUME_NAME)).toBeTruthy() + volumes = containerVolumes([], false, true) + expect(volumes.every(e => e.name === POD_VOLUME_NAME)).toBeTruthy() + volumes = containerVolumes([], false, false) + expect(volumes.every(e => e.name === POD_VOLUME_NAME)).toBeTruthy() + }) + }) +})