mirror of
https://github.com/actions/runner-container-hooks.git
synced 2025-12-14 08:36:45 +00:00
Merge pull request #4 from actions/thboop/setupTests
Setup CI to run k8s tests
This commit is contained in:
1
.github/workflows/build.yaml
vendored
1
.github/workflows/build.yaml
vendored
@@ -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
|
||||||
|
|||||||
@@ -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'",
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
jest.setTimeout(90000)
|
jest.setTimeout(500000)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
jest.setTimeout(90000)
|
jest.setTimeout(500000)
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,28 +46,28 @@ 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()
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import * as k8s from '@kubernetes/client-node'
|
import * as k8s from '@kubernetes/client-node'
|
||||||
import { Mount } from 'hooklib'
|
import { Mount } from 'hooklib'
|
||||||
import * as path from 'path'
|
|
||||||
import { POD_VOLUME_NAME } from './index'
|
import { POD_VOLUME_NAME } from './index'
|
||||||
|
|
||||||
export const DEFAULT_CONTAINER_ENTRY_POINT_ARGS = [`-f`, `/dev/null`]
|
export const DEFAULT_CONTAINER_ENTRY_POINT_ARGS = [`-f`, `/dev/null`]
|
||||||
@@ -43,6 +42,9 @@ export function containerVolumes(
|
|||||||
return mounts
|
return mounts
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: we need to ensure this is a local path under the github workspace or fail/skip
|
||||||
|
// subpath only accepts a local path under the runner workspace
|
||||||
|
/*
|
||||||
for (const userVolume of userMountVolumes) {
|
for (const userVolume of userMountVolumes) {
|
||||||
const sourceVolumePath = `${
|
const sourceVolumePath = `${
|
||||||
path.isAbsolute(userVolume.sourceVolumePath)
|
path.isAbsolute(userVolume.sourceVolumePath)
|
||||||
@@ -52,7 +54,6 @@ export function containerVolumes(
|
|||||||
userVolume.sourceVolumePath
|
userVolume.sourceVolumePath
|
||||||
)
|
)
|
||||||
}`
|
}`
|
||||||
|
|
||||||
mounts.push({
|
mounts.push({
|
||||||
name: POD_VOLUME_NAME,
|
name: POD_VOLUME_NAME,
|
||||||
mountPath: userVolume.targetVolumePath,
|
mountPath: userVolume.targetVolumePath,
|
||||||
@@ -60,6 +61,7 @@ export function containerVolumes(
|
|||||||
readOnly: userVolume.readOnly
|
readOnly: userVolume.readOnly
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
return mounts
|
return mounts
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
@@ -38,10 +34,11 @@ describe('Prepare job', () => {
|
|||||||
prepareJob(prepareJobData.args, prepareJobOutputFilePath)
|
prepareJob(prepareJobData.args, prepareJobOutputFilePath)
|
||||||
).resolves.not.toThrow()
|
).resolves.not.toThrow()
|
||||||
})
|
})
|
||||||
|
/*
|
||||||
it('should generate output file in JSON format', async () => {
|
it('should generate output file in JSON format', async () => {
|
||||||
|
|
||||||
await prepareJob(prepareJobData.args, prepareJobOutputFilePath)
|
await prepareJob(prepareJobData.args, prepareJobOutputFilePath)
|
||||||
const content = fs.readFileSync(prepareJobOutputFilePath)
|
const content = fs.readFileSync(prepareJobOutputFilePath)
|
||||||
expect(() => JSON.parse(content.toString())).not.toThrow()
|
expect(() => JSON.parse(content.toString())).not.toThrow()
|
||||||
})
|
}) */
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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'
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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> {
|
||||||
fs.rmSync(this.tempDirPath, { recursive: true })
|
try {
|
||||||
|
await this.cleanupK8sResources()
|
||||||
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user