Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add functionality to provide optional pod template #50

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@ node_modules/
lib/
dist/
**/tests/_temp/**
packages/k8s/tests/test-kind.yaml
packages/k8s/tests/test-kind.yaml
14 changes: 14 additions & 0 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
@@ -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",
}
]
}
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
11 changes: 11 additions & 0 deletions packages/k8s/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/k8s/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
11 changes: 8 additions & 3 deletions packages/k8s/src/hooks/prepare-job.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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) {
Expand All @@ -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}`)
}

Expand All @@ -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(
Expand Down Expand Up @@ -153,7 +158,7 @@ async function copyExternalsToRoot(): Promise<void> {
}
}

function createPodSpec(
export function createContainerSpec(
container,
name: string,
jobContainer = false
Expand Down
2 changes: 1 addition & 1 deletion packages/k8s/src/hooks/run-container-step.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ export async function runContainerStep(
return Number(exitCode) || 1
}

function createPodSpec(
export function createPodSpec(
container: RunContainerStepArgs,
secretName?: string
): k8s.V1Container {
Expand Down
34 changes: 34 additions & 0 deletions packages/k8s/src/k8s/index.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -11,6 +13,7 @@ import {
RunnerInstanceLabel
} from '../hooks/constants'
import { PodPhase } from './utils'
import * as fs from 'fs'

const kc = new k8s.KubeConfig()

Expand Down Expand Up @@ -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 = [
{
Expand All @@ -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<k8s.V1Pod>(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<k8s.V1Job> {
Expand Down
19 changes: 19 additions & 0 deletions packages/k8s/tests/podtemplate.yaml
Original file line number Diff line number Diff line change
@@ -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"
69 changes: 67 additions & 2 deletions packages/k8s/tests/prepare-job-test.ts
Original file line number Diff line number Diff line change
@@ -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()

Expand All @@ -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')
})
Expand Down Expand Up @@ -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)
})
})
2 changes: 1 addition & 1 deletion packages/k8s/tests/test-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, '')
Expand Down