diff --git a/.gitignore b/.gitignore index a48fc2b5..c4b5ccf0 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,4 @@ node_modules/ lib/ dist/ **/tests/_temp/** -packages/k8s/tests/test-kind.yaml \ No newline at end of file +packages/k8s/tests/test-kind.yaml diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..3a81b27b --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,14 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "name": "run prepare job tests", + "request": "launch", + "program": "${workspaceFolder}/packages/k8s/node_modules/.bin/jest", + "args": ["packages/k8s/tests/prepare-job-test.ts", "--runInBand"], + "cwd": "${workspaceFolder}/packages/k8s", + "console": "integratedTerminal", + } + ] +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index bc1b844c..ec62b50b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "hooks", - "version": "0.1.3", + "version": "0.3.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "hooks", - "version": "0.1.3", + "version": "0.3.0", "license": "MIT", "devDependencies": { "@types/jest": "^27.5.1", diff --git a/package.json b/package.json index 42e2922a..0f6f2ac0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hooks", - "version": "0.2.0", + "version": "0.3.0", "description": "Three projects are included - k8s: a kubernetes hook implementation that spins up pods dynamically to run a job - docker: A hook implementation of the runner's docker implementation - A hook lib, which contains shared typescript definitions and utilities that the other packages consume", "main": "", "directories": { diff --git a/packages/k8s/package-lock.json b/packages/k8s/package-lock.json index f91eda53..b5908d3b 100644 --- a/packages/k8s/package-lock.json +++ b/packages/k8s/package-lock.json @@ -13,6 +13,7 @@ "@actions/exec": "^1.1.1", "@actions/io": "^1.1.2", "@kubernetes/client-node": "^0.16.3", + "@types/lodash": "^4.14.191", "hooklib": "file:../hooklib" }, "devDependencies": { @@ -1179,6 +1180,11 @@ "@types/node": "*" } }, + "node_modules/@types/lodash": { + "version": "4.14.191", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.191.tgz", + "integrity": "sha512-BdZ5BCCvho3EIXw6wUCXHe7rS53AIDPLE+JzwgT+OsJk53oBfbSmZZ7CX4VaRoN78N+TJpFi9QPlfIVNmJYWxQ==" + }, "node_modules/@types/minipass": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@types/minipass/-/minipass-3.1.2.tgz", @@ -6078,6 +6084,11 @@ "@types/node": "*" } }, + "@types/lodash": { + "version": "4.14.191", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.191.tgz", + "integrity": "sha512-BdZ5BCCvho3EIXw6wUCXHe7rS53AIDPLE+JzwgT+OsJk53oBfbSmZZ7CX4VaRoN78N+TJpFi9QPlfIVNmJYWxQ==" + }, "@types/minipass": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@types/minipass/-/minipass-3.1.2.tgz", diff --git a/packages/k8s/package.json b/packages/k8s/package.json index 3d8299e0..afb6122a 100644 --- a/packages/k8s/package.json +++ b/packages/k8s/package.json @@ -17,6 +17,7 @@ "@actions/exec": "^1.1.1", "@actions/io": "^1.1.2", "@kubernetes/client-node": "^0.16.3", + "@types/lodash": "^4.14.191", "hooklib": "file:../hooklib" }, "devDependencies": { diff --git a/packages/k8s/src/hooks/prepare-job.ts b/packages/k8s/src/hooks/prepare-job.ts index 7d79f968..58654f84 100644 --- a/packages/k8s/src/hooks/prepare-job.ts +++ b/packages/k8s/src/hooks/prepare-job.ts @@ -17,6 +17,7 @@ import { PodPhase } from '../k8s/utils' import { JOB_CONTAINER_NAME } from './constants' +import { HttpError } from '@kubernetes/client-node' export async function prepareJob( args: prepareJobArgs, @@ -31,14 +32,14 @@ export async function prepareJob( let container: k8s.V1Container | undefined = undefined if (args.container?.image) { core.debug(`Using image '${args.container.image}' for job image`) - container = createPodSpec(args.container, JOB_CONTAINER_NAME, true) + container = createContainerSpec(args.container, JOB_CONTAINER_NAME, true) } let services: k8s.V1Container[] = [] if (args.services?.length) { services = args.services.map(service => { core.debug(`Adding service '${service.image}' to pod definition`) - return createPodSpec(service, service.image.split(':')[0]) + return createContainerSpec(service, service.image.split(':')[0]) }) } if (!container && !services?.length) { @@ -49,6 +50,9 @@ export async function prepareJob( createdPod = await createPod(container, services, args.container.registry) } catch (err) { await prunePods() + if (err instanceof HttpError) { + core.error(JSON.stringify(err.body)) + } throw new Error(`failed to create job pod: ${err}`) } @@ -58,6 +62,7 @@ export async function prepareJob( core.debug( `Job pod created, waiting for it to come online ${createdPod?.metadata?.name}` ) + core.debug(k8s.dumpYaml(createdPod)) try { await waitForPodPhases( @@ -153,7 +158,7 @@ async function copyExternalsToRoot(): Promise { } } -function createPodSpec( +export function createContainerSpec( container, name: string, jobContainer = false diff --git a/packages/k8s/src/hooks/run-container-step.ts b/packages/k8s/src/hooks/run-container-step.ts index 3fc1ee62..79b9f5b7 100644 --- a/packages/k8s/src/hooks/run-container-step.ts +++ b/packages/k8s/src/hooks/run-container-step.ts @@ -75,7 +75,7 @@ export async function runContainerStep( return Number(exitCode) || 1 } -function createPodSpec( +export function createPodSpec( container: RunContainerStepArgs, secretName?: string ): k8s.V1Container { diff --git a/packages/k8s/src/k8s/index.ts b/packages/k8s/src/k8s/index.ts index 62f5e898..f7eefa7d 100644 --- a/packages/k8s/src/k8s/index.ts +++ b/packages/k8s/src/k8s/index.ts @@ -1,5 +1,7 @@ import * as core from '@actions/core' import * as k8s from '@kubernetes/client-node' +import * as _ from 'lodash' + import { ContainerInfo, Registry } from 'hooklib' import * as stream from 'stream' import { @@ -11,6 +13,7 @@ import { RunnerInstanceLabel } from '../hooks/constants' import { PodPhase } from './utils' +import * as fs from 'fs' const kc = new k8s.KubeConfig() @@ -85,6 +88,7 @@ export async function createPod( appPod.spec.containers = containers appPod.spec.restartPolicy = 'Never' appPod.spec.nodeName = await getCurrentNodeName() + const claimName = getVolumeClaimName() appPod.spec.volumes = [ { @@ -103,10 +107,40 @@ export async function createPod( appPod.spec.imagePullSecrets = [secretReference] } + //Enrich the job spec with the fields defined in the template if there is one + const podTemplatePath = process.env.ACTIONS_RUNNER_POD_TEMPLATE_PATH + if (podTemplatePath !== undefined) { + core.debug('Podtemplate provided, merging fields with pod spec') + const yaml = fs.readFileSync(podTemplatePath, 'utf8') + const template = k8s.loadYaml(yaml) + appPod.spec = _.mergeWith(appPod.spec, template.spec, podSpecCustomizer) + } + const { body } = await k8sApi.createNamespacedPod(namespace(), appPod) return body } +/** + * Custom function to pass to the lodash mergeWith to merge the podSpec with the provided template. + * Will concat all arrays it encounters during the merge, except for the container list of the spec. + * Will also skip merging the "image", "name", "command", "args" values of the template + * + * https://lodash.com/docs/4.17.15#mergeWith + */ +function podSpecCustomizer(objValue, srcValue, key): any[] | undefined { + if (['image', 'name', 'command', 'args'].includes(key)) { + return objValue + } + + //check if passed object is array and concat instead of merge if yes + if (_.isArray(objValue)) { + if (key === 'containers') { + return + } + return objValue.concat(srcValue) + } +} + export async function createJob( container: k8s.V1Container ): Promise { diff --git a/packages/k8s/tests/podtemplate.yaml b/packages/k8s/tests/podtemplate.yaml new file mode 100644 index 00000000..9d229344 --- /dev/null +++ b/packages/k8s/tests/podtemplate.yaml @@ -0,0 +1,19 @@ +apiVersion: v1 +kind: Pod +metadata: + name: thisvaluewillbeignored +spec: + containers: + # the runner will override the "image", "name" and "command" fields + - image: "test/test" + name: "thisvaluewillbeignored" + command: + - "these" + - "are" + - "overridden" + resources: + requests: + cpu: "128m" + env: + - name: "TEST" + value: "testvalue" diff --git a/packages/k8s/tests/prepare-job-test.ts b/packages/k8s/tests/prepare-job-test.ts index 1bb60676..a411e0c1 100644 --- a/packages/k8s/tests/prepare-job-test.ts +++ b/packages/k8s/tests/prepare-job-test.ts @@ -1,8 +1,21 @@ import * as fs from 'fs' import * as path from 'path' -import { cleanupJob } from '../src/hooks' -import { prepareJob } from '../src/hooks/prepare-job' +import { cleanupJob, createPodSpec } from '../src/hooks' +import { createContainerSpec, prepareJob } from '../src/hooks/prepare-job' import { TestHelper } from './test-setup' +import { createJob, createPod, waitForPodPhases } from '../src/k8s' +import { + V1EnvVar, + V1ResourceRequirements, + V1Volume, + V1VolumeMount +} from '@kubernetes/client-node' +import { JOB_CONTAINER_NAME } from '../src/hooks/constants' +import { + DEFAULT_CONTAINER_ENTRY_POINT, + DEFAULT_CONTAINER_ENTRY_POINT_ARGS, + PodPhase +} from '../src/k8s/utils' jest.useRealTimers() @@ -16,6 +29,12 @@ describe('Prepare job', () => { beforeEach(async () => { testHelper = new TestHelper() await testHelper.initialize() + + await waitForPodPhases( + testHelper.podName, + new Set([PodPhase.RUNNING]), + new Set([PodPhase.PENDING]) + ) prepareJobData = testHelper.getPrepareJobDefinition() prepareJobOutputFilePath = testHelper.createFile('prepare-job-output.json') }) @@ -71,4 +90,50 @@ describe('Prepare job', () => { prepareJob(prepareJobData.args, prepareJobOutputFilePath) ).rejects.toThrow() }) + + it('should have the extra fields set if ACTIONS_RUNNER_POD_TEMPLATE_PATH env variable is set', async () => { + process.env.ACTIONS_RUNNER_POD_TEMPLATE_PATH = path.resolve( + __dirname, + 'podtemplate.yaml' + ) + + const container = createContainerSpec( + prepareJobData.args.container, + JOB_CONTAINER_NAME, + true + ) + const services = prepareJobData.args.services.map(service => { + return createContainerSpec(service, service.image.split(':')[0]) + }) + const pod = await createPod(container, services) + + // name, image,command,args should not be overwritten + expect(pod.spec?.containers[0].name).toEqual('job') + expect(pod.spec?.containers[0].image).toEqual('node:14.16') + expect(pod.spec?.containers[0].command).toEqual([ + DEFAULT_CONTAINER_ENTRY_POINT + ]) + expect(pod.spec?.containers[0].args).toEqual( + DEFAULT_CONTAINER_ENTRY_POINT_ARGS + ) + + //rest of template should be appended + expect(pod.spec?.containers[0].env).toContainEqual({ + name: 'TEST', + value: 'testvalue' + } as V1EnvVar) + expect(pod.spec?.containers[0].env).toContainEqual({ + name: 'NODE_ENV', + value: 'development' + } as V1EnvVar) + + expect(pod.spec?.containers[0].resources).toEqual({ + requests: { cpu: '128m' } + } as V1ResourceRequirements) + + expect(pod.spec?.containers[0].volumeMounts).toContainEqual({ + name: 'work', + mountPath: '/__w' + } as V1VolumeMount) + }) }) diff --git a/packages/k8s/tests/test-setup.ts b/packages/k8s/tests/test-setup.ts index 25efcf5d..777886db 100644 --- a/packages/k8s/tests/test-setup.ts +++ b/packages/k8s/tests/test-setup.ts @@ -13,7 +13,7 @@ const k8sStorageApi = kc.makeApiClient(k8s.StorageV1Api) export class TestHelper { private tempDirPath: string - private podName: string + public podName: string constructor() { this.tempDirPath = `${__dirname}/_temp/runner` this.podName = uuidv4().replace(/-/g, '')