Compare commits

..

1 Commits

Author SHA1 Message Date
Nikola Jokic
4f9272a5ce Added resource requirement based on --cpus and --memory,-m 2022-06-03 11:38:59 +02:00
62 changed files with 2060 additions and 4070 deletions

1
.gitattributes vendored
View File

@@ -1 +0,0 @@
*.png filter=lfs diff=lfs merge=lfs -text

View File

@@ -11,11 +11,6 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- run: sed -i "s|{{PATHTOREPO}}|$(pwd)|" packages/k8s/tests/test-kind.yaml
name: Setup kind cluster yaml config
- uses: helm/kind-action@v1.2.0
with:
config: packages/k8s/tests/test-kind.yaml
- run: npm install
name: Install dependencies
- run: npm run bootstrap
@@ -26,6 +21,6 @@ jobs:
- name: Check linter
run: |
npm run lint
git diff --exit-code -- ':!packages/k8s/tests/test-kind.yaml'
git diff --exit-code
- name: Run tests
run: npm run test

View File

@@ -13,47 +13,28 @@ jobs:
- run: npm run build-all
name: Build packages
- uses: actions/github-script@v6
id: releaseVersion
id: releaseNotes
with:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
script: |
const fs = require('fs');
const hookVersion = require('./package.json').version
var releaseNotes = fs.readFileSync('${{ github.workspace }}/releaseNotes.md', 'utf8').replace(/<HOOK_VERSION>/g, hookVersion)
console.log(releaseNotes)
core.setOutput('version', hookVersion);
core.setOutput('note', releaseNotes);
- name: Zip up releases
run: |
zip -r -j actions-runner-hooks-docker-${{ steps.releaseVersion.outputs.version }}.zip packages/docker/dist
zip -r -j actions-runner-hooks-k8s-${{ steps.releaseVersion.outputs.version }}.zip packages/k8s/dist
- name: Calculate SHA
id: sha
shell: bash
run: |
sha_docker=$(sha256sum actions-runner-hooks-docker-${{ steps.releaseVersion.outputs.version }}.zip | awk '{print $1}')
echo "Docker SHA: $sha_docker"
echo "docker-sha=$sha_docker" >> $GITHUB_OUTPUT
sha_k8s=$(sha256sum actions-runner-hooks-k8s-${{ steps.releaseVersion.outputs.version }}.zip | awk '{print $1}')
echo "K8s SHA: $sha_k8s"
echo "k8s-sha=$sha_k8s" >> $GITHUB_OUTPUT
- name: replace SHA
id: releaseNotes
uses: actions/github-script@v6
with:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
script: |
const fs = require('fs');
var releaseNotes = fs.readFileSync('${{ github.workspace }}/releaseNotes.md', 'utf8').replace(/<HOOK_VERSION>/g, '${{ steps.releaseVersion.outputs.version }}')
releaseNotes = releaseNotes.replace(/<DOCKER_SHA>/g, '${{ steps.sha.outputs.docker-sha }}')
releaseNotes = releaseNotes.replace(/<K8S_SHA>/g, '${{ steps.sha.outputs.k8s-sha }}')
console.log(releaseNotes)
core.setOutput('note', releaseNotes);
zip -r -j actions-runner-hooks-docker-${{ steps.releaseNotes.outputs.version }}.zip packages/docker/dist
zip -r -j actions-runner-hooks-k8s-${{ steps.releaseNotes.outputs.version }}.zip packages/k8s/dist
- uses: actions/create-release@v1
id: createRelease
name: Create ${{ steps.releaseVersion.outputs.version }} Hook Release
name: Create ${{ steps.releaseNotes.outputs.version }} Hook Release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: "v${{ steps.releaseVersion.outputs.version }}"
release_name: "v${{ steps.releaseVersion.outputs.version }}"
tag_name: "v${{ steps.releaseNotes.outputs.version }}"
release_name: "v${{ steps.releaseNotes.outputs.version }}"
body: |
${{ steps.releaseNotes.outputs.note }}
- name: Upload K8s hooks
@@ -62,8 +43,8 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.createRelease.outputs.upload_url }}
asset_path: ${{ github.workspace }}/actions-runner-hooks-k8s-${{ steps.releaseVersion.outputs.version }}.zip
asset_name: actions-runner-hooks-k8s-${{ steps.releaseVersion.outputs.version }}.zip
asset_path: ${{ github.workspace }}/actions-runner-hooks-k8s-${{ steps.releaseNotes.outputs.version }}.zip
asset_name: actions-runner-hooks-k8s-${{ steps.releaseNotes.outputs.version }}.zip
asset_content_type: application/octet-stream
- name: Upload docker hooks
uses: actions/upload-release-asset@v1
@@ -71,6 +52,6 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.createRelease.outputs.upload_url }}
asset_path: ${{ github.workspace }}/actions-runner-hooks-docker-${{ steps.releaseVersion.outputs.version }}.zip
asset_name: actions-runner-hooks-docker-${{ steps.releaseVersion.outputs.version }}.zip
asset_path: ${{ github.workspace }}/actions-runner-hooks-docker-${{ steps.releaseNotes.outputs.version }}.zip
asset_name: actions-runner-hooks-docker-${{ steps.releaseNotes.outputs.version }}.zip
asset_content_type: application/octet-stream

1
.gitignore vendored
View File

@@ -2,4 +2,3 @@ node_modules/
lib/
dist/
**/tests/_temp/**
packages/k8s/tests/test-kind.yaml

View File

@@ -1 +1 @@
* @actions/actions-launch @actions/runner-akvelon
* @actions/actions-runtime

View File

@@ -13,7 +13,7 @@ You'll need a runner compatible with hooks, a repository with container workflow
- You'll need a runner compatible with hooks, a repository with container workflows to which you can register the runner and the hooks from this repository.
- See [the runner contributing.md](../../github/CONTRIBUTING.MD) for how to get started with runner development.
- Build your hook using `npm run build`
- Enable the hooks by setting `ACTIONS_RUNNER_CONTAINER_HOOKS=./packages/{libraryname}/dist/index.js` file generated by [ncc](https://github.com/vercel/ncc)
- Enable the hooks by setting `ACTIONS_RUNNER_CONTAINER_HOOK=./packages/{libraryname}/dist/index.js` file generated by [ncc](https://github.com/vercel/ncc)
- Configure your self hosted runner against the a repository you have admin access
- Run a workflow with a container job, for example
```

View File

@@ -1,184 +0,0 @@
# ADR 0072: Using Ephemeral Containers
**Date:** 27 March 2023
**Status**: Rejected <!--Accepted|Rejected|Superceded|Deprecated-->
## Context
We are evaluating using Kubernetes [ephemeral containers](https://kubernetes.io/docs/concepts/workloads/pods/ephemeral-containers/) as a drop-in replacement for creating pods for [jobs that run in containers](https://docs.github.com/en/actions/using-jobs/running-jobs-in-a-container) and [service containers](https://docs.github.com/en/actions/using-containerized-services/about-service-containers).
The main motivator behind using ephemeral containers is to eliminate the need for [Persistent Volumes](https://kubernetes.io/docs/concepts/storage/persistent-volumes/). Persistent Volume implementations vary depending on the provider and we want to avoid building a dependency on it in order to provide our end-users a consistent experience.
With ephemeral containers we could leverage [emptyDir volumes](https://kubernetes.io/docs/concepts/storage/volumes/#emptydir) which fits our use case better and its behaviour is consistent across providers.
However, it's important to acknowledge that ephemeral containers were not designed to handle workloads but rather provide a mechanism to inspect running containers for debugging and troubleshooting purposes.
## Evaluation
The criteria that we are using to evaluate whether ephemeral containers are fit for purpose are:
- Networking
- Storage
- Security
- Resource limits
- Logs
- Customizability
### Networking
Ephemeral containers share the networking namespace of the pod they are attached to. This means that ephemeral containers can access the same network interfaces as the pod and can communicate with other containers in the same pod. However, ephemeral containers cannot have ports configured and as such the fields ports, livenessProbe, and readinessProbe are not available [^1][^2]
In this scenario we have 3 containers in a pod:
- `runner`: the main container that runs the GitHub Actions job
- `debugger`: the first ephemeral container
- `debugger2`: the second ephemeral container
By sequentially opening ports on each of these containers and connecting to them we can demonstrate that the communication flow between the runner and the debuggers is feasible.
<details>
<summary>1. Runner -> Debugger communication</summary>
![runner->debugger](./images/runner-debugger.png)
</details>
<details>
<summary>2. Debugger -> Runner communication</summary>
![debugger->runner](./images/debugger-runner.png)
</details>
<details>
<summary>3. Debugger2 -> Debugger communication</summary>
![debugger2->debugger](./images/debugger2-debugger.png)
</details>
### Storage
An emptyDir volume can be successfully mounted (read/write) by the runner as well as the ephemeral containers. This means that ephemeral containers can share data with the runner and other ephemeral containers.
<details>
<summary>Configuration</summary>
```yaml
# Extracted from the values.yaml for the gha-runner-scale-set helm chart
spec:
containers:
- name: runner
image: ghcr.io/actions/actions-runner:latest
command: ["/home/runner/run.sh"]
volumeMounts:
- mountPath: /workspace
name: work-volume
volumes:
- name: work-volume
emptyDir:
sizeLimit: 1Gi
```
```bash
# The API call to the Kubernetes API used to create the ephemeral containers
POD_NAME="arc-runner-set-6sfwd-runner-k7qq6"
NAMESPACE="arc-runners"
curl -v "https://<IP>:<PORT>/api/v1/namespaces/$NAMESPACE/pods/$POD_NAME/ephemeralcontainers" \
-X PATCH \
-H 'Content-Type: application/strategic-merge-patch+json' \
--cacert <PATH_TO_CACERT> \
--cert <PATH_TO_CERT> \
--key <PATH_TO_CLIENT_KEY> \
-d '
{
"spec":
{
"ephemeralContainers":
[
{
"name": "debugger",
"command": ["sh"],
"image": "ghcr.io/actions/actions-runner:latest",
"targetContainerName": "runner",
"stdin": true,
"tty": true,
"volumeMounts": [{
"mountPath": "/workspace",
"name": "work-volume",
"readOnly": false
}]
},
{
"name": "debugger2",
"command": ["sh"],
"image": "ghcr.io/actions/actions-runner:latest",
"targetContainerName": "runner",
"stdin": true,
"tty": true,
"volumeMounts": [{
"mountPath": "/workspace",
"name": "work-volume",
"readOnly": false
}]
}
]
}
}'
```
</details>
<details>
<summary>emptyDir volume mount</summary>
![emptyDir volume mount](./images/emptyDir_volume.png)
</details>
### Security
According to the [ephemeral containers API specification](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.26/#ephemeralcontainer-v1-core) the configuration of the `securityContext` field is possible.
Ephemeral containers share the same network namespace as the pod they are attached to. This means that ephemeral containers can access the same network interfaces as the pod and can communicate with other containers in the same pod.
It is also possible for ephemeral containers to [share the process namespace](https://kubernetes.io/docs/tasks/configure-pod-container/share-process-namespace/) with the other containers in the pod. This is disabled by default.
The above could have unpredictable security implications.
### Resource limits
Resources are not allowed for ephemeral containers. Ephemeral containers use spare resources already allocated to the pod. [^1] This is a major drawback as it means that ephemeral containers cannot be configured to have resource limits.
There are no guaranteed resources for ad-hoc troubleshooting. If troubleshooting causes a pod to exceed its resource limit it may be evicted. [^3]
### Logs
Since ephemeral containers can share volumes with the runner container, it's possible to write logs to the same volume and have them available to the runner container.
### Customizability
Ephemeral containers can run any image and tag provided, they can be customized to run any arbitrary job. However, it's important to note that the following are not feasible:
- Lifecycle is not allowed for ephemeral containers
- Ephemeral containers will stop when their command exits, such as exiting a shell, and they will not be restarted. Unlike `kubectl exec`, processes in Ephemeral Containers will not receive an `EOF` if their connections are interrupted, so shells won't automatically exit on disconnect. There is no API support for killing or restarting an ephemeral container. The only way to exit the container is to send it an OS signal. [^4]
- Probes are not allowed for ephemeral containers.
- Ports are not allowed for ephemeral containers.
## Decision
While the evaluation shows that ephemeral containers can be used to run jobs in containers, it's important to acknowledge that ephemeral containers were not designed to handle workloads but rather provide a mechanism to inspect running containers for debugging and troubleshooting purposes.
Given the limitations of ephemeral containers, we decided not to use them outside of their intended purpose.
## Consequences
Proposal rejected, no further action required. This document will be used as a reference for future discussions.
[^1]: https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.26/#ephemeralcontainer-v1-core
[^2]: https://kubernetes.io/docs/concepts/workloads/pods/ephemeral-containers/
[^3]: https://github.com/kubernetes/enhancements/blob/master/keps/sig-node/277-ephemeral-containers/README.md#notesconstraintscaveats
[^4]: https://github.com/kubernetes/enhancements/blob/master/keps/sig-node/277-ephemeral-containers/README.md#ephemeral-container-lifecycle

View File

@@ -1,32 +0,0 @@
# ADR 0096: Hook extensions
**Date:** 3 August 2023
**Status**: Accepted <!--Accepted|Rejected|Superceded|Deprecated-->
## Context
The current implementation of container hooks does not allow users to customize the pods created by the hook. While the implementation is designed to be used as is or as a starting point, building and maintaining a custom hook implementation just to specify additional fields is not a good user experience.
## Decision
We have decided to add hook extensions to the container hook implementation. This will allow users to customize the pods created by the hook by specifying additional fields. The hook extensions will be implemented in a way that is backwards-compatible with the existing hook implementation.
To allow customization, the runner executing the hook should have `ACTIONS_RUNNER_CONTAINER_HOOK_TEMPLATE` environment variable pointing to a yaml file on the runner system. The extension specified in that file will be applied both for job pods, and container steps.
If environment variable is set, but the file can't be read, the hook will fail, signaling incorrect configuration.
If the environment variable does not exist, the hook will apply the default spec.
In case the hook is able to read the extended spec, it will first create a default configuration, and then merged modified fields in the following way:
1. The `.metadata` fields that will be appended if they are not reserved are `labels` and `annotations`.
2. The pod spec fields except for `containers` and `volumes` are applied from the template, possibly overwriting the field.
3. The volumes are applied in form of appending additional volumes to the default volumes.
4. The containers are merged based on the name assigned to them:
1. If the name of the container *is not* "$job", the entire spec of the container will be added to the pod definition.
2. If the name of the container *is* "$job", the `name` and the `image` fields are going to be ignored and the spec will be applied so that `env`, `volumeMounts`, `ports` are appended to the default container spec created by the hook, while the rest of the fields are going to be applied to the newly created container spec.
## Consequences
The addition of hook extensions will provide a better user experience for users who need to customize the pods created by the container hook. However, it will require additional effort to provide the template to the runner pod, and configure it properly.

BIN
docs/adrs/images/debugger-runner.png (Stored with Git LFS)

Binary file not shown.

BIN
docs/adrs/images/debugger2-debugger.png (Stored with Git LFS)

Binary file not shown.

BIN
docs/adrs/images/emptyDir_volume.png (Stored with Git LFS)

Binary file not shown.

BIN
docs/adrs/images/runner-debugger.png (Stored with Git LFS)

Binary file not shown.

View File

@@ -1,3 +0,0 @@
#!/bin/bash
echo "Hello World"

View File

@@ -1,30 +0,0 @@
metadata:
annotations:
annotated-by: "extension"
labels:
labeled-by: "extension"
spec:
securityContext:
runAsUser: 1000
runAsGroup: 3000
restartPolicy: Never
containers:
- name: $job # overwirtes job container
env:
- name: ENV1
value: "value1"
imagePullPolicy: Always
image: "busybox:1.28" # Ignored
command:
- sh
args:
- -c
- sleep 50
- name: side-car
image: "ubuntu:latest" # required
command:
- sh
args:
- -c
- sleep 60

View File

@@ -5,7 +5,7 @@
"args": {
"container": {
"image": "node:14.16",
"workingDirectory": "/__w/repo/repo",
"workingDirectory": "/__w/thboop-test2/thboop-test2",
"createOptions": "--cpus 1",
"environmentVariables": {
"NODE_ENV": "development"
@@ -24,37 +24,37 @@
"readOnly": false
},
{
"sourceVolumePath": "/Users/thomas/git/runner/_layout/_work",
"sourceVolumePath": "//Users/thomas/git/runner/_layout/_work",
"targetVolumePath": "/__w",
"readOnly": false
},
{
"sourceVolumePath": "/Users/thomas/git/runner/_layout/externals",
"sourceVolumePath": "//Users/thomas/git/runner/_layout/externals",
"targetVolumePath": "/__e",
"readOnly": true
},
{
"sourceVolumePath": "/Users/thomas/git/runner/_layout/_work/_temp",
"sourceVolumePath": "//Users/thomas/git/runner/_layout/_work/_temp",
"targetVolumePath": "/__w/_temp",
"readOnly": false
},
{
"sourceVolumePath": "/Users/thomas/git/runner/_layout/_work/_actions",
"sourceVolumePath": "//Users/thomas/git/runner/_layout/_work/_actions",
"targetVolumePath": "/__w/_actions",
"readOnly": false
},
{
"sourceVolumePath": "/Users/thomas/git/runner/_layout/_work/_tool",
"sourceVolumePath": "//Users/thomas/git/runner/_layout/_work/_tool",
"targetVolumePath": "/__w/_tool",
"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",
"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",
"readOnly": false
}
@@ -73,8 +73,6 @@
"contextName": "redis",
"image": "redis",
"createOptions": "--cpus 1",
"entrypoint": null,
"entryPointArgs": [],
"environmentVariables": {},
"userMountVolumes": [
{

View File

@@ -12,11 +12,11 @@
"image": "node:14.16",
"dockerfile": null,
"entryPointArgs": [
"-e",
"example-script.sh"
"-c",
"echo \"hello world2\""
],
"entryPoint": "bash",
"workingDirectory": "/__w/repo/repo",
"workingDirectory": "/__w/thboop-test2/thboop-test2",
"createOptions": "--cpus 1",
"environmentVariables": {
"NODE_ENV": "development"
@@ -34,27 +34,27 @@
],
"systemMountVolumes": [
{
"sourceVolumePath": "/Users/thomas/git/runner/_layout/_work",
"sourceVolumePath": "//Users/thomas/git/runner/_layout/_work",
"targetVolumePath": "/__w",
"readOnly": false
},
{
"sourceVolumePath": "/Users/thomas/git/runner/_layout/externals",
"sourceVolumePath": "//Users/thomas/git/runner/_layout/externals",
"targetVolumePath": "/__e",
"readOnly": true
},
{
"sourceVolumePath": "/Users/thomas/git/runner/_layout/_work/_temp",
"sourceVolumePath": "//Users/thomas/git/runner/_layout/_work/_temp",
"targetVolumePath": "/__w/_temp",
"readOnly": false
},
{
"sourceVolumePath": "/Users/thomas/git/runner/_layout/_work/_actions",
"sourceVolumePath": "//Users/thomas/git/runner/_layout/_work/_actions",
"targetVolumePath": "/__w/_actions",
"readOnly": false
},
{
"sourceVolumePath": "/Users/thomas/git/runner/_layout/_work/_tool",
"sourceVolumePath": "//Users/thomas/git/runner/_layout/_work/_tool",
"targetVolumePath": "/__w/_tool",
"readOnly": false
},
@@ -64,7 +64,7 @@
"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",
"readOnly": false
}

View File

@@ -10,8 +10,8 @@
},
"args": {
"entryPointArgs": [
"-e",
"example-script.sh"
"-c",
"echo \"hello world\""
],
"entryPoint": "bash",
"environmentVariables": {
@@ -21,6 +21,6 @@
"/foo/bar",
"bar/foo"
],
"workingDirectory": "/__w/repo/repo"
"workingDirectory": "/__w/thboop-test2/thboop-test2"
}
}

28
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "hooks",
"version": "0.5.0",
"version": "0.1.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "hooks",
"version": "0.5.0",
"version": "0.1.0",
"license": "MIT",
"devDependencies": {
"@types/jest": "^27.5.1",
@@ -1800,9 +1800,9 @@
"dev": true
},
"node_modules/json5": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz",
"integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz",
"integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==",
"dev": true,
"dependencies": {
"minimist": "^1.2.0"
@@ -2625,9 +2625,9 @@
}
},
"node_modules/word-wrap": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.4.tgz",
"integrity": "sha512-2V81OA4ugVo5pRo46hAoD2ivUJx8jXmWXfUkY4KFNw0hEptvN0QfH3K4nHiwzGeKl5rFKedV48QVoqYavy4YpA==",
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz",
"integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==",
"dev": true,
"engines": {
"node": ">=0.10.0"
@@ -3926,9 +3926,9 @@
"dev": true
},
"json5": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz",
"integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz",
"integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==",
"dev": true,
"requires": {
"minimist": "^1.2.0"
@@ -4509,9 +4509,9 @@
}
},
"word-wrap": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.4.tgz",
"integrity": "sha512-2V81OA4ugVo5pRo46hAoD2ivUJx8jXmWXfUkY4KFNw0hEptvN0QfH3K4nHiwzGeKl5rFKedV48QVoqYavy4YpA==",
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz",
"integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==",
"dev": true
},
"wrappy": {

View File

@@ -1,13 +1,13 @@
{
"name": "hooks",
"version": "0.5.0",
"version": "0.1.0",
"description": "Three projects are included - k8s: a kubernetes hook implementation that spins up pods dynamically to run a job - docker: A hook implementation of the runner's docker implementation - A hook lib, which contains shared typescript definitions and utilities that the other packages consume",
"main": "",
"directories": {
"doc": "docs"
},
"scripts": {
"test": "npm run test --prefix packages/docker && npm run test --prefix packages/k8s",
"test": "npm run test --prefix packages/docker",
"bootstrap": "npm install --prefix packages/hooklib && npm install --prefix packages/k8s && npm install --prefix packages/docker",
"format": "prettier --write '**/*.ts'",
"format-check": "prettier --check '**/*.ts'",

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -10,10 +10,9 @@
"author": "",
"license": "MIT",
"dependencies": {
"@actions/core": "^1.9.1",
"@actions/core": "^1.6.0",
"@actions/exec": "^1.1.1",
"hooklib": "file:../hooklib",
"shlex": "^2.1.2",
"uuid": "^8.3.2"
},
"devDependencies": {

View File

@@ -2,11 +2,12 @@ import * as core from '@actions/core'
import * as fs from 'fs'
import {
ContainerInfo,
Registry,
JobContainerInfo,
RunContainerStepArgs,
ServiceContainerInfo
ServiceContainerInfo,
StepContainerInfo
} from 'hooklib/lib'
import * as path from 'path'
import path from 'path'
import { env } from 'process'
import { v4 as uuidv4 } from 'uuid'
import { runDockerCommand, RunDockerCommandOptions } from '../utils'
@@ -42,15 +43,19 @@ export async function createContainer(
}
if (args.environmentVariables) {
for (const [key] of Object.entries(args.environmentVariables)) {
for (const [key, value] of Object.entries(args.environmentVariables)) {
dockerArgs.push('-e')
dockerArgs.push(key)
if (!value) {
dockerArgs.push(`"${key}"`)
} else {
dockerArgs.push(`"${key}=${value}"`)
}
}
}
const mountVolumes = [
...(args.userMountVolumes || []),
...(args.systemMountVolumes || [])
...((args as JobContainerInfo | StepContainerInfo).systemMountVolumes || [])
]
for (const mountVolume of mountVolumes) {
dockerArgs.push(
@@ -69,9 +74,7 @@ export async function createContainer(
}
}
const id = (
await runDockerCommand(dockerArgs, { env: args.environmentVariables })
).trim()
const id = (await runDockerCommand(dockerArgs)).trim()
if (!id) {
throw new Error('Could not read id from docker command')
}
@@ -91,12 +94,11 @@ export async function containerPull(
image: string,
configLocation: string
): Promise<void> {
const dockerArgs: string[] = []
const dockerArgs: string[] = ['pull']
if (configLocation) {
dockerArgs.push('--config')
dockerArgs.push(configLocation)
}
dockerArgs.push('pull')
dockerArgs.push(image)
for (let i = 0; i < 3; i++) {
try {
@@ -144,41 +146,17 @@ export async function containerBuild(
args: RunContainerStepArgs,
tag: string
): Promise<void> {
if (!args.dockerfile) {
throw new Error("Container build expects 'args.dockerfile' to be set")
}
const context = path.dirname(`${env.GITHUB_WORKSPACE}/${args.dockerfile}`)
const dockerArgs: string[] = ['build']
dockerArgs.push('-t', tag)
dockerArgs.push('-f', args.dockerfile)
dockerArgs.push(getBuildContext(args.dockerfile))
dockerArgs.push('-f', `${env.GITHUB_WORKSPACE}/${args.dockerfile}`)
dockerArgs.push(context)
// TODO: figure out build working directory
await runDockerCommand(dockerArgs, {
workingDir: getWorkingDir(args.dockerfile)
workingDir: args['buildWorkingDirectory']
})
}
function getBuildContext(dockerfilePath: string): string {
return path.dirname(dockerfilePath)
}
function getWorkingDir(dockerfilePath: string): string {
const workspace = env.GITHUB_WORKSPACE as string
let workingDir = workspace
if (!dockerfilePath?.includes(workspace)) {
// This is container action
const pathSplit = dockerfilePath.split('/')
const actionIndex = pathSplit?.findIndex(d => d === '_actions')
if (actionIndex) {
const actionSubdirectoryDepth = 3 // handle + repo + [branch | tag]
pathSplit.splice(actionIndex + actionSubdirectoryDepth + 1)
workingDir = pathSplit.join('/')
}
}
return workingDir
}
export async function containerLogs(id: string): Promise<void> {
const dockerArgs: string[] = ['logs']
dockerArgs.push('--details')
@@ -193,18 +171,6 @@ export async function containerNetworkRemove(network: string): Promise<void> {
await runDockerCommand(dockerArgs)
}
export async function containerNetworkPrune(): Promise<void> {
const dockerArgs = [
'network',
'prune',
'--force',
'--filter',
`label=${getRunnerLabel()}`
]
await runDockerCommand(dockerArgs)
}
export async function containerPrune(): Promise<void> {
const dockerPSArgs: string[] = [
'ps',
@@ -272,36 +238,22 @@ export async function healthCheck({
export async function containerPorts(id: string): Promise<string[]> {
const dockerArgs = ['port', id]
const portMappings = (await runDockerCommand(dockerArgs)).trim()
return portMappings.split('\n').filter(p => !!p)
return portMappings.split('\n')
}
export async function getContainerEnvValue(
id: string,
name: string
): Promise<string> {
const dockerArgs = [
'inspect',
`--format='{{range $index, $value := .Config.Env}}{{if eq (index (split $value "=") 0) "${name}"}}{{index (split $value "=") 1}}{{end}}{{end}}'`,
id
]
const value = (await runDockerCommand(dockerArgs)).trim()
const lines = value.split('\n')
return lines.length ? lines[0].replace(/^'/, '').replace(/'$/, '') : ''
}
export async function registryLogin(registry?: Registry): Promise<string> {
if (!registry) {
export async function registryLogin(args): Promise<string> {
if (!args.registry) {
return ''
}
const credentials = {
username: registry.username,
password: registry.password
username: args.registry.username,
password: args.registry.password
}
const configLocation = `${env.RUNNER_TEMP}/.docker_${uuidv4()}`
fs.mkdirSync(configLocation)
try {
await dockerLogin(configLocation, registry.serverUrl, credentials)
await dockerLogin(configLocation, args.registry.serverUrl, credentials)
} catch (error) {
fs.rmdirSync(configLocation, { recursive: true })
throw error
@@ -319,7 +271,7 @@ export async function registryLogout(configLocation: string): Promise<void> {
async function dockerLogin(
configLocation: string,
registry: string,
credentials: { username?: string; password?: string }
credentials: { username: string; password: string }
): Promise<void> {
const credentialsArgs =
credentials.username && credentials.password
@@ -355,36 +307,30 @@ export async function containerExecStep(
): Promise<void> {
const dockerArgs: string[] = ['exec', '-i']
dockerArgs.push(`--workdir=${args.workingDirectory}`)
for (const [key] of Object.entries(args['environmentVariables'])) {
for (const [key, value] of Object.entries(args['environmentVariables'])) {
dockerArgs.push('-e')
dockerArgs.push(key)
if (!value) {
dockerArgs.push(`"${key}"`)
} else {
dockerArgs.push(`"${key}=${value}"`)
}
}
if (args.prependPath?.length) {
// TODO: remove compatibility with typeof prependPath === 'string' as we bump to next major version, the hooks will lose PrependPath compat with runners 2.293.0 and older
const prependPath =
typeof args.prependPath === 'string'
? args.prependPath
: args.prependPath.join(':')
dockerArgs.push(
'-e',
`PATH=${prependPath}:${await getContainerEnvValue(containerId, 'PATH')}`
)
}
// Todo figure out prepend path and update it here
// (we need to pass path in as -e Path={fullpath}) where {fullpath is the prepend path added to the current containers path}
dockerArgs.push(containerId)
dockerArgs.push(args.entryPoint)
for (const entryPointArg of args.entryPointArgs) {
dockerArgs.push(entryPointArg)
}
await runDockerCommand(dockerArgs, { env: args.environmentVariables })
await runDockerCommand(dockerArgs)
}
export async function containerRun(
args: RunContainerStepArgs,
name: string,
network?: string
network: string
): Promise<void> {
if (!args.image) {
throw new Error('expected image to be set')
@@ -394,15 +340,15 @@ export async function containerRun(
dockerArgs.push('--name', name)
dockerArgs.push(`--workdir=${args.workingDirectory}`)
dockerArgs.push(`--label=${getRunnerLabel()}`)
if (network) {
dockerArgs.push(`--network=${network}`)
}
if (args.createOptions) {
dockerArgs.push(...args.createOptions.split(' '))
}
if (args.environmentVariables) {
for (const [key] of Object.entries(args.environmentVariables)) {
for (const [key, value] of Object.entries(args.environmentVariables)) {
// Pass in this way to avoid printing secrets
env[key] = value ?? undefined
dockerArgs.push('-e')
dockerArgs.push(key)
}
@@ -428,14 +374,11 @@ export async function containerRun(
dockerArgs.push(args.image)
if (args.entryPointArgs) {
for (const entryPointArg of args.entryPointArgs) {
if (!entryPointArg) {
continue
}
dockerArgs.push(entryPointArg)
}
}
await runDockerCommand(dockerArgs, { env: args.environmentVariables })
await runDockerCommand(dockerArgs)
}
export async function isContainerAlpine(containerId: string): Promise<boolean> {
@@ -444,7 +387,7 @@ export async function isContainerAlpine(containerId: string): Promise<boolean> {
containerId,
'sh',
'-c',
`'[ $(cat /etc/*release* | grep -i -e "^ID=*alpine*" -c) != 0 ] || exit 1'`
"[ $(cat /etc/*release* | grep -i -e '^ID=*alpine*' -c) != 0 ] || exit 1"
]
try {
await runDockerCommand(dockerArgs)

View File

@@ -1,9 +1,21 @@
import {
containerNetworkPrune,
containerPrune
containerRemove,
containerNetworkRemove
} from '../dockerCommands/container'
export async function cleanupJob(): Promise<void> {
await containerPrune()
await containerNetworkPrune()
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export async function cleanupJob(args, state, responseFile): Promise<void> {
const containerIds: string[] = []
if (state?.container) {
containerIds.push(state.container)
}
if (state?.services) {
containerIds.push(state.services)
}
if (containerIds.length > 0) {
await containerRemove(containerIds)
}
if (state.network) {
await containerNetworkRemove(state.network)
}
}

View File

@@ -40,7 +40,7 @@ export async function prepareJob(
if (!container?.image) {
core.info('No job container provided, skipping')
} else {
setupContainer(container, true)
setupContainer(container)
const configLocation = await registryLogin(container.registry)
try {
@@ -48,7 +48,6 @@ export async function prepareJob(
} finally {
await registryLogout(configLocation)
}
containerMetadata = await createContainer(
container,
generateContainerName(container.image),
@@ -79,7 +78,6 @@ export async function prepareJob(
generateContainerName(service.image),
networkName
)
servicesMetadata.push(response)
await containerStart(response.id)
}
@@ -96,10 +94,7 @@ export async function prepareJob(
)
}
let isAlpine = false
if (containerMetadata?.id) {
isAlpine = await isContainerAlpine(containerMetadata.id)
}
const isAlpine = await isContainerAlpine(containerMetadata!.id)
if (containerMetadata?.id) {
containerMetadata.ports = await containerPorts(containerMetadata.id)
@@ -110,10 +105,7 @@ export async function prepareJob(
}
}
const healthChecks: Promise<void>[] = []
if (containerMetadata) {
healthChecks.push(healthCheck(containerMetadata))
}
const healthChecks: Promise<void>[] = [healthCheck(containerMetadata!)]
for (const service of servicesMetadata) {
healthChecks.push(healthCheck(service))
}
@@ -141,6 +133,7 @@ function generateResponseFile(
servicesMetadata?: ContainerMetadata[],
isAlpine = false
): void {
// todo figure out if we are alpine
const response = {
state: { network: networkName },
context: {},
@@ -174,12 +167,10 @@ function generateResponseFile(
writeToResponseFile(responseFile, JSON.stringify(response))
}
function setupContainer(container, jobContainer = false): void {
if (!container.entryPoint && jobContainer) {
function setupContainer(container): void {
container.entryPointArgs = [`-f`, `/dev/null`]
container.entryPoint = 'tail'
}
}
function generateNetworkName(): string {
return `github_network_${uuidv4()}`
@@ -195,15 +186,15 @@ function transformDockerPortsToContextPorts(
meta: ContainerMetadata
): ContextPorts {
// ex: '80/tcp -> 0.0.0.0:80'
const re = /^(\d+)(\/\w+)? -> (.*):(\d+)$/
const re = /^(\d+)\/(\w+)? -> (.*):(\d+)$/
const contextPorts: ContextPorts = {}
if (meta.ports?.length) {
if (meta.ports) {
for (const port of meta.ports) {
const matches = port.match(re)
if (!matches) {
throw new Error(
'Container ports could not match the regex: "^(\\d+)(\\/\\w+)? -> (.*):(\\d+)$"'
'Container ports could not match the regex: "^(\\d+)\\/(\\w+)? -> (.*):(\\d+)$"'
)
}
contextPorts[matches[1]] = matches[matches.length - 1]

View File

@@ -1,12 +1,13 @@
import { RunContainerStepArgs } from 'hooklib/lib'
import { v4 as uuidv4 } from 'uuid'
import {
containerBuild,
containerPull,
containerRun,
registryLogin,
registryLogout
registryLogout,
containerPull,
containerRun
} from '../dockerCommands'
import { v4 as uuidv4 } from 'uuid'
import * as core from '@actions/core'
import { RunContainerStepArgs } from 'hooklib/lib'
import { getRunnerLabel } from '../dockerCommands/constants'
export async function runContainerStep(
@@ -14,23 +15,23 @@ export async function runContainerStep(
state
): Promise<void> {
const tag = generateBuildTag() // for docker build
if (args.image) {
const configLocation = await registryLogin(args.registry)
if (!args.image) {
core.error('expected an image')
} else {
if (args.dockerfile) {
await containerBuild(args, tag)
args.image = tag
} else {
const configLocation = await registryLogin(args)
try {
await containerPull(args.image, configLocation)
} finally {
await registryLogout(configLocation)
}
} else if (args.dockerfile) {
await containerBuild(args, tag)
args.image = tag
} else {
throw new Error(
'run container step should have image or dockerfile fields specified'
)
}
}
// container will get pruned at the end of the job based on the label, no need to cleanup here
await containerRun(args, tag.split(':')[1], state?.network)
await containerRun(args, tag.split(':')[1], state.network)
}
function generateBuildTag(): string {

View File

@@ -13,23 +13,22 @@ import {
runContainerStep,
runScriptStep
} from './hooks'
import { checkEnvironment } from './utils'
async function run(): Promise<void> {
try {
checkEnvironment()
const input = await getInputFromStdin()
const args = input['args']
const command = input['command']
const responseFile = input['responseFile']
const state = input['state']
try {
switch (command) {
case Command.PrepareJob:
await prepareJob(args as PrepareJobArgs, responseFile)
return exit(0)
case Command.CleanupJob:
await cleanupJob()
await cleanupJob(null, state, null)
return exit(0)
case Command.RunScriptStep:
await runScriptStep(args as RunScriptStepArgs, state)

View File

@@ -2,23 +2,18 @@
/* eslint-disable @typescript-eslint/no-require-imports */
/* eslint-disable import/no-commonjs */
import * as core from '@actions/core'
import { env } from 'process'
// Import this way otherwise typescript has errors
const exec = require('@actions/exec')
const shlex = require('shlex')
export interface RunDockerCommandOptions {
workingDir?: string
input?: Buffer
env?: { [key: string]: string }
}
export async function runDockerCommand(
args: string[],
options?: RunDockerCommandOptions
): Promise<string> {
options = optionsWithDockerEnvs(options)
args = fixArgs(args)
const pipes = await exec.getExecOutput('docker', args, options)
if (pipes.exitCode !== 0) {
core.error(`Docker failed with exit code ${pipes.exitCode}`)
@@ -27,45 +22,6 @@ export async function runDockerCommand(
return Promise.resolve(pipes.stdout)
}
export function optionsWithDockerEnvs(
options?: RunDockerCommandOptions
): RunDockerCommandOptions | undefined {
// From https://docs.docker.com/engine/reference/commandline/cli/#environment-variables
const dockerCliEnvs = new Set([
'DOCKER_API_VERSION',
'DOCKER_CERT_PATH',
'DOCKER_CONFIG',
'DOCKER_CONTENT_TRUST_SERVER',
'DOCKER_CONTENT_TRUST',
'DOCKER_CONTEXT',
'DOCKER_DEFAULT_PLATFORM',
'DOCKER_HIDE_LEGACY_COMMANDS',
'DOCKER_HOST',
'DOCKER_STACK_ORCHESTRATOR',
'DOCKER_TLS_VERIFY',
'BUILDKIT_PROGRESS'
])
const dockerEnvs = {}
for (const key in process.env) {
if (dockerCliEnvs.has(key)) {
dockerEnvs[key] = process.env[key]
}
}
const newOptions = {
workingDir: options?.workingDir,
input: options?.input,
env: options?.env || {}
}
// Set docker envs or overwrite provided ones
for (const [key, value] of Object.entries(dockerEnvs)) {
newOptions.env[key] = value as string
}
return newOptions
}
export function sanitize(val: string): string {
if (!val || typeof val !== 'string') {
return ''
@@ -86,16 +42,6 @@ export function sanitize(val: string): string {
return newNameBuilder.join('')
}
export function fixArgs(args: string[]): string[] {
return shlex.split(args.join(' '))
}
export function checkEnvironment(): void {
if (!env.GITHUB_WORKSPACE) {
throw new Error('GITHUB_WORKSPACE is not set')
}
}
// isAlpha accepts single character and checks if
// that character is [a-zA-Z]
function isAlpha(val: string): boolean {

View File

@@ -1,33 +1,62 @@
import { PrepareJobArgs } from 'hooklib/lib'
import { cleanupJob, prepareJob } from '../src/hooks'
import { prepareJob, cleanupJob } from '../src/hooks'
import { v4 as uuidv4 } from 'uuid'
import * as fs from 'fs'
import * as path from 'path'
import TestSetup from './test-setup'
const prepareJobInputPath = path.resolve(
`${__dirname}/../../../examples/prepare-job.json`
)
const tmpOutputDir = `${__dirname}/${uuidv4()}`
let prepareJobOutputPath: string
let prepareJobData: any
let testSetup: TestSetup
jest.useRealTimers()
describe('cleanup job', () => {
beforeAll(() => {
fs.mkdirSync(tmpOutputDir, { recursive: true })
})
afterAll(() => {
fs.rmSync(tmpOutputDir, { recursive: true })
})
beforeEach(async () => {
const prepareJobRawData = fs.readFileSync(prepareJobInputPath, 'utf8')
prepareJobData = JSON.parse(prepareJobRawData.toString())
prepareJobOutputPath = `${tmpOutputDir}/prepare-job-output-${uuidv4()}.json`
fs.writeFileSync(prepareJobOutputPath, '')
testSetup = new TestSetup()
testSetup.initialize()
const prepareJobDefinition = testSetup.getPrepareJobDefinition()
prepareJobData.args.container.userMountVolumes = testSetup.userMountVolumes
prepareJobData.args.container.systemMountVolumes =
testSetup.systemMountVolumes
prepareJobData.args.container.workingDirectory = testSetup.workingDirectory
const prepareJobOutput = testSetup.createOutputFile(
'prepare-job-output.json'
)
await prepareJob(
prepareJobDefinition.args as PrepareJobArgs,
prepareJobOutput
)
await prepareJob(prepareJobData.args, prepareJobOutputPath)
})
afterEach(() => {
fs.rmSync(prepareJobOutputPath, { force: true })
testSetup.teardown()
})
it('should cleanup successfully', async () => {
await expect(cleanupJob()).resolves.not.toThrow()
const prepareJobOutputContent = fs.readFileSync(
prepareJobOutputPath,
'utf-8'
)
const parsedPrepareJobOutput = JSON.parse(prepareJobOutputContent)
await expect(
cleanupJob(prepareJobData.args, parsedPrepareJobOutput.state, null)
).resolves.not.toThrow()
})
})

View File

@@ -1,27 +0,0 @@
import { containerBuild } from '../src/dockerCommands'
import TestSetup from './test-setup'
let testSetup
let runContainerStepDefinition
describe('container build', () => {
beforeEach(() => {
testSetup = new TestSetup()
testSetup.initialize()
runContainerStepDefinition = testSetup.getRunContainerStepDefinition()
})
afterEach(() => {
testSetup.teardown()
})
it('should build container', async () => {
runContainerStepDefinition.image = ''
const actionPath = testSetup.initializeDockerAction()
runContainerStepDefinition.dockerfile = `${actionPath}/Dockerfile`
await expect(
containerBuild(runContainerStepDefinition, 'example-test-tag')
).resolves.not.toThrow()
})
})

View File

@@ -4,7 +4,7 @@ jest.useRealTimers()
describe('container pull', () => {
it('should fail', async () => {
const arg = { image: 'does-not-exist' }
const arg = { image: 'doesNotExist' }
await expect(containerPull(arg.image, '')).rejects.toThrow()
})
it('should succeed', async () => {

View File

@@ -1,72 +1,102 @@
import * as fs from 'fs'
import {
cleanupJob,
prepareJob,
runContainerStep,
runScriptStep
cleanupJob,
runScriptStep,
runContainerStep
} from '../src/hooks'
import * as fs from 'fs'
import * as path from 'path'
import { v4 as uuidv4 } from 'uuid'
import TestSetup from './test-setup'
let definitions
const prepareJobJson = fs.readFileSync(
path.resolve(__dirname + '/../../../examples/prepare-job.json'),
'utf8'
)
const containerStepJson = fs.readFileSync(
path.resolve(__dirname + '/../../../examples/run-container-step.json'),
'utf8'
)
const tmpOutputDir = `${__dirname}/_temp/${uuidv4()}`
let prepareJobData: any
let scriptStepJson: any
let scriptStepData: any
let containerStepData: any
let prepareJobOutputFilePath: string
let testSetup: TestSetup
describe('e2e', () => {
beforeAll(() => {
fs.mkdirSync(tmpOutputDir, { recursive: true })
})
afterAll(() => {
fs.rmSync(tmpOutputDir, { recursive: true })
})
beforeEach(() => {
// init dirs
testSetup = new TestSetup()
testSetup.initialize()
definitions = {
prepareJob: testSetup.getPrepareJobDefinition(),
runScriptStep: testSetup.getRunScriptStepDefinition(),
runContainerStep: testSetup.getRunContainerStepDefinition()
}
prepareJobData = JSON.parse(prepareJobJson)
prepareJobData.args.container.userMountVolumes = testSetup.userMountVolumes
prepareJobData.args.container.systemMountVolumes =
testSetup.systemMountVolumes
prepareJobData.args.container.workingDirectory = testSetup.workingDirectory
scriptStepJson = fs.readFileSync(
path.resolve(__dirname + '/../../../examples/run-script-step.json'),
'utf8'
)
scriptStepData = JSON.parse(scriptStepJson)
scriptStepData.args.workingDirectory = testSetup.workingDirectory
containerStepData = JSON.parse(containerStepJson)
containerStepData.args.workingDirectory = testSetup.workingDirectory
containerStepData.args.userMountVolumes = testSetup.userMountVolumes
containerStepData.args.systemMountVolumes = testSetup.systemMountVolumes
prepareJobOutputFilePath = `${tmpOutputDir}/prepare-job-output-${uuidv4()}.json`
fs.writeFileSync(prepareJobOutputFilePath, '')
})
afterEach(() => {
fs.rmSync(prepareJobOutputFilePath, { force: true })
testSetup.teardown()
})
it('should prepare job, then run script step, then run container step then cleanup', async () => {
const prepareJobOutput = testSetup.createOutputFile(
'prepare-job-output.json'
)
await expect(
prepareJob(definitions.prepareJob.args, prepareJobOutput)
prepareJob(prepareJobData.args, prepareJobOutputFilePath)
).resolves.not.toThrow()
let rawState = fs.readFileSync(prepareJobOutput, 'utf-8')
let rawState = fs.readFileSync(prepareJobOutputFilePath, 'utf-8')
let resp = JSON.parse(rawState)
await expect(
runScriptStep(definitions.runScriptStep.args, resp.state)
runScriptStep(scriptStepData.args, resp.state)
).resolves.not.toThrow()
await expect(
runContainerStep(definitions.runContainerStep.args, resp.state)
runContainerStep(containerStepData.args, resp.state)
).resolves.not.toThrow()
await expect(cleanupJob()).resolves.not.toThrow()
await expect(cleanupJob(resp, resp.state, null)).resolves.not.toThrow()
})
it('should prepare job, then run script step, then run container step with Dockerfile then cleanup', async () => {
const prepareJobOutput = testSetup.createOutputFile(
'prepare-job-output.json'
)
await expect(
prepareJob(definitions.prepareJob.args, prepareJobOutput)
prepareJob(prepareJobData.args, prepareJobOutputFilePath)
).resolves.not.toThrow()
let rawState = fs.readFileSync(prepareJobOutput, 'utf-8')
let rawState = fs.readFileSync(prepareJobOutputFilePath, 'utf-8')
let resp = JSON.parse(rawState)
await expect(
runScriptStep(definitions.runScriptStep.args, resp.state)
runScriptStep(scriptStepData.args, resp.state)
).resolves.not.toThrow()
const dockerfilePath = `${testSetup.workingDirectory}/Dockerfile`
const dockerfilePath = `${tmpOutputDir}/Dockerfile`
fs.writeFileSync(
dockerfilePath,
`FROM ubuntu:latest
@@ -74,17 +104,14 @@ ENV TEST=test
ENTRYPOINT [ "tail", "-f", "/dev/null" ]
`
)
const containerStepDataCopy = JSON.parse(
JSON.stringify(definitions.runContainerStep)
)
const containerStepDataCopy = JSON.parse(JSON.stringify(containerStepData))
process.env.GITHUB_WORKSPACE = tmpOutputDir
containerStepDataCopy.args.dockerfile = 'Dockerfile'
containerStepDataCopy.args.context = '.'
console.log(containerStepDataCopy.args)
await expect(
runContainerStep(containerStepDataCopy.args, resp.state)
).resolves.not.toThrow()
await expect(cleanupJob()).resolves.not.toThrow()
await expect(cleanupJob(resp, resp.state, null)).resolves.not.toThrow()
})
})

View File

@@ -1,18 +1,40 @@
import * as fs from 'fs'
import { v4 as uuidv4 } from 'uuid'
import { prepareJob } from '../src/hooks'
import TestSetup from './test-setup'
jest.useRealTimers()
let prepareJobDefinition
let prepareJobOutputPath: string
let prepareJobData: any
const tmpOutputDir = `${__dirname}/_temp/${uuidv4()}`
const prepareJobInputPath = `${__dirname}/../../../examples/prepare-job.json`
let testSetup: TestSetup
describe('prepare job', () => {
beforeEach(() => {
beforeAll(() => {
fs.mkdirSync(tmpOutputDir, { recursive: true })
})
afterAll(() => {
fs.rmSync(tmpOutputDir, { recursive: true })
})
beforeEach(async () => {
testSetup = new TestSetup()
testSetup.initialize()
prepareJobDefinition = testSetup.getPrepareJobDefinition()
let prepareJobRawData = fs.readFileSync(prepareJobInputPath, 'utf8')
prepareJobData = JSON.parse(prepareJobRawData.toString())
prepareJobData.args.container.userMountVolumes = testSetup.userMountVolumes
prepareJobData.args.container.systemMountVolumes =
testSetup.systemMountVolumes
prepareJobData.args.container.workingDirectory = testSetup.workingDirectory
prepareJobOutputPath = `${tmpOutputDir}/prepare-job-output-${uuidv4()}.json`
fs.writeFileSync(prepareJobOutputPath, '')
})
afterEach(() => {
@@ -20,68 +42,38 @@ describe('prepare job', () => {
})
it('should not throw', async () => {
const prepareJobOutput = testSetup.createOutputFile(
'prepare-job-output.json'
)
await expect(
prepareJob(prepareJobDefinition.args, prepareJobOutput)
prepareJob(prepareJobData.args, prepareJobOutputPath)
).resolves.not.toThrow()
expect(() => fs.readFileSync(prepareJobOutput, 'utf-8')).not.toThrow()
expect(() => fs.readFileSync(prepareJobOutputPath, 'utf-8')).not.toThrow()
})
it('should have JSON output written to a file', async () => {
const prepareJobOutput = testSetup.createOutputFile(
'prepare-job-output.json'
await prepareJob(prepareJobData.args, prepareJobOutputPath)
const prepareJobOutputContent = fs.readFileSync(
prepareJobOutputPath,
'utf-8'
)
await prepareJob(prepareJobDefinition.args, prepareJobOutput)
const prepareJobOutputContent = fs.readFileSync(prepareJobOutput, 'utf-8')
expect(() => JSON.parse(prepareJobOutputContent)).not.toThrow()
})
it('should have context written to a file', async () => {
const prepareJobOutput = testSetup.createOutputFile(
'prepare-job-output.json'
)
await prepareJob(prepareJobDefinition.args, prepareJobOutput)
const parsedPrepareJobOutput = JSON.parse(
fs.readFileSync(prepareJobOutput, 'utf-8')
await prepareJob(prepareJobData.args, prepareJobOutputPath)
const prepareJobOutputContent = fs.readFileSync(
prepareJobOutputPath,
'utf-8'
)
const parsedPrepareJobOutput = JSON.parse(prepareJobOutputContent)
expect(parsedPrepareJobOutput.context).toBeDefined()
})
it('should have isAlpine field set correctly', async () => {
let prepareJobOutput = testSetup.createOutputFile(
'prepare-job-output-alpine.json'
)
const prepareJobArgsClone = JSON.parse(
JSON.stringify(prepareJobDefinition.args)
)
prepareJobArgsClone.container.image = 'alpine:latest'
await prepareJob(prepareJobArgsClone, prepareJobOutput)
let parsedPrepareJobOutput = JSON.parse(
fs.readFileSync(prepareJobOutput, 'utf-8')
)
expect(parsedPrepareJobOutput.isAlpine).toBe(true)
prepareJobOutput = testSetup.createOutputFile(
'prepare-job-output-ubuntu.json'
)
prepareJobArgsClone.container.image = 'ubuntu:latest'
await prepareJob(prepareJobArgsClone, prepareJobOutput)
parsedPrepareJobOutput = JSON.parse(
fs.readFileSync(prepareJobOutput, 'utf-8')
)
expect(parsedPrepareJobOutput.isAlpine).toBe(false)
})
it('should have container ids written to file', async () => {
const prepareJobOutput = testSetup.createOutputFile(
'prepare-job-output.json'
await prepareJob(prepareJobData.args, prepareJobOutputPath)
const prepareJobOutputContent = fs.readFileSync(
prepareJobOutputPath,
'utf-8'
)
await prepareJob(prepareJobDefinition.args, prepareJobOutput)
const prepareJobOutputContent = fs.readFileSync(prepareJobOutput, 'utf-8')
const parsedPrepareJobOutput = JSON.parse(prepareJobOutputContent)
expect(parsedPrepareJobOutput.context.container.id).toBeDefined()
@@ -90,11 +82,11 @@ describe('prepare job', () => {
})
it('should have ports for context written in form [containerPort]:[hostPort]', async () => {
const prepareJobOutput = testSetup.createOutputFile(
'prepare-job-output.json'
await prepareJob(prepareJobData.args, prepareJobOutputPath)
const prepareJobOutputContent = fs.readFileSync(
prepareJobOutputPath,
'utf-8'
)
await prepareJob(prepareJobDefinition.args, prepareJobOutput)
const prepareJobOutputContent = fs.readFileSync(prepareJobOutput, 'utf-8')
const parsedPrepareJobOutput = JSON.parse(prepareJobOutputContent)
const mainContainerPorts = parsedPrepareJobOutput.context.container.ports
@@ -108,14 +100,4 @@ describe('prepare job', () => {
expect(redisServicePorts['80']).toBe('8080')
expect(redisServicePorts['8080']).toBe('8088')
})
it('should run prepare job without job container without exception', async () => {
prepareJobDefinition.args.container = null
const prepareJobOutput = testSetup.createOutputFile(
'prepare-job-output.json'
)
await expect(
prepareJob(prepareJobDefinition.args, prepareJobOutput)
).resolves.not.toThrow()
})
})

View File

@@ -1,78 +0,0 @@
import * as fs from 'fs'
import { PrepareJobResponse } from 'hooklib/lib'
import { prepareJob, runScriptStep } from '../src/hooks'
import TestSetup from './test-setup'
jest.useRealTimers()
let testSetup: TestSetup
let definitions
let prepareJobResponse: PrepareJobResponse
describe('run script step', () => {
beforeEach(async () => {
testSetup = new TestSetup()
testSetup.initialize()
definitions = {
prepareJob: testSetup.getPrepareJobDefinition(),
runScriptStep: testSetup.getRunScriptStepDefinition()
}
const prepareJobOutput = testSetup.createOutputFile(
'prepare-job-output.json'
)
await prepareJob(definitions.prepareJob.args, prepareJobOutput)
prepareJobResponse = JSON.parse(fs.readFileSync(prepareJobOutput, 'utf-8'))
})
it('Should run script step without exceptions', async () => {
await expect(
runScriptStep(definitions.runScriptStep.args, prepareJobResponse.state)
).resolves.not.toThrow()
})
it('Should have path variable changed in container with prepend path string', async () => {
definitions.runScriptStep.args.prependPath = '/some/path'
definitions.runScriptStep.args.entryPoint = '/bin/bash'
definitions.runScriptStep.args.entryPointArgs = [
'-c',
`'if [[ ! $(env | grep "^PATH=") = "PATH=${definitions.runScriptStep.args.prependPath}:"* ]]; then exit 1; fi'`
]
await expect(
runScriptStep(definitions.runScriptStep.args, prepareJobResponse.state)
).resolves.not.toThrow()
})
it("Should fix expansion and print correctly in container's stdout", async () => {
const spy = jest.spyOn(process.stdout, 'write').mockImplementation()
definitions.runScriptStep.args.entryPoint = 'echo'
definitions.runScriptStep.args.entryPointArgs = ['"Mona', 'the', `Octocat"`]
await expect(
runScriptStep(definitions.runScriptStep.args, prepareJobResponse.state)
).resolves.not.toThrow()
expect(spy).toHaveBeenCalledWith(
expect.stringContaining('Mona the Octocat')
)
spy.mockRestore()
})
it('Should have path variable changed in container with prepend path string array', async () => {
definitions.runScriptStep.args.prependPath = ['/some/other/path']
definitions.runScriptStep.args.entryPoint = '/bin/bash'
definitions.runScriptStep.args.entryPointArgs = [
'-c',
`'if [[ ! $(env | grep "^PATH=") = "PATH=${definitions.runScriptStep.args.prependPath.join(
':'
)}:"* ]]; then exit 1; fi'`
]
await expect(
runScriptStep(definitions.runScriptStep.args, prepareJobResponse.state)
).resolves.not.toThrow()
})
})

View File

@@ -1,15 +1,11 @@
import * as fs from 'fs'
import { Mount } from 'hooklib'
import { HookData } from 'hooklib/lib'
import * as path from 'path'
import { env } from 'process'
import { v4 as uuidv4 } from 'uuid'
import { env } from 'process'
import { Mount } from 'hooklib'
export default class TestSetup {
private testdir: string
private runnerMockDir: string
readonly runnerOutputDir: string
private runnerMockSubdirs = {
work: '_work',
externals: 'externals',
@@ -20,16 +16,15 @@ export default class TestSetup {
githubWorkflow: '_work/_temp/_github_workflow'
}
private readonly projectName = 'repo'
private readonly projectName = 'example'
constructor() {
this.testdir = `${__dirname}/_temp/${uuidv4()}`
this.runnerMockDir = `${this.testdir}/runner/_layout`
this.runnerOutputDir = `${this.testdir}/outputs`
}
private get allTestDirectories() {
const resp = [this.testdir, this.runnerMockDir, this.runnerOutputDir]
const resp = [this.testdir, this.runnerMockDir]
for (const [key, value] of Object.entries(this.runnerMockSubdirs)) {
resp.push(`${this.runnerMockDir}/${value}`)
@@ -43,27 +38,30 @@ export default class TestSetup {
}
public initialize(): void {
env['GITHUB_WORKSPACE'] = this.workingDirectory
for (const dir of this.allTestDirectories) {
fs.mkdirSync(dir, { recursive: true })
}
env['RUNNER_NAME'] = 'test'
env[
'RUNNER_TEMP'
] = `${this.runnerMockDir}/${this.runnerMockSubdirs.workTemp}`
for (const dir of this.allTestDirectories) {
fs.mkdirSync(dir, { recursive: true })
}
fs.copyFileSync(
path.resolve(`${__dirname}/../../../examples/example-script.sh`),
`${env.RUNNER_TEMP}/example-script.sh`
)
}
public teardown(): void {
fs.rmdirSync(this.testdir, { recursive: true })
}
private get systemMountVolumes(): Mount[] {
public get userMountVolumes(): Mount[] {
return [
{
sourceVolumePath: 'my_docker_volume',
targetVolumePath: '/volume_mount',
readOnly: false
}
]
}
public get systemMountVolumes(): Mount[] {
return [
{
sourceVolumePath: '/var/run/docker.sock',
@@ -108,89 +106,7 @@ export default class TestSetup {
]
}
public createOutputFile(name: string): string {
let filePath = path.join(this.runnerOutputDir, name || `${uuidv4()}.json`)
fs.writeFileSync(filePath, '')
return filePath
}
public get workingDirectory(): string {
return `${this.runnerMockDir}/_work/${this.projectName}/${this.projectName}`
}
public get containerWorkingDirectory(): string {
return `/__w/${this.projectName}/${this.projectName}`
}
public initializeDockerAction(): string {
const actionPath = `${this.testdir}/_actions/example-handle/example-repo/example-branch/mock-directory`
fs.mkdirSync(actionPath, { recursive: true })
this.writeDockerfile(actionPath)
this.writeEntrypoint(actionPath)
return actionPath
}
private writeDockerfile(actionPath: string) {
const content = `FROM alpine:3.10
COPY entrypoint.sh /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]`
fs.writeFileSync(`${actionPath}/Dockerfile`, content)
}
private writeEntrypoint(actionPath) {
const content = `#!/bin/sh -l
echo "Hello $1"
time=$(date)
echo "::set-output name=time::$time"`
const entryPointPath = `${actionPath}/entrypoint.sh`
fs.writeFileSync(entryPointPath, content)
fs.chmodSync(entryPointPath, 0o755)
}
public getPrepareJobDefinition(): HookData {
const prepareJob = JSON.parse(
fs.readFileSync(
path.resolve(__dirname + '/../../../examples/prepare-job.json'),
'utf8'
)
)
prepareJob.args.container.systemMountVolumes = this.systemMountVolumes
prepareJob.args.container.workingDirectory = this.workingDirectory
prepareJob.args.container.userMountVolumes = undefined
prepareJob.args.container.registry = null
prepareJob.args.services.forEach(s => {
s.registry = null
})
return prepareJob
}
public getRunScriptStepDefinition(): HookData {
const runScriptStep = JSON.parse(
fs.readFileSync(
path.resolve(__dirname + '/../../../examples/run-script-step.json'),
'utf8'
)
)
runScriptStep.args.entryPointArgs[1] = `/__w/_temp/example-script.sh`
return runScriptStep
}
public getRunContainerStepDefinition(): HookData {
const runContainerStep = JSON.parse(
fs.readFileSync(
path.resolve(__dirname + '/../../../examples/run-container-step.json'),
'utf8'
)
)
runContainerStep.args.entryPointArgs[1] = `/__w/_temp/example-script.sh`
runContainerStep.args.systemMountVolumes = this.systemMountVolumes
runContainerStep.args.workingDirectory = this.workingDirectory
runContainerStep.args.userMountVolumes = undefined
runContainerStep.args.registry = null
return runContainerStep
}
}

View File

@@ -1,4 +1,4 @@
import { optionsWithDockerEnvs, sanitize, fixArgs } from '../src/utils'
import { sanitize } from '../src/utils'
describe('Utilities', () => {
it('should return sanitized image name', () => {
@@ -9,72 +9,4 @@ describe('Utilities', () => {
const validStr = 'teststr8_one'
expect(sanitize(validStr)).toBe(validStr)
})
test.each([
[['"Hello', 'World"'], ['Hello World']],
[
[
'sh',
'-c',
`'[ $(cat /etc/*release* | grep -i -e "^ID=*alpine*" -c) != 0 ] || exit 1'`
],
[
'sh',
'-c',
`[ $(cat /etc/*release* | grep -i -e "^ID=*alpine*" -c) != 0 ] || exit 1`
]
],
[
[
'sh',
'-c',
`'[ $(cat /etc/*release* | grep -i -e '\\''^ID=*alpine*'\\'' -c) != 0 ] || exit 1'`
],
[
'sh',
'-c',
`[ $(cat /etc/*release* | grep -i -e '^ID=*alpine*' -c) != 0 ] || exit 1`
]
]
])('should fix split arguments(%p, %p)', (args, expected) => {
const got = fixArgs(args)
expect(got).toStrictEqual(expected)
})
describe('with docker options', () => {
it('should augment options with docker environment variables', () => {
process.env.DOCKER_HOST = 'unix:///run/user/1001/docker.sock'
process.env.DOCKER_NOTEXIST = 'notexist'
const optionDefinitions: any = [
undefined,
{},
{ env: {} },
{ env: { DOCKER_HOST: 'unix://var/run/docker.sock' } }
]
for (const opt of optionDefinitions) {
let options = optionsWithDockerEnvs(opt)
expect(options).toBeDefined()
expect(options?.env).toBeDefined()
expect(options?.env?.DOCKER_HOST).toBe(process.env.DOCKER_HOST)
expect(options?.env?.DOCKER_NOTEXIST).toBeUndefined()
}
})
it('should not overwrite other options', () => {
process.env.DOCKER_HOST = 'unix:///run/user/1001/docker.sock'
const opt = {
workingDir: 'test',
input: Buffer.from('test')
}
const options = optionsWithDockerEnvs(opt)
expect(options).toBeDefined()
expect(options?.workingDir).toBe(opt.workingDir)
expect(options?.input).toBe(opt.input)
expect(options?.env).toStrictEqual({
DOCKER_HOST: process.env.DOCKER_HOST
})
})
})
})

View File

@@ -9,7 +9,7 @@
"version": "0.1.0",
"license": "MIT",
"dependencies": {
"@actions/core": "^1.9.1"
"@actions/core": "^1.6.0"
},
"devDependencies": {
"@types/node": "^17.0.23",
@@ -22,20 +22,19 @@
}
},
"node_modules/@actions/core": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@actions/core/-/core-1.9.1.tgz",
"integrity": "sha512-5ad+U2YGrmmiw6du20AQW5XuWo7UKN2052FjSV7MX+Wfjf8sCqcsZe62NfgHys4QI4/Y+vQvLKYL8jWtA1ZBTA==",
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@actions/core/-/core-1.6.0.tgz",
"integrity": "sha512-NB1UAZomZlCV/LmJqkLhNTqtKfFXJZAUPcfl/zqG7EfsQdeUJtaWO98SGbuQ3pydJ3fHl2CvI/51OKYlCYYcaw==",
"dependencies": {
"@actions/http-client": "^2.0.1",
"uuid": "^8.3.2"
"@actions/http-client": "^1.0.11"
}
},
"node_modules/@actions/http-client": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-2.0.1.tgz",
"integrity": "sha512-PIXiMVtz6VvyaRsGY268qvj57hXQEpsYogYOu2nrQhlf+XCGmZstmuZBbAybUl1nQGnvS1k1eEsQ69ZoD7xlSw==",
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-1.0.11.tgz",
"integrity": "sha512-VRYHGQV1rqnROJqdMvGUbY/Kn8vriQe/F9HR2AlYHzmKuM/p3kjNuXhmdBfcVgsvRWTz5C5XW5xvndZrVBuAYg==",
"dependencies": {
"tunnel": "^0.0.6"
"tunnel": "0.0.6"
}
},
"node_modules/@eslint/eslintrc": {
@@ -1742,9 +1741,9 @@
"dev": true
},
"node_modules/json5": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz",
"integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz",
"integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==",
"dev": true,
"dependencies": {
"minimist": "^1.2.0"
@@ -2215,9 +2214,9 @@
}
},
"node_modules/semver": {
"version": "7.5.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz",
"integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==",
"version": "7.3.7",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz",
"integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==",
"dev": true,
"dependencies": {
"lru-cache": "^6.0.0"
@@ -2486,14 +2485,6 @@
"punycode": "^2.1.0"
}
},
"node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/v8-compile-cache": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz",
@@ -2532,9 +2523,9 @@
}
},
"node_modules/word-wrap": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.4.tgz",
"integrity": "sha512-2V81OA4ugVo5pRo46hAoD2ivUJx8jXmWXfUkY4KFNw0hEptvN0QfH3K4nHiwzGeKl5rFKedV48QVoqYavy4YpA==",
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz",
"integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==",
"dev": true,
"engines": {
"node": ">=0.10.0"
@@ -2555,20 +2546,19 @@
},
"dependencies": {
"@actions/core": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@actions/core/-/core-1.9.1.tgz",
"integrity": "sha512-5ad+U2YGrmmiw6du20AQW5XuWo7UKN2052FjSV7MX+Wfjf8sCqcsZe62NfgHys4QI4/Y+vQvLKYL8jWtA1ZBTA==",
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@actions/core/-/core-1.6.0.tgz",
"integrity": "sha512-NB1UAZomZlCV/LmJqkLhNTqtKfFXJZAUPcfl/zqG7EfsQdeUJtaWO98SGbuQ3pydJ3fHl2CvI/51OKYlCYYcaw==",
"requires": {
"@actions/http-client": "^2.0.1",
"uuid": "^8.3.2"
"@actions/http-client": "^1.0.11"
}
},
"@actions/http-client": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-2.0.1.tgz",
"integrity": "sha512-PIXiMVtz6VvyaRsGY268qvj57hXQEpsYogYOu2nrQhlf+XCGmZstmuZBbAybUl1nQGnvS1k1eEsQ69ZoD7xlSw==",
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-1.0.11.tgz",
"integrity": "sha512-VRYHGQV1rqnROJqdMvGUbY/Kn8vriQe/F9HR2AlYHzmKuM/p3kjNuXhmdBfcVgsvRWTz5C5XW5xvndZrVBuAYg==",
"requires": {
"tunnel": "^0.0.6"
"tunnel": "0.0.6"
}
},
"@eslint/eslintrc": {
@@ -3789,9 +3779,9 @@
"dev": true
},
"json5": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz",
"integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz",
"integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==",
"dev": true,
"requires": {
"minimist": "^1.2.0"
@@ -4119,9 +4109,9 @@
}
},
"semver": {
"version": "7.5.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz",
"integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==",
"version": "7.3.7",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz",
"integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==",
"dev": true,
"requires": {
"lru-cache": "^6.0.0"
@@ -4310,11 +4300,6 @@
"punycode": "^2.1.0"
}
},
"uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="
},
"v8-compile-cache": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz",
@@ -4344,9 +4329,9 @@
}
},
"word-wrap": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.4.tgz",
"integrity": "sha512-2V81OA4ugVo5pRo46hAoD2ivUJx8jXmWXfUkY4KFNw0hEptvN0QfH3K4nHiwzGeKl5rFKedV48QVoqYavy4YpA==",
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz",
"integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==",
"dev": true
},
"wrappy": {

View File

@@ -23,6 +23,6 @@
"typescript": "^4.6.3"
},
"dependencies": {
"@actions/core": "^1.9.1"
"@actions/core": "^1.6.0"
}
}

View File

@@ -34,7 +34,6 @@ export interface ContainerInfo {
createOptions?: string
environmentVariables?: { [key: string]: string }
userMountVolumes?: Mount[]
systemMountVolumes?: Mount[]
registry?: Registry
portMappings?: string[]
}
@@ -74,6 +73,14 @@ export enum Protocol {
UDP = 'udp'
}
export enum PodPhase {
PENDING = 'Pending',
RUNNING = 'Running',
SUCCEEDED = 'Succeded',
FAILED = 'Failed',
UNKNOWN = 'Unknown'
}
export interface PrepareJobResponse {
state?: object
context?: ContainerContext

View File

@@ -1,3 +1,4 @@
import * as core from '@actions/core'
import * as events from 'events'
import * as fs from 'fs'
import * as os from 'os'
@@ -12,6 +13,7 @@ export async function getInputFromStdin(): Promise<HookData> {
})
rl.on('line', line => {
core.debug(`Line from STDIN: ${line}`)
input = line
})
await events.default.once(rl, 'close')

View File

@@ -6,40 +6,7 @@ This implementation provides a way to dynamically spin up jobs to run container
## Pre-requisites
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
```
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
namespace: default
name: runner-role
rules:
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list", "create", "delete"]
- apiGroups: [""]
resources: ["pods/exec"]
verbs: ["get", "create"]
- apiGroups: [""]
resources: ["pods/log"]
verbs: ["get", "list", "watch",]
- apiGroups: ["batch"]
resources: ["jobs"]
verbs: ["get", "list", "create", "delete"]
- apiGroups: [""]
resources: ["secrets"]
verbs: ["get", "list", "create", "delete"]
```
- The `ACTIONS_RUNNER_REQUIRE_JOB_CONTAINER=true` should be set to true
- 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 `ACTIONS_RUNNER_CLAIM_NAME` env should be set to the persistent volume claim that contains the runner's working directory, otherwise it defaults to `${ACTIONS_RUNNER_POD_NAME}-work`
- 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
## Limitations
- A [job containers](https://docs.github.com/en/actions/using-jobs/running-jobs-in-a-container) will be required for all jobs
- Building container actions from a dockerfile is not supported at this time
- Container actions will not have access to the services network or job container network
- Docker [create options](https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idcontaineroptions) are not supported
- The `ACTIONS_RUNNER_CLAIM_NAME` should be set to the persistent volume claim that contains the runner's working directory

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -13,13 +13,11 @@
"author": "",
"license": "MIT",
"dependencies": {
"@actions/core": "^1.9.1",
"@actions/core": "^1.6.0",
"@actions/exec": "^1.1.1",
"@actions/io": "^1.1.2",
"@kubernetes/client-node": "^0.18.1",
"hooklib": "file:../hooklib",
"js-yaml": "^4.1.0",
"shlex": "^2.1.2"
"@kubernetes/client-node": "^0.16.3",
"hooklib": "file:../hooklib"
},
"devDependencies": {
"@types/jest": "^27.4.1",

View File

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

View File

@@ -20,34 +20,28 @@ export function getJobPodName(): string {
export function getStepPodName(): string {
return `${getRunnerPodName().substring(
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)}`
}
export function getVolumeClaimName(): string {
const name = process.env.ACTIONS_RUNNER_CLAIM_NAME
if (!name) {
return `${getRunnerPodName()}-work`
throw new Error(
"'ACTIONS_RUNNER_CLAIM_NAME' is required, please contact your self hosted runner administrator"
)
}
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)}`
}
export const MAX_POD_NAME_LENGTH = 63
export const STEP_POD_NAME_SUFFIX_LENGTH = 8
const MAX_POD_NAME_LENGTH = 63
const STEP_POD_NAME_SUFFIX_LENGTH = 8
export const JOB_CONTAINER_NAME = 'job'
export const JOB_CONTAINER_EXTENSION_NAME = '$job'
export class RunnerInstanceLabel {
private podName: string
runnerhook: string
constructor() {
this.podName = getRunnerPodName()
this.runnerhook = process.env.ACTIONS_RUNNER_POD_NAME as string
}
get key(): string {
@@ -55,10 +49,10 @@ export class RunnerInstanceLabel {
}
get value(): string {
return this.podName
return this.runnerhook
}
toString(): string {
return `runner-pod=${this.podName}`
return `runner-pod=${this.runnerhook}`
}
}

View File

@@ -2,106 +2,82 @@ import * as core from '@actions/core'
import * as io from '@actions/io'
import * as k8s from '@kubernetes/client-node'
import {
JobContainerInfo,
ContextPorts,
PrepareJobArgs,
PodPhase,
prepareJobArgs,
writeToResponseFile
} from 'hooklib'
import path from 'path'
import {
containerPorts,
createPod,
isAuthPermissionsOK,
isPodContainerAlpine,
prunePods,
waitForPodPhases,
getPrepareJobTimeoutSeconds
namespace,
podPrune,
requiredPermissions,
waitForPodPhases
} from '../k8s'
import {
containerVolumes,
DEFAULT_CONTAINER_ENTRY_POINT,
DEFAULT_CONTAINER_ENTRY_POINT_ARGS,
generateContainerName,
mergeContainerWithOptions,
readExtensionFromFile,
PodPhase,
fixArgs
DEFAULT_CONTAINER_ENTRY_POINT_ARGS
} from '../k8s/utils'
import { JOB_CONTAINER_EXTENSION_NAME, JOB_CONTAINER_NAME } from './constants'
import { JOB_CONTAINER_NAME } from './constants'
export async function prepareJob(
args: PrepareJobArgs,
args: prepareJobArgs,
responseFile
): Promise<void> {
if (!args.container) {
throw new Error('Job Container is required.')
await podPrune()
if (!(await isAuthPermissionsOK())) {
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.`
)
}
await prunePods()
const extension = readExtensionFromFile()
await copyExternalsToRoot()
let container: k8s.V1Container | undefined = undefined
if (args.container?.image) {
core.debug(`Using image '${args.container.image}' for job image`)
container = createContainerSpec(
args.container,
JOB_CONTAINER_NAME,
true,
extension
)
core.info(`Using image '${args.container.image}' for job image`)
container = createPodSpec(args.container, JOB_CONTAINER_NAME, true)
}
let services: k8s.V1Container[] = []
if (args.services?.length) {
services = args.services.map(service => {
core.debug(`Adding service '${service.image}' to pod definition`)
return createContainerSpec(
service,
generateContainerName(service.image),
false,
undefined
)
core.info(`Adding service '${service.image}' to pod definition`)
return createPodSpec(service, service.image.split(':')[0])
})
}
if (!container && !services?.length) {
throw new Error('No containers exist, skipping hook invocation')
}
let createdPod: k8s.V1Pod | undefined = undefined
try {
createdPod = await createPod(
container,
services,
args.container.registry,
extension
)
createdPod = await createPod(container, services, args.registry)
} catch (err) {
await prunePods()
await podPrune()
throw new Error(`failed to create job pod: ${err}`)
}
if (!createdPod?.metadata?.name) {
throw new Error('created pod should have metadata.name')
}
core.debug(
`Job pod created, waiting for it to come online ${createdPod?.metadata?.name}`
)
try {
await waitForPodPhases(
createdPod.metadata.name,
new Set([PodPhase.RUNNING]),
new Set([PodPhase.PENDING]),
getPrepareJobTimeoutSeconds()
new Set([PodPhase.PENDING])
)
} catch (err) {
await prunePods()
await podPrune()
throw new Error(`Pod failed to come online with error: ${err}`)
}
core.debug('Job pod is ready for traffic')
core.info('Pod is ready for traffic')
let isAlpine = false
try {
@@ -112,7 +88,7 @@ export async function prepareJob(
} catch (err) {
throw new Error(`Failed to determine if the pod is alpine: ${err}`)
}
core.debug(`Setting isAlpine to ${isAlpine}`)
generateResponseFile(responseFile, createdPod, isAlpine)
}
@@ -121,13 +97,8 @@ function generateResponseFile(
appPod: k8s.V1Pod,
isAlpine
): void {
if (!appPod.metadata?.name) {
throw new Error('app pod must have metadata.name specified')
}
const response = {
state: {
jobPod: appPod.metadata.name
},
state: {},
context: {},
isAlpine
}
@@ -155,12 +126,14 @@ function generateResponseFile(
)
if (serviceContainers?.length) {
response.context['services'] = serviceContainers.map(c => {
if (!c.ports) {
return
}
const ctxPorts: ContextPorts = {}
if (c.ports?.length) {
for (const port of c.ports) {
ctxPorts[port.containerPort] = port.hostPort
}
}
return {
image: c.image,
@@ -182,32 +155,33 @@ async function copyExternalsToRoot(): Promise<void> {
}
}
export function createContainerSpec(
container: JobContainerInfo,
function createPodSpec(
container,
name: string,
jobContainer = false,
extension?: k8s.V1PodTemplateSpec
jobContainer = false
): k8s.V1Container {
if (!container.entryPoint && jobContainer) {
container.entryPoint = DEFAULT_CONTAINER_ENTRY_POINT
core.info(JSON.stringify(container))
if (!container.entryPointArgs) {
container.entryPointArgs = DEFAULT_CONTAINER_ENTRY_POINT_ARGS
}
container.entryPointArgs = DEFAULT_CONTAINER_ENTRY_POINT_ARGS
if (!container.entryPoint) {
container.entryPoint = DEFAULT_CONTAINER_ENTRY_POINT
}
const podContainer = {
name,
image: container.image,
command: [container.entryPoint],
args: container.entryPointArgs,
ports: containerPorts(container)
} as k8s.V1Container
if (container.workingDirectory) {
podContainer.workingDir = container.workingDirectory
}
if (container.entryPoint) {
podContainer.command = [container.entryPoint]
}
if (container.entryPointArgs?.length > 0) {
podContainer.args = fixArgs(container.entryPointArgs)
if (container.createOptions) {
podContainer.resources = getResourceRequirements(container.createOptions)
}
podContainer.env = []
@@ -224,17 +198,64 @@ export function createContainerSpec(
jobContainer
)
if (!extension) {
return podContainer
}
const from = extension.spec?.containers?.find(
c => c.name === JOB_CONTAINER_EXTENSION_NAME
function getResourceRequirements(
createOptions: string
): k8s.V1ResourceRequirements {
const rr = new k8s.V1ResourceRequirements()
rr.limits = {}
rr.requests = {}
const options = parseOptions(createOptions)
for (const [key, value] of Object.entries(options)) {
switch (key) {
case '--cpus':
rr.requests.cpu = value
break
case '--memory':
case '-m':
rr.limits.memory = value
break
default:
core.warning(
`Container option ${key} is not supported. Supported options are ['--cpus', '--memory', '-m']`
)
if (from) {
mergeContainerWithOptions(podContainer, from)
}
}
return podContainer
return rr
}
function parseOptions(options: string): { [option: string]: string } {
const rv: { [option: string]: string } = {}
const spaceSplit = options.split(' ')
for (let i = 0; i < spaceSplit.length; i++) {
if (!spaceSplit[i].startsWith('-')) {
throw new Error(`Options specified in wrong format: ${options}`)
}
const optSplit = spaceSplit[i].split('=')
const optName = optSplit[0]
let optValue = ''
switch (optSplit.length) {
case 1:
if (spaceSplit.length <= i + 1) {
throw new Error(`Option ${optName} must have a value`)
}
optValue = spaceSplit[++i]
break
case 2:
optValue = optSplit[1]
break
default:
throw new Error(`failed to parse option ${spaceSplit[i]}`)
}
rv[optName] = optValue
}
return rv
}

View File

@@ -1,42 +1,23 @@
import * as core from '@actions/core'
import * as k8s from '@kubernetes/client-node'
import { RunContainerStepArgs } from 'hooklib'
import * as core from '@actions/core'
import { PodPhase } from 'hooklib'
import {
createJob,
createSecretForEnvs,
getContainerJobPodName,
getPodLogs,
getPodStatus,
waitForJobToComplete,
waitForPodPhases
} from '../k8s'
import {
containerVolumes,
PodPhase,
mergeContainerWithOptions,
readExtensionFromFile,
fixArgs
} from '../k8s/utils'
import { JOB_CONTAINER_EXTENSION_NAME, JOB_CONTAINER_NAME } from './constants'
import { JOB_CONTAINER_NAME } from './constants'
import { containerVolumes } from '../k8s/utils'
export async function runContainerStep(
stepContainer: RunContainerStepArgs
): Promise<number> {
export async function runContainerStep(stepContainer): Promise<number> {
if (stepContainer.dockerfile) {
throw new Error('Building container actions is not currently supported')
}
let secretName: string | undefined = undefined
if (stepContainer.environmentVariables) {
secretName = await createSecretForEnvs(stepContainer.environmentVariables)
}
const extension = readExtensionFromFile()
core.debug(`Created secret ${secretName} for container job envs`)
const container = createContainerSpec(stepContainer, secretName, extension)
const job = await createJob(container, extension)
const container = createPodSpec(stepContainer)
const job = await createJob(container)
if (!job.metadata?.name) {
throw new Error(
`Expected job ${JSON.stringify(
@@ -44,77 +25,45 @@ export async function runContainerStep(
)} to have correctly set the metadata.name`
)
}
core.debug(`Job created, waiting for pod to start: ${job.metadata?.name}`)
const podName = await getContainerJobPodName(job.metadata.name)
await waitForPodPhases(
podName,
new Set([PodPhase.COMPLETED, PodPhase.RUNNING, PodPhase.SUCCEEDED]),
new Set([PodPhase.PENDING, PodPhase.UNKNOWN])
new Set([PodPhase.COMPLETED, PodPhase.RUNNING]),
new Set([PodPhase.PENDING])
)
core.debug('Container step is running or complete, pulling logs')
await getPodLogs(podName, JOB_CONTAINER_NAME)
core.debug('Waiting for container job to complete')
await waitForJobToComplete(job.metadata.name)
// pod has failed so pull the status code from the container
const status = await getPodStatus(podName)
if (status?.phase === 'Succeeded') {
if (!status?.containerStatuses?.length) {
core.warning(`Can't determine container status`)
return 0
}
if (!status?.containerStatuses?.length) {
core.error(
`Can't determine container status from response: ${JSON.stringify(
status
)}`
)
return 1
}
const exitCode =
status.containerStatuses[status.containerStatuses.length - 1].state
?.terminated?.exitCode
return Number(exitCode) || 1
return Number(exitCode) || 0
}
function createContainerSpec(
container: RunContainerStepArgs,
secretName?: string,
extension?: k8s.V1PodTemplateSpec
): k8s.V1Container {
function createPodSpec(container): k8s.V1Container {
const podContainer = new k8s.V1Container()
podContainer.name = JOB_CONTAINER_NAME
podContainer.image = container.image
podContainer.workingDir = container.workingDirectory
podContainer.command = container.entryPoint
? [container.entryPoint]
: undefined
podContainer.args = container.entryPointArgs?.length
? fixArgs(container.entryPointArgs)
: undefined
if (secretName) {
podContainer.envFrom = [
{
secretRef: {
name: secretName,
optional: false
}
}
]
}
podContainer.volumeMounts = containerVolumes(undefined, false, true)
if (!extension) {
return podContainer
if (container.entryPoint) {
podContainer.command = [container.entryPoint, ...container.entryPointArgs]
}
const from = extension.spec?.containers?.find(
c => c.name === JOB_CONTAINER_EXTENSION_NAME
)
if (from) {
mergeContainerWithOptions(podContainer, from)
podContainer.env = []
for (const [key, value] of Object.entries(
container['environmentVariables']
)) {
if (value && key !== 'HOME') {
podContainer.env.push({ name: key, value: value as string })
}
}
podContainer.volumeMounts = containerVolumes()
return podContainer
}

View File

@@ -1,8 +1,6 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import * as fs from 'fs'
import { RunScriptStepArgs } from 'hooklib'
import { execPodStep } from '../k8s'
import { writeEntryPointScript } from '../k8s/utils'
import { JOB_CONTAINER_NAME } from './constants'
export async function runScriptStep(
@@ -10,26 +8,31 @@ export async function runScriptStep(
state,
responseFile
): Promise<void> {
const { entryPoint, entryPointArgs, environmentVariables } = args
const { containerPath, runnerPath } = writeEntryPointScript(
args.workingDirectory,
entryPoint,
entryPointArgs,
args.prependPath,
environmentVariables
const cb = new CommandsBuilder(
args.entryPoint,
args.entryPointArgs,
args.environmentVariables
)
await execPodStep(cb.command, state.jobPod, JOB_CONTAINER_NAME)
}
args.entryPoint = 'sh'
args.entryPointArgs = ['-e', containerPath]
try {
await execPodStep(
[args.entryPoint, ...args.entryPointArgs],
state.jobPod,
JOB_CONTAINER_NAME
)
} catch (err) {
throw new Error(`failed to run script step: ${err}`)
} finally {
fs.rmSync(runnerPath)
class CommandsBuilder {
constructor(
private entryPoint: string,
private entryPointArgs: string[],
private environmentVariables: { [key: string]: string }
) {}
get command(): string[] {
const envCommands: string[] = []
if (
this.environmentVariables &&
Object.entries(this.environmentVariables).length
) {
for (const [key, value] of Object.entries(this.environmentVariables)) {
envCommands.push(`${key}=${value}`)
}
}
return ['env', ...envCommands, this.entryPoint, ...this.entryPointArgs]
}
}

View File

@@ -1,4 +1,3 @@
import * as core from '@actions/core'
import { Command, getInputFromStdin, prepareJobArgs } from 'hooklib'
import {
cleanupJob,
@@ -6,45 +5,40 @@ import {
runContainerStep,
runScriptStep
} from './hooks'
import { isAuthPermissionsOK, namespace, requiredPermissions } from './k8s'
async function run(): Promise<void> {
try {
const input = await getInputFromStdin()
const args = input['args']
const command = input['command']
const responseFile = input['responseFile']
const state = input['state']
if (!(await isAuthPermissionsOK())) {
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.`
)
}
let exitCode = 0
try {
switch (command) {
case Command.PrepareJob:
await prepareJob(args as prepareJobArgs, responseFile)
return process.exit(0)
break
case Command.CleanupJob:
await cleanupJob()
return process.exit(0)
break
case Command.RunScriptStep:
await runScriptStep(args, state, null)
return process.exit(0)
break
case Command.RunContainerStep:
exitCode = await runContainerStep(args)
return process.exit(exitCode)
break
case Command.runContainerStep:
default:
throw new Error(`Command not recognized: ${command}`)
}
} catch (error) {
core.error(error as Error)
process.exit(1)
// eslint-disable-next-line no-console
console.log(error)
exitCode = 1
}
process.exitCode = exitCode
}
void run()

View File

@@ -1,21 +1,13 @@
import * as core from '@actions/core'
import * as k8s from '@kubernetes/client-node'
import { ContainerInfo, Registry } from 'hooklib'
import { ContainerInfo, PodPhase, Registry } from 'hooklib'
import * as stream from 'stream'
import { v4 as uuidv4 } from 'uuid'
import {
getJobPodName,
getRunnerPodName,
getSecretName,
getStepPodName,
getVolumeClaimName,
RunnerInstanceLabel
} from '../hooks/constants'
import {
PodPhase,
mergePodSpecWithOptions,
mergeObjectMeta,
useKubeScheduler
} from './utils'
const kc = new k8s.KubeConfig()
@@ -25,8 +17,6 @@ const k8sApi = kc.makeApiClient(k8s.CoreV1Api)
const k8sBatchV1Api = kc.makeApiClient(k8s.BatchV1Api)
const k8sAuthorizationV1Api = kc.makeApiClient(k8s.AuthorizationV1Api)
const DEFAULT_WAIT_FOR_POD_TIME_SECONDS = 10 * 60 // 10 min
export const POD_VOLUME_NAME = 'work'
export const requiredPermissions = [
@@ -53,20 +43,20 @@ export const requiredPermissions = [
verbs: ['get', 'list', 'create', 'delete'],
resource: 'jobs',
subresource: ''
},
{
}
]
const secretPermission = {
group: '',
verbs: ['create', 'delete', 'get', 'list'],
verbs: ['get', 'list', 'create', 'delete'],
resource: 'secrets',
subresource: ''
}
]
export async function createPod(
jobContainer?: k8s.V1Container,
services?: k8s.V1Container[],
registry?: Registry,
extension?: k8s.V1PodTemplateSpec
registry?: Registry
): Promise<k8s.V1Pod> {
const containers: k8s.V1Container[] = []
if (jobContainer) {
@@ -88,16 +78,11 @@ export async function createPod(
appPod.metadata.labels = {
[instanceLabel.key]: instanceLabel.value
}
appPod.metadata.annotations = {}
appPod.spec = new k8s.V1PodSpec()
appPod.spec.containers = containers
appPod.spec.restartPolicy = 'Never'
if (!useKubeScheduler()) {
appPod.spec.nodeName = await getCurrentNodeName()
}
const claimName = getVolumeClaimName()
appPod.spec.volumes = [
{
@@ -107,6 +92,7 @@ export async function createPod(
]
if (registry) {
if (await isSecretsAuthOK()) {
const secret = await createDockerSecret(registry)
if (!secret?.metadata?.name) {
throw new Error(`created secret does not have secret.metadata.name`)
@@ -114,14 +100,11 @@ export async function createPod(
const secretReference = new k8s.V1LocalObjectReference()
secretReference.name = secret.metadata.name
appPod.spec.imagePullSecrets = [secretReference]
} else {
throw new Error(
`Pulls from private registry is not allowed. Please contact your self hosted runner administrator. Service account needs permissions for ${secretPermission.verbs} in resource ${secretPermission.resource}`
)
}
if (extension?.metadata) {
mergeObjectMeta(appPod, extension.metadata)
}
if (extension?.spec) {
mergePodSpecWithOptions(appPod.spec, extension.spec)
}
const { body } = await k8sApi.createNamespacedPod(namespace(), appPod)
@@ -129,18 +112,15 @@ export async function createPod(
}
export async function createJob(
container: k8s.V1Container,
extension?: k8s.V1PodTemplateSpec
container: k8s.V1Container
): Promise<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 = { [runnerInstanceLabel.key]: runnerInstanceLabel.value }
job.metadata.annotations = {}
job.metadata.name = getJobPodName()
job.metadata.labels = { 'runner-pod': getRunnerPodName() }
job.spec = new k8s.V1JobSpec()
job.spec.ttlSecondsAfterFinished = 300
@@ -148,17 +128,11 @@ export async function createJob(
job.spec.template = new k8s.V1PodTemplateSpec()
job.spec.template.spec = new k8s.V1PodSpec()
job.spec.template.metadata = new k8s.V1ObjectMeta()
job.spec.template.metadata.labels = {}
job.spec.template.metadata.annotations = {}
job.spec.template.spec.containers = [container]
job.spec.template.spec.restartPolicy = 'Never'
if (!useKubeScheduler()) {
job.spec.template.spec.nodeName = await getCurrentNodeName()
}
const claimName = getVolumeClaimName()
const claimName = `${runnerName()}-work`
job.spec.template.spec.volumes = [
{
name: 'work',
@@ -166,17 +140,6 @@ export async function createJob(
}
]
if (extension) {
if (extension.metadata) {
// apply metadata both to the job and the pod created by the job
mergeObjectMeta(job, extension.metadata)
mergeObjectMeta(job.spec.template, extension.metadata)
}
if (extension.spec) {
mergePodSpecWithOptions(job.spec.template.spec, extension.spec)
}
}
const { body } = await k8sBatchV1Api.createNamespacedJob(namespace(), job)
return body
}
@@ -210,13 +173,7 @@ export async function getContainerJobPodName(jobName: string): Promise<string> {
}
export async function deletePod(podName: string): Promise<void> {
await k8sApi.deleteNamespacedPod(
podName,
namespace(),
undefined,
undefined,
0
)
await k8sApi.deleteNamespacedPod(podName, namespace())
}
export async function execPodStep(
@@ -225,8 +182,13 @@ export async function execPodStep(
containerName: string,
stdin?: stream.Readable
): Promise<void> {
// TODO, we need to add the path from `prependPath` to the PATH variable. How can we do that? Maybe another exec before running this one?
// Maybe something like, get the current path, if these entries aren't in it, add them, then set the current path to that?
// TODO: how do we set working directory? There doesn't seem to be an easy way to do it. Should we cd then execute our bash script?
const exec = new k8s.Exec(kc)
await new Promise(async function (resolve, reject) {
return new Promise(async function (resolve, reject) {
try {
await exec.exec(
namespace(),
podName,
@@ -239,18 +201,17 @@ export async function execPodStep(
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)
resolve()
} else {
core.debug(
JSON.stringify({
message: resp?.message,
details: resp?.details
})
reject(
JSON.stringify({ message: resp?.message, details: resp?.details })
)
reject(resp?.message)
}
}
)
} catch (error) {
reject(error)
}
})
}
@@ -273,100 +234,46 @@ export async function createDockerSecret(
): Promise<k8s.V1Secret> {
const authContent = {
auths: {
[registry.serverUrl || 'https://index.docker.io/v1/']: {
[registry.serverUrl]: {
username: registry.username,
password: registry.password,
auth: Buffer.from(`${registry.username}:${registry.password}`).toString(
auth: Buffer.from(
`${registry.username}:${registry.password}`,
'base64'
)
).toString()
}
}
}
const runnerInstanceLabel = new RunnerInstanceLabel()
const secretName = getSecretName()
const secretName = generateSecretName()
const secret = new k8s.V1Secret()
secret.immutable = true
secret.apiVersion = 'v1'
secret.metadata = new k8s.V1ObjectMeta()
secret.metadata.name = secretName
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)).toString(
'.dockerconfigjson': Buffer.from(
JSON.stringify(authContent),
'base64'
)
).toString()
}
const { body } = await k8sApi.createNamespacedSecret(namespace(), secret)
return body
}
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 = {
[runnerInstanceLabel.key]: runnerInstanceLabel.value
}
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(
podName: string,
awaitingPhases: Set<PodPhase>,
backOffPhases: Set<PodPhase>,
maxTimeSeconds = DEFAULT_WAIT_FOR_POD_TIME_SECONDS
maxTimeSeconds = 45 * 60 // 45 min
): Promise<void> {
const backOffManager = new BackOffManager(maxTimeSeconds)
let phase: PodPhase = PodPhase.UNKNOWN
try {
while (true) {
phase = await getPodPhase(podName)
if (awaitingPhases.has(phase)) {
return
}
@@ -383,25 +290,6 @@ export async function waitForPodPhases(
}
}
export function getPrepareJobTimeoutSeconds(): number {
const envTimeoutSeconds =
process.env['ACTIONS_RUNNER_PREPARE_JOB_TIMEOUT_SECONDS']
if (!envTimeoutSeconds) {
return DEFAULT_WAIT_FOR_POD_TIME_SECONDS
}
const timeoutSeconds = parseInt(envTimeoutSeconds, 10)
if (!timeoutSeconds || timeoutSeconds <= 0) {
core.warning(
`Prepare job timeout is invalid ("${timeoutSeconds}"): use an int > 0`
)
return DEFAULT_WAIT_FOR_POD_TIME_SECONDS
}
return timeoutSeconds
}
async function getPodPhase(podName: string): Promise<PodPhase> {
const podPhaseLookup = new Set<string>([
PodPhase.PENDING,
@@ -416,7 +304,7 @@ async function getPodPhase(podName: string): Promise<PodPhase> {
if (!pod.status?.phase || !podPhaseLookup.has(pod.status.phase)) {
return PodPhase.UNKNOWN
}
return pod.status?.phase as PodPhase
return pod.status?.phase
}
async function isJobSucceeded(jobName: string): Promise<boolean> {
@@ -440,7 +328,7 @@ export async function getPodLogs(
})
logStream.on('error', err => {
process.stderr.write(err.message)
process.stderr.write(JSON.stringify(err))
})
const r = await log.log(namespace(), podName, containerName, logStream, {
@@ -452,7 +340,7 @@ export async function getPodLogs(
await new Promise(resolve => r.on('close', () => resolve(null)))
}
export async function prunePods(): Promise<void> {
export async function podPrune(): Promise<void> {
const podList = await k8sApi.listNamespacedPod(
namespace(),
undefined,
@@ -501,6 +389,26 @@ export async function isAuthPermissionsOK(): Promise<boolean> {
return responses.every(resp => resp.body.status?.allowed)
}
export async function isSecretsAuthOK(): Promise<boolean> {
const sar = new k8s.V1SelfSubjectAccessReview()
const asyncs: Promise<{
response: unknown
body: k8s.V1SelfSubjectAccessReview
}>[] = []
for (const verb of secretPermission.verbs) {
sar.spec = new k8s.V1SelfSubjectAccessReviewSpec()
sar.spec.resourceAttributes = new k8s.V1ResourceAttributes()
sar.spec.resourceAttributes.verb = verb
sar.spec.resourceAttributes.namespace = namespace()
sar.spec.resourceAttributes.group = secretPermission.group
sar.spec.resourceAttributes.resource = secretPermission.resource
sar.spec.resourceAttributes.subresource = secretPermission.subresource
asyncs.push(k8sAuthorizationV1Api.createSelfSubjectAccessReview(sar))
}
const responses = await Promise.all(asyncs)
return responses.every(resp => resp.body.status?.allowed)
}
export async function isPodContainerAlpine(
podName: string,
containerName: string
@@ -511,7 +419,7 @@ export async function isPodContainerAlpine(
[
'sh',
'-c',
`'[ $(cat /etc/*release* | grep -i -e "^ID=*alpine*" -c) != 0 ] || exit 1'`
"[ $(cat /etc/*release* | grep -i -e '^ID=*alpine*' -c) != 0 ] || exit 1"
],
podName,
containerName
@@ -532,7 +440,6 @@ async function getCurrentNodeName(): Promise<string> {
}
return nodeName
}
export function namespace(): string {
if (process.env['ACTIONS_RUNNER_KUBERNETES_NAMESPACE']) {
return process.env['ACTIONS_RUNNER_KUBERNETES_NAMESPACE']
@@ -547,6 +454,20 @@ export function namespace(): string {
return context.namespace
}
function generateSecretName(): string {
return `github-secret-${uuidv4()}`
}
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
@@ -576,46 +497,28 @@ class BackOffManager {
export function containerPorts(
container: ContainerInfo
): k8s.V1ContainerPort[] {
// 8080:8080/tcp
const portFormat = /(\d{1,5})(:(\d{1,5}))?(\/(tcp|udp))?/
const ports: k8s.V1ContainerPort[] = []
if (!container.portMappings?.length) {
return ports
}
for (const portDefinition of container.portMappings) {
const portProtoSplit = portDefinition.split('/')
if (portProtoSplit.length > 2) {
throw new Error(`Unexpected port format: ${portDefinition}`)
const submatches = portFormat.exec(portDefinition)
if (!submatches) {
throw new Error(
`Port definition "${portDefinition}" is in incorrect format`
)
}
const port = new k8s.V1ContainerPort()
port.protocol =
portProtoSplit.length === 2 ? portProtoSplit[1].toUpperCase() : 'TCP'
const portSplit = portProtoSplit[0].split(':')
if (portSplit.length > 2) {
throw new Error('ports should have at most one ":" separator')
port.hostPort = Number(submatches[1])
if (submatches[3]) {
port.containerPort = Number(submatches[3])
}
const parsePort = (p: string): number => {
const num = Number(p)
if (!Number.isInteger(num) || num < 1 || num > 65535) {
throw new Error(`invalid container port: ${p}`)
}
return num
}
if (portSplit.length === 1) {
port.containerPort = parsePort(portSplit[0])
if (submatches[5]) {
port.protocol = submatches[5].toUpperCase()
} else {
port.hostPort = parsePort(portSplit[0])
port.containerPort = parsePort(portSplit[1])
port.protocol = 'TCP'
}
ports.push(port)
}
return ports
}
export async function getPodByName(name): Promise<k8s.V1Pod> {
const { body } = await k8sApi.readNamespacedPod(name, namespace())
return body
}

View File

@@ -1,24 +1,14 @@
import * as k8s from '@kubernetes/client-node'
import * as fs from 'fs'
import * as yaml from 'js-yaml'
import * as core from '@actions/core'
import { Mount } from 'hooklib'
import * as path from 'path'
import { v1 as uuidv4 } from 'uuid'
import { POD_VOLUME_NAME } from './index'
import { JOB_CONTAINER_EXTENSION_NAME } from '../hooks/constants'
import * as shlex from 'shlex'
export const DEFAULT_CONTAINER_ENTRY_POINT_ARGS = [`-f`, `/dev/null`]
export const DEFAULT_CONTAINER_ENTRY_POINT = 'tail'
export const ENV_HOOK_TEMPLATE_PATH = 'ACTIONS_RUNNER_CONTAINER_HOOK_TEMPLATE'
export const ENV_USE_KUBE_SCHEDULER = 'ACTIONS_RUNNER_USE_KUBE_SCHEDULER'
export function containerVolumes(
userMountVolumes: Mount[] = [],
jobContainer = true,
containerAction = false
jobContainer = true
): k8s.V1VolumeMount[] {
const mounts: k8s.V1VolumeMount[] = [
{
@@ -27,25 +17,6 @@ export function containerVolumes(
}
]
const workspacePath = process.env.GITHUB_WORKSPACE as string
if (containerAction) {
const i = workspacePath.lastIndexOf('_work/')
const workspaceRelativePath = workspacePath.slice(i + '_work/'.length)
mounts.push(
{
name: POD_VOLUME_NAME,
mountPath: '/github/workspace',
subPath: workspaceRelativePath
},
{
name: POD_VOLUME_NAME,
mountPath: '/github/file_commands',
subPath: '_temp/_runner_file_commands'
}
)
return mounts
}
if (!jobContainer) {
return mounts
}
@@ -73,20 +44,14 @@ export function containerVolumes(
}
for (const userVolume of userMountVolumes) {
let sourceVolumePath = ''
if (path.isAbsolute(userVolume.sourceVolumePath)) {
if (!userVolume.sourceVolumePath.startsWith(workspacePath)) {
throw new Error(
'Volume mounts outside of the work folder are not supported'
const sourceVolumePath = `${
path.isAbsolute(userVolume.sourceVolumePath)
? userVolume.sourceVolumePath
: path.join(
process.env.GITHUB_WORKSPACE as string,
userVolume.sourceVolumePath
)
}
// source volume path should be relative path
sourceVolumePath = userVolume.sourceVolumePath.slice(
workspacePath.length + 1
)
} else {
sourceVolumePath = userVolume.sourceVolumePath
}
}`
mounts.push({
name: POD_VOLUME_NAME,
@@ -98,192 +63,3 @@ export function containerVolumes(
return mounts
}
export function writeEntryPointScript(
workingDirectory: string,
entryPoint: string,
entryPointArgs?: string[],
prependPath?: string[],
environmentVariables?: { [key: string]: string }
): { containerPath: string; runnerPath: string } {
let exportPath = ''
if (prependPath?.length) {
// TODO: remove compatibility with typeof prependPath === 'string' as we bump to next major version, the hooks will lose PrependPath compat with runners 2.293.0 and older
const prepend =
typeof prependPath === 'string' ? prependPath : prependPath.join(':')
exportPath = `export PATH=${prepend}:$PATH`
}
let environmentPrefix = ''
if (environmentVariables && Object.entries(environmentVariables).length) {
const envBuffer: string[] = []
for (const [key, value] of Object.entries(environmentVariables)) {
if (
key.includes(`=`) ||
key.includes(`'`) ||
key.includes(`"`) ||
key.includes(`$`)
) {
throw new Error(
`environment key ${key} is invalid - the key must not contain =, $, ', or "`
)
}
envBuffer.push(
`"${key}=${value
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"')
.replace(/\$/g, '\\$')
.replace(/`/g, '\\`')}"`
)
}
environmentPrefix = `env ${envBuffer.join(' ')} `
}
const content = `#!/bin/sh -l
${exportPath}
cd ${workingDirectory} && \
exec ${environmentPrefix} ${entryPoint} ${
entryPointArgs?.length ? entryPointArgs.join(' ') : ''
}
`
const filename = `${uuidv4()}.sh`
const entryPointPath = `${process.env.RUNNER_TEMP}/${filename}`
fs.writeFileSync(entryPointPath, content)
return {
containerPath: `/__w/_temp/${filename}`,
runnerPath: entryPointPath
}
}
export function generateContainerName(image: string): string {
const nameWithTag = image.split('/').pop()
const name = nameWithTag?.split(':').at(0)
if (!name) {
throw new Error(`Image definition '${image}' is invalid`)
}
return name
}
// Overwrite or append based on container options
//
// Keep in mind, envs and volumes could be passed as fields in container definition
// so default volume mounts and envs are appended first, and then create options are used
// to append more values
//
// Rest of the fields are just applied
// For example, container.createOptions.container.image is going to overwrite container.image field
export function mergeContainerWithOptions(
base: k8s.V1Container,
from: k8s.V1Container
): void {
for (const [key, value] of Object.entries(from)) {
if (key === 'name') {
if (value !== base.name && value !== JOB_CONTAINER_EXTENSION_NAME) {
core.warning("Skipping name override: name can't be overwritten")
}
continue
} else if (key === 'image') {
core.warning("Skipping image override: image can't be overwritten")
continue
} else if (key === 'env') {
const envs = value as k8s.V1EnvVar[]
base.env = mergeLists(base.env, envs)
} else if (key === 'volumeMounts' && value) {
const volumeMounts = value as k8s.V1VolumeMount[]
base.volumeMounts = mergeLists(base.volumeMounts, volumeMounts)
} else if (key === 'ports' && value) {
const ports = value as k8s.V1ContainerPort[]
base.ports = mergeLists(base.ports, ports)
} else {
base[key] = value
}
}
}
export function mergePodSpecWithOptions(
base: k8s.V1PodSpec,
from: k8s.V1PodSpec
): void {
for (const [key, value] of Object.entries(from)) {
if (key === 'containers') {
base.containers.push(
...from.containers.filter(e => !e.name?.startsWith('$'))
)
} else if (key === 'volumes' && value) {
const volumes = value as k8s.V1Volume[]
base.volumes = mergeLists(base.volumes, volumes)
} else {
base[key] = value
}
}
}
export function mergeObjectMeta(
base: { metadata?: k8s.V1ObjectMeta },
from: k8s.V1ObjectMeta
): void {
if (!base.metadata?.labels || !base.metadata?.annotations) {
throw new Error(
"Can't merge metadata: base.metadata or base.annotations field is undefined"
)
}
if (from?.labels) {
for (const [key, value] of Object.entries(from.labels)) {
if (base.metadata?.labels?.[key]) {
core.warning(`Label ${key} is already defined and will be overwritten`)
}
base.metadata.labels[key] = value
}
}
if (from?.annotations) {
for (const [key, value] of Object.entries(from.annotations)) {
if (base.metadata?.annotations?.[key]) {
core.warning(
`Annotation ${key} is already defined and will be overwritten`
)
}
base.metadata.annotations[key] = value
}
}
}
export function readExtensionFromFile(): k8s.V1PodTemplateSpec | undefined {
const filePath = process.env[ENV_HOOK_TEMPLATE_PATH]
if (!filePath) {
return undefined
}
const doc = yaml.load(fs.readFileSync(filePath, 'utf8'))
if (!doc || typeof doc !== 'object') {
throw new Error(`Failed to parse ${filePath}`)
}
return doc as k8s.V1PodTemplateSpec
}
export function useKubeScheduler(): boolean {
return process.env[ENV_USE_KUBE_SCHEDULER] === 'true'
}
export enum PodPhase {
PENDING = 'Pending',
RUNNING = 'Running',
SUCCEEDED = 'Succeeded',
FAILED = 'Failed',
UNKNOWN = 'Unknown',
COMPLETED = 'Completed'
}
function mergeLists<T>(base?: T[], from?: T[]): T[] {
const b: T[] = base || []
if (!from?.length) {
return b
}
b.push(...from)
return b
}
export function fixArgs(args: string[]): string[] {
return shlex.split(args.join(' '))
}

View File

@@ -1,65 +1,31 @@
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'
import * as path from 'path'
import * as fs from 'fs'
import { prepareJob, cleanupJob } from '../src/hooks'
import { TestTempOutput } from './test-setup'
let testHelper: TestHelper
let testTempOutput: TestTempOutput
const prepareJobJsonPath = path.resolve(
`${__dirname}/../../../examples/prepare-job.json`
)
let prepareJobOutputFilePath: string
describe('Cleanup Job', () => {
beforeEach(async () => {
testHelper = new TestHelper()
await testHelper.initialize()
let prepareJobData = testHelper.getPrepareJobDefinition()
const prepareJobOutputFilePath = testHelper.createFile(
const prepareJobJson = fs.readFileSync(prepareJobJsonPath)
let prepareJobData = JSON.parse(prepareJobJson.toString())
testTempOutput = new TestTempOutput()
testTempOutput.initialize()
prepareJobOutputFilePath = testTempOutput.createFile(
'prepare-job-output.json'
)
await prepareJob(prepareJobData.args, prepareJobOutputFilePath)
})
afterEach(async () => {
await testHelper.cleanup()
})
it('should not throw', async () => {
const outputJson = fs.readFileSync(prepareJobOutputFilePath)
const outputData = JSON.parse(outputJson.toString())
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)
})
})

View File

@@ -1,182 +0,0 @@
import {
getJobPodName,
getRunnerPodName,
getSecretName,
getStepPodName,
getVolumeClaimName,
JOB_CONTAINER_NAME,
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)
}
})
})
describe('const values', () => {
it('should have constants set', () => {
expect(JOB_CONTAINER_NAME).toBeTruthy()
expect(MAX_POD_NAME_LENGTH).toBeGreaterThan(0)
expect(STEP_POD_NAME_SUFFIX_LENGTH).toBeGreaterThan(0)
})
})
})

View File

@@ -1,36 +1,51 @@
import * as fs from 'fs'
import * as path from 'path'
import {
cleanupJob,
prepareJob,
runContainerStep,
runScriptStep
} from '../src/hooks'
import { TestHelper } from './test-setup'
import { TestTempOutput } from './test-setup'
jest.useRealTimers()
let testHelper: TestHelper
let testTempOutput: TestTempOutput
const prepareJobJsonPath = path.resolve(
`${__dirname}/../../../../examples/prepare-job.json`
)
const runScriptStepJsonPath = path.resolve(
`${__dirname}/../../../../examples/run-script-step.json`
)
let runContainerStepJsonPath = path.resolve(
`${__dirname}/../../../../examples/run-container-step.json`
)
let prepareJobData: any
let prepareJobOutputFilePath: string
describe('e2e', () => {
beforeEach(async () => {
testHelper = new TestHelper()
await testHelper.initialize()
beforeEach(() => {
const prepareJobJson = fs.readFileSync(prepareJobJsonPath)
prepareJobData = JSON.parse(prepareJobJson.toString())
prepareJobData = testHelper.getPrepareJobDefinition()
prepareJobOutputFilePath = testHelper.createFile('prepare-job-output.json')
testTempOutput = new TestTempOutput()
testTempOutput.initialize()
prepareJobOutputFilePath = testTempOutput.createFile(
'prepare-job-output.json'
)
})
afterEach(async () => {
await testHelper.cleanup()
testTempOutput.cleanup()
})
it('should prepare job, run script step, run container step then cleanup without errors', async () => {
await expect(
prepareJob(prepareJobData.args, prepareJobOutputFilePath)
).resolves.not.toThrow()
const scriptStepData = testHelper.getRunScriptStepDefinition()
const scriptStepContent = fs.readFileSync(runScriptStepJsonPath)
const scriptStepData = JSON.parse(scriptStepContent.toString())
const prepareJobOutputJson = fs.readFileSync(prepareJobOutputFilePath)
const prepareJobOutputData = JSON.parse(prepareJobOutputJson.toString())
@@ -39,7 +54,8 @@ describe('e2e', () => {
runScriptStep(scriptStepData.args, prepareJobOutputData.state, null)
).resolves.not.toThrow()
const runContainerStepData = testHelper.getRunContainerStepDefinition()
const runContainerStepContent = fs.readFileSync(runContainerStepJsonPath)
const runContainerStepData = JSON.parse(runContainerStepContent.toString())
await expect(
runContainerStep(runContainerStepData.args)

View File

@@ -1,515 +0,0 @@
import * as fs from 'fs'
import { containerPorts, POD_VOLUME_NAME } from '../src/k8s'
import {
containerVolumes,
generateContainerName,
writeEntryPointScript,
mergePodSpecWithOptions,
mergeContainerWithOptions,
readExtensionFromFile,
ENV_HOOK_TEMPLATE_PATH
} from '../src/k8s/utils'
import * as k8s from '@kubernetes/client-node'
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 throw if environment variable name contains double quote', () => {
expect(() =>
writeEntryPointScript(
'/test',
'sh',
['-e', 'script.sh'],
['/prepend/path'],
{
'SOME"_ENV': 'SOME_VALUE'
}
)
).toThrow()
})
it('should throw if environment variable name contains =', () => {
expect(() =>
writeEntryPointScript(
'/test',
'sh',
['-e', 'script.sh'],
['/prepend/path'],
{
'SOME=ENV': 'SOME_VALUE'
}
)
).toThrow()
})
it('should throw if environment variable name contains single quote', () => {
expect(() =>
writeEntryPointScript(
'/test',
'sh',
['-e', 'script.sh'],
['/prepend/path'],
{
"SOME'_ENV": 'SOME_VALUE'
}
)
).toThrow()
})
it('should throw if environment variable name contains dollar', () => {
expect(() =>
writeEntryPointScript(
'/test',
'sh',
['-e', 'script.sh'],
['/prepend/path'],
{
SOME_$_ENV: 'SOME_VALUE'
}
)
).toThrow()
})
it('should escape double quote, dollar and backslash in environment variable values', () => {
const { runnerPath } = writeEntryPointScript(
'/test',
'sh',
['-e', 'script.sh'],
['/prepend/path'],
{
DQUOTE: '"',
BACK_SLASH: '\\',
DOLLAR: '$'
}
)
expect(fs.existsSync(runnerPath)).toBe(true)
const script = fs.readFileSync(runnerPath, 'utf8')
expect(script).toContain('"DQUOTE=\\"')
expect(script).toContain('"BACK_SLASH=\\\\"')
expect(script).toContain('"DOLLAR=\\$"')
})
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)
let workspace = volumes.find(e => e.mountPath === '/github/workspace')
let fileCommands = volumes.find(
e => e.mountPath === '/github/file_commands'
)
expect(workspace).toBeTruthy()
expect(workspace?.subPath).toBe('repo/repo')
expect(fileCommands).toBeTruthy()
expect(fileCommands?.subPath).toBe('_temp/_runner_file_commands')
volumes = containerVolumes([], false, true)
workspace = volumes.find(e => e.mountPath === '/github/workspace')
fileCommands = volumes.find(e => e.mountPath === '/github/file_commands')
expect(workspace).toBeTruthy()
expect(workspace?.subPath).toBe('repo/repo')
expect(fileCommands).toBeTruthy()
expect(fileCommands?.subPath).toBe('_temp/_runner_file_commands')
})
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()
})
it('should parse container ports', () => {
const tt = [
{
spec: '8080:80',
want: {
containerPort: 80,
hostPort: 8080,
protocol: 'TCP'
}
},
{
spec: '8080:80/udp',
want: {
containerPort: 80,
hostPort: 8080,
protocol: 'UDP'
}
},
{
spec: '8080/udp',
want: {
containerPort: 8080,
hostPort: undefined,
protocol: 'UDP'
}
},
{
spec: '8080',
want: {
containerPort: 8080,
hostPort: undefined,
protocol: 'TCP'
}
}
]
for (const tc of tt) {
const got = containerPorts({ portMappings: [tc.spec] })
for (const [key, value] of Object.entries(tc.want)) {
expect(got[0][key]).toBe(value)
}
}
})
it('should throw when ports are out of range (0, 65536)', () => {
expect(() => containerPorts({ portMappings: ['65536'] })).toThrow()
expect(() => containerPorts({ portMappings: ['0'] })).toThrow()
expect(() => containerPorts({ portMappings: ['65536/udp'] })).toThrow()
expect(() => containerPorts({ portMappings: ['0/udp'] })).toThrow()
expect(() => containerPorts({ portMappings: ['1:65536'] })).toThrow()
expect(() => containerPorts({ portMappings: ['65536:1'] })).toThrow()
expect(() => containerPorts({ portMappings: ['1:65536/tcp'] })).toThrow()
expect(() => containerPorts({ portMappings: ['65536:1/tcp'] })).toThrow()
expect(() => containerPorts({ portMappings: ['1:'] })).toThrow()
expect(() => containerPorts({ portMappings: [':1'] })).toThrow()
expect(() => containerPorts({ portMappings: ['1:/tcp'] })).toThrow()
expect(() => containerPorts({ portMappings: [':1/tcp'] })).toThrow()
})
it('should throw on multi ":" splits', () => {
expect(() => containerPorts({ portMappings: ['1:1:1'] })).toThrow()
})
it('should throw on multi "/" splits', () => {
expect(() => containerPorts({ portMappings: ['1:1/tcp/udp'] })).toThrow()
expect(() => containerPorts({ portMappings: ['1/tcp/udp'] })).toThrow()
})
})
describe('generate container name', () => {
it('should return the container name from image string', () => {
expect(
generateContainerName('public.ecr.aws/localstack/localstack')
).toEqual('localstack')
expect(
generateContainerName(
'public.ecr.aws/url/with/multiple/slashes/postgres:latest'
)
).toEqual('postgres')
expect(generateContainerName('postgres')).toEqual('postgres')
expect(generateContainerName('postgres:latest')).toEqual('postgres')
expect(generateContainerName('localstack/localstack')).toEqual(
'localstack'
)
expect(generateContainerName('localstack/localstack:latest')).toEqual(
'localstack'
)
})
it('should throw on invalid image string', () => {
expect(() =>
generateContainerName('localstack/localstack/:latest')
).toThrow()
expect(() => generateContainerName(':latest')).toThrow()
})
})
describe('read extension', () => {
beforeEach(async () => {
testHelper = new TestHelper()
await testHelper.initialize()
})
afterEach(async () => {
await testHelper.cleanup()
})
it('should throw if env variable is set but file does not exist', () => {
process.env[ENV_HOOK_TEMPLATE_PATH] =
'/path/that/does/not/exist/data.yaml'
expect(() => readExtensionFromFile()).toThrow()
})
it('should return undefined if env variable is not set', () => {
delete process.env[ENV_HOOK_TEMPLATE_PATH]
expect(readExtensionFromFile()).toBeUndefined()
})
it('should throw if file is empty', () => {
let filePath = testHelper.createFile('data.yaml')
process.env[ENV_HOOK_TEMPLATE_PATH] = filePath
expect(() => readExtensionFromFile()).toThrow()
})
it('should throw if file is not valid yaml', () => {
let filePath = testHelper.createFile('data.yaml')
fs.writeFileSync(filePath, 'invalid yaml')
process.env[ENV_HOOK_TEMPLATE_PATH] = filePath
expect(() => readExtensionFromFile()).toThrow()
})
it('should return object if file is valid', () => {
let filePath = testHelper.createFile('data.yaml')
fs.writeFileSync(
filePath,
`
metadata:
labels:
label-name: label-value
annotations:
annotation-name: annotation-value
spec:
containers:
- name: test
image: node:14.16
- name: job
image: ubuntu:latest`
)
process.env[ENV_HOOK_TEMPLATE_PATH] = filePath
const extension = readExtensionFromFile()
expect(extension).toBeDefined()
})
})
it('should merge container spec', () => {
const base = {
image: 'node:14.16',
name: 'test',
env: [
{
name: 'TEST',
value: 'TEST'
}
],
ports: [
{
containerPort: 8080,
hostPort: 8080,
protocol: 'TCP'
}
]
} as k8s.V1Container
const from = {
ports: [
{
containerPort: 9090,
hostPort: 9090,
protocol: 'TCP'
}
],
env: [
{
name: 'TEST_TWO',
value: 'TEST_TWO'
}
],
image: 'ubuntu:latest',
name: 'overwrite'
} as k8s.V1Container
const expectContainer = {
name: base.name,
image: base.image,
ports: [
...(base.ports as k8s.V1ContainerPort[]),
...(from.ports as k8s.V1ContainerPort[])
],
env: [...(base.env as k8s.V1EnvVar[]), ...(from.env as k8s.V1EnvVar[])]
}
const expectJobContainer = JSON.parse(JSON.stringify(expectContainer))
expectJobContainer.name = base.name
mergeContainerWithOptions(base, from)
expect(base).toStrictEqual(expectContainer)
})
it('should merge pod spec', () => {
const base = {
containers: [
{
image: 'node:14.16',
name: 'test',
env: [
{
name: 'TEST',
value: 'TEST'
}
],
ports: [
{
containerPort: 8080,
hostPort: 8080,
protocol: 'TCP'
}
]
}
],
restartPolicy: 'Never'
} as k8s.V1PodSpec
const from = {
securityContext: {
runAsUser: 1000,
fsGroup: 2000
},
restartPolicy: 'Always',
volumes: [
{
name: 'work',
emptyDir: {}
}
],
containers: [
{
image: 'ubuntu:latest',
name: 'side-car',
env: [
{
name: 'TEST',
value: 'TEST'
}
],
ports: [
{
containerPort: 8080,
hostPort: 8080,
protocol: 'TCP'
}
]
}
]
} as k8s.V1PodSpec
const expected = JSON.parse(JSON.stringify(base))
expected.securityContext = from.securityContext
expected.restartPolicy = from.restartPolicy
expected.volumes = from.volumes
expected.containers.push(from.containers[0])
mergePodSpecWithOptions(base, from)
expect(base).toStrictEqual(expected)
})
})

View File

@@ -1,37 +1,36 @@
import * as fs from 'fs'
import * as path from 'path'
import { cleanupJob } from '../src/hooks'
import { createContainerSpec, prepareJob } from '../src/hooks/prepare-job'
import { TestHelper } from './test-setup'
import {
ENV_HOOK_TEMPLATE_PATH,
ENV_USE_KUBE_SCHEDULER,
generateContainerName,
readExtensionFromFile
} from '../src/k8s/utils'
import { getPodByName } from '../src/k8s'
import { V1Container } from '@kubernetes/client-node'
import * as yaml from 'js-yaml'
import { JOB_CONTAINER_NAME } from '../src/hooks/constants'
import { prepareJob } from '../src/hooks/prepare-job'
import { TestTempOutput } from './test-setup'
jest.useRealTimers()
let testHelper: TestHelper
let testTempOutput: TestTempOutput
const prepareJobJsonPath = path.resolve(
`${__dirname}/../../../examples/prepare-job.json`
)
let prepareJobData: any
let prepareJobOutputFilePath: string
describe('Prepare job', () => {
beforeEach(async () => {
testHelper = new TestHelper()
await testHelper.initialize()
prepareJobData = testHelper.getPrepareJobDefinition()
prepareJobOutputFilePath = testHelper.createFile('prepare-job-output.json')
beforeEach(() => {
const prepareJobJson = fs.readFileSync(prepareJobJsonPath)
prepareJobData = JSON.parse(prepareJobJson.toString())
testTempOutput = new TestTempOutput()
testTempOutput.initialize()
prepareJobOutputFilePath = testTempOutput.createFile(
'prepare-job-output.json'
)
})
afterEach(async () => {
const outputJson = fs.readFileSync(prepareJobOutputFilePath)
const outputData = JSON.parse(outputJson.toString())
await cleanupJob()
await testHelper.cleanup()
testTempOutput.cleanup()
})
it('should not throw exception', async () => {
@@ -45,114 +44,4 @@ describe('Prepare job', () => {
const content = fs.readFileSync(prepareJobOutputFilePath)
expect(() => JSON.parse(content.toString())).not.toThrow()
})
it('should prepare job with absolute path for userVolumeMount', async () => {
prepareJobData.args.container.userMountVolumes = [
{
sourceVolumePath: path.join(
process.env.GITHUB_WORKSPACE as string,
'/myvolume'
),
targetVolumePath: '/volume_mount',
readOnly: false
}
]
await expect(
prepareJob(prepareJobData.args, prepareJobOutputFilePath)
).resolves.not.toThrow()
})
it('should throw an exception if the user volume mount is absolute path outside of GITHUB_WORKSPACE', async () => {
prepareJobData.args.container.userMountVolumes = [
{
sourceVolumePath: '/somewhere/not/in/gh-workspace',
targetVolumePath: '/containermount',
readOnly: false
}
]
await expect(
prepareJob(prepareJobData.args, prepareJobOutputFilePath)
).rejects.toThrow()
})
it('should not run prepare job without the job container', async () => {
prepareJobData.args.container = undefined
await expect(
prepareJob(prepareJobData.args, prepareJobOutputFilePath)
).rejects.toThrow()
})
it('should not set command + args for service container if not passed in args', async () => {
const services = prepareJobData.args.services.map(service => {
return createContainerSpec(service, generateContainerName(service.image))
}) as [V1Container]
expect(services[0].command).toBe(undefined)
expect(services[0].args).toBe(undefined)
})
it('should run pod with extensions applied', async () => {
process.env[ENV_HOOK_TEMPLATE_PATH] = path.join(
__dirname,
'../../../examples/extension.yaml'
)
await expect(
prepareJob(prepareJobData.args, prepareJobOutputFilePath)
).resolves.not.toThrow()
delete process.env[ENV_HOOK_TEMPLATE_PATH]
const content = JSON.parse(
fs.readFileSync(prepareJobOutputFilePath).toString()
)
const got = await getPodByName(content.state.jobPod)
expect(got.metadata?.annotations?.['annotated-by']).toBe('extension')
expect(got.metadata?.labels?.['labeled-by']).toBe('extension')
expect(got.spec?.securityContext?.runAsUser).toBe(1000)
expect(got.spec?.securityContext?.runAsGroup).toBe(3000)
// job container
expect(got.spec?.containers[0].name).toBe(JOB_CONTAINER_NAME)
expect(got.spec?.containers[0].image).toBe('node:14.16')
expect(got.spec?.containers[0].command).toEqual(['sh'])
expect(got.spec?.containers[0].args).toEqual(['-c', 'sleep 50'])
// service container
expect(got.spec?.containers[1].image).toBe('redis')
expect(got.spec?.containers[1].command).toBeFalsy()
expect(got.spec?.containers[1].args).toBeFalsy()
// side-car
expect(got.spec?.containers[2].name).toBe('side-car')
expect(got.spec?.containers[2].image).toBe('ubuntu:latest')
expect(got.spec?.containers[2].command).toEqual(['sh'])
expect(got.spec?.containers[2].args).toEqual(['-c', 'sleep 60'])
})
it('should not throw exception using kube scheduler', async () => {
// only for ReadWriteMany volumes or single node cluster
process.env[ENV_USE_KUBE_SCHEDULER] = 'true'
await expect(
prepareJob(prepareJobData.args, prepareJobOutputFilePath)
).resolves.not.toThrow()
delete process.env[ENV_USE_KUBE_SCHEDULER]
})
test.each([undefined, null, []])(
'should not throw exception when portMapping=%p',
async pm => {
prepareJobData.args.services.forEach(s => {
s.portMappings = pm
})
await prepareJob(prepareJobData.args, prepareJobOutputFilePath)
const content = JSON.parse(
fs.readFileSync(prepareJobOutputFilePath).toString()
)
expect(() => content.context.services[0].image).not.toThrow()
}
)
})

View File

@@ -1,79 +1,25 @@
import { TestTempOutput } from './test-setup'
import * as path from 'path'
import { runContainerStep } from '../src/hooks'
import { TestHelper } from './test-setup'
import { ENV_HOOK_TEMPLATE_PATH } from '../src/k8s/utils'
import * as fs from 'fs'
import * as yaml from 'js-yaml'
import { JOB_CONTAINER_EXTENSION_NAME } from '../src/hooks/constants'
jest.useRealTimers()
let testHelper: TestHelper
let testTempOutput: TestTempOutput
let runContainerStepJsonPath = path.resolve(
`${__dirname}/../../../examples/run-container-step.json`
)
let runContainerStepData: any
describe('Run container step', () => {
beforeEach(async () => {
testHelper = new TestHelper()
await testHelper.initialize()
runContainerStepData = testHelper.getRunContainerStepDefinition()
beforeAll(() => {
const content = fs.readFileSync(runContainerStepJsonPath)
runContainerStepData = JSON.parse(content.toString())
process.env.RUNNER_NAME = 'testjob'
})
afterEach(async () => {
await testHelper.cleanup()
})
it('should not throw', async () => {
const exitCode = await runContainerStep(runContainerStepData.args)
expect(exitCode).toBe(0)
})
it('should run pod with extensions applied', async () => {
const extension = {
metadata: {
annotations: {
foo: 'bar'
},
labels: {
bar: 'baz'
}
},
spec: {
containers: [
{
name: JOB_CONTAINER_EXTENSION_NAME,
command: ['sh'],
args: ['-c', 'echo test']
},
{
name: 'side-container',
image: 'ubuntu:latest',
command: ['sh'],
args: ['-c', 'echo test']
}
],
restartPolicy: 'Never',
securityContext: {
runAsUser: 1000,
runAsGroup: 3000
}
}
}
let filePath = testHelper.createFile()
fs.writeFileSync(filePath, yaml.dump(extension))
process.env[ENV_HOOK_TEMPLATE_PATH] = filePath
await expect(
runContainerStep(runContainerStepData.args)
).resolves.not.toThrow()
delete process.env[ENV_HOOK_TEMPLATE_PATH]
})
it('should shold have env variables available', async () => {
runContainerStepData.args.entryPoint = 'bash'
runContainerStepData.args.entryPointArgs = [
'-c',
"'if [[ -z $NODE_ENV ]]; then exit 1; fi'"
]
await expect(
runContainerStep(runContainerStepData.args)
).resolves.not.toThrow()

View File

@@ -1,26 +1,31 @@
import { prepareJob, cleanupJob, runScriptStep } from '../src/hooks'
import { TestTempOutput } from './test-setup'
import * as path from 'path'
import * as fs from 'fs'
import { cleanupJob, prepareJob, runScriptStep } from '../src/hooks'
import { TestHelper } from './test-setup'
jest.useRealTimers()
let testHelper: TestHelper
let testTempOutput: TestTempOutput
const prepareJobJsonPath = path.resolve(
`${__dirname}/../../../examples/prepare-job.json`
)
let prepareJobData: any
let prepareJobOutputFilePath: string
let prepareJobOutputData: any
let runScriptStepDefinition
describe('Run script step', () => {
beforeEach(async () => {
testHelper = new TestHelper()
await testHelper.initialize()
const prepareJobOutputFilePath = testHelper.createFile(
const prepareJobJson = fs.readFileSync(prepareJobJsonPath)
prepareJobData = JSON.parse(prepareJobJson.toString())
console.log(prepareJobData)
testTempOutput = new TestTempOutput()
testTempOutput.initialize()
prepareJobOutputFilePath = testTempOutput.createFile(
'prepare-job-output.json'
)
const prepareJobData = testHelper.getPrepareJobDefinition()
runScriptStepDefinition = testHelper.getRunScriptStepDefinition()
await prepareJob(prepareJobData.args, prepareJobOutputFilePath)
const outputContent = fs.readFileSync(prepareJobOutputFilePath)
prepareJobOutputData = JSON.parse(outputContent.toString())
@@ -28,7 +33,7 @@ describe('Run script step', () => {
afterEach(async () => {
await cleanupJob()
await testHelper.cleanup()
testTempOutput.cleanup()
})
// NOTE: To use this test, do kubectl apply -f podspec.yaml (from podspec examples)
@@ -36,97 +41,21 @@ describe('Run script step', () => {
// npm run test run-script-step
it('should not throw an exception', async () => {
await expect(
runScriptStep(
runScriptStepDefinition.args,
prepareJobOutputData.state,
null
)
).resolves.not.toThrow()
})
it('should fail if the working directory does not exist', async () => {
runScriptStepDefinition.args.workingDirectory = '/foo/bar'
await expect(
runScriptStep(
runScriptStepDefinition.args,
prepareJobOutputData.state,
null
)
).rejects.toThrow()
})
it('should shold have env variables available', async () => {
runScriptStepDefinition.args.entryPoint = 'bash'
runScriptStepDefinition.args.entryPointArgs = [
'-c',
"'if [[ -z $NODE_ENV ]]; then exit 1; fi'"
]
await expect(
runScriptStep(
runScriptStepDefinition.args,
prepareJobOutputData.state,
null
)
).resolves.not.toThrow()
})
it('Should have path variable changed in container with prepend path string', async () => {
runScriptStepDefinition.args.prependPath = '/some/path'
runScriptStepDefinition.args.entryPoint = '/bin/bash'
runScriptStepDefinition.args.entryPointArgs = [
'-c',
`'if [[ ! $(env | grep "^PATH=") = "PATH=${runScriptStepDefinition.args.prependPath}:"* ]]; then exit 1; fi'`
]
await expect(
runScriptStep(
runScriptStepDefinition.args,
prepareJobOutputData.state,
null
)
).resolves.not.toThrow()
})
it('Dollar symbols in environment variables should not be expanded', async () => {
runScriptStepDefinition.args.environmentVariables = {
VARIABLE1: '$VAR',
VARIABLE2: '${VAR}',
VARIABLE3: '$(VAR)'
const args = {
entryPointArgs: ['echo "test"'],
entryPoint: '/bin/bash',
environmentVariables: {
NODE_ENV: 'development'
},
prependPath: ['/foo/bar', 'bar/foo'],
workingDirectory: '/__w/thboop-test2/thboop-test2'
}
runScriptStepDefinition.args.entryPointArgs = [
'-c',
'\'if [[ -z "$VARIABLE1" ]]; then exit 1; fi\'',
'\'if [[ -z "$VARIABLE2" ]]; then exit 2; fi\'',
'\'if [[ -z "$VARIABLE3" ]]; then exit 3; fi\''
]
const state = {
jobPod: prepareJobOutputData.state.jobPod
}
const responseFile = null
await expect(
runScriptStep(
runScriptStepDefinition.args,
prepareJobOutputData.state,
null
)
).resolves.not.toThrow()
})
it('Should have path variable changed in container with prepend path string array', async () => {
runScriptStepDefinition.args.prependPath = ['/some/other/path']
runScriptStepDefinition.args.entryPoint = '/bin/bash'
runScriptStepDefinition.args.entryPointArgs = [
'-c',
`'if [[ ! $(env | grep "^PATH=") = "PATH=${runScriptStepDefinition.args.prependPath.join(
':'
)}:"* ]]; then exit 1; fi'`
]
await expect(
runScriptStep(
runScriptStepDefinition.args,
prepareJobOutputData.state,
null
)
runScriptStep(args, state, responseFile)
).resolves.not.toThrow()
})
})

View File

@@ -1,18 +0,0 @@
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
- role: control-plane
# add a mount from /path/to/my/files on the host to /files on the node
extraMounts:
- hostPath: {{PATHTOREPO}}
containerPath: {{PATHTOREPO}}
# optional: if set, the mount is read-only.
# default false
readOnly: false
# optional: if set, the mount needs SELinux relabeling.
# default false
selinuxRelabel: false
# optional: set propagation mode (None, HostToContainer or Bidirectional)
# see https://kubernetes.io/docs/concepts/storage/volumes/#mount-propagation
# default None
propagation: None

View File

@@ -1,80 +1,20 @@
import * as k8s from '@kubernetes/client-node'
import * as fs from 'fs'
import { HookData } from 'hooklib/lib'
import * as path from 'path'
import { v4 as uuidv4 } from 'uuid'
const kc = new k8s.KubeConfig()
kc.loadFromDefault()
const k8sApi = kc.makeApiClient(k8s.CoreV1Api)
const k8sStorageApi = kc.makeApiClient(k8s.StorageV1Api)
export class TestHelper {
export class TestTempOutput {
private tempDirPath: string
private podName: string
constructor() {
this.tempDirPath = `${__dirname}/_temp/runner`
this.podName = uuidv4().replace(/-/g, '')
this.tempDirPath = `${__dirname}/_temp/${uuidv4()}`
}
public async initialize(): Promise<void> {
process.env['ACTIONS_RUNNER_POD_NAME'] = `${this.podName}`
process.env['RUNNER_WORKSPACE'] = `${this.tempDirPath}/_work/repo`
process.env['RUNNER_TEMP'] = `${this.tempDirPath}/_work/_temp`
process.env['GITHUB_WORKSPACE'] = `${this.tempDirPath}/_work/repo/repo`
process.env['ACTIONS_RUNNER_KUBERNETES_NAMESPACE'] = 'default'
fs.mkdirSync(`${this.tempDirPath}/_work/repo/repo`, { recursive: true })
fs.mkdirSync(`${this.tempDirPath}/externals`, { recursive: true })
fs.mkdirSync(process.env.RUNNER_TEMP, { recursive: true })
fs.copyFileSync(
path.resolve(`${__dirname}/../../../examples/example-script.sh`),
`${process.env.RUNNER_TEMP}/example-script.sh`
)
await this.cleanupK8sResources()
try {
await this.createTestVolume()
await this.createTestJobPod()
} catch (e) {
console.log(e)
}
public initialize(): void {
fs.mkdirSync(this.tempDirPath, { recursive: true })
}
public async cleanup(): Promise<void> {
try {
await this.cleanupK8sResources()
public cleanup(): void {
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 {
const filePath = `${this.tempDirPath}/${fileName || uuidv4()}`
fs.writeFileSync(filePath, '')
@@ -85,112 +25,4 @@ export class TestHelper {
const filePath = `${this.tempDirPath}/${fileName}`
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}/_work`
}
}
}
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)
}
public getPrepareJobDefinition(): HookData {
const prepareJob = JSON.parse(
fs.readFileSync(
path.resolve(__dirname + '/../../../examples/prepare-job.json'),
'utf8'
)
)
prepareJob.args.container.userMountVolumes = undefined
prepareJob.args.container.registry = null
prepareJob.args.services.forEach(s => {
s.registry = null
})
return prepareJob
}
public getRunScriptStepDefinition(): HookData {
const runScriptStep = JSON.parse(
fs.readFileSync(
path.resolve(__dirname + '/../../../examples/run-script-step.json'),
'utf8'
)
)
runScriptStep.args.entryPointArgs[1] = `/__w/_temp/example-script.sh`
return runScriptStep
}
public getRunContainerStepDefinition(): HookData {
const runContainerStep = JSON.parse(
fs.readFileSync(
path.resolve(__dirname + '/../../../examples/run-container-step.json'),
'utf8'
)
)
runContainerStep.args.entryPointArgs[1] = `/__w/_temp/example-script.sh`
runContainerStep.args.userMountVolumes = undefined
runContainerStep.args.registry = null
return runContainerStep
}
}

View File

@@ -1,14 +1,7 @@
<!-- ## Features -->
## Features
- Initial Release
## Bugs
- Add option to use the kubernetes scheduler for workflow pods [#111]
- Docker and K8s: Fix shell arguments when split by the runner [#115]
<!-- ## Misc -->
## SHA-256 Checksums
The SHA-256 checksums for the packages included in this build are shown below:
- actions-runner-hooks-docker-<HOOK_VERSION>.zip <DOCKER_SHA>
- actions-runner-hooks-k8s-<HOOK_VERSION>.zip <K8S_SHA>
## Misc