fixed merge conflict, repaired paths in examples

This commit is contained in:
Nikola Jokic
2022-06-08 11:02:33 +02:00
23 changed files with 300 additions and 114 deletions

View File

@@ -10,6 +10,7 @@ jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: helm/kind-action@v1.2.0
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- run: npm install - run: npm install
name: Install dependencies name: Install dependencies

View File

@@ -5,7 +5,7 @@
"args": { "args": {
"container": { "container": {
"image": "node:14.16", "image": "node:14.16",
"workingDirectory": "/__w/thboop-test2/thboop-test2", "workingDirectory": "/__w/repo/repo",
"createOptions": "--cpus 1", "createOptions": "--cpus 1",
"environmentVariables": { "environmentVariables": {
"NODE_ENV": "development" "NODE_ENV": "development"
@@ -24,37 +24,37 @@
"readOnly": false "readOnly": false
}, },
{ {
"sourceVolumePath": "//Users/thomas/git/runner/_layout/_work", "sourceVolumePath": "/Users/thomas/git/runner/_layout/_work",
"targetVolumePath": "/__w", "targetVolumePath": "/__w",
"readOnly": false "readOnly": false
}, },
{ {
"sourceVolumePath": "//Users/thomas/git/runner/_layout/externals", "sourceVolumePath": "/Users/thomas/git/runner/_layout/externals",
"targetVolumePath": "/__e", "targetVolumePath": "/__e",
"readOnly": true "readOnly": true
}, },
{ {
"sourceVolumePath": "//Users/thomas/git/runner/_layout/_work/_temp", "sourceVolumePath": "/Users/thomas/git/runner/_layout/_work/_temp",
"targetVolumePath": "/__w/_temp", "targetVolumePath": "/__w/_temp",
"readOnly": false "readOnly": false
}, },
{ {
"sourceVolumePath": "//Users/thomas/git/runner/_layout/_work/_actions", "sourceVolumePath": "/Users/thomas/git/runner/_layout/_work/_actions",
"targetVolumePath": "/__w/_actions", "targetVolumePath": "/__w/_actions",
"readOnly": false "readOnly": false
}, },
{ {
"sourceVolumePath": "//Users/thomas/git/runner/_layout/_work/_tool", "sourceVolumePath": "/Users/thomas/git/runner/_layout/_work/_tool",
"targetVolumePath": "/__w/_tool", "targetVolumePath": "/__w/_tool",
"readOnly": false "readOnly": false
}, },
{ {
"sourceVolumePath": "//Users/thomas/git/runner/_layout/_work/_temp/_github_home", "sourceVolumePath": "/Users/thomas/git/runner/_layout/_work/_temp/_github_home",
"targetVolumePath": "/github/home", "targetVolumePath": "/github/home",
"readOnly": false "readOnly": false
}, },
{ {
"sourceVolumePath": "//Users/thomas/git/runner/_layout/_work/_temp/_github_workflow", "sourceVolumePath": "/Users/thomas/git/runner/_layout/_work/_temp/_github_workflow",
"targetVolumePath": "/github/workflow", "targetVolumePath": "/github/workflow",
"readOnly": false "readOnly": false
} }

View File

@@ -16,7 +16,7 @@
"echo \"hello world2\"" "echo \"hello world2\""
], ],
"entryPoint": "bash", "entryPoint": "bash",
"workingDirectory": "/__w/thboop-test2/thboop-test2", "workingDirectory": "/__w/repo/repo",
"createOptions": "--cpus 1", "createOptions": "--cpus 1",
"environmentVariables": { "environmentVariables": {
"NODE_ENV": "development" "NODE_ENV": "development"
@@ -34,27 +34,27 @@
], ],
"systemMountVolumes": [ "systemMountVolumes": [
{ {
"sourceVolumePath": "//Users/thomas/git/runner/_layout/_work", "sourceVolumePath": "/Users/thomas/git/runner/_layout/_work",
"targetVolumePath": "/__w", "targetVolumePath": "/__w",
"readOnly": false "readOnly": false
}, },
{ {
"sourceVolumePath": "//Users/thomas/git/runner/_layout/externals", "sourceVolumePath": "/Users/thomas/git/runner/_layout/externals",
"targetVolumePath": "/__e", "targetVolumePath": "/__e",
"readOnly": true "readOnly": true
}, },
{ {
"sourceVolumePath": "//Users/thomas/git/runner/_layout/_work/_temp", "sourceVolumePath": "/Users/thomas/git/runner/_layout/_work/_temp",
"targetVolumePath": "/__w/_temp", "targetVolumePath": "/__w/_temp",
"readOnly": false "readOnly": false
}, },
{ {
"sourceVolumePath": "//Users/thomas/git/runner/_layout/_work/_actions", "sourceVolumePath": "/Users/thomas/git/runner/_layout/_work/_actions",
"targetVolumePath": "/__w/_actions", "targetVolumePath": "/__w/_actions",
"readOnly": false "readOnly": false
}, },
{ {
"sourceVolumePath": "//Users/thomas/git/runner/_layout/_work/_tool", "sourceVolumePath": "/Users/thomas/git/runner/_layout/_work/_tool",
"targetVolumePath": "/__w/_tool", "targetVolumePath": "/__w/_tool",
"readOnly": false "readOnly": false
}, },
@@ -64,7 +64,7 @@
"readOnly": false "readOnly": false
}, },
{ {
"sourceVolumePath": "//Users/thomas/git/runner/_layout/_work/_temp/_github_workflow", "sourceVolumePath": "/Users/thomas/git/runner/_layout/_work/_temp/_github_workflow",
"targetVolumePath": "/github/workflow", "targetVolumePath": "/github/workflow",
"readOnly": false "readOnly": false
} }

View File

@@ -21,6 +21,6 @@
"/foo/bar", "/foo/bar",
"bar/foo" "bar/foo"
], ],
"workingDirectory": "/__w/thboop-test2/thboop-test2" "workingDirectory": "/__w/repo/repo"
} }
} }

View File

@@ -7,7 +7,7 @@
"doc": "docs" "doc": "docs"
}, },
"scripts": { "scripts": {
"test": "npm run test --prefix packages/docker", "test": "npm run test --prefix packages/docker && npm run test --prefix packages/k8s",
"bootstrap": "npm install --prefix packages/hooklib && npm install --prefix packages/k8s && npm install --prefix packages/docker", "bootstrap": "npm install --prefix packages/hooklib && npm install --prefix packages/k8s && npm install --prefix packages/docker",
"format": "prettier --write '**/*.ts'", "format": "prettier --write '**/*.ts'",
"format-check": "prettier --check '**/*.ts'", "format-check": "prettier --check '**/*.ts'",

View File

@@ -1 +1 @@
jest.setTimeout(90000) jest.setTimeout(500000)

View File

@@ -108,7 +108,6 @@ ENTRYPOINT [ "tail", "-f", "/dev/null" ]
process.env.GITHUB_WORKSPACE = tmpOutputDir process.env.GITHUB_WORKSPACE = tmpOutputDir
containerStepDataCopy.args.dockerfile = 'Dockerfile' containerStepDataCopy.args.dockerfile = 'Dockerfile'
containerStepDataCopy.args.context = '.' containerStepDataCopy.args.context = '.'
console.log(containerStepDataCopy.args)
await expect( await expect(
runContainerStep(containerStepDataCopy.args, resp.state) runContainerStep(containerStepDataCopy.args, resp.state)
).resolves.not.toThrow() ).resolves.not.toThrow()

View File

@@ -76,7 +76,7 @@ export enum Protocol {
export enum PodPhase { export enum PodPhase {
PENDING = 'Pending', PENDING = 'Pending',
RUNNING = 'Running', RUNNING = 'Running',
SUCCEEDED = 'Succeded', SUCCEEDED = 'Succeeded',
FAILED = 'Failed', FAILED = 'Failed',
UNKNOWN = 'Unknown' UNKNOWN = 'Unknown'
} }

View File

@@ -6,7 +6,24 @@ This implementation provides a way to dynamically spin up jobs to run container
## Pre-requisites ## Pre-requisites
Some things are expected to be set when using these hooks Some things are expected to be set when using these hooks
- The runner itself should be running in a pod, with a service account with the following permissions - The runner itself should be running in a pod, with a service account with the following permissions
- The `ACTIONS_RUNNER_REQUIRE_JOB_CONTAINER=true` should be set to true ```
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: [""]
resources: ["pods/exec"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: [""]
resources: ["pods/log"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: ["batch"]
resources: ["jobs"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
```
- The `ACTIONS_RUNNER_POD_NAME` env should be set to the name of the pod - The `ACTIONS_RUNNER_POD_NAME` env should be set to the name of the pod
- The `ACTIONS_RUNNER_REQUIRE_JOB_CONTAINER` env should be set to true to prevent the runner from running any jobs outside of a container
- The runner pod should map a persistent volume claim into the `_work` directory - The runner pod should map a persistent volume claim into the `_work` directory
- The `ACTIONS_RUNNER_CLAIM_NAME` should be set to the persistent volume claim that contains the runner's working directory - The `ACTIONS_RUNNER_CLAIM_NAME` env should be set to the persistent volume claim that contains the runner's working directory
- Some actions runner env's are expected to be set. These are set automatically by the runner.
- `RUNNER_WORKSPACE` is expected to be set to the workspace of the runner
- `GITHUB_WORKSPACE` is expected to be set to the workspace of the job

View File

@@ -1 +1 @@
jest.setTimeout(90000) jest.setTimeout(500000)

View File

@@ -1,5 +1,6 @@
import { podPrune } from '../k8s' import { pruneSecrets, prunePods } from '../k8s'
export async function cleanupJob(): Promise<void> { export async function cleanupJob(): Promise<void> {
await podPrune() await prunePods()
await pruneSecrets()
} }

View File

@@ -20,7 +20,7 @@ export function getJobPodName(): string {
export function getStepPodName(): string { export function getStepPodName(): string {
return `${getRunnerPodName().substring( return `${getRunnerPodName().substring(
0, 0,
MAX_POD_NAME_LENGTH - ('-step'.length + STEP_POD_NAME_SUFFIX_LENGTH) MAX_POD_NAME_LENGTH - ('-step-'.length + STEP_POD_NAME_SUFFIX_LENGTH)
)}-step-${uuidv4().substring(0, STEP_POD_NAME_SUFFIX_LENGTH)}` )}-step-${uuidv4().substring(0, STEP_POD_NAME_SUFFIX_LENGTH)}`
} }
@@ -34,6 +34,13 @@ export function getVolumeClaimName(): string {
return name return name
} }
export function getSecretName(): string {
return `${getRunnerPodName().substring(
0,
MAX_POD_NAME_LENGTH - ('-secret-'.length + STEP_POD_NAME_SUFFIX_LENGTH)
)}-secret-${uuidv4().substring(0, STEP_POD_NAME_SUFFIX_LENGTH)}`
}
const MAX_POD_NAME_LENGTH = 63 const MAX_POD_NAME_LENGTH = 63
const STEP_POD_NAME_SUFFIX_LENGTH = 8 const STEP_POD_NAME_SUFFIX_LENGTH = 8
export const JOB_CONTAINER_NAME = 'job' export const JOB_CONTAINER_NAME = 'job'

View File

@@ -14,7 +14,7 @@ import {
isAuthPermissionsOK, isAuthPermissionsOK,
isPodContainerAlpine, isPodContainerAlpine,
namespace, namespace,
podPrune, prunePods,
requiredPermissions, requiredPermissions,
waitForPodPhases waitForPodPhases
} from '../k8s' } from '../k8s'
@@ -29,7 +29,7 @@ export async function prepareJob(
args: prepareJobArgs, args: prepareJobArgs,
responseFile responseFile
): Promise<void> { ): Promise<void> {
await podPrune() await prunePods()
if (!(await isAuthPermissionsOK())) { if (!(await isAuthPermissionsOK())) {
throw new Error( throw new Error(
`The Service account needs the following permissions ${JSON.stringify( `The Service account needs the following permissions ${JSON.stringify(
@@ -58,8 +58,8 @@ export async function prepareJob(
try { try {
createdPod = await createPod(container, services, args.registry) createdPod = await createPod(container, services, args.registry)
} catch (err) { } catch (err) {
await podPrune() await prunePods()
throw new Error(`failed to create job pod: ${err}`) throw new Error(`failed to create job pod: ${JSON.stringify(err)}`)
} }
if (!createdPod?.metadata?.name) { if (!createdPod?.metadata?.name) {
@@ -73,7 +73,7 @@ export async function prepareJob(
new Set([PodPhase.PENDING]) new Set([PodPhase.PENDING])
) )
} catch (err) { } catch (err) {
await podPrune() await prunePods()
throw new Error(`Pod failed to come online with error: ${err}`) throw new Error(`Pod failed to come online with error: ${err}`)
} }

View File

@@ -3,6 +3,7 @@ import * as core from '@actions/core'
import { PodPhase } from 'hooklib' import { PodPhase } from 'hooklib'
import { import {
createJob, createJob,
createSecretForEnvs,
getContainerJobPodName, getContainerJobPodName,
getPodLogs, getPodLogs,
getPodStatus, getPodStatus,
@@ -16,7 +17,13 @@ export async function runContainerStep(stepContainer): Promise<number> {
if (stepContainer.dockerfile) { if (stepContainer.dockerfile) {
throw new Error('Building container actions is not currently supported') throw new Error('Building container actions is not currently supported')
} }
const container = createPodSpec(stepContainer) let secretName: string | undefined = undefined
if (stepContainer['environmentVariables']) {
secretName = await createSecretForEnvs(
stepContainer['environmentVariables']
)
}
const container = createPodSpec(stepContainer, secretName)
const job = await createJob(container) const job = await createJob(container)
if (!job.metadata?.name) { if (!job.metadata?.name) {
throw new Error( throw new Error(
@@ -25,12 +32,11 @@ export async function runContainerStep(stepContainer): Promise<number> {
)} to have correctly set the metadata.name` )} to have correctly set the metadata.name`
) )
} }
const podName = await getContainerJobPodName(job.metadata.name) const podName = await getContainerJobPodName(job.metadata.name)
await waitForPodPhases( await waitForPodPhases(
podName, podName,
new Set([PodPhase.COMPLETED, PodPhase.RUNNING]), new Set([PodPhase.COMPLETED, PodPhase.RUNNING, PodPhase.SUCCEEDED]),
new Set([PodPhase.PENDING]) new Set([PodPhase.PENDING, PodPhase.UNKNOWN])
) )
await getPodLogs(podName, JOB_CONTAINER_NAME) await getPodLogs(podName, JOB_CONTAINER_NAME)
await waitForJobToComplete(job.metadata.name) await waitForJobToComplete(job.metadata.name)
@@ -40,29 +46,29 @@ export async function runContainerStep(stepContainer): Promise<number> {
core.warning(`Can't determine container status`) core.warning(`Can't determine container status`)
return 0 return 0
} }
const exitCode = const exitCode =
status.containerStatuses[status.containerStatuses.length - 1].state status.containerStatuses[status.containerStatuses.length - 1].state
?.terminated?.exitCode ?.terminated?.exitCode
return Number(exitCode) || 0 return Number(exitCode) || 0
} }
function createPodSpec(container): k8s.V1Container { function createPodSpec(container, secretName?: string): k8s.V1Container {
const podContainer = new k8s.V1Container() const podContainer = new k8s.V1Container()
podContainer.name = JOB_CONTAINER_NAME podContainer.name = JOB_CONTAINER_NAME
podContainer.image = container.image podContainer.image = container.image
if (container.entryPoint) { if (container.entryPoint) {
podContainer.command = [container.entryPoint, ...container.entryPointArgs] podContainer.command = [container.entryPoint, ...container.entryPointArgs]
} }
if (secretName) {
podContainer.env = [] podContainer.envFrom = [
for (const [key, value] of Object.entries( {
container['environmentVariables'] secretRef: {
)) { name: secretName,
if (value && key !== 'HOME') { optional: false
podContainer.env.push({ name: key, value: value as string })
} }
} }
]
}
podContainer.volumeMounts = containerVolumes() podContainer.volumeMounts = containerVolumes()
return podContainer return podContainer

View File

@@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/no-unused-vars */ /* eslint-disable @typescript-eslint/no-unused-vars */
import { RunScriptStepArgs } from 'hooklib' import { RunScriptStepArgs } from 'hooklib'
import { execPodStep } from '../k8s' import { execPodStep } from '../k8s'
import { JOB_CONTAINER_NAME } from './constants' import { getJobPodName, JOB_CONTAINER_NAME } from './constants'
export async function runScriptStep( export async function runScriptStep(
args: RunScriptStepArgs, args: RunScriptStepArgs,
@@ -13,7 +13,7 @@ export async function runScriptStep(
args.entryPointArgs, args.entryPointArgs,
args.environmentVariables args.environmentVariables
) )
await execPodStep(cb.command, state.jobPod, JOB_CONTAINER_NAME) await execPodStep(cb.command, getJobPodName(), JOB_CONTAINER_NAME)
} }
class CommandsBuilder { class CommandsBuilder {

View File

@@ -1,10 +1,11 @@
import * as k8s from '@kubernetes/client-node' import * as k8s from '@kubernetes/client-node'
import { ContainerInfo, PodPhase, Registry } from 'hooklib' import { ContainerInfo, PodPhase, Registry } from 'hooklib'
import * as stream from 'stream' import * as stream from 'stream'
import { v4 as uuidv4 } from 'uuid'
import { import {
getJobPodName, getJobPodName,
getRunnerPodName, getRunnerPodName,
getSecretName,
getStepPodName,
getVolumeClaimName, getVolumeClaimName,
RunnerInstanceLabel RunnerInstanceLabel
} from '../hooks/constants' } from '../hooks/constants'
@@ -119,7 +120,7 @@ export async function createJob(
job.apiVersion = 'batch/v1' job.apiVersion = 'batch/v1'
job.kind = 'Job' job.kind = 'Job'
job.metadata = new k8s.V1ObjectMeta() job.metadata = new k8s.V1ObjectMeta()
job.metadata.name = getJobPodName() job.metadata.name = getStepPodName()
job.metadata.labels = { 'runner-pod': getRunnerPodName() } job.metadata.labels = { 'runner-pod': getRunnerPodName() }
job.spec = new k8s.V1JobSpec() job.spec = new k8s.V1JobSpec()
@@ -173,7 +174,13 @@ export async function getContainerJobPodName(jobName: string): Promise<string> {
} }
export async function deletePod(podName: string): Promise<void> { export async function deletePod(podName: string): Promise<void> {
await k8sApi.deleteNamespacedPod(podName, namespace()) await k8sApi.deleteNamespacedPod(
podName,
namespace(),
undefined,
undefined,
0
)
} }
export async function execPodStep( export async function execPodStep(
@@ -244,12 +251,13 @@ export async function createDockerSecret(
} }
} }
} }
const secretName = generateSecretName() const secretName = getSecretName()
const secret = new k8s.V1Secret() const secret = new k8s.V1Secret()
secret.immutable = true secret.immutable = true
secret.apiVersion = 'v1' secret.apiVersion = 'v1'
secret.metadata = new k8s.V1ObjectMeta() secret.metadata = new k8s.V1ObjectMeta()
secret.metadata.name = secretName secret.metadata.name = secretName
secret.metadata.labels = { 'runner-pod': getRunnerPodName() }
secret.kind = 'Secret' secret.kind = 'Secret'
secret.data = { secret.data = {
'.dockerconfigjson': Buffer.from( '.dockerconfigjson': Buffer.from(
@@ -262,6 +270,50 @@ export async function createDockerSecret(
return body return body
} }
export async function createSecretForEnvs(envs: {
[key: string]: string
}): Promise<string> {
const secret = new k8s.V1Secret()
const secretName = getSecretName()
secret.immutable = true
secret.apiVersion = 'v1'
secret.metadata = new k8s.V1ObjectMeta()
secret.metadata.name = secretName
secret.metadata.labels = { 'runner-pod': getRunnerPodName() }
secret.kind = 'Secret'
secret.data = {}
for (const [key, value] of Object.entries(envs)) {
secret.data[key] = Buffer.from(value).toString('base64')
}
await k8sApi.createNamespacedSecret(namespace(), secret)
return secretName
}
export async function deleteSecret(secretName: string): Promise<void> {
await k8sApi.deleteNamespacedSecret(secretName, namespace())
}
export async function pruneSecrets(): Promise<void> {
const secretList = await k8sApi.listNamespacedSecret(
namespace(),
undefined,
undefined,
undefined,
undefined,
new RunnerInstanceLabel().toString()
)
if (!secretList.body.items.length) {
return
}
await Promise.all(
secretList.body.items.map(
secret => secret.metadata?.name && deleteSecret(secret.metadata.name)
)
)
}
export async function waitForPodPhases( export async function waitForPodPhases(
podName: string, podName: string,
awaitingPhases: Set<PodPhase>, awaitingPhases: Set<PodPhase>,
@@ -273,7 +325,6 @@ export async function waitForPodPhases(
try { try {
while (true) { while (true) {
phase = await getPodPhase(podName) phase = await getPodPhase(podName)
if (awaitingPhases.has(phase)) { if (awaitingPhases.has(phase)) {
return return
} }
@@ -340,7 +391,7 @@ export async function getPodLogs(
await new Promise(resolve => r.on('close', () => resolve(null))) await new Promise(resolve => r.on('close', () => resolve(null)))
} }
export async function podPrune(): Promise<void> { export async function prunePods(): Promise<void> {
const podList = await k8sApi.listNamespacedPod( const podList = await k8sApi.listNamespacedPod(
namespace(), namespace(),
undefined, undefined,
@@ -454,10 +505,6 @@ export function namespace(): string {
return context.namespace return context.namespace
} }
function generateSecretName(): string {
return `github-secret-${uuidv4()}`
}
function runnerName(): string { function runnerName(): string {
const name = process.env.ACTIONS_RUNNER_POD_NAME const name = process.env.ACTIONS_RUNNER_POD_NAME
if (!name) { if (!name) {

View File

@@ -52,9 +52,9 @@ export function containerVolumes(
'absolute path volume mounts outside of the work folder are not supported' 'absolute path volume mounts outside of the work folder are not supported'
) )
} }
sourceVolumePath = userVolume.sourceVolumePath sourceVolumePath = userVolume.sourceVolumePath.slice(workspacePath.length)
} else { } else {
sourceVolumePath = path.join(workspacePath, userVolume.sourceVolumePath) sourceVolumePath = userVolume.sourceVolumePath
} }
mounts.push({ mounts.push({

View File

@@ -1,9 +1,9 @@
import * as path from 'path' import * as path from 'path'
import * as fs from 'fs' import * as fs from 'fs'
import { prepareJob, cleanupJob } from '../src/hooks' import { prepareJob, cleanupJob } from '../src/hooks'
import { TestTempOutput } from './test-setup' import { TestHelper } from './test-setup'
let testTempOutput: TestTempOutput let testHelper: TestHelper
const prepareJobJsonPath = path.resolve( const prepareJobJsonPath = path.resolve(
`${__dirname}/../../../examples/prepare-job.json` `${__dirname}/../../../examples/prepare-job.json`
@@ -16,16 +16,15 @@ describe('Cleanup Job', () => {
const prepareJobJson = fs.readFileSync(prepareJobJsonPath) const prepareJobJson = fs.readFileSync(prepareJobJsonPath)
let prepareJobData = JSON.parse(prepareJobJson.toString()) let prepareJobData = JSON.parse(prepareJobJson.toString())
testTempOutput = new TestTempOutput() testHelper = new TestHelper()
testTempOutput.initialize() await testHelper.initialize()
prepareJobOutputFilePath = testTempOutput.createFile( prepareJobOutputFilePath = testHelper.createFile('prepare-job-output.json')
'prepare-job-output.json'
)
await prepareJob(prepareJobData.args, prepareJobOutputFilePath) await prepareJob(prepareJobData.args, prepareJobOutputFilePath)
}) })
it('should not throw', async () => { it('should not throw', async () => {
const outputJson = fs.readFileSync(prepareJobOutputFilePath)
const outputData = JSON.parse(outputJson.toString())
await expect(cleanupJob()).resolves.not.toThrow() await expect(cleanupJob()).resolves.not.toThrow()
}) })
afterEach(async () => {
await testHelper.cleanup()
})
}) })

View File

@@ -6,38 +6,36 @@ import {
runContainerStep, runContainerStep,
runScriptStep runScriptStep
} from '../src/hooks' } from '../src/hooks'
import { TestTempOutput } from './test-setup' import { TestHelper } from './test-setup'
jest.useRealTimers() jest.useRealTimers()
let testTempOutput: TestTempOutput let testHelper: TestHelper
const prepareJobJsonPath = path.resolve( const prepareJobJsonPath = path.resolve(
`${__dirname}/../../../../examples/prepare-job.json` `${__dirname}/../../../examples/prepare-job.json`
) )
const runScriptStepJsonPath = path.resolve( const runScriptStepJsonPath = path.resolve(
`${__dirname}/../../../../examples/run-script-step.json` `${__dirname}/../../../examples/run-script-step.json`
) )
let runContainerStepJsonPath = path.resolve( let runContainerStepJsonPath = path.resolve(
`${__dirname}/../../../../examples/run-container-step.json` `${__dirname}/../../../examples/run-container-step.json`
) )
let prepareJobData: any let prepareJobData: any
let prepareJobOutputFilePath: string let prepareJobOutputFilePath: string
describe('e2e', () => { describe('e2e', () => {
beforeEach(() => { beforeEach(async () => {
const prepareJobJson = fs.readFileSync(prepareJobJsonPath) const prepareJobJson = fs.readFileSync(prepareJobJsonPath)
prepareJobData = JSON.parse(prepareJobJson.toString()) prepareJobData = JSON.parse(prepareJobJson.toString())
testTempOutput = new TestTempOutput() testHelper = new TestHelper()
testTempOutput.initialize() await testHelper.initialize()
prepareJobOutputFilePath = testTempOutput.createFile( prepareJobOutputFilePath = testHelper.createFile('prepare-job-output.json')
'prepare-job-output.json'
)
}) })
afterEach(async () => { afterEach(async () => {
testTempOutput.cleanup() await testHelper.cleanup()
}) })
it('should prepare job, run script step, run container step then cleanup without errors', async () => { it('should prepare job, run script step, run container step then cleanup without errors', async () => {
await expect( await expect(

View File

@@ -2,11 +2,11 @@ import * as fs from 'fs'
import * as path from 'path' import * as path from 'path'
import { cleanupJob } from '../src/hooks' import { cleanupJob } from '../src/hooks'
import { prepareJob } from '../src/hooks/prepare-job' import { prepareJob } from '../src/hooks/prepare-job'
import { TestTempOutput } from './test-setup' import { TestHelper } from './test-setup'
jest.useRealTimers() jest.useRealTimers()
let testTempOutput: TestTempOutput let testHelper: TestHelper
const prepareJobJsonPath = path.resolve( const prepareJobJsonPath = path.resolve(
`${__dirname}/../../../examples/prepare-job.json` `${__dirname}/../../../examples/prepare-job.json`
@@ -16,21 +16,17 @@ let prepareJobData: any
let prepareJobOutputFilePath: string let prepareJobOutputFilePath: string
describe('Prepare job', () => { describe('Prepare job', () => {
beforeEach(() => { beforeEach(async () => {
const prepareJobJson = fs.readFileSync(prepareJobJsonPath) const prepareJobJson = fs.readFileSync(prepareJobJsonPath)
prepareJobData = JSON.parse(prepareJobJson.toString()) prepareJobData = JSON.parse(prepareJobJson.toString())
testTempOutput = new TestTempOutput() testHelper = new TestHelper()
testTempOutput.initialize() await testHelper.initialize()
prepareJobOutputFilePath = testTempOutput.createFile( prepareJobOutputFilePath = testHelper.createFile('prepare-job-output.json')
'prepare-job-output.json'
)
}) })
afterEach(async () => { afterEach(async () => {
const outputJson = fs.readFileSync(prepareJobOutputFilePath)
const outputData = JSON.parse(outputJson.toString())
await cleanupJob() await cleanupJob()
testTempOutput.cleanup() await testHelper.cleanup()
}) })
it('should not throw exception', async () => { it('should not throw exception', async () => {

View File

@@ -1,11 +1,11 @@
import { TestTempOutput } from './test-setup' import * as fs from 'fs'
import * as path from 'path' import * as path from 'path'
import { runContainerStep } from '../src/hooks' import { runContainerStep } from '../src/hooks'
import * as fs from 'fs' import { TestHelper } from './test-setup'
jest.useRealTimers() jest.useRealTimers()
let testTempOutput: TestTempOutput let testHelper: TestHelper
let runContainerStepJsonPath = path.resolve( let runContainerStepJsonPath = path.resolve(
`${__dirname}/../../../examples/run-container-step.json` `${__dirname}/../../../examples/run-container-step.json`
@@ -14,14 +14,18 @@ let runContainerStepJsonPath = path.resolve(
let runContainerStepData: any let runContainerStepData: any
describe('Run container step', () => { describe('Run container step', () => {
beforeAll(() => { beforeAll(async () => {
const content = fs.readFileSync(runContainerStepJsonPath) const content = fs.readFileSync(runContainerStepJsonPath)
runContainerStepData = JSON.parse(content.toString()) runContainerStepData = JSON.parse(content.toString())
process.env.RUNNER_NAME = 'testjob' testHelper = new TestHelper()
await testHelper.initialize()
}) })
it('should not throw', async () => { it('should not throw', async () => {
await expect( await expect(
runContainerStep(runContainerStepData.args) runContainerStep(runContainerStepData.args)
).resolves.not.toThrow() ).resolves.not.toThrow()
}) })
afterEach(async () => {
await testHelper.cleanup()
})
}) })

View File

@@ -1,11 +1,11 @@
import { prepareJob, cleanupJob, runScriptStep } from '../src/hooks' import { prepareJob, cleanupJob, runScriptStep } from '../src/hooks'
import { TestTempOutput } from './test-setup' import { TestHelper } from './test-setup'
import * as path from 'path' import * as path from 'path'
import * as fs from 'fs' import * as fs from 'fs'
jest.useRealTimers() jest.useRealTimers()
let testTempOutput: TestTempOutput let testHelper: TestHelper
const prepareJobJsonPath = path.resolve( const prepareJobJsonPath = path.resolve(
`${__dirname}/../../../examples/prepare-job.json` `${__dirname}/../../../examples/prepare-job.json`
@@ -19,13 +19,10 @@ describe('Run script step', () => {
beforeEach(async () => { beforeEach(async () => {
const prepareJobJson = fs.readFileSync(prepareJobJsonPath) const prepareJobJson = fs.readFileSync(prepareJobJsonPath)
prepareJobData = JSON.parse(prepareJobJson.toString()) prepareJobData = JSON.parse(prepareJobJson.toString())
console.log(prepareJobData)
testTempOutput = new TestTempOutput() testHelper = new TestHelper()
testTempOutput.initialize() await testHelper.initialize()
prepareJobOutputFilePath = testTempOutput.createFile( prepareJobOutputFilePath = testHelper.createFile('prepare-job-output.json')
'prepare-job-output.json'
)
await prepareJob(prepareJobData.args, prepareJobOutputFilePath) await prepareJob(prepareJobData.args, prepareJobOutputFilePath)
const outputContent = fs.readFileSync(prepareJobOutputFilePath) const outputContent = fs.readFileSync(prepareJobOutputFilePath)
prepareJobOutputData = JSON.parse(outputContent.toString()) prepareJobOutputData = JSON.parse(outputContent.toString())
@@ -33,7 +30,7 @@ describe('Run script step', () => {
afterEach(async () => { afterEach(async () => {
await cleanupJob() await cleanupJob()
testTempOutput.cleanup() await testHelper.cleanup()
}) })
// NOTE: To use this test, do kubectl apply -f podspec.yaml (from podspec examples) // NOTE: To use this test, do kubectl apply -f podspec.yaml (from podspec examples)
@@ -42,8 +39,8 @@ describe('Run script step', () => {
it('should not throw an exception', async () => { it('should not throw an exception', async () => {
const args = { const args = {
entryPointArgs: ['echo "test"'], entryPointArgs: ['-c', 'echo "test"'],
entryPoint: '/bin/bash', entryPoint: 'bash',
environmentVariables: { environmentVariables: {
NODE_ENV: 'development' NODE_ENV: 'development'
}, },

View File

@@ -1,20 +1,69 @@
import * as k8s from '@kubernetes/client-node'
import * as fs from 'fs' import * as fs from 'fs'
import { v4 as uuidv4 } from 'uuid' import { v4 as uuidv4 } from 'uuid'
export class TestTempOutput { 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 tempDirPath: string
private podName: string
constructor() { constructor() {
this.tempDirPath = `${__dirname}/_temp/${uuidv4()}` this.tempDirPath = `${__dirname}/_temp/runner`
this.podName = uuidv4().replace(/-/g, '')
} }
public initialize(): void { public async initialize(): Promise<void> {
fs.mkdirSync(this.tempDirPath, { recursive: true }) process.env['ACTIONS_RUNNER_POD_NAME'] = `${this.podName}`
process.env['ACTIONS_RUNNER_CLAIM_NAME'] = `${this.podName}-work`
process.env['RUNNER_WORKSPACE'] = `${this.tempDirPath}/work/repo`
process.env['GITHUB_WORKSPACE'] = `${this.tempDirPath}/work/repo/repo`
process.env['ACTIONS_RUNNER_KUBERNETES_NAMESPACE'] = 'default'
await this.cleanupK8sResources()
try {
await this.createTestVolume()
await this.createTestJobPod()
} catch {}
fs.mkdirSync(`${this.tempDirPath}/work/repo/repo`, { recursive: true })
fs.mkdirSync(`${this.tempDirPath}/externals`, { recursive: true })
} }
public cleanup(): void { public async cleanup(): Promise<void> {
try {
await this.cleanupK8sResources()
fs.rmSync(this.tempDirPath, { recursive: true }) fs.rmSync(this.tempDirPath, { recursive: true })
} catch {}
}
public async cleanupK8sResources() {
await k8sApi
.deleteNamespacedPersistentVolumeClaim(
`${this.podName}-work`,
'default',
undefined,
undefined,
0
)
.catch(e => {})
await k8sApi.deletePersistentVolume(`${this.podName}-pv`).catch(e => {})
await k8sStorageApi.deleteStorageClass('local-storage').catch(e => {})
await k8sApi
.deleteNamespacedPod(this.podName, 'default', undefined, undefined, 0)
.catch(e => {})
await k8sApi
.deleteNamespacedPod(
`${this.podName}-workflow`,
'default',
undefined,
undefined,
0
)
.catch(e => {})
} }
public createFile(fileName?: string): string { public createFile(fileName?: string): string {
const filePath = `${this.tempDirPath}/${fileName || uuidv4()}` const filePath = `${this.tempDirPath}/${fileName || uuidv4()}`
fs.writeFileSync(filePath, '') fs.writeFileSync(filePath, '')
@@ -25,4 +74,69 @@ export class TestTempOutput {
const filePath = `${this.tempDirPath}/${fileName}` const filePath = `${this.tempDirPath}/${fileName}`
fs.rmSync(filePath) fs.rmSync(filePath)
} }
public async createTestJobPod() {
const container = {
name: 'nginx',
image: 'nginx:latest',
imagePullPolicy: 'IfNotPresent'
} as k8s.V1Container
const pod: k8s.V1Pod = {
metadata: {
name: this.podName
},
spec: {
restartPolicy: 'Never',
containers: [container]
}
} as k8s.V1Pod
await k8sApi.createNamespacedPod('default', pod)
}
public async createTestVolume() {
var sc: k8s.V1StorageClass = {
metadata: {
name: 'local-storage'
},
provisioner: 'kubernetes.io/no-provisioner',
volumeBindingMode: 'Immediate'
}
await k8sStorageApi.createStorageClass(sc)
var volume: k8s.V1PersistentVolume = {
metadata: {
name: `${this.podName}-pv`
},
spec: {
storageClassName: 'local-storage',
capacity: {
storage: '2Gi'
},
volumeMode: 'Filesystem',
accessModes: ['ReadWriteOnce'],
hostPath: {
path: this.tempDirPath
}
}
}
await k8sApi.createPersistentVolume(volume)
var volumeClaim: k8s.V1PersistentVolumeClaim = {
metadata: {
name: `${this.podName}-work`
},
spec: {
accessModes: ['ReadWriteOnce'],
volumeMode: 'Filesystem',
storageClassName: 'local-storage',
volumeName: `${this.podName}-pv`,
resources: {
requests: {
storage: '1Gi'
}
}
}
}
await k8sApi.createNamespacedPersistentVolumeClaim('default', volumeClaim)
}
} }