mirror of
https://github.com/actions/runner-container-hooks.git
synced 2025-12-18 02:36:44 +00:00
Compare commits
17 Commits
v0.1.1
...
fhammerl/k
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3df7ec55b | ||
|
|
16276a2a22 | ||
|
|
abeebd2a37 | ||
|
|
2eecf2d378 | ||
|
|
0c38d44dbd | ||
|
|
a62b81fc95 | ||
|
|
ae0066ae41 | ||
|
|
6c9241fb0e | ||
|
|
efe66bb99b | ||
|
|
9a50e3a796 | ||
|
|
16eb238caa | ||
|
|
8e06496e34 | ||
|
|
e2033b29c7 | ||
|
|
eb47baaf5e | ||
|
|
20c19dae27 | ||
|
|
4307828719 | ||
|
|
5c6995dba1 |
@@ -1 +1 @@
|
||||
* @actions/actions-runtime
|
||||
* @actions/actions-runtime @actions/runner-akvelon
|
||||
|
||||
64
docs/adrs/0034-build-docker-with-kaniko.md
Normal file
64
docs/adrs/0034-build-docker-with-kaniko.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# ADR 0034: Build container-action Dockerfiles with Kaniko
|
||||
|
||||
**Date**: 2023-01-26
|
||||
|
||||
**Status**: In Progress
|
||||
|
||||
# Background
|
||||
|
||||
[Building Dockerfiles in k8s using Kaniko](https://github.com/actions/runner-container-hooks/issues/23) has been on the radar since the beginning of container hooks.
|
||||
Currently, this is possible in ARC using a [dind/docker-in-docker](https://github.com/actions-runner-controller/actions-runner-controller/blob/master/runner/actions-runner-dind.dockerfile) sidecar container.
|
||||
This container needs to be launched using `--privileged`, which presents a security concern.
|
||||
|
||||
As an alternative tool, a container running [Kaniko](https://github.com/GoogleContainerTools/kaniko) can be used to build these files instead.
|
||||
Kaniko doesn't need to be `--privileged`.
|
||||
Whether using dind/docker-in-docker sidecar or Kaniko, in this ADR I will refer to these containers as '**builder containers**'
|
||||
|
||||
# Guiding Principles
|
||||
- **Security:** running a Kaniko builder container should be possible without the `--privileged` flag
|
||||
- **Feature parity with Docker:** Any 'Dockerfile' that can be built with vanilla Docker should also be possible to build using a Kaniko build container
|
||||
- **Ease of Use:** The customer should be able to build and push Docker images with minimal configuration
|
||||
|
||||
## Limitations
|
||||
|
||||
### User provided registry
|
||||
The user needs to provide a a remote registry (like ghcr.io or dockerhub) and credentials, for the Kaniko builder container to push to and k8s to pull from later. This is the user's responsiblity so that our solution remains lightweight and generic.
|
||||
- Alternatively, a user-managed local Docker Registry within the k8s cluster can of course be used instead
|
||||
|
||||
### Kaniko feature limit
|
||||
Anything Kaniko can't do we'll be by definition unable to help with. Potential incompatibilities / inconsistencies between Docker and Kaniko will naturally be inherited by our solution.
|
||||
|
||||
## Interface
|
||||
The user will set `containerMode:kubernetes`, because this is a change to the behaviour of our k8s hooks
|
||||
|
||||
The user will set two ENVs:
|
||||
- `ACTIONS_RUNNER_CONTAINER_HOOKS_K8S_REGISTRY_HOST`: e.g. `ghcr.io/OWNER` or `dockerhandle`.
|
||||
- `ACTIONS_RUNNER_CONTAINER_HOOKS_K8S_REGISTRY_SECRET_NAME`: e.g. `docker-secret`: the name of the `k8s` secret resource that allows you to authenticate against the registry with the given handle above
|
||||
|
||||
The workspace is used as the image name.
|
||||
|
||||
The image tag is a random generated string.
|
||||
|
||||
To execute a container-action, we then run a k8s job by loading the image from the specified registry
|
||||
|
||||
## Additional configuration
|
||||
|
||||
Users may want to use different URLs for the registry when pushing and pulling an image as they will be invoked by different machines on different networks.
|
||||
|
||||
- The **Kaniko build container pushes the image** after building is a pod that belongs to the runner pod.
|
||||
- The **kubelet pulls the image** before starting a pod.
|
||||
|
||||
The above two might not resolve all host names 100% the same so it makes sense to allow different push and pull URLs.
|
||||
|
||||
ENVs `ACTIONS_RUNNER_CONTAINER_HOOKS_K8S_REGISTRY_HOST_PUSH` and `ACTIONS_RUNNER_CONTAINER_HOOKS_K8S_REGISTRY_HOST_PULL` will be preferred if set.
|
||||
|
||||
### Example
|
||||
|
||||
As an example, a cluster local docker registry could be a long running pod exposed as a service _and_ as a NodePort.
|
||||
|
||||
The Kaniko builder pod would push to `my-local-registry.default.svc.cluster.local:12345/foohandle`. (`ACTIONS_RUNNER_CONTAINER_HOOKS_K8S_REGISTRY_HOST_PUSH`)
|
||||
This URL cannot be resolved by the kubelet to pull the image, so we need a secondary URL to pull it - in this case, using the NodePort, this URL is localhost:NODEPORT/foohandle. (`ACTIONS_RUNNER_CONTAINER_HOOKS_K8S_REGISTRY_HOST_PULL)
|
||||
|
||||
|
||||
## Consequences
|
||||
- Users build container-actions with a local Dockerfile in their k8s cluster without a privileged docker builder container
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hooks",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.3",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hooks",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.3",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^27.5.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hooks",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.3",
|
||||
"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": {
|
||||
|
||||
@@ -52,7 +52,9 @@ describe('run script step', () => {
|
||||
definitions.runScriptStep.args.entryPoint = '/bin/bash'
|
||||
definitions.runScriptStep.args.entryPointArgs = [
|
||||
'-c',
|
||||
`if [[ ! $(env | grep "^PATH=") = "PATH=${definitions.runScriptStep.args.prependPath}:"* ]]; then exit 1; fi`
|
||||
`if [[ ! $(env | grep "^PATH=") = "PATH=${definitions.runScriptStep.args.prependPath.join(
|
||||
':'
|
||||
)}:"* ]]; then exit 1; fi`
|
||||
]
|
||||
await expect(
|
||||
runScriptStep(definitions.runScriptStep.args, prepareJobResponse.state)
|
||||
|
||||
@@ -39,14 +39,14 @@ export function getSecretName(): string {
|
||||
)}-secret-${uuidv4().substring(0, STEP_POD_NAME_SUFFIX_LENGTH)}`
|
||||
}
|
||||
|
||||
const MAX_POD_NAME_LENGTH = 63
|
||||
const STEP_POD_NAME_SUFFIX_LENGTH = 8
|
||||
export const MAX_POD_NAME_LENGTH = 63
|
||||
export const STEP_POD_NAME_SUFFIX_LENGTH = 8
|
||||
export const JOB_CONTAINER_NAME = 'job'
|
||||
|
||||
export class RunnerInstanceLabel {
|
||||
runnerhook: string
|
||||
private podName: string
|
||||
constructor() {
|
||||
this.runnerhook = process.env.ACTIONS_RUNNER_POD_NAME as string
|
||||
this.podName = getRunnerPodName()
|
||||
}
|
||||
|
||||
get key(): string {
|
||||
@@ -54,10 +54,10 @@ export class RunnerInstanceLabel {
|
||||
}
|
||||
|
||||
get value(): string {
|
||||
return this.runnerhook
|
||||
return this.podName
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return `runner-pod=${this.runnerhook}`
|
||||
return `runner-pod=${this.podName}`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,10 +46,10 @@ export async function prepareJob(
|
||||
}
|
||||
let createdPod: k8s.V1Pod | undefined = undefined
|
||||
try {
|
||||
createdPod = await createPod(container, services, args.registry)
|
||||
createdPod = await createPod(container, services, args.container.registry)
|
||||
} catch (err) {
|
||||
await prunePods()
|
||||
throw new Error(`failed to create job pod: ${JSON.stringify(err)}`)
|
||||
throw new Error(`failed to create job pod: ${err}`)
|
||||
}
|
||||
|
||||
if (!createdPod?.metadata?.name) {
|
||||
|
||||
@@ -28,7 +28,7 @@ export async function runScriptStep(
|
||||
JOB_CONTAINER_NAME
|
||||
)
|
||||
} catch (err) {
|
||||
throw new Error(`failed to run script step: ${JSON.stringify(err)}`)
|
||||
throw new Error(`failed to run script step: ${err}`)
|
||||
} finally {
|
||||
fs.rmSync(runnerPath)
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ async function run(): Promise<void> {
|
||||
throw new Error(
|
||||
`The Service account needs the following permissions ${JSON.stringify(
|
||||
requiredPermissions
|
||||
)} on the pod resource in the '${namespace}' namespace. Please contact your self hosted runner administrator.`
|
||||
)} on the pod resource in the '${namespace()}' namespace. Please contact your self hosted runner administrator.`
|
||||
)
|
||||
}
|
||||
switch (command) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import * as core from '@actions/core'
|
||||
import * as k8s from '@kubernetes/client-node'
|
||||
import { ContainerInfo, Registry } from 'hooklib'
|
||||
import * as stream from 'stream'
|
||||
@@ -109,13 +110,14 @@ export async function createPod(
|
||||
export async function createJob(
|
||||
container: k8s.V1Container
|
||||
): Promise<k8s.V1Job> {
|
||||
const job = new k8s.V1Job()
|
||||
const runnerInstanceLabel = new RunnerInstanceLabel()
|
||||
|
||||
const job = new k8s.V1Job()
|
||||
job.apiVersion = 'batch/v1'
|
||||
job.kind = 'Job'
|
||||
job.metadata = new k8s.V1ObjectMeta()
|
||||
job.metadata.name = getStepPodName()
|
||||
job.metadata.labels = { 'runner-pod': getRunnerPodName() }
|
||||
job.metadata.labels = { [runnerInstanceLabel.key]: runnerInstanceLabel.value }
|
||||
|
||||
job.spec = new k8s.V1JobSpec()
|
||||
job.spec.ttlSecondsAfterFinished = 300
|
||||
@@ -127,7 +129,7 @@ export async function createJob(
|
||||
job.spec.template.spec.restartPolicy = 'Never'
|
||||
job.spec.template.spec.nodeName = await getCurrentNodeName()
|
||||
|
||||
const claimName = `${runnerName()}-work`
|
||||
const claimName = getVolumeClaimName()
|
||||
job.spec.template.spec.volumes = [
|
||||
{
|
||||
name: 'work',
|
||||
@@ -185,33 +187,30 @@ export async function execPodStep(
|
||||
): Promise<void> {
|
||||
const exec = new k8s.Exec(kc)
|
||||
await new Promise(async function (resolve, reject) {
|
||||
try {
|
||||
await exec.exec(
|
||||
namespace(),
|
||||
podName,
|
||||
containerName,
|
||||
command,
|
||||
process.stdout,
|
||||
process.stderr,
|
||||
stdin ?? null,
|
||||
false /* tty */,
|
||||
resp => {
|
||||
// kube.exec returns an error if exit code is not 0, but we can't actually get the exit code
|
||||
if (resp.status === 'Success') {
|
||||
resolve(resp.code)
|
||||
} else {
|
||||
reject(
|
||||
JSON.stringify({
|
||||
message: resp?.message,
|
||||
details: resp?.details
|
||||
})
|
||||
)
|
||||
}
|
||||
await exec.exec(
|
||||
namespace(),
|
||||
podName,
|
||||
containerName,
|
||||
command,
|
||||
process.stdout,
|
||||
process.stderr,
|
||||
stdin ?? null,
|
||||
false /* tty */,
|
||||
resp => {
|
||||
// kube.exec returns an error if exit code is not 0, but we can't actually get the exit code
|
||||
if (resp.status === 'Success') {
|
||||
resolve(resp.code)
|
||||
} else {
|
||||
core.debug(
|
||||
JSON.stringify({
|
||||
message: resp?.message,
|
||||
details: resp?.details
|
||||
})
|
||||
)
|
||||
reject(resp?.message)
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
reject(JSON.stringify(error))
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -234,29 +233,34 @@ export async function createDockerSecret(
|
||||
): Promise<k8s.V1Secret> {
|
||||
const authContent = {
|
||||
auths: {
|
||||
[registry.serverUrl]: {
|
||||
[registry.serverUrl || 'https://index.docker.io/v1/']: {
|
||||
username: registry.username,
|
||||
password: registry.password,
|
||||
auth: Buffer.from(
|
||||
`${registry.username}:${registry.password}`,
|
||||
auth: Buffer.from(`${registry.username}:${registry.password}`).toString(
|
||||
'base64'
|
||||
).toString()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const runnerInstanceLabel = new RunnerInstanceLabel()
|
||||
|
||||
const secretName = getSecretName()
|
||||
const secret = new k8s.V1Secret()
|
||||
secret.immutable = true
|
||||
secret.apiVersion = 'v1'
|
||||
secret.metadata = new k8s.V1ObjectMeta()
|
||||
secret.metadata.name = secretName
|
||||
secret.metadata.labels = { 'runner-pod': getRunnerPodName() }
|
||||
secret.metadata.namespace = namespace()
|
||||
secret.metadata.labels = {
|
||||
[runnerInstanceLabel.key]: runnerInstanceLabel.value
|
||||
}
|
||||
secret.type = 'kubernetes.io/dockerconfigjson'
|
||||
secret.kind = 'Secret'
|
||||
secret.data = {
|
||||
'.dockerconfigjson': Buffer.from(
|
||||
JSON.stringify(authContent),
|
||||
'.dockerconfigjson': Buffer.from(JSON.stringify(authContent)).toString(
|
||||
'base64'
|
||||
).toString()
|
||||
)
|
||||
}
|
||||
|
||||
const { body } = await k8sApi.createNamespacedSecret(namespace(), secret)
|
||||
@@ -266,13 +270,18 @@ export async function createDockerSecret(
|
||||
export async function createSecretForEnvs(envs: {
|
||||
[key: string]: string
|
||||
}): Promise<string> {
|
||||
const runnerInstanceLabel = new RunnerInstanceLabel()
|
||||
|
||||
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.metadata.labels = {
|
||||
[runnerInstanceLabel.key]: runnerInstanceLabel.value
|
||||
}
|
||||
secret.kind = 'Secret'
|
||||
secret.data = {}
|
||||
for (const [key, value] of Object.entries(envs)) {
|
||||
@@ -372,7 +381,7 @@ export async function getPodLogs(
|
||||
})
|
||||
|
||||
logStream.on('error', err => {
|
||||
process.stderr.write(JSON.stringify(err))
|
||||
process.stderr.write(err.message)
|
||||
})
|
||||
|
||||
const r = await log.log(namespace(), podName, containerName, logStream, {
|
||||
@@ -478,16 +487,6 @@ export function namespace(): string {
|
||||
return context.namespace
|
||||
}
|
||||
|
||||
function runnerName(): string {
|
||||
const name = process.env.ACTIONS_RUNNER_POD_NAME
|
||||
if (!name) {
|
||||
throw new Error(
|
||||
'Failed to determine runner name. "ACTIONS_RUNNER_POD_NAME" env variables should be set.'
|
||||
)
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
class BackOffManager {
|
||||
private backOffSeconds = 1
|
||||
totalTime = 0
|
||||
|
||||
@@ -20,18 +20,18 @@ export function containerVolumes(
|
||||
}
|
||||
]
|
||||
|
||||
const workspacePath = process.env.GITHUB_WORKSPACE as string
|
||||
if (containerAction) {
|
||||
const workspace = process.env.GITHUB_WORKSPACE as string
|
||||
mounts.push(
|
||||
{
|
||||
name: POD_VOLUME_NAME,
|
||||
mountPath: '/github/workspace',
|
||||
subPath: workspace.substring(workspace.indexOf('work/') + 1)
|
||||
subPath: workspacePath.substring(workspacePath.indexOf('work/') + 1)
|
||||
},
|
||||
{
|
||||
name: POD_VOLUME_NAME,
|
||||
mountPath: '/github/file_commands',
|
||||
subPath: workspace.substring(workspace.indexOf('work/') + 1)
|
||||
subPath: workspacePath.substring(workspacePath.indexOf('work/') + 1)
|
||||
}
|
||||
)
|
||||
return mounts
|
||||
@@ -63,7 +63,6 @@ export function containerVolumes(
|
||||
return mounts
|
||||
}
|
||||
|
||||
const workspacePath = process.env.GITHUB_WORKSPACE as string
|
||||
for (const userVolume of userMountVolumes) {
|
||||
let sourceVolumePath = ''
|
||||
if (path.isAbsolute(userVolume.sourceVolumePath)) {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import * as k8s from '@kubernetes/client-node'
|
||||
import { cleanupJob, prepareJob } from '../src/hooks'
|
||||
import { RunnerInstanceLabel } from '../src/hooks/constants'
|
||||
import { namespace } from '../src/k8s'
|
||||
import { TestHelper } from './test-setup'
|
||||
|
||||
let testHelper: TestHelper
|
||||
@@ -13,10 +16,50 @@ describe('Cleanup Job', () => {
|
||||
)
|
||||
await prepareJob(prepareJobData.args, prepareJobOutputFilePath)
|
||||
})
|
||||
it('should not throw', async () => {
|
||||
await expect(cleanupJob()).resolves.not.toThrow()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await testHelper.cleanup()
|
||||
})
|
||||
|
||||
it('should not throw', async () => {
|
||||
await expect(cleanupJob()).resolves.not.toThrow()
|
||||
})
|
||||
|
||||
it('should have no runner linked pods running', async () => {
|
||||
await cleanupJob()
|
||||
const kc = new k8s.KubeConfig()
|
||||
|
||||
kc.loadFromDefault()
|
||||
const k8sApi = kc.makeApiClient(k8s.CoreV1Api)
|
||||
|
||||
const podList = await k8sApi.listNamespacedPod(
|
||||
namespace(),
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
new RunnerInstanceLabel().toString()
|
||||
)
|
||||
|
||||
expect(podList.body.items.length).toBe(0)
|
||||
})
|
||||
|
||||
it('should have no runner linked secrets', async () => {
|
||||
await cleanupJob()
|
||||
const kc = new k8s.KubeConfig()
|
||||
|
||||
kc.loadFromDefault()
|
||||
const k8sApi = kc.makeApiClient(k8s.CoreV1Api)
|
||||
|
||||
const secretList = await k8sApi.listNamespacedSecret(
|
||||
namespace(),
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
new RunnerInstanceLabel().toString()
|
||||
)
|
||||
|
||||
expect(secretList.body.items.length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
173
packages/k8s/tests/constants-test.ts
Normal file
173
packages/k8s/tests/constants-test.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import {
|
||||
getJobPodName,
|
||||
getRunnerPodName,
|
||||
getSecretName,
|
||||
getStepPodName,
|
||||
getVolumeClaimName,
|
||||
MAX_POD_NAME_LENGTH,
|
||||
RunnerInstanceLabel,
|
||||
STEP_POD_NAME_SUFFIX_LENGTH
|
||||
} from '../src/hooks/constants'
|
||||
|
||||
describe('constants', () => {
|
||||
describe('runner instance label', () => {
|
||||
beforeEach(() => {
|
||||
process.env.ACTIONS_RUNNER_POD_NAME = 'example'
|
||||
})
|
||||
it('should throw if ACTIONS_RUNNER_POD_NAME env is not set', () => {
|
||||
delete process.env.ACTIONS_RUNNER_POD_NAME
|
||||
expect(() => new RunnerInstanceLabel()).toThrow()
|
||||
})
|
||||
|
||||
it('should have key truthy', () => {
|
||||
const runnerInstanceLabel = new RunnerInstanceLabel()
|
||||
expect(typeof runnerInstanceLabel.key).toBe('string')
|
||||
expect(runnerInstanceLabel.key).toBeTruthy()
|
||||
expect(runnerInstanceLabel.key.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should have value as runner pod name', () => {
|
||||
const name = process.env.ACTIONS_RUNNER_POD_NAME as string
|
||||
const runnerInstanceLabel = new RunnerInstanceLabel()
|
||||
expect(typeof runnerInstanceLabel.value).toBe('string')
|
||||
expect(runnerInstanceLabel.value).toBe(name)
|
||||
})
|
||||
|
||||
it('should have toString combination of key and value', () => {
|
||||
const runnerInstanceLabel = new RunnerInstanceLabel()
|
||||
expect(runnerInstanceLabel.toString()).toBe(
|
||||
`${runnerInstanceLabel.key}=${runnerInstanceLabel.value}`
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getRunnerPodName', () => {
|
||||
it('should throw if ACTIONS_RUNNER_POD_NAME env is not set', () => {
|
||||
delete process.env.ACTIONS_RUNNER_POD_NAME
|
||||
expect(() => getRunnerPodName()).toThrow()
|
||||
|
||||
process.env.ACTIONS_RUNNER_POD_NAME = ''
|
||||
expect(() => getRunnerPodName()).toThrow()
|
||||
})
|
||||
|
||||
it('should return corrent ACTIONS_RUNNER_POD_NAME name', () => {
|
||||
const name = 'example'
|
||||
process.env.ACTIONS_RUNNER_POD_NAME = name
|
||||
expect(getRunnerPodName()).toBe(name)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getJobPodName', () => {
|
||||
it('should throw on getJobPodName if ACTIONS_RUNNER_POD_NAME env is not set', () => {
|
||||
delete process.env.ACTIONS_RUNNER_POD_NAME
|
||||
expect(() => getJobPodName()).toThrow()
|
||||
|
||||
process.env.ACTIONS_RUNNER_POD_NAME = ''
|
||||
expect(() => getRunnerPodName()).toThrow()
|
||||
})
|
||||
|
||||
it('should contain suffix -workflow', () => {
|
||||
const tableTests = [
|
||||
{
|
||||
podName: 'test',
|
||||
expect: 'test-workflow'
|
||||
},
|
||||
{
|
||||
// podName.length == 63
|
||||
podName:
|
||||
'abcdaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
expect:
|
||||
'abcdaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-workflow'
|
||||
}
|
||||
]
|
||||
|
||||
for (const tt of tableTests) {
|
||||
process.env.ACTIONS_RUNNER_POD_NAME = tt.podName
|
||||
const actual = getJobPodName()
|
||||
expect(actual).toBe(tt.expect)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('getVolumeClaimName', () => {
|
||||
it('should throw if ACTIONS_RUNNER_POD_NAME env is not set', () => {
|
||||
delete process.env.ACTIONS_RUNNER_CLAIM_NAME
|
||||
delete process.env.ACTIONS_RUNNER_POD_NAME
|
||||
expect(() => getVolumeClaimName()).toThrow()
|
||||
|
||||
process.env.ACTIONS_RUNNER_POD_NAME = ''
|
||||
expect(() => getVolumeClaimName()).toThrow()
|
||||
})
|
||||
|
||||
it('should return ACTIONS_RUNNER_CLAIM_NAME env if set', () => {
|
||||
const claimName = 'testclaim'
|
||||
process.env.ACTIONS_RUNNER_CLAIM_NAME = claimName
|
||||
process.env.ACTIONS_RUNNER_POD_NAME = 'example'
|
||||
expect(getVolumeClaimName()).toBe(claimName)
|
||||
})
|
||||
|
||||
it('should contain suffix -work if ACTIONS_RUNNER_CLAIM_NAME is not set', () => {
|
||||
delete process.env.ACTIONS_RUNNER_CLAIM_NAME
|
||||
process.env.ACTIONS_RUNNER_POD_NAME = 'example'
|
||||
expect(getVolumeClaimName()).toBe('example-work')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSecretName', () => {
|
||||
it('should throw if ACTIONS_RUNNER_POD_NAME env is not set', () => {
|
||||
delete process.env.ACTIONS_RUNNER_POD_NAME
|
||||
expect(() => getSecretName()).toThrow()
|
||||
|
||||
process.env.ACTIONS_RUNNER_POD_NAME = ''
|
||||
expect(() => getSecretName()).toThrow()
|
||||
})
|
||||
|
||||
it('should contain suffix -secret- and name trimmed', () => {
|
||||
const podNames = [
|
||||
'test',
|
||||
'abcdaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
|
||||
]
|
||||
|
||||
for (const podName of podNames) {
|
||||
process.env.ACTIONS_RUNNER_POD_NAME = podName
|
||||
const actual = getSecretName()
|
||||
const re = new RegExp(
|
||||
`${podName.substring(
|
||||
MAX_POD_NAME_LENGTH -
|
||||
'-secret-'.length -
|
||||
STEP_POD_NAME_SUFFIX_LENGTH
|
||||
)}-secret-[a-z0-9]{8,}`
|
||||
)
|
||||
expect(actual).toMatch(re)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('getStepPodName', () => {
|
||||
it('should throw if ACTIONS_RUNNER_POD_NAME env is not set', () => {
|
||||
delete process.env.ACTIONS_RUNNER_POD_NAME
|
||||
expect(() => getStepPodName()).toThrow()
|
||||
|
||||
process.env.ACTIONS_RUNNER_POD_NAME = ''
|
||||
expect(() => getStepPodName()).toThrow()
|
||||
})
|
||||
|
||||
it('should contain suffix -step- and name trimmed', () => {
|
||||
const podNames = [
|
||||
'test',
|
||||
'abcdaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
|
||||
]
|
||||
|
||||
for (const podName of podNames) {
|
||||
process.env.ACTIONS_RUNNER_POD_NAME = podName
|
||||
const actual = getStepPodName()
|
||||
const re = new RegExp(
|
||||
`${podName.substring(
|
||||
MAX_POD_NAME_LENGTH - '-step-'.length - STEP_POD_NAME_SUFFIX_LENGTH
|
||||
)}-step-[a-z0-9]{8,}`
|
||||
)
|
||||
expect(actual).toMatch(re)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
153
packages/k8s/tests/k8s-utils-test.ts
Normal file
153
packages/k8s/tests/k8s-utils-test.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import * as fs from 'fs'
|
||||
import { POD_VOLUME_NAME } from '../src/k8s'
|
||||
import { containerVolumes, writeEntryPointScript } from '../src/k8s/utils'
|
||||
import { TestHelper } from './test-setup'
|
||||
|
||||
let testHelper: TestHelper
|
||||
|
||||
describe('k8s utils', () => {
|
||||
describe('write entrypoint', () => {
|
||||
beforeEach(async () => {
|
||||
testHelper = new TestHelper()
|
||||
await testHelper.initialize()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await testHelper.cleanup()
|
||||
})
|
||||
|
||||
it('should not throw', () => {
|
||||
expect(() =>
|
||||
writeEntryPointScript(
|
||||
'/test',
|
||||
'sh',
|
||||
['-e', 'script.sh'],
|
||||
['/prepend/path'],
|
||||
{
|
||||
SOME_ENV: 'SOME_VALUE'
|
||||
}
|
||||
)
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
it('should throw if RUNNER_TEMP is not set', () => {
|
||||
delete process.env.RUNNER_TEMP
|
||||
expect(() =>
|
||||
writeEntryPointScript(
|
||||
'/test',
|
||||
'sh',
|
||||
['-e', 'script.sh'],
|
||||
['/prepend/path'],
|
||||
{
|
||||
SOME_ENV: 'SOME_VALUE'
|
||||
}
|
||||
)
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
it('should return object with containerPath and runnerPath', () => {
|
||||
const { containerPath, runnerPath } = writeEntryPointScript(
|
||||
'/test',
|
||||
'sh',
|
||||
['-e', 'script.sh'],
|
||||
['/prepend/path'],
|
||||
{
|
||||
SOME_ENV: 'SOME_VALUE'
|
||||
}
|
||||
)
|
||||
expect(containerPath).toMatch(/\/__w\/_temp\/.*\.sh/)
|
||||
const re = new RegExp(`${process.env.RUNNER_TEMP}/.*\\.sh`)
|
||||
expect(runnerPath).toMatch(re)
|
||||
})
|
||||
|
||||
it('should write entrypoint path and the file should exist', () => {
|
||||
const { runnerPath } = writeEntryPointScript(
|
||||
'/test',
|
||||
'sh',
|
||||
['-e', 'script.sh'],
|
||||
['/prepend/path'],
|
||||
{
|
||||
SOME_ENV: 'SOME_VALUE'
|
||||
}
|
||||
)
|
||||
expect(fs.existsSync(runnerPath)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('container volumes', () => {
|
||||
beforeEach(async () => {
|
||||
testHelper = new TestHelper()
|
||||
await testHelper.initialize()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await testHelper.cleanup()
|
||||
})
|
||||
|
||||
it('should throw if container action and GITHUB_WORKSPACE env is not set', () => {
|
||||
delete process.env.GITHUB_WORKSPACE
|
||||
expect(() => containerVolumes([], true, true)).toThrow()
|
||||
expect(() => containerVolumes([], false, true)).toThrow()
|
||||
})
|
||||
|
||||
it('should always have work mount', () => {
|
||||
let volumes = containerVolumes([], true, true)
|
||||
expect(volumes.find(e => e.mountPath === '/__w')).toBeTruthy()
|
||||
volumes = containerVolumes([], true, false)
|
||||
expect(volumes.find(e => e.mountPath === '/__w')).toBeTruthy()
|
||||
volumes = containerVolumes([], false, true)
|
||||
expect(volumes.find(e => e.mountPath === '/__w')).toBeTruthy()
|
||||
volumes = containerVolumes([], false, false)
|
||||
expect(volumes.find(e => e.mountPath === '/__w')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should have container action volumes', () => {
|
||||
let volumes = containerVolumes([], true, true)
|
||||
expect(
|
||||
volumes.find(e => e.mountPath === '/github/workspace')
|
||||
).toBeTruthy()
|
||||
expect(
|
||||
volumes.find(e => e.mountPath === '/github/file_commands')
|
||||
).toBeTruthy()
|
||||
volumes = containerVolumes([], false, true)
|
||||
expect(
|
||||
volumes.find(e => e.mountPath === '/github/workspace')
|
||||
).toBeTruthy()
|
||||
expect(
|
||||
volumes.find(e => e.mountPath === '/github/file_commands')
|
||||
).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should have externals, github home and github workflow mounts if job container', () => {
|
||||
const volumes = containerVolumes()
|
||||
expect(volumes.find(e => e.mountPath === '/__e')).toBeTruthy()
|
||||
expect(volumes.find(e => e.mountPath === '/github/home')).toBeTruthy()
|
||||
expect(volumes.find(e => e.mountPath === '/github/workflow')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should throw if user volume source volume path is not in workspace', () => {
|
||||
expect(() =>
|
||||
containerVolumes(
|
||||
[
|
||||
{
|
||||
sourceVolumePath: '/outside/of/workdir'
|
||||
}
|
||||
],
|
||||
true,
|
||||
false
|
||||
)
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
it(`all volumes should have name ${POD_VOLUME_NAME}`, () => {
|
||||
let volumes = containerVolumes([], true, true)
|
||||
expect(volumes.every(e => e.name === POD_VOLUME_NAME)).toBeTruthy()
|
||||
volumes = containerVolumes([], true, false)
|
||||
expect(volumes.every(e => e.name === POD_VOLUME_NAME)).toBeTruthy()
|
||||
volumes = containerVolumes([], false, true)
|
||||
expect(volumes.every(e => e.name === POD_VOLUME_NAME)).toBeTruthy()
|
||||
volumes = containerVolumes([], false, false)
|
||||
expect(volumes.every(e => e.name === POD_VOLUME_NAME)).toBeTruthy()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -94,7 +94,9 @@ describe('Run script step', () => {
|
||||
runScriptStepDefinition.args.entryPoint = '/bin/bash'
|
||||
runScriptStepDefinition.args.entryPointArgs = [
|
||||
'-c',
|
||||
`'if [[ ! $(env | grep "^PATH=") = "PATH=${runScriptStepDefinition.args.prependPath}:"* ]]; then exit 1; fi'`
|
||||
`'if [[ ! $(env | grep "^PATH=") = "PATH=${runScriptStepDefinition.args.prependPath.join(
|
||||
':'
|
||||
)}:"* ]]; then exit 1; fi'`
|
||||
]
|
||||
|
||||
await expect(
|
||||
|
||||
@@ -40,7 +40,7 @@ export class TestHelper {
|
||||
await this.createTestVolume()
|
||||
await this.createTestJobPod()
|
||||
} catch (e) {
|
||||
console.log(JSON.stringify(e))
|
||||
console.log(e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
## Features
|
||||
- Loosened the restriction on `ACTIONS_RUNNER_CLAIM_NAME` to be optional, not required for k8s hooks
|
||||
|
||||
## Bugs
|
||||
|
||||
- Fixed an issue where default private registry images did not pull correctly [#25]
|
||||
|
||||
## Misc
|
||||
Reference in New Issue
Block a user