mirror of
https://github.com/actions/runner-container-hooks.git
synced 2025-12-17 18:26:44 +00:00
Compare commits
118 Commits
nikola-jok
...
v0.5.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
638bd19c9d | ||
|
|
50e14cf868 | ||
|
|
921be5b85f | ||
|
|
0cce49705b | ||
|
|
46c92fe43e | ||
|
|
56208347f1 | ||
|
|
c093f87779 | ||
|
|
c47c74ad9e | ||
|
|
90a6236466 | ||
|
|
496287d61d | ||
|
|
5264b6cd7d | ||
|
|
b58b13134a | ||
|
|
8ea7e21dec | ||
|
|
64000d716a | ||
|
|
4ff4b552a6 | ||
|
|
4cdcf09c43 | ||
|
|
5107bb1d41 | ||
|
|
547ed30dc3 | ||
|
|
17fb66892c | ||
|
|
9319a8566a | ||
|
|
669ec6f706 | ||
|
|
aa658859f8 | ||
|
|
8b83223a2b | ||
|
|
586a052286 | ||
|
|
730509f702 | ||
|
|
3fc91e4132 | ||
|
|
ebbe2bdaff | ||
|
|
17837d25d2 | ||
|
|
c37c5ca584 | ||
|
|
04b58be49a | ||
|
|
89ff7d1155 | ||
|
|
6dbb0b61b7 | ||
|
|
c92bb5544e | ||
|
|
26f4a32c30 | ||
|
|
10c6c0aa70 | ||
|
|
d735152125 | ||
|
|
ae31f04223 | ||
|
|
7754cb80eb | ||
|
|
ae432db512 | ||
|
|
4448b61e00 | ||
|
|
bf39b9bf16 | ||
|
|
5b597b0fe2 | ||
|
|
0e1ba7bdc8 | ||
|
|
73914b840c | ||
|
|
b537fd4c92 | ||
|
|
17d2b3b850 | ||
|
|
ea011028f5 | ||
|
|
eaae191ebb | ||
|
|
418d484160 | ||
|
|
ce3c55d086 | ||
|
|
d988d965c5 | ||
|
|
23cc6dda6f | ||
|
|
8986035ca8 | ||
|
|
e975289683 | ||
|
|
a555151eef | ||
|
|
16eb238caa | ||
|
|
8e06496e34 | ||
|
|
e2033b29c7 | ||
|
|
eb47baaf5e | ||
|
|
20c19dae27 | ||
|
|
4307828719 | ||
|
|
5c6995dba1 | ||
|
|
bb1a033ed7 | ||
|
|
898063bddd | ||
|
|
266b8edb99 | ||
|
|
47cbf5a0d7 | ||
|
|
de4553f25a | ||
|
|
8ea57170d8 | ||
|
|
643bf36fd8 | ||
|
|
de59bd8716 | ||
|
|
d3ec1c0040 | ||
|
|
3e04b45585 | ||
|
|
2b386f7cbd | ||
|
|
bf362ba0dd | ||
|
|
7ae8942b3d | ||
|
|
347e68d3c9 | ||
|
|
7c4e0f8d51 | ||
|
|
cd310988c9 | ||
|
|
1bfc52f466 | ||
|
|
2aa6f9d9c8 | ||
|
|
3d0ca83d2d | ||
|
|
5daaae120b | ||
|
|
df448fbbb0 | ||
|
|
ee2554e2c0 | ||
|
|
f764d18c4c | ||
|
|
55761eab39 | ||
|
|
51bd8b62a4 | ||
|
|
150bc0503a | ||
|
|
9ce39e5a60 | ||
|
|
8351f842bd | ||
|
|
bf3707d7e0 | ||
|
|
88b7b19db7 | ||
|
|
dd5dfb3e48 | ||
|
|
84a57de2e3 | ||
|
|
fa680b2073 | ||
|
|
02f0b322a0 | ||
|
|
ecb9376000 | ||
|
|
cc90cd2361 | ||
|
|
152c4e1cc8 | ||
|
|
d0e094649e | ||
|
|
3ba45d3d7e | ||
|
|
58ebf56ad3 | ||
|
|
e928fa3252 | ||
|
|
ddf09ad7bd | ||
|
|
689a74e352 | ||
|
|
55c9198ada | ||
|
|
bd7e053180 | ||
|
|
0ebccbd8c6 | ||
|
|
ec8131abb7 | ||
|
|
7010d21bff | ||
|
|
c65ec28bbb | ||
|
|
c2f9b10f4d | ||
|
|
1e49f4ba5b | ||
|
|
171956673c | ||
|
|
3ab4ae20f9 | ||
|
|
b0cf60b678 | ||
|
|
5ec2edbe11 | ||
|
|
4b7efe88ef |
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
*.png filter=lfs diff=lfs merge=lfs -text
|
||||||
7
.github/workflows/build.yaml
vendored
7
.github/workflows/build.yaml
vendored
@@ -11,6 +11,11 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- 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
|
- run: npm install
|
||||||
name: Install dependencies
|
name: Install dependencies
|
||||||
- run: npm run bootstrap
|
- run: npm run bootstrap
|
||||||
@@ -21,6 +26,6 @@ jobs:
|
|||||||
- name: Check linter
|
- name: Check linter
|
||||||
run: |
|
run: |
|
||||||
npm run lint
|
npm run lint
|
||||||
git diff --exit-code
|
git diff --exit-code -- ':!packages/k8s/tests/test-kind.yaml'
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: npm run test
|
run: npm run test
|
||||||
45
.github/workflows/release.yaml
vendored
45
.github/workflows/release.yaml
vendored
@@ -13,28 +13,47 @@ jobs:
|
|||||||
- run: npm run build-all
|
- run: npm run build-all
|
||||||
name: Build packages
|
name: Build packages
|
||||||
- uses: actions/github-script@v6
|
- uses: actions/github-script@v6
|
||||||
id: releaseNotes
|
id: releaseVersion
|
||||||
with:
|
with:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
script: |
|
script: |
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const hookVersion = require('./package.json').version
|
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('version', hookVersion);
|
||||||
core.setOutput('note', releaseNotes);
|
|
||||||
- name: Zip up releases
|
- name: Zip up releases
|
||||||
run: |
|
run: |
|
||||||
zip -r -j actions-runner-hooks-docker-${{ steps.releaseNotes.outputs.version }}.zip packages/docker/dist
|
zip -r -j actions-runner-hooks-docker-${{ steps.releaseVersion.outputs.version }}.zip packages/docker/dist
|
||||||
zip -r -j actions-runner-hooks-k8s-${{ steps.releaseNotes.outputs.version }}.zip packages/k8s/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);
|
||||||
- uses: actions/create-release@v1
|
- uses: actions/create-release@v1
|
||||||
id: createRelease
|
id: createRelease
|
||||||
name: Create ${{ steps.releaseNotes.outputs.version }} Hook Release
|
name: Create ${{ steps.releaseVersion.outputs.version }} Hook Release
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
with:
|
with:
|
||||||
tag_name: "v${{ steps.releaseNotes.outputs.version }}"
|
tag_name: "v${{ steps.releaseVersion.outputs.version }}"
|
||||||
release_name: "v${{ steps.releaseNotes.outputs.version }}"
|
release_name: "v${{ steps.releaseVersion.outputs.version }}"
|
||||||
body: |
|
body: |
|
||||||
${{ steps.releaseNotes.outputs.note }}
|
${{ steps.releaseNotes.outputs.note }}
|
||||||
- name: Upload K8s hooks
|
- name: Upload K8s hooks
|
||||||
@@ -43,8 +62,8 @@ jobs:
|
|||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
with:
|
with:
|
||||||
upload_url: ${{ steps.createRelease.outputs.upload_url }}
|
upload_url: ${{ steps.createRelease.outputs.upload_url }}
|
||||||
asset_path: ${{ github.workspace }}/actions-runner-hooks-k8s-${{ steps.releaseNotes.outputs.version }}.zip
|
asset_path: ${{ github.workspace }}/actions-runner-hooks-k8s-${{ steps.releaseVersion.outputs.version }}.zip
|
||||||
asset_name: actions-runner-hooks-k8s-${{ steps.releaseNotes.outputs.version }}.zip
|
asset_name: actions-runner-hooks-k8s-${{ steps.releaseVersion.outputs.version }}.zip
|
||||||
asset_content_type: application/octet-stream
|
asset_content_type: application/octet-stream
|
||||||
- name: Upload docker hooks
|
- name: Upload docker hooks
|
||||||
uses: actions/upload-release-asset@v1
|
uses: actions/upload-release-asset@v1
|
||||||
@@ -52,6 +71,6 @@ jobs:
|
|||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
with:
|
with:
|
||||||
upload_url: ${{ steps.createRelease.outputs.upload_url }}
|
upload_url: ${{ steps.createRelease.outputs.upload_url }}
|
||||||
asset_path: ${{ github.workspace }}/actions-runner-hooks-docker-${{ steps.releaseNotes.outputs.version }}.zip
|
asset_path: ${{ github.workspace }}/actions-runner-hooks-docker-${{ steps.releaseVersion.outputs.version }}.zip
|
||||||
asset_name: actions-runner-hooks-docker-${{ steps.releaseNotes.outputs.version }}.zip
|
asset_name: actions-runner-hooks-docker-${{ steps.releaseVersion.outputs.version }}.zip
|
||||||
asset_content_type: application/octet-stream
|
asset_content_type: application/octet-stream
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -2,3 +2,4 @@ node_modules/
|
|||||||
lib/
|
lib/
|
||||||
dist/
|
dist/
|
||||||
**/tests/_temp/**
|
**/tests/_temp/**
|
||||||
|
packages/k8s/tests/test-kind.yaml
|
||||||
@@ -1 +1 @@
|
|||||||
* @actions/actions-runtime
|
* @actions/actions-launch @actions/runner-akvelon
|
||||||
|
|||||||
@@ -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.
|
- 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.
|
- See [the runner contributing.md](../../github/CONTRIBUTING.MD) for how to get started with runner development.
|
||||||
- Build your hook using `npm run build`
|
- Build your hook using `npm run build`
|
||||||
- Enable the hooks by setting `ACTIONS_RUNNER_CONTAINER_HOOK=./packages/{libraryname}/dist/index.js` file generated by [ncc](https://github.com/vercel/ncc)
|
- Enable the hooks by setting `ACTIONS_RUNNER_CONTAINER_HOOKS=./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
|
- Configure your self hosted runner against the a repository you have admin access
|
||||||
- Run a workflow with a container job, for example
|
- Run a workflow with a container job, for example
|
||||||
```
|
```
|
||||||
|
|||||||
184
docs/adrs/0072-using-ephemeral-containers.md
Normal file
184
docs/adrs/0072-using-ephemeral-containers.md
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
# 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>
|
||||||
|
|
||||||
|

|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>2. Debugger -> Runner communication</summary>
|
||||||
|
|
||||||
|

|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>3. Debugger2 -> Debugger communication</summary>
|
||||||
|
|
||||||
|

|
||||||
|
</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>
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
</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
|
||||||
32
docs/adrs/0096-hook-extensions.md
Normal file
32
docs/adrs/0096-hook-extensions.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# 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)
Normal file
BIN
docs/adrs/images/debugger-runner.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
docs/adrs/images/debugger2-debugger.png
(Stored with Git LFS)
Normal file
BIN
docs/adrs/images/debugger2-debugger.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
docs/adrs/images/emptyDir_volume.png
(Stored with Git LFS)
Normal file
BIN
docs/adrs/images/emptyDir_volume.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
docs/adrs/images/runner-debugger.png
(Stored with Git LFS)
Normal file
BIN
docs/adrs/images/runner-debugger.png
(Stored with Git LFS)
Normal file
Binary file not shown.
3
examples/example-script.sh
Normal file
3
examples/example-script.sh
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
echo "Hello World"
|
||||||
30
examples/extension.yaml
Normal file
30
examples/extension.yaml
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
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
|
||||||
|
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
"args": {
|
"args": {
|
||||||
"container": {
|
"container": {
|
||||||
"image": "node:14.16",
|
"image": "node:14.16",
|
||||||
"workingDirectory": "/__w/thboop-test2/thboop-test2",
|
"workingDirectory": "/__w/repo/repo",
|
||||||
"createOptions": "--cpus 1",
|
"createOptions": "--cpus 1",
|
||||||
"environmentVariables": {
|
"environmentVariables": {
|
||||||
"NODE_ENV": "development"
|
"NODE_ENV": "development"
|
||||||
@@ -24,37 +24,37 @@
|
|||||||
"readOnly": false
|
"readOnly": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"sourceVolumePath": "//Users/thomas/git/runner/_layout/_work",
|
"sourceVolumePath": "/Users/thomas/git/runner/_layout/_work",
|
||||||
"targetVolumePath": "/__w",
|
"targetVolumePath": "/__w",
|
||||||
"readOnly": false
|
"readOnly": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"sourceVolumePath": "//Users/thomas/git/runner/_layout/externals",
|
"sourceVolumePath": "/Users/thomas/git/runner/_layout/externals",
|
||||||
"targetVolumePath": "/__e",
|
"targetVolumePath": "/__e",
|
||||||
"readOnly": true
|
"readOnly": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"sourceVolumePath": "//Users/thomas/git/runner/_layout/_work/_temp",
|
"sourceVolumePath": "/Users/thomas/git/runner/_layout/_work/_temp",
|
||||||
"targetVolumePath": "/__w/_temp",
|
"targetVolumePath": "/__w/_temp",
|
||||||
"readOnly": false
|
"readOnly": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"sourceVolumePath": "//Users/thomas/git/runner/_layout/_work/_actions",
|
"sourceVolumePath": "/Users/thomas/git/runner/_layout/_work/_actions",
|
||||||
"targetVolumePath": "/__w/_actions",
|
"targetVolumePath": "/__w/_actions",
|
||||||
"readOnly": false
|
"readOnly": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"sourceVolumePath": "//Users/thomas/git/runner/_layout/_work/_tool",
|
"sourceVolumePath": "/Users/thomas/git/runner/_layout/_work/_tool",
|
||||||
"targetVolumePath": "/__w/_tool",
|
"targetVolumePath": "/__w/_tool",
|
||||||
"readOnly": false
|
"readOnly": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"sourceVolumePath": "//Users/thomas/git/runner/_layout/_work/_temp/_github_home",
|
"sourceVolumePath": "/Users/thomas/git/runner/_layout/_work/_temp/_github_home",
|
||||||
"targetVolumePath": "/github/home",
|
"targetVolumePath": "/github/home",
|
||||||
"readOnly": false
|
"readOnly": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"sourceVolumePath": "//Users/thomas/git/runner/_layout/_work/_temp/_github_workflow",
|
"sourceVolumePath": "/Users/thomas/git/runner/_layout/_work/_temp/_github_workflow",
|
||||||
"targetVolumePath": "/github/workflow",
|
"targetVolumePath": "/github/workflow",
|
||||||
"readOnly": false
|
"readOnly": false
|
||||||
}
|
}
|
||||||
@@ -73,6 +73,8 @@
|
|||||||
"contextName": "redis",
|
"contextName": "redis",
|
||||||
"image": "redis",
|
"image": "redis",
|
||||||
"createOptions": "--cpus 1",
|
"createOptions": "--cpus 1",
|
||||||
|
"entrypoint": null,
|
||||||
|
"entryPointArgs": [],
|
||||||
"environmentVariables": {},
|
"environmentVariables": {},
|
||||||
"userMountVolumes": [
|
"userMountVolumes": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -12,11 +12,11 @@
|
|||||||
"image": "node:14.16",
|
"image": "node:14.16",
|
||||||
"dockerfile": null,
|
"dockerfile": null,
|
||||||
"entryPointArgs": [
|
"entryPointArgs": [
|
||||||
"-c",
|
"-e",
|
||||||
"echo \"hello world2\""
|
"example-script.sh"
|
||||||
],
|
],
|
||||||
"entryPoint": "bash",
|
"entryPoint": "bash",
|
||||||
"workingDirectory": "/__w/thboop-test2/thboop-test2",
|
"workingDirectory": "/__w/repo/repo",
|
||||||
"createOptions": "--cpus 1",
|
"createOptions": "--cpus 1",
|
||||||
"environmentVariables": {
|
"environmentVariables": {
|
||||||
"NODE_ENV": "development"
|
"NODE_ENV": "development"
|
||||||
@@ -34,27 +34,27 @@
|
|||||||
],
|
],
|
||||||
"systemMountVolumes": [
|
"systemMountVolumes": [
|
||||||
{
|
{
|
||||||
"sourceVolumePath": "//Users/thomas/git/runner/_layout/_work",
|
"sourceVolumePath": "/Users/thomas/git/runner/_layout/_work",
|
||||||
"targetVolumePath": "/__w",
|
"targetVolumePath": "/__w",
|
||||||
"readOnly": false
|
"readOnly": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"sourceVolumePath": "//Users/thomas/git/runner/_layout/externals",
|
"sourceVolumePath": "/Users/thomas/git/runner/_layout/externals",
|
||||||
"targetVolumePath": "/__e",
|
"targetVolumePath": "/__e",
|
||||||
"readOnly": true
|
"readOnly": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"sourceVolumePath": "//Users/thomas/git/runner/_layout/_work/_temp",
|
"sourceVolumePath": "/Users/thomas/git/runner/_layout/_work/_temp",
|
||||||
"targetVolumePath": "/__w/_temp",
|
"targetVolumePath": "/__w/_temp",
|
||||||
"readOnly": false
|
"readOnly": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"sourceVolumePath": "//Users/thomas/git/runner/_layout/_work/_actions",
|
"sourceVolumePath": "/Users/thomas/git/runner/_layout/_work/_actions",
|
||||||
"targetVolumePath": "/__w/_actions",
|
"targetVolumePath": "/__w/_actions",
|
||||||
"readOnly": false
|
"readOnly": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"sourceVolumePath": "//Users/thomas/git/runner/_layout/_work/_tool",
|
"sourceVolumePath": "/Users/thomas/git/runner/_layout/_work/_tool",
|
||||||
"targetVolumePath": "/__w/_tool",
|
"targetVolumePath": "/__w/_tool",
|
||||||
"readOnly": false
|
"readOnly": false
|
||||||
},
|
},
|
||||||
@@ -64,7 +64,7 @@
|
|||||||
"readOnly": false
|
"readOnly": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"sourceVolumePath": "//Users/thomas/git/runner/_layout/_work/_temp/_github_workflow",
|
"sourceVolumePath": "/Users/thomas/git/runner/_layout/_work/_temp/_github_workflow",
|
||||||
"targetVolumePath": "/github/workflow",
|
"targetVolumePath": "/github/workflow",
|
||||||
"readOnly": false
|
"readOnly": false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,8 @@
|
|||||||
},
|
},
|
||||||
"args": {
|
"args": {
|
||||||
"entryPointArgs": [
|
"entryPointArgs": [
|
||||||
"-c",
|
"-e",
|
||||||
"echo \"hello world\""
|
"example-script.sh"
|
||||||
],
|
],
|
||||||
"entryPoint": "bash",
|
"entryPoint": "bash",
|
||||||
"environmentVariables": {
|
"environmentVariables": {
|
||||||
@@ -21,6 +21,6 @@
|
|||||||
"/foo/bar",
|
"/foo/bar",
|
||||||
"bar/foo"
|
"bar/foo"
|
||||||
],
|
],
|
||||||
"workingDirectory": "/__w/thboop-test2/thboop-test2"
|
"workingDirectory": "/__w/repo/repo"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
28
package-lock.json
generated
28
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "hooks",
|
"name": "hooks",
|
||||||
"version": "0.1.0",
|
"version": "0.5.1",
|
||||||
"lockfileVersion": 2,
|
"lockfileVersion": 2,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "hooks",
|
"name": "hooks",
|
||||||
"version": "0.1.0",
|
"version": "0.5.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/jest": "^27.5.1",
|
"@types/jest": "^27.5.1",
|
||||||
@@ -1800,9 +1800,9 @@
|
|||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/json5": {
|
"node_modules/json5": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz",
|
||||||
"integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==",
|
"integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"minimist": "^1.2.0"
|
"minimist": "^1.2.0"
|
||||||
@@ -2625,9 +2625,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/word-wrap": {
|
"node_modules/word-wrap": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.4.tgz",
|
||||||
"integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==",
|
"integrity": "sha512-2V81OA4ugVo5pRo46hAoD2ivUJx8jXmWXfUkY4KFNw0hEptvN0QfH3K4nHiwzGeKl5rFKedV48QVoqYavy4YpA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -3926,9 +3926,9 @@
|
|||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"json5": {
|
"json5": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz",
|
||||||
"integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==",
|
"integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"requires": {
|
"requires": {
|
||||||
"minimist": "^1.2.0"
|
"minimist": "^1.2.0"
|
||||||
@@ -4509,9 +4509,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"word-wrap": {
|
"word-wrap": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.4.tgz",
|
||||||
"integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==",
|
"integrity": "sha512-2V81OA4ugVo5pRo46hAoD2ivUJx8jXmWXfUkY4KFNw0hEptvN0QfH3K4nHiwzGeKl5rFKedV48QVoqYavy4YpA==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"wrappy": {
|
"wrappy": {
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
{
|
{
|
||||||
"name": "hooks",
|
"name": "hooks",
|
||||||
"version": "0.1.0",
|
"version": "0.5.1",
|
||||||
"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",
|
"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": "",
|
"main": "",
|
||||||
"directories": {
|
"directories": {
|
||||||
"doc": "docs"
|
"doc": "docs"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "npm run test --prefix packages/docker",
|
"test": "npm run test --prefix packages/docker && npm run test --prefix packages/k8s",
|
||||||
"bootstrap": "npm install --prefix packages/hooklib && npm install --prefix packages/k8s && npm install --prefix packages/docker",
|
"bootstrap": "npm install --prefix packages/hooklib && npm install --prefix packages/k8s && npm install --prefix packages/docker",
|
||||||
"format": "prettier --write '**/*.ts'",
|
"format": "prettier --write '**/*.ts'",
|
||||||
"format-check": "prettier --check '**/*.ts'",
|
"format-check": "prettier --check '**/*.ts'",
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
jest.setTimeout(90000)
|
jest.setTimeout(500000)
|
||||||
|
|||||||
764
packages/docker/package-lock.json
generated
764
packages/docker/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -10,9 +10,10 @@
|
|||||||
"author": "",
|
"author": "",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/core": "^1.6.0",
|
"@actions/core": "^1.9.1",
|
||||||
"@actions/exec": "^1.1.1",
|
"@actions/exec": "^1.1.1",
|
||||||
"hooklib": "file:../hooklib",
|
"hooklib": "file:../hooklib",
|
||||||
|
"shlex": "^2.1.2",
|
||||||
"uuid": "^8.3.2"
|
"uuid": "^8.3.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -2,12 +2,11 @@ import * as core from '@actions/core'
|
|||||||
import * as fs from 'fs'
|
import * as fs from 'fs'
|
||||||
import {
|
import {
|
||||||
ContainerInfo,
|
ContainerInfo,
|
||||||
JobContainerInfo,
|
Registry,
|
||||||
RunContainerStepArgs,
|
RunContainerStepArgs,
|
||||||
ServiceContainerInfo,
|
ServiceContainerInfo
|
||||||
StepContainerInfo
|
|
||||||
} from 'hooklib/lib'
|
} from 'hooklib/lib'
|
||||||
import path from 'path'
|
import * as path from 'path'
|
||||||
import { env } from 'process'
|
import { env } from 'process'
|
||||||
import { v4 as uuidv4 } from 'uuid'
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
import { runDockerCommand, RunDockerCommandOptions } from '../utils'
|
import { runDockerCommand, RunDockerCommandOptions } from '../utils'
|
||||||
@@ -43,19 +42,15 @@ export async function createContainer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (args.environmentVariables) {
|
if (args.environmentVariables) {
|
||||||
for (const [key, value] of Object.entries(args.environmentVariables)) {
|
for (const [key] of Object.entries(args.environmentVariables)) {
|
||||||
dockerArgs.push('-e')
|
dockerArgs.push('-e')
|
||||||
if (!value) {
|
dockerArgs.push(key)
|
||||||
dockerArgs.push(`"${key}"`)
|
|
||||||
} else {
|
|
||||||
dockerArgs.push(`"${key}=${value}"`)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const mountVolumes = [
|
const mountVolumes = [
|
||||||
...(args.userMountVolumes || []),
|
...(args.userMountVolumes || []),
|
||||||
...((args as JobContainerInfo | StepContainerInfo).systemMountVolumes || [])
|
...(args.systemMountVolumes || [])
|
||||||
]
|
]
|
||||||
for (const mountVolume of mountVolumes) {
|
for (const mountVolume of mountVolumes) {
|
||||||
dockerArgs.push(
|
dockerArgs.push(
|
||||||
@@ -74,7 +69,9 @@ export async function createContainer(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const id = (await runDockerCommand(dockerArgs)).trim()
|
const id = (
|
||||||
|
await runDockerCommand(dockerArgs, { env: args.environmentVariables })
|
||||||
|
).trim()
|
||||||
if (!id) {
|
if (!id) {
|
||||||
throw new Error('Could not read id from docker command')
|
throw new Error('Could not read id from docker command')
|
||||||
}
|
}
|
||||||
@@ -94,11 +91,12 @@ export async function containerPull(
|
|||||||
image: string,
|
image: string,
|
||||||
configLocation: string
|
configLocation: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const dockerArgs: string[] = ['pull']
|
const dockerArgs: string[] = []
|
||||||
if (configLocation) {
|
if (configLocation) {
|
||||||
dockerArgs.push('--config')
|
dockerArgs.push('--config')
|
||||||
dockerArgs.push(configLocation)
|
dockerArgs.push(configLocation)
|
||||||
}
|
}
|
||||||
|
dockerArgs.push('pull')
|
||||||
dockerArgs.push(image)
|
dockerArgs.push(image)
|
||||||
for (let i = 0; i < 3; i++) {
|
for (let i = 0; i < 3; i++) {
|
||||||
try {
|
try {
|
||||||
@@ -146,17 +144,41 @@ export async function containerBuild(
|
|||||||
args: RunContainerStepArgs,
|
args: RunContainerStepArgs,
|
||||||
tag: string
|
tag: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const context = path.dirname(`${env.GITHUB_WORKSPACE}/${args.dockerfile}`)
|
if (!args.dockerfile) {
|
||||||
|
throw new Error("Container build expects 'args.dockerfile' to be set")
|
||||||
|
}
|
||||||
|
|
||||||
const dockerArgs: string[] = ['build']
|
const dockerArgs: string[] = ['build']
|
||||||
dockerArgs.push('-t', tag)
|
dockerArgs.push('-t', tag)
|
||||||
dockerArgs.push('-f', `${env.GITHUB_WORKSPACE}/${args.dockerfile}`)
|
dockerArgs.push('-f', args.dockerfile)
|
||||||
dockerArgs.push(context)
|
dockerArgs.push(getBuildContext(args.dockerfile))
|
||||||
// TODO: figure out build working directory
|
|
||||||
await runDockerCommand(dockerArgs, {
|
await runDockerCommand(dockerArgs, {
|
||||||
workingDir: args['buildWorkingDirectory']
|
workingDir: getWorkingDir(args.dockerfile)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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> {
|
export async function containerLogs(id: string): Promise<void> {
|
||||||
const dockerArgs: string[] = ['logs']
|
const dockerArgs: string[] = ['logs']
|
||||||
dockerArgs.push('--details')
|
dockerArgs.push('--details')
|
||||||
@@ -171,6 +193,18 @@ export async function containerNetworkRemove(network: string): Promise<void> {
|
|||||||
await runDockerCommand(dockerArgs)
|
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> {
|
export async function containerPrune(): Promise<void> {
|
||||||
const dockerPSArgs: string[] = [
|
const dockerPSArgs: string[] = [
|
||||||
'ps',
|
'ps',
|
||||||
@@ -238,22 +272,36 @@ export async function healthCheck({
|
|||||||
export async function containerPorts(id: string): Promise<string[]> {
|
export async function containerPorts(id: string): Promise<string[]> {
|
||||||
const dockerArgs = ['port', id]
|
const dockerArgs = ['port', id]
|
||||||
const portMappings = (await runDockerCommand(dockerArgs)).trim()
|
const portMappings = (await runDockerCommand(dockerArgs)).trim()
|
||||||
return portMappings.split('\n')
|
return portMappings.split('\n').filter(p => !!p)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function registryLogin(args): Promise<string> {
|
export async function getContainerEnvValue(
|
||||||
if (!args.registry) {
|
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) {
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
const credentials = {
|
const credentials = {
|
||||||
username: args.registry.username,
|
username: registry.username,
|
||||||
password: args.registry.password
|
password: registry.password
|
||||||
}
|
}
|
||||||
|
|
||||||
const configLocation = `${env.RUNNER_TEMP}/.docker_${uuidv4()}`
|
const configLocation = `${env.RUNNER_TEMP}/.docker_${uuidv4()}`
|
||||||
fs.mkdirSync(configLocation)
|
fs.mkdirSync(configLocation)
|
||||||
try {
|
try {
|
||||||
await dockerLogin(configLocation, args.registry.serverUrl, credentials)
|
await dockerLogin(configLocation, registry.serverUrl, credentials)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
fs.rmdirSync(configLocation, { recursive: true })
|
fs.rmdirSync(configLocation, { recursive: true })
|
||||||
throw error
|
throw error
|
||||||
@@ -271,7 +319,7 @@ export async function registryLogout(configLocation: string): Promise<void> {
|
|||||||
async function dockerLogin(
|
async function dockerLogin(
|
||||||
configLocation: string,
|
configLocation: string,
|
||||||
registry: string,
|
registry: string,
|
||||||
credentials: { username: string; password: string }
|
credentials: { username?: string; password?: string }
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const credentialsArgs =
|
const credentialsArgs =
|
||||||
credentials.username && credentials.password
|
credentials.username && credentials.password
|
||||||
@@ -307,30 +355,36 @@ export async function containerExecStep(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const dockerArgs: string[] = ['exec', '-i']
|
const dockerArgs: string[] = ['exec', '-i']
|
||||||
dockerArgs.push(`--workdir=${args.workingDirectory}`)
|
dockerArgs.push(`--workdir=${args.workingDirectory}`)
|
||||||
for (const [key, value] of Object.entries(args['environmentVariables'])) {
|
for (const [key] of Object.entries(args['environmentVariables'])) {
|
||||||
dockerArgs.push('-e')
|
dockerArgs.push('-e')
|
||||||
if (!value) {
|
dockerArgs.push(key)
|
||||||
dockerArgs.push(`"${key}"`)
|
|
||||||
} else {
|
|
||||||
dockerArgs.push(`"${key}=${value}"`)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Todo figure out prepend path and update it here
|
if (args.prependPath?.length) {
|
||||||
// (we need to pass path in as -e Path={fullpath}) where {fullpath is the prepend path added to the current containers path}
|
// 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')}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
dockerArgs.push(containerId)
|
dockerArgs.push(containerId)
|
||||||
dockerArgs.push(args.entryPoint)
|
dockerArgs.push(args.entryPoint)
|
||||||
for (const entryPointArg of args.entryPointArgs) {
|
for (const entryPointArg of args.entryPointArgs) {
|
||||||
dockerArgs.push(entryPointArg)
|
dockerArgs.push(entryPointArg)
|
||||||
}
|
}
|
||||||
await runDockerCommand(dockerArgs)
|
await runDockerCommand(dockerArgs, { env: args.environmentVariables })
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function containerRun(
|
export async function containerRun(
|
||||||
args: RunContainerStepArgs,
|
args: RunContainerStepArgs,
|
||||||
name: string,
|
name: string,
|
||||||
network: string
|
network?: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!args.image) {
|
if (!args.image) {
|
||||||
throw new Error('expected image to be set')
|
throw new Error('expected image to be set')
|
||||||
@@ -340,15 +394,15 @@ export async function containerRun(
|
|||||||
dockerArgs.push('--name', name)
|
dockerArgs.push('--name', name)
|
||||||
dockerArgs.push(`--workdir=${args.workingDirectory}`)
|
dockerArgs.push(`--workdir=${args.workingDirectory}`)
|
||||||
dockerArgs.push(`--label=${getRunnerLabel()}`)
|
dockerArgs.push(`--label=${getRunnerLabel()}`)
|
||||||
|
if (network) {
|
||||||
dockerArgs.push(`--network=${network}`)
|
dockerArgs.push(`--network=${network}`)
|
||||||
|
}
|
||||||
|
|
||||||
if (args.createOptions) {
|
if (args.createOptions) {
|
||||||
dockerArgs.push(...args.createOptions.split(' '))
|
dockerArgs.push(...args.createOptions.split(' '))
|
||||||
}
|
}
|
||||||
if (args.environmentVariables) {
|
if (args.environmentVariables) {
|
||||||
for (const [key, value] of Object.entries(args.environmentVariables)) {
|
for (const [key] of Object.entries(args.environmentVariables)) {
|
||||||
// Pass in this way to avoid printing secrets
|
|
||||||
env[key] = value ?? undefined
|
|
||||||
dockerArgs.push('-e')
|
dockerArgs.push('-e')
|
||||||
dockerArgs.push(key)
|
dockerArgs.push(key)
|
||||||
}
|
}
|
||||||
@@ -374,11 +428,14 @@ export async function containerRun(
|
|||||||
dockerArgs.push(args.image)
|
dockerArgs.push(args.image)
|
||||||
if (args.entryPointArgs) {
|
if (args.entryPointArgs) {
|
||||||
for (const entryPointArg of args.entryPointArgs) {
|
for (const entryPointArg of args.entryPointArgs) {
|
||||||
|
if (!entryPointArg) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
dockerArgs.push(entryPointArg)
|
dockerArgs.push(entryPointArg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await runDockerCommand(dockerArgs)
|
await runDockerCommand(dockerArgs, { env: args.environmentVariables })
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function isContainerAlpine(containerId: string): Promise<boolean> {
|
export async function isContainerAlpine(containerId: string): Promise<boolean> {
|
||||||
@@ -387,7 +444,7 @@ export async function isContainerAlpine(containerId: string): Promise<boolean> {
|
|||||||
containerId,
|
containerId,
|
||||||
'sh',
|
'sh',
|
||||||
'-c',
|
'-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 {
|
try {
|
||||||
await runDockerCommand(dockerArgs)
|
await runDockerCommand(dockerArgs)
|
||||||
|
|||||||
@@ -1,21 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
containerRemove,
|
containerNetworkPrune,
|
||||||
containerNetworkRemove
|
containerPrune
|
||||||
} from '../dockerCommands/container'
|
} from '../dockerCommands/container'
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
export async function cleanupJob(): Promise<void> {
|
||||||
export async function cleanupJob(args, state, responseFile): Promise<void> {
|
await containerPrune()
|
||||||
const containerIds: string[] = []
|
await containerNetworkPrune()
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export async function prepareJob(
|
|||||||
if (!container?.image) {
|
if (!container?.image) {
|
||||||
core.info('No job container provided, skipping')
|
core.info('No job container provided, skipping')
|
||||||
} else {
|
} else {
|
||||||
setupContainer(container)
|
setupContainer(container, true)
|
||||||
|
|
||||||
const configLocation = await registryLogin(container.registry)
|
const configLocation = await registryLogin(container.registry)
|
||||||
try {
|
try {
|
||||||
@@ -48,6 +48,7 @@ export async function prepareJob(
|
|||||||
} finally {
|
} finally {
|
||||||
await registryLogout(configLocation)
|
await registryLogout(configLocation)
|
||||||
}
|
}
|
||||||
|
|
||||||
containerMetadata = await createContainer(
|
containerMetadata = await createContainer(
|
||||||
container,
|
container,
|
||||||
generateContainerName(container.image),
|
generateContainerName(container.image),
|
||||||
@@ -78,6 +79,7 @@ export async function prepareJob(
|
|||||||
generateContainerName(service.image),
|
generateContainerName(service.image),
|
||||||
networkName
|
networkName
|
||||||
)
|
)
|
||||||
|
|
||||||
servicesMetadata.push(response)
|
servicesMetadata.push(response)
|
||||||
await containerStart(response.id)
|
await containerStart(response.id)
|
||||||
}
|
}
|
||||||
@@ -94,7 +96,10 @@ export async function prepareJob(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const isAlpine = await isContainerAlpine(containerMetadata!.id)
|
let isAlpine = false
|
||||||
|
if (containerMetadata?.id) {
|
||||||
|
isAlpine = await isContainerAlpine(containerMetadata.id)
|
||||||
|
}
|
||||||
|
|
||||||
if (containerMetadata?.id) {
|
if (containerMetadata?.id) {
|
||||||
containerMetadata.ports = await containerPorts(containerMetadata.id)
|
containerMetadata.ports = await containerPorts(containerMetadata.id)
|
||||||
@@ -105,7 +110,10 @@ export async function prepareJob(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const healthChecks: Promise<void>[] = [healthCheck(containerMetadata!)]
|
const healthChecks: Promise<void>[] = []
|
||||||
|
if (containerMetadata) {
|
||||||
|
healthChecks.push(healthCheck(containerMetadata))
|
||||||
|
}
|
||||||
for (const service of servicesMetadata) {
|
for (const service of servicesMetadata) {
|
||||||
healthChecks.push(healthCheck(service))
|
healthChecks.push(healthCheck(service))
|
||||||
}
|
}
|
||||||
@@ -133,7 +141,6 @@ function generateResponseFile(
|
|||||||
servicesMetadata?: ContainerMetadata[],
|
servicesMetadata?: ContainerMetadata[],
|
||||||
isAlpine = false
|
isAlpine = false
|
||||||
): void {
|
): void {
|
||||||
// todo figure out if we are alpine
|
|
||||||
const response = {
|
const response = {
|
||||||
state: { network: networkName },
|
state: { network: networkName },
|
||||||
context: {},
|
context: {},
|
||||||
@@ -167,10 +174,12 @@ function generateResponseFile(
|
|||||||
writeToResponseFile(responseFile, JSON.stringify(response))
|
writeToResponseFile(responseFile, JSON.stringify(response))
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupContainer(container): void {
|
function setupContainer(container, jobContainer = false): void {
|
||||||
|
if (!container.entryPoint && jobContainer) {
|
||||||
container.entryPointArgs = [`-f`, `/dev/null`]
|
container.entryPointArgs = [`-f`, `/dev/null`]
|
||||||
container.entryPoint = 'tail'
|
container.entryPoint = 'tail'
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function generateNetworkName(): string {
|
function generateNetworkName(): string {
|
||||||
return `github_network_${uuidv4()}`
|
return `github_network_${uuidv4()}`
|
||||||
@@ -186,15 +195,15 @@ function transformDockerPortsToContextPorts(
|
|||||||
meta: ContainerMetadata
|
meta: ContainerMetadata
|
||||||
): ContextPorts {
|
): ContextPorts {
|
||||||
// ex: '80/tcp -> 0.0.0.0:80'
|
// ex: '80/tcp -> 0.0.0.0:80'
|
||||||
const re = /^(\d+)\/(\w+)? -> (.*):(\d+)$/
|
const re = /^(\d+)(\/\w+)? -> (.*):(\d+)$/
|
||||||
const contextPorts: ContextPorts = {}
|
const contextPorts: ContextPorts = {}
|
||||||
|
|
||||||
if (meta.ports) {
|
if (meta.ports?.length) {
|
||||||
for (const port of meta.ports) {
|
for (const port of meta.ports) {
|
||||||
const matches = port.match(re)
|
const matches = port.match(re)
|
||||||
if (!matches) {
|
if (!matches) {
|
||||||
throw new Error(
|
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]
|
contextPorts[matches[1]] = matches[matches.length - 1]
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
|
import { RunContainerStepArgs } from 'hooklib/lib'
|
||||||
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
import {
|
import {
|
||||||
containerBuild,
|
containerBuild,
|
||||||
registryLogin,
|
|
||||||
registryLogout,
|
|
||||||
containerPull,
|
containerPull,
|
||||||
containerRun
|
containerRun,
|
||||||
|
registryLogin,
|
||||||
|
registryLogout
|
||||||
} from '../dockerCommands'
|
} from '../dockerCommands'
|
||||||
import { v4 as uuidv4 } from 'uuid'
|
|
||||||
import * as core from '@actions/core'
|
|
||||||
import { RunContainerStepArgs } from 'hooklib/lib'
|
|
||||||
import { getRunnerLabel } from '../dockerCommands/constants'
|
import { getRunnerLabel } from '../dockerCommands/constants'
|
||||||
|
|
||||||
export async function runContainerStep(
|
export async function runContainerStep(
|
||||||
@@ -15,23 +14,23 @@ export async function runContainerStep(
|
|||||||
state
|
state
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const tag = generateBuildTag() // for docker build
|
const tag = generateBuildTag() // for docker build
|
||||||
if (!args.image) {
|
if (args.image) {
|
||||||
core.error('expected an image')
|
const configLocation = await registryLogin(args.registry)
|
||||||
} else {
|
|
||||||
if (args.dockerfile) {
|
|
||||||
await containerBuild(args, tag)
|
|
||||||
args.image = tag
|
|
||||||
} else {
|
|
||||||
const configLocation = await registryLogin(args)
|
|
||||||
try {
|
try {
|
||||||
await containerPull(args.image, configLocation)
|
await containerPull(args.image, configLocation)
|
||||||
} finally {
|
} finally {
|
||||||
await registryLogout(configLocation)
|
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
|
// 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 {
|
function generateBuildTag(): string {
|
||||||
|
|||||||
@@ -13,22 +13,23 @@ import {
|
|||||||
runContainerStep,
|
runContainerStep,
|
||||||
runScriptStep
|
runScriptStep
|
||||||
} from './hooks'
|
} from './hooks'
|
||||||
|
import { checkEnvironment } from './utils'
|
||||||
|
|
||||||
async function run(): Promise<void> {
|
async function run(): Promise<void> {
|
||||||
|
try {
|
||||||
|
checkEnvironment()
|
||||||
const input = await getInputFromStdin()
|
const input = await getInputFromStdin()
|
||||||
|
|
||||||
const args = input['args']
|
const args = input['args']
|
||||||
const command = input['command']
|
const command = input['command']
|
||||||
const responseFile = input['responseFile']
|
const responseFile = input['responseFile']
|
||||||
const state = input['state']
|
const state = input['state']
|
||||||
|
|
||||||
try {
|
|
||||||
switch (command) {
|
switch (command) {
|
||||||
case Command.PrepareJob:
|
case Command.PrepareJob:
|
||||||
await prepareJob(args as PrepareJobArgs, responseFile)
|
await prepareJob(args as PrepareJobArgs, responseFile)
|
||||||
return exit(0)
|
return exit(0)
|
||||||
case Command.CleanupJob:
|
case Command.CleanupJob:
|
||||||
await cleanupJob(null, state, null)
|
await cleanupJob()
|
||||||
return exit(0)
|
return exit(0)
|
||||||
case Command.RunScriptStep:
|
case Command.RunScriptStep:
|
||||||
await runScriptStep(args as RunScriptStepArgs, state)
|
await runScriptStep(args as RunScriptStepArgs, state)
|
||||||
|
|||||||
@@ -2,18 +2,23 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||||
/* eslint-disable import/no-commonjs */
|
/* eslint-disable import/no-commonjs */
|
||||||
import * as core from '@actions/core'
|
import * as core from '@actions/core'
|
||||||
|
import { env } from 'process'
|
||||||
// Import this way otherwise typescript has errors
|
// Import this way otherwise typescript has errors
|
||||||
const exec = require('@actions/exec')
|
const exec = require('@actions/exec')
|
||||||
|
const shlex = require('shlex')
|
||||||
|
|
||||||
export interface RunDockerCommandOptions {
|
export interface RunDockerCommandOptions {
|
||||||
workingDir?: string
|
workingDir?: string
|
||||||
input?: Buffer
|
input?: Buffer
|
||||||
|
env?: { [key: string]: string }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runDockerCommand(
|
export async function runDockerCommand(
|
||||||
args: string[],
|
args: string[],
|
||||||
options?: RunDockerCommandOptions
|
options?: RunDockerCommandOptions
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
|
options = optionsWithDockerEnvs(options)
|
||||||
|
args = fixArgs(args)
|
||||||
const pipes = await exec.getExecOutput('docker', args, options)
|
const pipes = await exec.getExecOutput('docker', args, options)
|
||||||
if (pipes.exitCode !== 0) {
|
if (pipes.exitCode !== 0) {
|
||||||
core.error(`Docker failed with exit code ${pipes.exitCode}`)
|
core.error(`Docker failed with exit code ${pipes.exitCode}`)
|
||||||
@@ -22,6 +27,45 @@ export async function runDockerCommand(
|
|||||||
return Promise.resolve(pipes.stdout)
|
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 {
|
export function sanitize(val: string): string {
|
||||||
if (!val || typeof val !== 'string') {
|
if (!val || typeof val !== 'string') {
|
||||||
return ''
|
return ''
|
||||||
@@ -42,6 +86,16 @@ export function sanitize(val: string): string {
|
|||||||
return newNameBuilder.join('')
|
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
|
// isAlpha accepts single character and checks if
|
||||||
// that character is [a-zA-Z]
|
// that character is [a-zA-Z]
|
||||||
function isAlpha(val: string): boolean {
|
function isAlpha(val: string): boolean {
|
||||||
|
|||||||
@@ -1,62 +1,33 @@
|
|||||||
import { prepareJob, cleanupJob } from '../src/hooks'
|
import { PrepareJobArgs } from 'hooklib/lib'
|
||||||
import { v4 as uuidv4 } from 'uuid'
|
import { cleanupJob, prepareJob } from '../src/hooks'
|
||||||
import * as fs from 'fs'
|
|
||||||
import * as path from 'path'
|
|
||||||
import TestSetup from './test-setup'
|
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
|
let testSetup: TestSetup
|
||||||
|
|
||||||
jest.useRealTimers()
|
jest.useRealTimers()
|
||||||
|
|
||||||
describe('cleanup job', () => {
|
describe('cleanup job', () => {
|
||||||
beforeAll(() => {
|
|
||||||
fs.mkdirSync(tmpOutputDir, { recursive: true })
|
|
||||||
})
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
fs.rmSync(tmpOutputDir, { recursive: true })
|
|
||||||
})
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
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 = new TestSetup()
|
||||||
testSetup.initialize()
|
testSetup.initialize()
|
||||||
|
|
||||||
prepareJobData.args.container.userMountVolumes = testSetup.userMountVolumes
|
const prepareJobDefinition = testSetup.getPrepareJobDefinition()
|
||||||
prepareJobData.args.container.systemMountVolumes =
|
|
||||||
testSetup.systemMountVolumes
|
|
||||||
prepareJobData.args.container.workingDirectory = testSetup.workingDirectory
|
|
||||||
|
|
||||||
await prepareJob(prepareJobData.args, prepareJobOutputPath)
|
const prepareJobOutput = testSetup.createOutputFile(
|
||||||
|
'prepare-job-output.json'
|
||||||
|
)
|
||||||
|
|
||||||
|
await prepareJob(
|
||||||
|
prepareJobDefinition.args as PrepareJobArgs,
|
||||||
|
prepareJobOutput
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
fs.rmSync(prepareJobOutputPath, { force: true })
|
|
||||||
testSetup.teardown()
|
testSetup.teardown()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should cleanup successfully', async () => {
|
it('should cleanup successfully', async () => {
|
||||||
const prepareJobOutputContent = fs.readFileSync(
|
await expect(cleanupJob()).resolves.not.toThrow()
|
||||||
prepareJobOutputPath,
|
|
||||||
'utf-8'
|
|
||||||
)
|
|
||||||
const parsedPrepareJobOutput = JSON.parse(prepareJobOutputContent)
|
|
||||||
await expect(
|
|
||||||
cleanupJob(prepareJobData.args, parsedPrepareJobOutput.state, null)
|
|
||||||
).resolves.not.toThrow()
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
27
packages/docker/tests/container-build-test.ts
Normal file
27
packages/docker/tests/container-build-test.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -4,7 +4,7 @@ jest.useRealTimers()
|
|||||||
|
|
||||||
describe('container pull', () => {
|
describe('container pull', () => {
|
||||||
it('should fail', async () => {
|
it('should fail', async () => {
|
||||||
const arg = { image: 'doesNotExist' }
|
const arg = { image: 'does-not-exist' }
|
||||||
await expect(containerPull(arg.image, '')).rejects.toThrow()
|
await expect(containerPull(arg.image, '')).rejects.toThrow()
|
||||||
})
|
})
|
||||||
it('should succeed', async () => {
|
it('should succeed', async () => {
|
||||||
|
|||||||
@@ -1,102 +1,72 @@
|
|||||||
import {
|
|
||||||
prepareJob,
|
|
||||||
cleanupJob,
|
|
||||||
runScriptStep,
|
|
||||||
runContainerStep
|
|
||||||
} from '../src/hooks'
|
|
||||||
import * as fs from 'fs'
|
import * as fs from 'fs'
|
||||||
import * as path from 'path'
|
import {
|
||||||
import { v4 as uuidv4 } from 'uuid'
|
cleanupJob,
|
||||||
|
prepareJob,
|
||||||
|
runContainerStep,
|
||||||
|
runScriptStep
|
||||||
|
} from '../src/hooks'
|
||||||
import TestSetup from './test-setup'
|
import TestSetup from './test-setup'
|
||||||
|
|
||||||
const prepareJobJson = fs.readFileSync(
|
let definitions
|
||||||
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
|
let testSetup: TestSetup
|
||||||
|
|
||||||
describe('e2e', () => {
|
describe('e2e', () => {
|
||||||
beforeAll(() => {
|
|
||||||
fs.mkdirSync(tmpOutputDir, { recursive: true })
|
|
||||||
})
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
fs.rmSync(tmpOutputDir, { recursive: true })
|
|
||||||
})
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// init dirs
|
|
||||||
testSetup = new TestSetup()
|
testSetup = new TestSetup()
|
||||||
testSetup.initialize()
|
testSetup.initialize()
|
||||||
|
|
||||||
prepareJobData = JSON.parse(prepareJobJson)
|
definitions = {
|
||||||
prepareJobData.args.container.userMountVolumes = testSetup.userMountVolumes
|
prepareJob: testSetup.getPrepareJobDefinition(),
|
||||||
prepareJobData.args.container.systemMountVolumes =
|
runScriptStep: testSetup.getRunScriptStepDefinition(),
|
||||||
testSetup.systemMountVolumes
|
runContainerStep: testSetup.getRunContainerStepDefinition()
|
||||||
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(() => {
|
afterEach(() => {
|
||||||
fs.rmSync(prepareJobOutputFilePath, { force: true })
|
|
||||||
testSetup.teardown()
|
testSetup.teardown()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should prepare job, then run script step, then run container step then cleanup', async () => {
|
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(
|
await expect(
|
||||||
prepareJob(prepareJobData.args, prepareJobOutputFilePath)
|
prepareJob(definitions.prepareJob.args, prepareJobOutput)
|
||||||
).resolves.not.toThrow()
|
).resolves.not.toThrow()
|
||||||
let rawState = fs.readFileSync(prepareJobOutputFilePath, 'utf-8')
|
|
||||||
|
let rawState = fs.readFileSync(prepareJobOutput, 'utf-8')
|
||||||
let resp = JSON.parse(rawState)
|
let resp = JSON.parse(rawState)
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
runScriptStep(scriptStepData.args, resp.state)
|
runScriptStep(definitions.runScriptStep.args, resp.state)
|
||||||
).resolves.not.toThrow()
|
).resolves.not.toThrow()
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
runContainerStep(containerStepData.args, resp.state)
|
runContainerStep(definitions.runContainerStep.args, resp.state)
|
||||||
).resolves.not.toThrow()
|
).resolves.not.toThrow()
|
||||||
await expect(cleanupJob(resp, resp.state, null)).resolves.not.toThrow()
|
|
||||||
|
await expect(cleanupJob()).resolves.not.toThrow()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should prepare job, then run script step, then run container step with Dockerfile then cleanup', async () => {
|
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(
|
await expect(
|
||||||
prepareJob(prepareJobData.args, prepareJobOutputFilePath)
|
prepareJob(definitions.prepareJob.args, prepareJobOutput)
|
||||||
).resolves.not.toThrow()
|
|
||||||
let rawState = fs.readFileSync(prepareJobOutputFilePath, 'utf-8')
|
|
||||||
let resp = JSON.parse(rawState)
|
|
||||||
await expect(
|
|
||||||
runScriptStep(scriptStepData.args, resp.state)
|
|
||||||
).resolves.not.toThrow()
|
).resolves.not.toThrow()
|
||||||
|
|
||||||
const dockerfilePath = `${tmpOutputDir}/Dockerfile`
|
let rawState = fs.readFileSync(prepareJobOutput, 'utf-8')
|
||||||
|
let resp = JSON.parse(rawState)
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
runScriptStep(definitions.runScriptStep.args, resp.state)
|
||||||
|
).resolves.not.toThrow()
|
||||||
|
|
||||||
|
const dockerfilePath = `${testSetup.workingDirectory}/Dockerfile`
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
dockerfilePath,
|
dockerfilePath,
|
||||||
`FROM ubuntu:latest
|
`FROM ubuntu:latest
|
||||||
@@ -104,14 +74,17 @@ ENV TEST=test
|
|||||||
ENTRYPOINT [ "tail", "-f", "/dev/null" ]
|
ENTRYPOINT [ "tail", "-f", "/dev/null" ]
|
||||||
`
|
`
|
||||||
)
|
)
|
||||||
const containerStepDataCopy = JSON.parse(JSON.stringify(containerStepData))
|
|
||||||
process.env.GITHUB_WORKSPACE = tmpOutputDir
|
const containerStepDataCopy = JSON.parse(
|
||||||
|
JSON.stringify(definitions.runContainerStep)
|
||||||
|
)
|
||||||
|
|
||||||
containerStepDataCopy.args.dockerfile = 'Dockerfile'
|
containerStepDataCopy.args.dockerfile = 'Dockerfile'
|
||||||
containerStepDataCopy.args.context = '.'
|
|
||||||
console.log(containerStepDataCopy.args)
|
|
||||||
await expect(
|
await expect(
|
||||||
runContainerStep(containerStepDataCopy.args, resp.state)
|
runContainerStep(containerStepDataCopy.args, resp.state)
|
||||||
).resolves.not.toThrow()
|
).resolves.not.toThrow()
|
||||||
await expect(cleanupJob(resp, resp.state, null)).resolves.not.toThrow()
|
|
||||||
|
await expect(cleanupJob()).resolves.not.toThrow()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,40 +1,18 @@
|
|||||||
import * as fs from 'fs'
|
import * as fs from 'fs'
|
||||||
import { v4 as uuidv4 } from 'uuid'
|
|
||||||
import { prepareJob } from '../src/hooks'
|
import { prepareJob } from '../src/hooks'
|
||||||
import TestSetup from './test-setup'
|
import TestSetup from './test-setup'
|
||||||
|
|
||||||
jest.useRealTimers()
|
jest.useRealTimers()
|
||||||
|
|
||||||
let prepareJobOutputPath: string
|
let prepareJobDefinition
|
||||||
let prepareJobData: any
|
|
||||||
const tmpOutputDir = `${__dirname}/_temp/${uuidv4()}`
|
|
||||||
const prepareJobInputPath = `${__dirname}/../../../examples/prepare-job.json`
|
|
||||||
|
|
||||||
let testSetup: TestSetup
|
let testSetup: TestSetup
|
||||||
|
|
||||||
describe('prepare job', () => {
|
describe('prepare job', () => {
|
||||||
beforeAll(() => {
|
beforeEach(() => {
|
||||||
fs.mkdirSync(tmpOutputDir, { recursive: true })
|
|
||||||
})
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
fs.rmSync(tmpOutputDir, { recursive: true })
|
|
||||||
})
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
testSetup = new TestSetup()
|
testSetup = new TestSetup()
|
||||||
testSetup.initialize()
|
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(() => {
|
afterEach(() => {
|
||||||
@@ -42,38 +20,68 @@ describe('prepare job', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should not throw', async () => {
|
it('should not throw', async () => {
|
||||||
|
const prepareJobOutput = testSetup.createOutputFile(
|
||||||
|
'prepare-job-output.json'
|
||||||
|
)
|
||||||
await expect(
|
await expect(
|
||||||
prepareJob(prepareJobData.args, prepareJobOutputPath)
|
prepareJob(prepareJobDefinition.args, prepareJobOutput)
|
||||||
).resolves.not.toThrow()
|
).resolves.not.toThrow()
|
||||||
|
|
||||||
expect(() => fs.readFileSync(prepareJobOutputPath, 'utf-8')).not.toThrow()
|
expect(() => fs.readFileSync(prepareJobOutput, 'utf-8')).not.toThrow()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should have JSON output written to a file', async () => {
|
it('should have JSON output written to a file', async () => {
|
||||||
await prepareJob(prepareJobData.args, prepareJobOutputPath)
|
const prepareJobOutput = testSetup.createOutputFile(
|
||||||
const prepareJobOutputContent = fs.readFileSync(
|
'prepare-job-output.json'
|
||||||
prepareJobOutputPath,
|
|
||||||
'utf-8'
|
|
||||||
)
|
)
|
||||||
|
await prepareJob(prepareJobDefinition.args, prepareJobOutput)
|
||||||
|
const prepareJobOutputContent = fs.readFileSync(prepareJobOutput, 'utf-8')
|
||||||
expect(() => JSON.parse(prepareJobOutputContent)).not.toThrow()
|
expect(() => JSON.parse(prepareJobOutputContent)).not.toThrow()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should have context written to a file', async () => {
|
it('should have context written to a file', async () => {
|
||||||
await prepareJob(prepareJobData.args, prepareJobOutputPath)
|
const prepareJobOutput = testSetup.createOutputFile(
|
||||||
const prepareJobOutputContent = fs.readFileSync(
|
'prepare-job-output.json'
|
||||||
prepareJobOutputPath,
|
)
|
||||||
'utf-8'
|
await prepareJob(prepareJobDefinition.args, prepareJobOutput)
|
||||||
|
const parsedPrepareJobOutput = JSON.parse(
|
||||||
|
fs.readFileSync(prepareJobOutput, 'utf-8')
|
||||||
)
|
)
|
||||||
const parsedPrepareJobOutput = JSON.parse(prepareJobOutputContent)
|
|
||||||
expect(parsedPrepareJobOutput.context).toBeDefined()
|
expect(parsedPrepareJobOutput.context).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should have container ids written to file', async () => {
|
it('should have isAlpine field set correctly', async () => {
|
||||||
await prepareJob(prepareJobData.args, prepareJobOutputPath)
|
let prepareJobOutput = testSetup.createOutputFile(
|
||||||
const prepareJobOutputContent = fs.readFileSync(
|
'prepare-job-output-alpine.json'
|
||||||
prepareJobOutputPath,
|
|
||||||
'utf-8'
|
|
||||||
)
|
)
|
||||||
|
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(prepareJobDefinition.args, prepareJobOutput)
|
||||||
|
const prepareJobOutputContent = fs.readFileSync(prepareJobOutput, 'utf-8')
|
||||||
const parsedPrepareJobOutput = JSON.parse(prepareJobOutputContent)
|
const parsedPrepareJobOutput = JSON.parse(prepareJobOutputContent)
|
||||||
|
|
||||||
expect(parsedPrepareJobOutput.context.container.id).toBeDefined()
|
expect(parsedPrepareJobOutput.context.container.id).toBeDefined()
|
||||||
@@ -82,11 +90,11 @@ describe('prepare job', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should have ports for context written in form [containerPort]:[hostPort]', async () => {
|
it('should have ports for context written in form [containerPort]:[hostPort]', async () => {
|
||||||
await prepareJob(prepareJobData.args, prepareJobOutputPath)
|
const prepareJobOutput = testSetup.createOutputFile(
|
||||||
const prepareJobOutputContent = fs.readFileSync(
|
'prepare-job-output.json'
|
||||||
prepareJobOutputPath,
|
|
||||||
'utf-8'
|
|
||||||
)
|
)
|
||||||
|
await prepareJob(prepareJobDefinition.args, prepareJobOutput)
|
||||||
|
const prepareJobOutputContent = fs.readFileSync(prepareJobOutput, 'utf-8')
|
||||||
const parsedPrepareJobOutput = JSON.parse(prepareJobOutputContent)
|
const parsedPrepareJobOutput = JSON.parse(prepareJobOutputContent)
|
||||||
|
|
||||||
const mainContainerPorts = parsedPrepareJobOutput.context.container.ports
|
const mainContainerPorts = parsedPrepareJobOutput.context.container.ports
|
||||||
@@ -100,4 +108,14 @@ describe('prepare job', () => {
|
|||||||
expect(redisServicePorts['80']).toBe('8080')
|
expect(redisServicePorts['80']).toBe('8080')
|
||||||
expect(redisServicePorts['8080']).toBe('8088')
|
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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
78
packages/docker/tests/run-script-step-test.ts
Normal file
78
packages/docker/tests/run-script-step-test.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,11 +1,15 @@
|
|||||||
import * as fs from 'fs'
|
import * as fs from 'fs'
|
||||||
import { v4 as uuidv4 } from 'uuid'
|
|
||||||
import { env } from 'process'
|
|
||||||
import { Mount } from 'hooklib'
|
import { Mount } from 'hooklib'
|
||||||
|
import { HookData } from 'hooklib/lib'
|
||||||
|
import * as path from 'path'
|
||||||
|
import { env } from 'process'
|
||||||
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
|
|
||||||
export default class TestSetup {
|
export default class TestSetup {
|
||||||
private testdir: string
|
private testdir: string
|
||||||
private runnerMockDir: string
|
private runnerMockDir: string
|
||||||
|
readonly runnerOutputDir: string
|
||||||
|
|
||||||
private runnerMockSubdirs = {
|
private runnerMockSubdirs = {
|
||||||
work: '_work',
|
work: '_work',
|
||||||
externals: 'externals',
|
externals: 'externals',
|
||||||
@@ -16,15 +20,16 @@ export default class TestSetup {
|
|||||||
githubWorkflow: '_work/_temp/_github_workflow'
|
githubWorkflow: '_work/_temp/_github_workflow'
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly projectName = 'example'
|
private readonly projectName = 'repo'
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.testdir = `${__dirname}/_temp/${uuidv4()}`
|
this.testdir = `${__dirname}/_temp/${uuidv4()}`
|
||||||
this.runnerMockDir = `${this.testdir}/runner/_layout`
|
this.runnerMockDir = `${this.testdir}/runner/_layout`
|
||||||
|
this.runnerOutputDir = `${this.testdir}/outputs`
|
||||||
}
|
}
|
||||||
|
|
||||||
private get allTestDirectories() {
|
private get allTestDirectories() {
|
||||||
const resp = [this.testdir, this.runnerMockDir]
|
const resp = [this.testdir, this.runnerMockDir, this.runnerOutputDir]
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(this.runnerMockSubdirs)) {
|
for (const [key, value] of Object.entries(this.runnerMockSubdirs)) {
|
||||||
resp.push(`${this.runnerMockDir}/${value}`)
|
resp.push(`${this.runnerMockDir}/${value}`)
|
||||||
@@ -38,30 +43,27 @@ export default class TestSetup {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public initialize(): void {
|
public initialize(): void {
|
||||||
for (const dir of this.allTestDirectories) {
|
env['GITHUB_WORKSPACE'] = this.workingDirectory
|
||||||
fs.mkdirSync(dir, { recursive: true })
|
|
||||||
}
|
|
||||||
env['RUNNER_NAME'] = 'test'
|
env['RUNNER_NAME'] = 'test'
|
||||||
env[
|
env[
|
||||||
'RUNNER_TEMP'
|
'RUNNER_TEMP'
|
||||||
] = `${this.runnerMockDir}/${this.runnerMockSubdirs.workTemp}`
|
] = `${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 {
|
public teardown(): void {
|
||||||
fs.rmdirSync(this.testdir, { recursive: true })
|
fs.rmdirSync(this.testdir, { recursive: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
public get userMountVolumes(): Mount[] {
|
private get systemMountVolumes(): Mount[] {
|
||||||
return [
|
|
||||||
{
|
|
||||||
sourceVolumePath: 'my_docker_volume',
|
|
||||||
targetVolumePath: '/volume_mount',
|
|
||||||
readOnly: false
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
public get systemMountVolumes(): Mount[] {
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
sourceVolumePath: '/var/run/docker.sock',
|
sourceVolumePath: '/var/run/docker.sock',
|
||||||
@@ -106,7 +108,89 @@ 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 {
|
public get workingDirectory(): string {
|
||||||
|
return `${this.runnerMockDir}/_work/${this.projectName}/${this.projectName}`
|
||||||
|
}
|
||||||
|
|
||||||
|
public get containerWorkingDirectory(): string {
|
||||||
return `/__w/${this.projectName}/${this.projectName}`
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { sanitize } from '../src/utils'
|
import { optionsWithDockerEnvs, sanitize, fixArgs } from '../src/utils'
|
||||||
|
|
||||||
describe('Utilities', () => {
|
describe('Utilities', () => {
|
||||||
it('should return sanitized image name', () => {
|
it('should return sanitized image name', () => {
|
||||||
@@ -9,4 +9,72 @@ describe('Utilities', () => {
|
|||||||
const validStr = 'teststr8_one'
|
const validStr = 'teststr8_one'
|
||||||
expect(sanitize(validStr)).toBe(validStr)
|
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
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
85
packages/hooklib/package-lock.json
generated
85
packages/hooklib/package-lock.json
generated
@@ -9,7 +9,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/core": "^1.6.0"
|
"@actions/core": "^1.9.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^17.0.23",
|
"@types/node": "^17.0.23",
|
||||||
@@ -22,19 +22,20 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@actions/core": {
|
"node_modules/@actions/core": {
|
||||||
"version": "1.6.0",
|
"version": "1.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/@actions/core/-/core-1.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/@actions/core/-/core-1.9.1.tgz",
|
||||||
"integrity": "sha512-NB1UAZomZlCV/LmJqkLhNTqtKfFXJZAUPcfl/zqG7EfsQdeUJtaWO98SGbuQ3pydJ3fHl2CvI/51OKYlCYYcaw==",
|
"integrity": "sha512-5ad+U2YGrmmiw6du20AQW5XuWo7UKN2052FjSV7MX+Wfjf8sCqcsZe62NfgHys4QI4/Y+vQvLKYL8jWtA1ZBTA==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/http-client": "^1.0.11"
|
"@actions/http-client": "^2.0.1",
|
||||||
|
"uuid": "^8.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@actions/http-client": {
|
"node_modules/@actions/http-client": {
|
||||||
"version": "1.0.11",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-1.0.11.tgz",
|
"resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-2.0.1.tgz",
|
||||||
"integrity": "sha512-VRYHGQV1rqnROJqdMvGUbY/Kn8vriQe/F9HR2AlYHzmKuM/p3kjNuXhmdBfcVgsvRWTz5C5XW5xvndZrVBuAYg==",
|
"integrity": "sha512-PIXiMVtz6VvyaRsGY268qvj57hXQEpsYogYOu2nrQhlf+XCGmZstmuZBbAybUl1nQGnvS1k1eEsQ69ZoD7xlSw==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tunnel": "0.0.6"
|
"tunnel": "^0.0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@eslint/eslintrc": {
|
"node_modules/@eslint/eslintrc": {
|
||||||
@@ -1741,9 +1742,9 @@
|
|||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/json5": {
|
"node_modules/json5": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz",
|
||||||
"integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==",
|
"integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"minimist": "^1.2.0"
|
"minimist": "^1.2.0"
|
||||||
@@ -2214,9 +2215,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/semver": {
|
"node_modules/semver": {
|
||||||
"version": "7.3.7",
|
"version": "7.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz",
|
||||||
"integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==",
|
"integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lru-cache": "^6.0.0"
|
"lru-cache": "^6.0.0"
|
||||||
@@ -2485,6 +2486,14 @@
|
|||||||
"punycode": "^2.1.0"
|
"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": {
|
"node_modules/v8-compile-cache": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz",
|
||||||
@@ -2523,9 +2532,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/word-wrap": {
|
"node_modules/word-wrap": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.4.tgz",
|
||||||
"integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==",
|
"integrity": "sha512-2V81OA4ugVo5pRo46hAoD2ivUJx8jXmWXfUkY4KFNw0hEptvN0QfH3K4nHiwzGeKl5rFKedV48QVoqYavy4YpA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -2546,19 +2555,20 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/core": {
|
"@actions/core": {
|
||||||
"version": "1.6.0",
|
"version": "1.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/@actions/core/-/core-1.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/@actions/core/-/core-1.9.1.tgz",
|
||||||
"integrity": "sha512-NB1UAZomZlCV/LmJqkLhNTqtKfFXJZAUPcfl/zqG7EfsQdeUJtaWO98SGbuQ3pydJ3fHl2CvI/51OKYlCYYcaw==",
|
"integrity": "sha512-5ad+U2YGrmmiw6du20AQW5XuWo7UKN2052FjSV7MX+Wfjf8sCqcsZe62NfgHys4QI4/Y+vQvLKYL8jWtA1ZBTA==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"@actions/http-client": "^1.0.11"
|
"@actions/http-client": "^2.0.1",
|
||||||
|
"uuid": "^8.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"@actions/http-client": {
|
"@actions/http-client": {
|
||||||
"version": "1.0.11",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-1.0.11.tgz",
|
"resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-2.0.1.tgz",
|
||||||
"integrity": "sha512-VRYHGQV1rqnROJqdMvGUbY/Kn8vriQe/F9HR2AlYHzmKuM/p3kjNuXhmdBfcVgsvRWTz5C5XW5xvndZrVBuAYg==",
|
"integrity": "sha512-PIXiMVtz6VvyaRsGY268qvj57hXQEpsYogYOu2nrQhlf+XCGmZstmuZBbAybUl1nQGnvS1k1eEsQ69ZoD7xlSw==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"tunnel": "0.0.6"
|
"tunnel": "^0.0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"@eslint/eslintrc": {
|
"@eslint/eslintrc": {
|
||||||
@@ -3779,9 +3789,9 @@
|
|||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"json5": {
|
"json5": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz",
|
||||||
"integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==",
|
"integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"requires": {
|
"requires": {
|
||||||
"minimist": "^1.2.0"
|
"minimist": "^1.2.0"
|
||||||
@@ -4109,9 +4119,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"semver": {
|
"semver": {
|
||||||
"version": "7.3.7",
|
"version": "7.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz",
|
||||||
"integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==",
|
"integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"requires": {
|
"requires": {
|
||||||
"lru-cache": "^6.0.0"
|
"lru-cache": "^6.0.0"
|
||||||
@@ -4300,6 +4310,11 @@
|
|||||||
"punycode": "^2.1.0"
|
"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": {
|
"v8-compile-cache": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz",
|
||||||
@@ -4329,9 +4344,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"word-wrap": {
|
"word-wrap": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.4.tgz",
|
||||||
"integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==",
|
"integrity": "sha512-2V81OA4ugVo5pRo46hAoD2ivUJx8jXmWXfUkY4KFNw0hEptvN0QfH3K4nHiwzGeKl5rFKedV48QVoqYavy4YpA==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"wrappy": {
|
"wrappy": {
|
||||||
|
|||||||
@@ -23,6 +23,6 @@
|
|||||||
"typescript": "^4.6.3"
|
"typescript": "^4.6.3"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/core": "^1.6.0"
|
"@actions/core": "^1.9.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export interface ContainerInfo {
|
|||||||
createOptions?: string
|
createOptions?: string
|
||||||
environmentVariables?: { [key: string]: string }
|
environmentVariables?: { [key: string]: string }
|
||||||
userMountVolumes?: Mount[]
|
userMountVolumes?: Mount[]
|
||||||
|
systemMountVolumes?: Mount[]
|
||||||
registry?: Registry
|
registry?: Registry
|
||||||
portMappings?: string[]
|
portMappings?: string[]
|
||||||
}
|
}
|
||||||
@@ -73,14 +74,6 @@ export enum Protocol {
|
|||||||
UDP = 'udp'
|
UDP = 'udp'
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum PodPhase {
|
|
||||||
PENDING = 'Pending',
|
|
||||||
RUNNING = 'Running',
|
|
||||||
SUCCEEDED = 'Succeded',
|
|
||||||
FAILED = 'Failed',
|
|
||||||
UNKNOWN = 'Unknown'
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PrepareJobResponse {
|
export interface PrepareJobResponse {
|
||||||
state?: object
|
state?: object
|
||||||
context?: ContainerContext
|
context?: ContainerContext
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import * as core from '@actions/core'
|
|
||||||
import * as events from 'events'
|
import * as events from 'events'
|
||||||
import * as fs from 'fs'
|
import * as fs from 'fs'
|
||||||
import * as os from 'os'
|
import * as os from 'os'
|
||||||
@@ -13,7 +12,6 @@ export async function getInputFromStdin(): Promise<HookData> {
|
|||||||
})
|
})
|
||||||
|
|
||||||
rl.on('line', line => {
|
rl.on('line', line => {
|
||||||
core.debug(`Line from STDIN: ${line}`)
|
|
||||||
input = line
|
input = line
|
||||||
})
|
})
|
||||||
await events.default.once(rl, 'close')
|
await events.default.once(rl, 'close')
|
||||||
|
|||||||
@@ -6,7 +6,40 @@ This implementation provides a way to dynamically spin up jobs to run container
|
|||||||
## Pre-requisites
|
## Pre-requisites
|
||||||
Some things are expected to be set when using these hooks
|
Some things are expected to be set when using these hooks
|
||||||
- The runner itself should be running in a pod, with a service account with the following permissions
|
- The runner itself should be running in a pod, with a service account with the following permissions
|
||||||
- The `ACTIONS_RUNNER_REQUIRE_JOB_CONTAINER=true` should be set to true
|
```
|
||||||
|
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_POD_NAME` env should be set to the name of the pod
|
- The `ACTIONS_RUNNER_POD_NAME` env should be set to the name of the pod
|
||||||
|
- The `ACTIONS_RUNNER_REQUIRE_JOB_CONTAINER` env should be set to true to prevent the runner from running any jobs outside of a container
|
||||||
- The runner pod should map a persistent volume claim into the `_work` directory
|
- The runner pod should map a persistent volume claim into the `_work` directory
|
||||||
- The `ACTIONS_RUNNER_CLAIM_NAME` should be set to the persistent volume claim that contains the runner's working directory
|
- The `ACTIONS_RUNNER_CLAIM_NAME` env should be set to the persistent volume claim that contains the runner's working directory, 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
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
jest.setTimeout(90000)
|
jest.setTimeout(500000)
|
||||||
|
|||||||
1590
packages/k8s/package-lock.json
generated
1590
packages/k8s/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -13,11 +13,13 @@
|
|||||||
"author": "",
|
"author": "",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/core": "^1.6.0",
|
"@actions/core": "^1.9.1",
|
||||||
"@actions/exec": "^1.1.1",
|
"@actions/exec": "^1.1.1",
|
||||||
"@actions/io": "^1.1.2",
|
"@actions/io": "^1.1.2",
|
||||||
"@kubernetes/client-node": "^0.16.3",
|
"@kubernetes/client-node": "^0.18.1",
|
||||||
"hooklib": "file:../hooklib"
|
"hooklib": "file:../hooklib",
|
||||||
|
"js-yaml": "^4.1.0",
|
||||||
|
"shlex": "^2.1.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/jest": "^27.4.1",
|
"@types/jest": "^27.4.1",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { podPrune } from '../k8s'
|
import { prunePods, pruneSecrets } from '../k8s'
|
||||||
|
|
||||||
export async function cleanupJob(): Promise<void> {
|
export async function cleanupJob(): Promise<void> {
|
||||||
await podPrune()
|
await Promise.all([prunePods(), pruneSecrets()])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,28 +20,34 @@ export function getJobPodName(): string {
|
|||||||
export function getStepPodName(): string {
|
export function getStepPodName(): string {
|
||||||
return `${getRunnerPodName().substring(
|
return `${getRunnerPodName().substring(
|
||||||
0,
|
0,
|
||||||
MAX_POD_NAME_LENGTH - ('-step'.length + STEP_POD_NAME_SUFFIX_LENGTH)
|
MAX_POD_NAME_LENGTH - ('-step-'.length + STEP_POD_NAME_SUFFIX_LENGTH)
|
||||||
)}-step-${uuidv4().substring(0, STEP_POD_NAME_SUFFIX_LENGTH)}`
|
)}-step-${uuidv4().substring(0, STEP_POD_NAME_SUFFIX_LENGTH)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getVolumeClaimName(): string {
|
export function getVolumeClaimName(): string {
|
||||||
const name = process.env.ACTIONS_RUNNER_CLAIM_NAME
|
const name = process.env.ACTIONS_RUNNER_CLAIM_NAME
|
||||||
if (!name) {
|
if (!name) {
|
||||||
throw new Error(
|
return `${getRunnerPodName()}-work`
|
||||||
"'ACTIONS_RUNNER_CLAIM_NAME' is required, please contact your self hosted runner administrator"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
return name
|
return name
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_POD_NAME_LENGTH = 63
|
export function getSecretName(): string {
|
||||||
const STEP_POD_NAME_SUFFIX_LENGTH = 8
|
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
|
||||||
export const JOB_CONTAINER_NAME = 'job'
|
export const JOB_CONTAINER_NAME = 'job'
|
||||||
|
export const JOB_CONTAINER_EXTENSION_NAME = '$job'
|
||||||
|
|
||||||
export class RunnerInstanceLabel {
|
export class RunnerInstanceLabel {
|
||||||
runnerhook: string
|
private podName: string
|
||||||
constructor() {
|
constructor() {
|
||||||
this.runnerhook = process.env.ACTIONS_RUNNER_POD_NAME as string
|
this.podName = getRunnerPodName()
|
||||||
}
|
}
|
||||||
|
|
||||||
get key(): string {
|
get key(): string {
|
||||||
@@ -49,10 +55,10 @@ export class RunnerInstanceLabel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get value(): string {
|
get value(): string {
|
||||||
return this.runnerhook
|
return this.podName
|
||||||
}
|
}
|
||||||
|
|
||||||
toString(): string {
|
toString(): string {
|
||||||
return `runner-pod=${this.runnerhook}`
|
return `runner-pod=${this.podName}`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,82 +2,108 @@ import * as core from '@actions/core'
|
|||||||
import * as io from '@actions/io'
|
import * as io from '@actions/io'
|
||||||
import * as k8s from '@kubernetes/client-node'
|
import * as k8s from '@kubernetes/client-node'
|
||||||
import {
|
import {
|
||||||
|
JobContainerInfo,
|
||||||
ContextPorts,
|
ContextPorts,
|
||||||
PodPhase,
|
PrepareJobArgs,
|
||||||
prepareJobArgs,
|
|
||||||
writeToResponseFile
|
writeToResponseFile
|
||||||
} from 'hooklib'
|
} from 'hooklib'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import {
|
import {
|
||||||
containerPorts,
|
containerPorts,
|
||||||
createPod,
|
createPod,
|
||||||
isAuthPermissionsOK,
|
|
||||||
isPodContainerAlpine,
|
isPodContainerAlpine,
|
||||||
namespace,
|
prunePods,
|
||||||
podPrune,
|
waitForPodPhases,
|
||||||
requiredPermissions,
|
getPrepareJobTimeoutSeconds
|
||||||
waitForPodPhases
|
|
||||||
} from '../k8s'
|
} from '../k8s'
|
||||||
import {
|
import {
|
||||||
containerVolumes,
|
containerVolumes,
|
||||||
DEFAULT_CONTAINER_ENTRY_POINT,
|
DEFAULT_CONTAINER_ENTRY_POINT,
|
||||||
DEFAULT_CONTAINER_ENTRY_POINT_ARGS
|
DEFAULT_CONTAINER_ENTRY_POINT_ARGS,
|
||||||
|
generateContainerName,
|
||||||
|
mergeContainerWithOptions,
|
||||||
|
readExtensionFromFile,
|
||||||
|
PodPhase,
|
||||||
|
fixArgs
|
||||||
} from '../k8s/utils'
|
} from '../k8s/utils'
|
||||||
import { JOB_CONTAINER_NAME } from './constants'
|
import { JOB_CONTAINER_EXTENSION_NAME, JOB_CONTAINER_NAME } from './constants'
|
||||||
|
|
||||||
export async function prepareJob(
|
export async function prepareJob(
|
||||||
args: prepareJobArgs,
|
args: PrepareJobArgs,
|
||||||
responseFile
|
responseFile
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await podPrune()
|
if (!args.container) {
|
||||||
if (!(await isAuthPermissionsOK())) {
|
throw new Error('Job Container is required.')
|
||||||
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()
|
await copyExternalsToRoot()
|
||||||
|
|
||||||
let container: k8s.V1Container | undefined = undefined
|
let container: k8s.V1Container | undefined = undefined
|
||||||
if (args.container?.image) {
|
if (args.container?.image) {
|
||||||
core.info(`Using image '${args.container.image}' for job image`)
|
core.debug(`Using image '${args.container.image}' for job image`)
|
||||||
container = createPodSpec(args.container, JOB_CONTAINER_NAME, true)
|
container = createContainerSpec(
|
||||||
|
args.container,
|
||||||
|
JOB_CONTAINER_NAME,
|
||||||
|
true,
|
||||||
|
extension
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
let services: k8s.V1Container[] = []
|
let services: k8s.V1Container[] = []
|
||||||
if (args.services?.length) {
|
if (args.services?.length) {
|
||||||
services = args.services.map(service => {
|
services = args.services.map(service => {
|
||||||
core.info(`Adding service '${service.image}' to pod definition`)
|
core.debug(`Adding service '${service.image}' to pod definition`)
|
||||||
return createPodSpec(service, service.image.split(':')[0])
|
return createContainerSpec(
|
||||||
|
service,
|
||||||
|
generateContainerName(service.image),
|
||||||
|
false,
|
||||||
|
undefined
|
||||||
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!container && !services?.length) {
|
if (!container && !services?.length) {
|
||||||
throw new Error('No containers exist, skipping hook invocation')
|
throw new Error('No containers exist, skipping hook invocation')
|
||||||
}
|
}
|
||||||
|
|
||||||
let createdPod: k8s.V1Pod | undefined = undefined
|
let createdPod: k8s.V1Pod | undefined = undefined
|
||||||
try {
|
try {
|
||||||
createdPod = await createPod(container, services, args.registry)
|
createdPod = await createPod(
|
||||||
|
container,
|
||||||
|
services,
|
||||||
|
args.container.registry,
|
||||||
|
extension
|
||||||
|
)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await podPrune()
|
await prunePods()
|
||||||
throw new Error(`failed to create job pod: ${err}`)
|
core.debug(`createPod failed: ${JSON.stringify(err)}`)
|
||||||
|
const message = (err as any)?.response?.body?.message || err
|
||||||
|
throw new Error(`failed to create job pod: ${message}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!createdPod?.metadata?.name) {
|
if (!createdPod?.metadata?.name) {
|
||||||
throw new Error('created pod should have 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 {
|
try {
|
||||||
await waitForPodPhases(
|
await waitForPodPhases(
|
||||||
createdPod.metadata.name,
|
createdPod.metadata.name,
|
||||||
new Set([PodPhase.RUNNING]),
|
new Set([PodPhase.RUNNING]),
|
||||||
new Set([PodPhase.PENDING])
|
new Set([PodPhase.PENDING]),
|
||||||
|
getPrepareJobTimeoutSeconds()
|
||||||
)
|
)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await podPrune()
|
await prunePods()
|
||||||
throw new Error(`Pod failed to come online with error: ${err}`)
|
throw new Error(`pod failed to come online with error: ${err}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
core.info('Pod is ready for traffic')
|
core.debug('Job pod is ready for traffic')
|
||||||
|
|
||||||
let isAlpine = false
|
let isAlpine = false
|
||||||
try {
|
try {
|
||||||
@@ -86,9 +112,13 @@ export async function prepareJob(
|
|||||||
JOB_CONTAINER_NAME
|
JOB_CONTAINER_NAME
|
||||||
)
|
)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new Error(`Failed to determine if the pod is alpine: ${err}`)
|
core.debug(
|
||||||
|
`Failed to determine if the pod is alpine: ${JSON.stringify(err)}`
|
||||||
|
)
|
||||||
|
const message = (err as any)?.response?.body?.message || err
|
||||||
|
throw new Error(`failed to determine if the pod is alpine: ${message}`)
|
||||||
}
|
}
|
||||||
|
core.debug(`Setting isAlpine to ${isAlpine}`)
|
||||||
generateResponseFile(responseFile, createdPod, isAlpine)
|
generateResponseFile(responseFile, createdPod, isAlpine)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,8 +127,13 @@ function generateResponseFile(
|
|||||||
appPod: k8s.V1Pod,
|
appPod: k8s.V1Pod,
|
||||||
isAlpine
|
isAlpine
|
||||||
): void {
|
): void {
|
||||||
|
if (!appPod.metadata?.name) {
|
||||||
|
throw new Error('app pod must have metadata.name specified')
|
||||||
|
}
|
||||||
const response = {
|
const response = {
|
||||||
state: {},
|
state: {
|
||||||
|
jobPod: appPod.metadata.name
|
||||||
|
},
|
||||||
context: {},
|
context: {},
|
||||||
isAlpine
|
isAlpine
|
||||||
}
|
}
|
||||||
@@ -126,14 +161,12 @@ function generateResponseFile(
|
|||||||
)
|
)
|
||||||
if (serviceContainers?.length) {
|
if (serviceContainers?.length) {
|
||||||
response.context['services'] = serviceContainers.map(c => {
|
response.context['services'] = serviceContainers.map(c => {
|
||||||
if (!c.ports) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const ctxPorts: ContextPorts = {}
|
const ctxPorts: ContextPorts = {}
|
||||||
|
if (c.ports?.length) {
|
||||||
for (const port of c.ports) {
|
for (const port of c.ports) {
|
||||||
ctxPorts[port.containerPort] = port.hostPort
|
ctxPorts[port.containerPort] = port.hostPort
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
image: c.image,
|
image: c.image,
|
||||||
@@ -155,30 +188,34 @@ async function copyExternalsToRoot(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createPodSpec(
|
export function createContainerSpec(
|
||||||
container,
|
container: JobContainerInfo,
|
||||||
name: string,
|
name: string,
|
||||||
jobContainer = false
|
jobContainer = false,
|
||||||
|
extension?: k8s.V1PodTemplateSpec
|
||||||
): k8s.V1Container {
|
): k8s.V1Container {
|
||||||
core.info(JSON.stringify(container))
|
if (!container.entryPoint && jobContainer) {
|
||||||
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
|
container.entryPoint = DEFAULT_CONTAINER_ENTRY_POINT
|
||||||
|
container.entryPointArgs = DEFAULT_CONTAINER_ENTRY_POINT_ARGS
|
||||||
}
|
}
|
||||||
|
|
||||||
const podContainer = {
|
const podContainer = {
|
||||||
name,
|
name,
|
||||||
image: container.image,
|
image: container.image,
|
||||||
command: [container.entryPoint],
|
|
||||||
args: container.entryPointArgs,
|
|
||||||
ports: containerPorts(container)
|
ports: containerPorts(container)
|
||||||
} as k8s.V1Container
|
} as k8s.V1Container
|
||||||
if (container.workingDirectory) {
|
if (container.workingDirectory) {
|
||||||
podContainer.workingDir = container.workingDirectory
|
podContainer.workingDir = container.workingDirectory
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (container.entryPoint) {
|
||||||
|
podContainer.command = [container.entryPoint]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (container.entryPointArgs?.length > 0) {
|
||||||
|
podContainer.args = fixArgs(container.entryPointArgs)
|
||||||
|
}
|
||||||
|
|
||||||
podContainer.env = []
|
podContainer.env = []
|
||||||
for (const [key, value] of Object.entries(
|
for (const [key, value] of Object.entries(
|
||||||
container['environmentVariables']
|
container['environmentVariables']
|
||||||
@@ -193,5 +230,17 @@ function createPodSpec(
|
|||||||
jobContainer
|
jobContainer
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (!extension) {
|
||||||
|
return podContainer
|
||||||
|
}
|
||||||
|
|
||||||
|
const from = extension.spec?.containers?.find(
|
||||||
|
c => c.name === JOB_CONTAINER_EXTENSION_NAME
|
||||||
|
)
|
||||||
|
|
||||||
|
if (from) {
|
||||||
|
mergeContainerWithOptions(podContainer, from)
|
||||||
|
}
|
||||||
|
|
||||||
return podContainer
|
return podContainer
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,50 @@
|
|||||||
import * as k8s from '@kubernetes/client-node'
|
|
||||||
import * as core from '@actions/core'
|
import * as core from '@actions/core'
|
||||||
import { PodPhase } from 'hooklib'
|
import * as k8s from '@kubernetes/client-node'
|
||||||
|
import { RunContainerStepArgs } from 'hooklib'
|
||||||
import {
|
import {
|
||||||
createJob,
|
createJob,
|
||||||
|
createSecretForEnvs,
|
||||||
getContainerJobPodName,
|
getContainerJobPodName,
|
||||||
getPodLogs,
|
getPodLogs,
|
||||||
getPodStatus,
|
getPodStatus,
|
||||||
waitForJobToComplete,
|
waitForJobToComplete,
|
||||||
waitForPodPhases
|
waitForPodPhases
|
||||||
} from '../k8s'
|
} from '../k8s'
|
||||||
import { JOB_CONTAINER_NAME } from './constants'
|
import {
|
||||||
import { containerVolumes } from '../k8s/utils'
|
containerVolumes,
|
||||||
|
PodPhase,
|
||||||
|
mergeContainerWithOptions,
|
||||||
|
readExtensionFromFile,
|
||||||
|
fixArgs
|
||||||
|
} from '../k8s/utils'
|
||||||
|
import { JOB_CONTAINER_EXTENSION_NAME, JOB_CONTAINER_NAME } from './constants'
|
||||||
|
|
||||||
export async function runContainerStep(stepContainer): Promise<number> {
|
export async function runContainerStep(
|
||||||
|
stepContainer: RunContainerStepArgs
|
||||||
|
): Promise<number> {
|
||||||
if (stepContainer.dockerfile) {
|
if (stepContainer.dockerfile) {
|
||||||
throw new Error('Building container actions is not currently supported')
|
throw new Error('Building container actions is not currently supported')
|
||||||
}
|
}
|
||||||
const container = createPodSpec(stepContainer)
|
|
||||||
const job = await createJob(container)
|
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)
|
||||||
|
|
||||||
|
let job: k8s.V1Job
|
||||||
|
try {
|
||||||
|
job = await createJob(container, extension)
|
||||||
|
} catch (err) {
|
||||||
|
core.debug(`createJob failed: ${JSON.stringify(err)}`)
|
||||||
|
const message = (err as any)?.response?.body?.message || err
|
||||||
|
throw new Error(`failed to run script step: ${message}`)
|
||||||
|
}
|
||||||
|
|
||||||
if (!job.metadata?.name) {
|
if (!job.metadata?.name) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Expected job ${JSON.stringify(
|
`Expected job ${JSON.stringify(
|
||||||
@@ -25,45 +52,86 @@ export async function runContainerStep(stepContainer): Promise<number> {
|
|||||||
)} to have correctly set the metadata.name`
|
)} 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)
|
let podName: string
|
||||||
await waitForPodPhases(
|
try {
|
||||||
podName,
|
podName = await getContainerJobPodName(job.metadata.name)
|
||||||
new Set([PodPhase.COMPLETED, PodPhase.RUNNING]),
|
} catch (err) {
|
||||||
new Set([PodPhase.PENDING])
|
core.debug(`getContainerJobPodName failed: ${JSON.stringify(err)}`)
|
||||||
)
|
const message = (err as any)?.response?.body?.message || err
|
||||||
await getPodLogs(podName, JOB_CONTAINER_NAME)
|
throw new Error(`failed to get container job pod name: ${message}`)
|
||||||
await waitForJobToComplete(job.metadata.name)
|
|
||||||
// pod has failed so pull the status code from the container
|
|
||||||
const status = await getPodStatus(podName)
|
|
||||||
if (!status?.containerStatuses?.length) {
|
|
||||||
core.warning(`Can't determine container status`)
|
|
||||||
return 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await waitForPodPhases(
|
||||||
|
podName,
|
||||||
|
new Set([PodPhase.COMPLETED, PodPhase.RUNNING, PodPhase.SUCCEEDED]),
|
||||||
|
new Set([PodPhase.PENDING, PodPhase.UNKNOWN])
|
||||||
|
)
|
||||||
|
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') {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if (!status?.containerStatuses?.length) {
|
||||||
|
core.error(
|
||||||
|
`Can't determine container status from response: ${JSON.stringify(
|
||||||
|
status
|
||||||
|
)}`
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
const exitCode =
|
const exitCode =
|
||||||
status.containerStatuses[status.containerStatuses.length - 1].state
|
status.containerStatuses[status.containerStatuses.length - 1].state
|
||||||
?.terminated?.exitCode
|
?.terminated?.exitCode
|
||||||
return Number(exitCode) || 0
|
return Number(exitCode) || 1
|
||||||
}
|
}
|
||||||
|
|
||||||
function createPodSpec(container): k8s.V1Container {
|
function createContainerSpec(
|
||||||
|
container: RunContainerStepArgs,
|
||||||
|
secretName?: string,
|
||||||
|
extension?: k8s.V1PodTemplateSpec
|
||||||
|
): k8s.V1Container {
|
||||||
const podContainer = new k8s.V1Container()
|
const podContainer = new k8s.V1Container()
|
||||||
podContainer.name = JOB_CONTAINER_NAME
|
podContainer.name = JOB_CONTAINER_NAME
|
||||||
podContainer.image = container.image
|
podContainer.image = container.image
|
||||||
if (container.entryPoint) {
|
podContainer.workingDir = container.workingDirectory
|
||||||
podContainer.command = [container.entryPoint, ...container.entryPointArgs]
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
podContainer.env = []
|
const from = extension.spec?.containers?.find(
|
||||||
for (const [key, value] of Object.entries(
|
c => c.name === JOB_CONTAINER_EXTENSION_NAME
|
||||||
container['environmentVariables']
|
)
|
||||||
)) {
|
if (from) {
|
||||||
if (value && key !== 'HOME') {
|
mergeContainerWithOptions(podContainer, from)
|
||||||
podContainer.env.push({ name: key, value: value as string })
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
podContainer.volumeMounts = containerVolumes()
|
|
||||||
|
|
||||||
return podContainer
|
return podContainer
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||||
|
import * as fs from 'fs'
|
||||||
|
import * as core from '@actions/core'
|
||||||
import { RunScriptStepArgs } from 'hooklib'
|
import { RunScriptStepArgs } from 'hooklib'
|
||||||
import { execPodStep } from '../k8s'
|
import { execPodStep } from '../k8s'
|
||||||
|
import { writeEntryPointScript } from '../k8s/utils'
|
||||||
import { JOB_CONTAINER_NAME } from './constants'
|
import { JOB_CONTAINER_NAME } from './constants'
|
||||||
|
|
||||||
export async function runScriptStep(
|
export async function runScriptStep(
|
||||||
@@ -8,31 +11,28 @@ export async function runScriptStep(
|
|||||||
state,
|
state,
|
||||||
responseFile
|
responseFile
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const cb = new CommandsBuilder(
|
const { entryPoint, entryPointArgs, environmentVariables } = args
|
||||||
args.entryPoint,
|
const { containerPath, runnerPath } = writeEntryPointScript(
|
||||||
args.entryPointArgs,
|
args.workingDirectory,
|
||||||
args.environmentVariables
|
entryPoint,
|
||||||
|
entryPointArgs,
|
||||||
|
args.prependPath,
|
||||||
|
environmentVariables
|
||||||
)
|
)
|
||||||
await execPodStep(cb.command, state.jobPod, JOB_CONTAINER_NAME)
|
|
||||||
}
|
|
||||||
|
|
||||||
class CommandsBuilder {
|
args.entryPoint = 'sh'
|
||||||
constructor(
|
args.entryPointArgs = ['-e', containerPath]
|
||||||
private entryPoint: string,
|
try {
|
||||||
private entryPointArgs: string[],
|
await execPodStep(
|
||||||
private environmentVariables: { [key: string]: string }
|
[args.entryPoint, ...args.entryPointArgs],
|
||||||
) {}
|
state.jobPod,
|
||||||
|
JOB_CONTAINER_NAME
|
||||||
get command(): string[] {
|
)
|
||||||
const envCommands: string[] = []
|
} catch (err) {
|
||||||
if (
|
core.debug(`execPodStep failed: ${JSON.stringify(err)}`)
|
||||||
this.environmentVariables &&
|
const message = (err as any)?.response?.body?.message || err
|
||||||
Object.entries(this.environmentVariables).length
|
throw new Error(`failed to run script step: ${message}`)
|
||||||
) {
|
} finally {
|
||||||
for (const [key, value] of Object.entries(this.environmentVariables)) {
|
fs.rmSync(runnerPath)
|
||||||
envCommands.push(`${key}=${value}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ['env', ...envCommands, this.entryPoint, ...this.entryPointArgs]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import * as core from '@actions/core'
|
||||||
import { Command, getInputFromStdin, prepareJobArgs } from 'hooklib'
|
import { Command, getInputFromStdin, prepareJobArgs } from 'hooklib'
|
||||||
import {
|
import {
|
||||||
cleanupJob,
|
cleanupJob,
|
||||||
@@ -5,40 +6,45 @@ import {
|
|||||||
runContainerStep,
|
runContainerStep,
|
||||||
runScriptStep
|
runScriptStep
|
||||||
} from './hooks'
|
} from './hooks'
|
||||||
|
import { isAuthPermissionsOK, namespace, requiredPermissions } from './k8s'
|
||||||
|
|
||||||
async function run(): Promise<void> {
|
async function run(): Promise<void> {
|
||||||
|
try {
|
||||||
const input = await getInputFromStdin()
|
const input = await getInputFromStdin()
|
||||||
|
|
||||||
const args = input['args']
|
const args = input['args']
|
||||||
const command = input['command']
|
const command = input['command']
|
||||||
const responseFile = input['responseFile']
|
const responseFile = input['responseFile']
|
||||||
const state = input['state']
|
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
|
let exitCode = 0
|
||||||
try {
|
|
||||||
switch (command) {
|
switch (command) {
|
||||||
case Command.PrepareJob:
|
case Command.PrepareJob:
|
||||||
await prepareJob(args as prepareJobArgs, responseFile)
|
await prepareJob(args as prepareJobArgs, responseFile)
|
||||||
break
|
return process.exit(0)
|
||||||
case Command.CleanupJob:
|
case Command.CleanupJob:
|
||||||
await cleanupJob()
|
await cleanupJob()
|
||||||
break
|
return process.exit(0)
|
||||||
case Command.RunScriptStep:
|
case Command.RunScriptStep:
|
||||||
await runScriptStep(args, state, null)
|
await runScriptStep(args, state, null)
|
||||||
break
|
return process.exit(0)
|
||||||
case Command.RunContainerStep:
|
case Command.RunContainerStep:
|
||||||
exitCode = await runContainerStep(args)
|
exitCode = await runContainerStep(args)
|
||||||
break
|
return process.exit(exitCode)
|
||||||
case Command.runContainerStep:
|
|
||||||
default:
|
default:
|
||||||
throw new Error(`Command not recognized: ${command}`)
|
throw new Error(`Command not recognized: ${command}`)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// eslint-disable-next-line no-console
|
core.error(error as Error)
|
||||||
console.log(error)
|
process.exit(1)
|
||||||
exitCode = 1
|
|
||||||
}
|
}
|
||||||
process.exitCode = exitCode
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void run()
|
void run()
|
||||||
|
|||||||
@@ -1,13 +1,22 @@
|
|||||||
|
import * as core from '@actions/core'
|
||||||
import * as k8s from '@kubernetes/client-node'
|
import * as k8s from '@kubernetes/client-node'
|
||||||
import { ContainerInfo, PodPhase, Registry } from 'hooklib'
|
import { ContainerInfo, Registry } from 'hooklib'
|
||||||
import * as stream from 'stream'
|
import * as stream from 'stream'
|
||||||
import { v4 as uuidv4 } from 'uuid'
|
|
||||||
import {
|
import {
|
||||||
getJobPodName,
|
getJobPodName,
|
||||||
getRunnerPodName,
|
getRunnerPodName,
|
||||||
|
getSecretName,
|
||||||
|
getStepPodName,
|
||||||
getVolumeClaimName,
|
getVolumeClaimName,
|
||||||
RunnerInstanceLabel
|
RunnerInstanceLabel
|
||||||
} from '../hooks/constants'
|
} from '../hooks/constants'
|
||||||
|
import {
|
||||||
|
PodPhase,
|
||||||
|
mergePodSpecWithOptions,
|
||||||
|
mergeObjectMeta,
|
||||||
|
useKubeScheduler,
|
||||||
|
fixArgs
|
||||||
|
} from './utils'
|
||||||
|
|
||||||
const kc = new k8s.KubeConfig()
|
const kc = new k8s.KubeConfig()
|
||||||
|
|
||||||
@@ -17,6 +26,8 @@ const k8sApi = kc.makeApiClient(k8s.CoreV1Api)
|
|||||||
const k8sBatchV1Api = kc.makeApiClient(k8s.BatchV1Api)
|
const k8sBatchV1Api = kc.makeApiClient(k8s.BatchV1Api)
|
||||||
const k8sAuthorizationV1Api = kc.makeApiClient(k8s.AuthorizationV1Api)
|
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 POD_VOLUME_NAME = 'work'
|
||||||
|
|
||||||
export const requiredPermissions = [
|
export const requiredPermissions = [
|
||||||
@@ -43,20 +54,20 @@ export const requiredPermissions = [
|
|||||||
verbs: ['get', 'list', 'create', 'delete'],
|
verbs: ['get', 'list', 'create', 'delete'],
|
||||||
resource: 'jobs',
|
resource: 'jobs',
|
||||||
subresource: ''
|
subresource: ''
|
||||||
}
|
},
|
||||||
]
|
{
|
||||||
|
|
||||||
const secretPermission = {
|
|
||||||
group: '',
|
group: '',
|
||||||
verbs: ['get', 'list', 'create', 'delete'],
|
verbs: ['create', 'delete', 'get', 'list'],
|
||||||
resource: 'secrets',
|
resource: 'secrets',
|
||||||
subresource: ''
|
subresource: ''
|
||||||
}
|
}
|
||||||
|
]
|
||||||
|
|
||||||
export async function createPod(
|
export async function createPod(
|
||||||
jobContainer?: k8s.V1Container,
|
jobContainer?: k8s.V1Container,
|
||||||
services?: k8s.V1Container[],
|
services?: k8s.V1Container[],
|
||||||
registry?: Registry
|
registry?: Registry,
|
||||||
|
extension?: k8s.V1PodTemplateSpec
|
||||||
): Promise<k8s.V1Pod> {
|
): Promise<k8s.V1Pod> {
|
||||||
const containers: k8s.V1Container[] = []
|
const containers: k8s.V1Container[] = []
|
||||||
if (jobContainer) {
|
if (jobContainer) {
|
||||||
@@ -78,11 +89,16 @@ export async function createPod(
|
|||||||
appPod.metadata.labels = {
|
appPod.metadata.labels = {
|
||||||
[instanceLabel.key]: instanceLabel.value
|
[instanceLabel.key]: instanceLabel.value
|
||||||
}
|
}
|
||||||
|
appPod.metadata.annotations = {}
|
||||||
|
|
||||||
appPod.spec = new k8s.V1PodSpec()
|
appPod.spec = new k8s.V1PodSpec()
|
||||||
appPod.spec.containers = containers
|
appPod.spec.containers = containers
|
||||||
appPod.spec.restartPolicy = 'Never'
|
appPod.spec.restartPolicy = 'Never'
|
||||||
|
|
||||||
|
if (!useKubeScheduler()) {
|
||||||
appPod.spec.nodeName = await getCurrentNodeName()
|
appPod.spec.nodeName = await getCurrentNodeName()
|
||||||
|
}
|
||||||
|
|
||||||
const claimName = getVolumeClaimName()
|
const claimName = getVolumeClaimName()
|
||||||
appPod.spec.volumes = [
|
appPod.spec.volumes = [
|
||||||
{
|
{
|
||||||
@@ -92,7 +108,6 @@ export async function createPod(
|
|||||||
]
|
]
|
||||||
|
|
||||||
if (registry) {
|
if (registry) {
|
||||||
if (await isSecretsAuthOK()) {
|
|
||||||
const secret = await createDockerSecret(registry)
|
const secret = await createDockerSecret(registry)
|
||||||
if (!secret?.metadata?.name) {
|
if (!secret?.metadata?.name) {
|
||||||
throw new Error(`created secret does not have secret.metadata.name`)
|
throw new Error(`created secret does not have secret.metadata.name`)
|
||||||
@@ -100,11 +115,14 @@ export async function createPod(
|
|||||||
const secretReference = new k8s.V1LocalObjectReference()
|
const secretReference = new k8s.V1LocalObjectReference()
|
||||||
secretReference.name = secret.metadata.name
|
secretReference.name = secret.metadata.name
|
||||||
appPod.spec.imagePullSecrets = [secretReference]
|
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)
|
const { body } = await k8sApi.createNamespacedPod(namespace(), appPod)
|
||||||
@@ -112,15 +130,18 @@ export async function createPod(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function createJob(
|
export async function createJob(
|
||||||
container: k8s.V1Container
|
container: k8s.V1Container,
|
||||||
|
extension?: k8s.V1PodTemplateSpec
|
||||||
): Promise<k8s.V1Job> {
|
): Promise<k8s.V1Job> {
|
||||||
const job = new k8s.V1Job()
|
const runnerInstanceLabel = new RunnerInstanceLabel()
|
||||||
|
|
||||||
|
const job = new k8s.V1Job()
|
||||||
job.apiVersion = 'batch/v1'
|
job.apiVersion = 'batch/v1'
|
||||||
job.kind = 'Job'
|
job.kind = 'Job'
|
||||||
job.metadata = new k8s.V1ObjectMeta()
|
job.metadata = new k8s.V1ObjectMeta()
|
||||||
job.metadata.name = getJobPodName()
|
job.metadata.name = getStepPodName()
|
||||||
job.metadata.labels = { 'runner-pod': getRunnerPodName() }
|
job.metadata.labels = { [runnerInstanceLabel.key]: runnerInstanceLabel.value }
|
||||||
|
job.metadata.annotations = {}
|
||||||
|
|
||||||
job.spec = new k8s.V1JobSpec()
|
job.spec = new k8s.V1JobSpec()
|
||||||
job.spec.ttlSecondsAfterFinished = 300
|
job.spec.ttlSecondsAfterFinished = 300
|
||||||
@@ -128,11 +149,17 @@ export async function createJob(
|
|||||||
job.spec.template = new k8s.V1PodTemplateSpec()
|
job.spec.template = new k8s.V1PodTemplateSpec()
|
||||||
|
|
||||||
job.spec.template.spec = new k8s.V1PodSpec()
|
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.containers = [container]
|
||||||
job.spec.template.spec.restartPolicy = 'Never'
|
job.spec.template.spec.restartPolicy = 'Never'
|
||||||
job.spec.template.spec.nodeName = await getCurrentNodeName()
|
|
||||||
|
|
||||||
const claimName = `${runnerName()}-work`
|
if (!useKubeScheduler()) {
|
||||||
|
job.spec.template.spec.nodeName = await getCurrentNodeName()
|
||||||
|
}
|
||||||
|
|
||||||
|
const claimName = getVolumeClaimName()
|
||||||
job.spec.template.spec.volumes = [
|
job.spec.template.spec.volumes = [
|
||||||
{
|
{
|
||||||
name: 'work',
|
name: 'work',
|
||||||
@@ -140,6 +167,17 @@ 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)
|
const { body } = await k8sBatchV1Api.createNamespacedJob(namespace(), job)
|
||||||
return body
|
return body
|
||||||
}
|
}
|
||||||
@@ -173,7 +211,13 @@ export async function getContainerJobPodName(jobName: string): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function deletePod(podName: string): Promise<void> {
|
export async function deletePod(podName: string): Promise<void> {
|
||||||
await k8sApi.deleteNamespacedPod(podName, namespace())
|
await k8sApi.deleteNamespacedPod(
|
||||||
|
podName,
|
||||||
|
namespace(),
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
0
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function execPodStep(
|
export async function execPodStep(
|
||||||
@@ -182,14 +226,12 @@ export async function execPodStep(
|
|||||||
containerName: string,
|
containerName: string,
|
||||||
stdin?: stream.Readable
|
stdin?: stream.Readable
|
||||||
): Promise<void> {
|
): 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)
|
const exec = new k8s.Exec(kc)
|
||||||
return new Promise(async function (resolve, reject) {
|
command = fixArgs(command)
|
||||||
try {
|
// Exec returns a websocket. If websocket fails, we should reject the promise. Otherwise, websocket will call a callback. Since at that point, websocket is not failing, we can safely resolve or reject the promise.
|
||||||
await exec.exec(
|
await new Promise(function (resolve, reject) {
|
||||||
|
exec
|
||||||
|
.exec(
|
||||||
namespace(),
|
namespace(),
|
||||||
podName,
|
podName,
|
||||||
containerName,
|
containerName,
|
||||||
@@ -201,17 +243,21 @@ export async function execPodStep(
|
|||||||
resp => {
|
resp => {
|
||||||
// kube.exec returns an error if exit code is not 0, but we can't actually get the exit code
|
// kube.exec returns an error if exit code is not 0, but we can't actually get the exit code
|
||||||
if (resp.status === 'Success') {
|
if (resp.status === 'Success') {
|
||||||
resolve()
|
resolve(resp.code)
|
||||||
} else {
|
} else {
|
||||||
reject(
|
core.debug(
|
||||||
JSON.stringify({ message: resp?.message, details: resp?.details })
|
JSON.stringify({
|
||||||
|
message: resp?.message,
|
||||||
|
details: resp?.details
|
||||||
|
})
|
||||||
)
|
)
|
||||||
|
reject(resp?.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
} catch (error) {
|
// If exec.exec fails, explicitly reject the outer promise
|
||||||
reject(error)
|
// eslint-disable-next-line github/no-then
|
||||||
}
|
.catch(e => reject(e))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,46 +280,100 @@ export async function createDockerSecret(
|
|||||||
): Promise<k8s.V1Secret> {
|
): Promise<k8s.V1Secret> {
|
||||||
const authContent = {
|
const authContent = {
|
||||||
auths: {
|
auths: {
|
||||||
[registry.serverUrl]: {
|
[registry.serverUrl || 'https://index.docker.io/v1/']: {
|
||||||
username: registry.username,
|
username: registry.username,
|
||||||
password: registry.password,
|
password: registry.password,
|
||||||
auth: Buffer.from(
|
auth: Buffer.from(`${registry.username}:${registry.password}`).toString(
|
||||||
`${registry.username}:${registry.password}`,
|
|
||||||
'base64'
|
'base64'
|
||||||
).toString()
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const secretName = generateSecretName()
|
|
||||||
|
const runnerInstanceLabel = new RunnerInstanceLabel()
|
||||||
|
|
||||||
|
const secretName = getSecretName()
|
||||||
const secret = new k8s.V1Secret()
|
const secret = new k8s.V1Secret()
|
||||||
secret.immutable = true
|
secret.immutable = true
|
||||||
secret.apiVersion = 'v1'
|
secret.apiVersion = 'v1'
|
||||||
secret.metadata = new k8s.V1ObjectMeta()
|
secret.metadata = new k8s.V1ObjectMeta()
|
||||||
secret.metadata.name = secretName
|
secret.metadata.name = secretName
|
||||||
|
secret.metadata.namespace = namespace()
|
||||||
|
secret.metadata.labels = {
|
||||||
|
[runnerInstanceLabel.key]: runnerInstanceLabel.value
|
||||||
|
}
|
||||||
|
secret.type = 'kubernetes.io/dockerconfigjson'
|
||||||
secret.kind = 'Secret'
|
secret.kind = 'Secret'
|
||||||
secret.data = {
|
secret.data = {
|
||||||
'.dockerconfigjson': Buffer.from(
|
'.dockerconfigjson': Buffer.from(JSON.stringify(authContent)).toString(
|
||||||
JSON.stringify(authContent),
|
|
||||||
'base64'
|
'base64'
|
||||||
).toString()
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const { body } = await k8sApi.createNamespacedSecret(namespace(), secret)
|
const { body } = await k8sApi.createNamespacedSecret(namespace(), secret)
|
||||||
return body
|
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(
|
export async function waitForPodPhases(
|
||||||
podName: string,
|
podName: string,
|
||||||
awaitingPhases: Set<PodPhase>,
|
awaitingPhases: Set<PodPhase>,
|
||||||
backOffPhases: Set<PodPhase>,
|
backOffPhases: Set<PodPhase>,
|
||||||
maxTimeSeconds = 45 * 60 // 45 min
|
maxTimeSeconds = DEFAULT_WAIT_FOR_POD_TIME_SECONDS
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const backOffManager = new BackOffManager(maxTimeSeconds)
|
const backOffManager = new BackOffManager(maxTimeSeconds)
|
||||||
let phase: PodPhase = PodPhase.UNKNOWN
|
let phase: PodPhase = PodPhase.UNKNOWN
|
||||||
try {
|
try {
|
||||||
while (true) {
|
while (true) {
|
||||||
phase = await getPodPhase(podName)
|
phase = await getPodPhase(podName)
|
||||||
|
|
||||||
if (awaitingPhases.has(phase)) {
|
if (awaitingPhases.has(phase)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -290,6 +390,25 @@ 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> {
|
async function getPodPhase(podName: string): Promise<PodPhase> {
|
||||||
const podPhaseLookup = new Set<string>([
|
const podPhaseLookup = new Set<string>([
|
||||||
PodPhase.PENDING,
|
PodPhase.PENDING,
|
||||||
@@ -304,7 +423,7 @@ async function getPodPhase(podName: string): Promise<PodPhase> {
|
|||||||
if (!pod.status?.phase || !podPhaseLookup.has(pod.status.phase)) {
|
if (!pod.status?.phase || !podPhaseLookup.has(pod.status.phase)) {
|
||||||
return PodPhase.UNKNOWN
|
return PodPhase.UNKNOWN
|
||||||
}
|
}
|
||||||
return pod.status?.phase
|
return pod.status?.phase as PodPhase
|
||||||
}
|
}
|
||||||
|
|
||||||
async function isJobSucceeded(jobName: string): Promise<boolean> {
|
async function isJobSucceeded(jobName: string): Promise<boolean> {
|
||||||
@@ -328,7 +447,7 @@ export async function getPodLogs(
|
|||||||
})
|
})
|
||||||
|
|
||||||
logStream.on('error', err => {
|
logStream.on('error', err => {
|
||||||
process.stderr.write(JSON.stringify(err))
|
process.stderr.write(err.message)
|
||||||
})
|
})
|
||||||
|
|
||||||
const r = await log.log(namespace(), podName, containerName, logStream, {
|
const r = await log.log(namespace(), podName, containerName, logStream, {
|
||||||
@@ -340,7 +459,7 @@ export async function getPodLogs(
|
|||||||
await new Promise(resolve => r.on('close', () => resolve(null)))
|
await new Promise(resolve => r.on('close', () => resolve(null)))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function podPrune(): Promise<void> {
|
export async function prunePods(): Promise<void> {
|
||||||
const podList = await k8sApi.listNamespacedPod(
|
const podList = await k8sApi.listNamespacedPod(
|
||||||
namespace(),
|
namespace(),
|
||||||
undefined,
|
undefined,
|
||||||
@@ -389,26 +508,6 @@ export async function isAuthPermissionsOK(): Promise<boolean> {
|
|||||||
return responses.every(resp => resp.body.status?.allowed)
|
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(
|
export async function isPodContainerAlpine(
|
||||||
podName: string,
|
podName: string,
|
||||||
containerName: string
|
containerName: string
|
||||||
@@ -419,7 +518,7 @@ export async function isPodContainerAlpine(
|
|||||||
[
|
[
|
||||||
'sh',
|
'sh',
|
||||||
'-c',
|
'-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,
|
podName,
|
||||||
containerName
|
containerName
|
||||||
@@ -440,6 +539,7 @@ async function getCurrentNodeName(): Promise<string> {
|
|||||||
}
|
}
|
||||||
return nodeName
|
return nodeName
|
||||||
}
|
}
|
||||||
|
|
||||||
export function namespace(): string {
|
export function namespace(): string {
|
||||||
if (process.env['ACTIONS_RUNNER_KUBERNETES_NAMESPACE']) {
|
if (process.env['ACTIONS_RUNNER_KUBERNETES_NAMESPACE']) {
|
||||||
return process.env['ACTIONS_RUNNER_KUBERNETES_NAMESPACE']
|
return process.env['ACTIONS_RUNNER_KUBERNETES_NAMESPACE']
|
||||||
@@ -454,20 +554,6 @@ export function namespace(): string {
|
|||||||
return context.namespace
|
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 {
|
class BackOffManager {
|
||||||
private backOffSeconds = 1
|
private backOffSeconds = 1
|
||||||
totalTime = 0
|
totalTime = 0
|
||||||
@@ -497,28 +583,46 @@ class BackOffManager {
|
|||||||
export function containerPorts(
|
export function containerPorts(
|
||||||
container: ContainerInfo
|
container: ContainerInfo
|
||||||
): k8s.V1ContainerPort[] {
|
): k8s.V1ContainerPort[] {
|
||||||
// 8080:8080/tcp
|
|
||||||
const portFormat = /(\d{1,5})(:(\d{1,5}))?(\/(tcp|udp))?/
|
|
||||||
|
|
||||||
const ports: k8s.V1ContainerPort[] = []
|
const ports: k8s.V1ContainerPort[] = []
|
||||||
|
if (!container.portMappings?.length) {
|
||||||
|
return ports
|
||||||
|
}
|
||||||
for (const portDefinition of container.portMappings) {
|
for (const portDefinition of container.portMappings) {
|
||||||
const submatches = portFormat.exec(portDefinition)
|
const portProtoSplit = portDefinition.split('/')
|
||||||
if (!submatches) {
|
if (portProtoSplit.length > 2) {
|
||||||
throw new Error(
|
throw new Error(`Unexpected port format: ${portDefinition}`)
|
||||||
`Port definition "${portDefinition}" is in incorrect format`
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const port = new k8s.V1ContainerPort()
|
const port = new k8s.V1ContainerPort()
|
||||||
port.hostPort = Number(submatches[1])
|
port.protocol =
|
||||||
if (submatches[3]) {
|
portProtoSplit.length === 2 ? portProtoSplit[1].toUpperCase() : 'TCP'
|
||||||
port.containerPort = Number(submatches[3])
|
|
||||||
|
const portSplit = portProtoSplit[0].split(':')
|
||||||
|
if (portSplit.length > 2) {
|
||||||
|
throw new Error('ports should have at most one ":" separator')
|
||||||
}
|
}
|
||||||
if (submatches[5]) {
|
|
||||||
port.protocol = submatches[5].toUpperCase()
|
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])
|
||||||
} else {
|
} else {
|
||||||
port.protocol = 'TCP'
|
port.hostPort = parsePort(portSplit[0])
|
||||||
|
port.containerPort = parsePort(portSplit[1])
|
||||||
}
|
}
|
||||||
|
|
||||||
ports.push(port)
|
ports.push(port)
|
||||||
}
|
}
|
||||||
return ports
|
return ports
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getPodByName(name): Promise<k8s.V1Pod> {
|
||||||
|
const { body } = await k8sApi.readNamespacedPod(name, namespace())
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,14 +1,24 @@
|
|||||||
import * as k8s from '@kubernetes/client-node'
|
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 { Mount } from 'hooklib'
|
||||||
import * as path from 'path'
|
import * as path from 'path'
|
||||||
|
import { v1 as uuidv4 } from 'uuid'
|
||||||
import { POD_VOLUME_NAME } from './index'
|
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_ARGS = [`-f`, `/dev/null`]
|
||||||
export const DEFAULT_CONTAINER_ENTRY_POINT = 'tail'
|
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(
|
export function containerVolumes(
|
||||||
userMountVolumes: Mount[] = [],
|
userMountVolumes: Mount[] = [],
|
||||||
jobContainer = true
|
jobContainer = true,
|
||||||
|
containerAction = false
|
||||||
): k8s.V1VolumeMount[] {
|
): k8s.V1VolumeMount[] {
|
||||||
const mounts: k8s.V1VolumeMount[] = [
|
const mounts: k8s.V1VolumeMount[] = [
|
||||||
{
|
{
|
||||||
@@ -17,6 +27,25 @@ 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) {
|
if (!jobContainer) {
|
||||||
return mounts
|
return mounts
|
||||||
}
|
}
|
||||||
@@ -44,14 +73,20 @@ export function containerVolumes(
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const userVolume of userMountVolumes) {
|
for (const userVolume of userMountVolumes) {
|
||||||
const sourceVolumePath = `${
|
let sourceVolumePath = ''
|
||||||
path.isAbsolute(userVolume.sourceVolumePath)
|
if (path.isAbsolute(userVolume.sourceVolumePath)) {
|
||||||
? userVolume.sourceVolumePath
|
if (!userVolume.sourceVolumePath.startsWith(workspacePath)) {
|
||||||
: path.join(
|
throw new Error(
|
||||||
process.env.GITHUB_WORKSPACE as string,
|
'Volume mounts outside of the work folder are not supported'
|
||||||
userVolume.sourceVolumePath
|
|
||||||
)
|
)
|
||||||
}`
|
}
|
||||||
|
// source volume path should be relative path
|
||||||
|
sourceVolumePath = userVolume.sourceVolumePath.slice(
|
||||||
|
workspacePath.length + 1
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
sourceVolumePath = userVolume.sourceVolumePath
|
||||||
|
}
|
||||||
|
|
||||||
mounts.push({
|
mounts.push({
|
||||||
name: POD_VOLUME_NAME,
|
name: POD_VOLUME_NAME,
|
||||||
@@ -63,3 +98,192 @@ export function containerVolumes(
|
|||||||
|
|
||||||
return mounts
|
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(' '))
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,31 +1,65 @@
|
|||||||
import * as path from 'path'
|
import * as k8s from '@kubernetes/client-node'
|
||||||
import * as fs from 'fs'
|
import { cleanupJob, prepareJob } from '../src/hooks'
|
||||||
import { prepareJob, cleanupJob } from '../src/hooks'
|
import { RunnerInstanceLabel } from '../src/hooks/constants'
|
||||||
import { TestTempOutput } from './test-setup'
|
import { namespace } from '../src/k8s'
|
||||||
|
import { TestHelper } from './test-setup'
|
||||||
|
|
||||||
let testTempOutput: TestTempOutput
|
let testHelper: TestHelper
|
||||||
|
|
||||||
const prepareJobJsonPath = path.resolve(
|
|
||||||
`${__dirname}/../../../examples/prepare-job.json`
|
|
||||||
)
|
|
||||||
|
|
||||||
let prepareJobOutputFilePath: string
|
|
||||||
|
|
||||||
describe('Cleanup Job', () => {
|
describe('Cleanup Job', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const prepareJobJson = fs.readFileSync(prepareJobJsonPath)
|
testHelper = new TestHelper()
|
||||||
let prepareJobData = JSON.parse(prepareJobJson.toString())
|
await testHelper.initialize()
|
||||||
|
let prepareJobData = testHelper.getPrepareJobDefinition()
|
||||||
testTempOutput = new TestTempOutput()
|
const prepareJobOutputFilePath = testHelper.createFile(
|
||||||
testTempOutput.initialize()
|
|
||||||
prepareJobOutputFilePath = testTempOutput.createFile(
|
|
||||||
'prepare-job-output.json'
|
'prepare-job-output.json'
|
||||||
)
|
)
|
||||||
await prepareJob(prepareJobData.args, prepareJobOutputFilePath)
|
await prepareJob(prepareJobData.args, prepareJobOutputFilePath)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await testHelper.cleanup()
|
||||||
|
})
|
||||||
|
|
||||||
it('should not throw', async () => {
|
it('should not throw', async () => {
|
||||||
const outputJson = fs.readFileSync(prepareJobOutputFilePath)
|
|
||||||
const outputData = JSON.parse(outputJson.toString())
|
|
||||||
await expect(cleanupJob()).resolves.not.toThrow()
|
await expect(cleanupJob()).resolves.not.toThrow()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
182
packages/k8s/tests/constants-test.ts
Normal file
182
packages/k8s/tests/constants-test.ts
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,51 +1,36 @@
|
|||||||
import * as fs from 'fs'
|
import * as fs from 'fs'
|
||||||
import * as path from 'path'
|
|
||||||
import {
|
import {
|
||||||
cleanupJob,
|
cleanupJob,
|
||||||
prepareJob,
|
prepareJob,
|
||||||
runContainerStep,
|
runContainerStep,
|
||||||
runScriptStep
|
runScriptStep
|
||||||
} from '../src/hooks'
|
} from '../src/hooks'
|
||||||
import { TestTempOutput } from './test-setup'
|
import { TestHelper } from './test-setup'
|
||||||
|
|
||||||
jest.useRealTimers()
|
jest.useRealTimers()
|
||||||
|
|
||||||
let testTempOutput: TestTempOutput
|
let testHelper: TestHelper
|
||||||
|
|
||||||
const prepareJobJsonPath = path.resolve(
|
|
||||||
`${__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 prepareJobData: any
|
||||||
|
|
||||||
let prepareJobOutputFilePath: string
|
let prepareJobOutputFilePath: string
|
||||||
describe('e2e', () => {
|
describe('e2e', () => {
|
||||||
beforeEach(() => {
|
beforeEach(async () => {
|
||||||
const prepareJobJson = fs.readFileSync(prepareJobJsonPath)
|
testHelper = new TestHelper()
|
||||||
prepareJobData = JSON.parse(prepareJobJson.toString())
|
await testHelper.initialize()
|
||||||
|
|
||||||
testTempOutput = new TestTempOutput()
|
prepareJobData = testHelper.getPrepareJobDefinition()
|
||||||
testTempOutput.initialize()
|
prepareJobOutputFilePath = testHelper.createFile('prepare-job-output.json')
|
||||||
prepareJobOutputFilePath = testTempOutput.createFile(
|
|
||||||
'prepare-job-output.json'
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
testTempOutput.cleanup()
|
await testHelper.cleanup()
|
||||||
})
|
})
|
||||||
it('should prepare job, run script step, run container step then cleanup without errors', async () => {
|
it('should prepare job, run script step, run container step then cleanup without errors', async () => {
|
||||||
await expect(
|
await expect(
|
||||||
prepareJob(prepareJobData.args, prepareJobOutputFilePath)
|
prepareJob(prepareJobData.args, prepareJobOutputFilePath)
|
||||||
).resolves.not.toThrow()
|
).resolves.not.toThrow()
|
||||||
|
|
||||||
const scriptStepContent = fs.readFileSync(runScriptStepJsonPath)
|
const scriptStepData = testHelper.getRunScriptStepDefinition()
|
||||||
const scriptStepData = JSON.parse(scriptStepContent.toString())
|
|
||||||
|
|
||||||
const prepareJobOutputJson = fs.readFileSync(prepareJobOutputFilePath)
|
const prepareJobOutputJson = fs.readFileSync(prepareJobOutputFilePath)
|
||||||
const prepareJobOutputData = JSON.parse(prepareJobOutputJson.toString())
|
const prepareJobOutputData = JSON.parse(prepareJobOutputJson.toString())
|
||||||
@@ -54,8 +39,7 @@ describe('e2e', () => {
|
|||||||
runScriptStep(scriptStepData.args, prepareJobOutputData.state, null)
|
runScriptStep(scriptStepData.args, prepareJobOutputData.state, null)
|
||||||
).resolves.not.toThrow()
|
).resolves.not.toThrow()
|
||||||
|
|
||||||
const runContainerStepContent = fs.readFileSync(runContainerStepJsonPath)
|
const runContainerStepData = testHelper.getRunContainerStepDefinition()
|
||||||
const runContainerStepData = JSON.parse(runContainerStepContent.toString())
|
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
runContainerStep(runContainerStepData.args)
|
runContainerStep(runContainerStepData.args)
|
||||||
|
|||||||
515
packages/k8s/tests/k8s-utils-test.ts
Normal file
515
packages/k8s/tests/k8s-utils-test.ts
Normal file
@@ -0,0 +1,515 @@
|
|||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,36 +1,37 @@
|
|||||||
import * as fs from 'fs'
|
import * as fs from 'fs'
|
||||||
import * as path from 'path'
|
import * as path from 'path'
|
||||||
import { cleanupJob } from '../src/hooks'
|
import { cleanupJob } from '../src/hooks'
|
||||||
import { prepareJob } from '../src/hooks/prepare-job'
|
import { createContainerSpec, prepareJob } from '../src/hooks/prepare-job'
|
||||||
import { TestTempOutput } from './test-setup'
|
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'
|
||||||
|
|
||||||
jest.useRealTimers()
|
jest.useRealTimers()
|
||||||
|
|
||||||
let testTempOutput: TestTempOutput
|
let testHelper: TestHelper
|
||||||
|
|
||||||
const prepareJobJsonPath = path.resolve(
|
|
||||||
`${__dirname}/../../../examples/prepare-job.json`
|
|
||||||
)
|
|
||||||
let prepareJobData: any
|
let prepareJobData: any
|
||||||
|
|
||||||
let prepareJobOutputFilePath: string
|
let prepareJobOutputFilePath: string
|
||||||
|
|
||||||
describe('Prepare job', () => {
|
describe('Prepare job', () => {
|
||||||
beforeEach(() => {
|
beforeEach(async () => {
|
||||||
const prepareJobJson = fs.readFileSync(prepareJobJsonPath)
|
testHelper = new TestHelper()
|
||||||
prepareJobData = JSON.parse(prepareJobJson.toString())
|
await testHelper.initialize()
|
||||||
|
prepareJobData = testHelper.getPrepareJobDefinition()
|
||||||
testTempOutput = new TestTempOutput()
|
prepareJobOutputFilePath = testHelper.createFile('prepare-job-output.json')
|
||||||
testTempOutput.initialize()
|
|
||||||
prepareJobOutputFilePath = testTempOutput.createFile(
|
|
||||||
'prepare-job-output.json'
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
const outputJson = fs.readFileSync(prepareJobOutputFilePath)
|
|
||||||
const outputData = JSON.parse(outputJson.toString())
|
|
||||||
await cleanupJob()
|
await cleanupJob()
|
||||||
testTempOutput.cleanup()
|
await testHelper.cleanup()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should not throw exception', async () => {
|
it('should not throw exception', async () => {
|
||||||
@@ -44,4 +45,123 @@ describe('Prepare job', () => {
|
|||||||
const content = fs.readFileSync(prepareJobOutputFilePath)
|
const content = fs.readFileSync(prepareJobOutputFilePath)
|
||||||
expect(() => JSON.parse(content.toString())).not.toThrow()
|
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 determine alpine correctly', async () => {
|
||||||
|
prepareJobData.args.container.image = 'alpine:latest'
|
||||||
|
await prepareJob(prepareJobData.args, prepareJobOutputFilePath)
|
||||||
|
const content = JSON.parse(
|
||||||
|
fs.readFileSync(prepareJobOutputFilePath).toString()
|
||||||
|
)
|
||||||
|
expect(content.isAlpine).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,25 +1,79 @@
|
|||||||
import { TestTempOutput } from './test-setup'
|
|
||||||
import * as path from 'path'
|
|
||||||
import { runContainerStep } from '../src/hooks'
|
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 fs from 'fs'
|
||||||
|
import * as yaml from 'js-yaml'
|
||||||
|
import { JOB_CONTAINER_EXTENSION_NAME } from '../src/hooks/constants'
|
||||||
|
|
||||||
jest.useRealTimers()
|
jest.useRealTimers()
|
||||||
|
|
||||||
let testTempOutput: TestTempOutput
|
let testHelper: TestHelper
|
||||||
|
|
||||||
let runContainerStepJsonPath = path.resolve(
|
|
||||||
`${__dirname}/../../../examples/run-container-step.json`
|
|
||||||
)
|
|
||||||
|
|
||||||
let runContainerStepData: any
|
let runContainerStepData: any
|
||||||
|
|
||||||
describe('Run container step', () => {
|
describe('Run container step', () => {
|
||||||
beforeAll(() => {
|
beforeEach(async () => {
|
||||||
const content = fs.readFileSync(runContainerStepJsonPath)
|
testHelper = new TestHelper()
|
||||||
runContainerStepData = JSON.parse(content.toString())
|
await testHelper.initialize()
|
||||||
process.env.RUNNER_NAME = 'testjob'
|
runContainerStepData = testHelper.getRunContainerStepDefinition()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await testHelper.cleanup()
|
||||||
|
})
|
||||||
|
|
||||||
it('should not throw', async () => {
|
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(
|
await expect(
|
||||||
runContainerStep(runContainerStepData.args)
|
runContainerStep(runContainerStepData.args)
|
||||||
).resolves.not.toThrow()
|
).resolves.not.toThrow()
|
||||||
|
|||||||
@@ -1,31 +1,26 @@
|
|||||||
import { prepareJob, cleanupJob, runScriptStep } from '../src/hooks'
|
|
||||||
import { TestTempOutput } from './test-setup'
|
|
||||||
import * as path from 'path'
|
|
||||||
import * as fs from 'fs'
|
import * as fs from 'fs'
|
||||||
|
import { cleanupJob, prepareJob, runScriptStep } from '../src/hooks'
|
||||||
|
import { TestHelper } from './test-setup'
|
||||||
|
|
||||||
jest.useRealTimers()
|
jest.useRealTimers()
|
||||||
|
|
||||||
let testTempOutput: TestTempOutput
|
let testHelper: TestHelper
|
||||||
|
|
||||||
const prepareJobJsonPath = path.resolve(
|
|
||||||
`${__dirname}/../../../examples/prepare-job.json`
|
|
||||||
)
|
|
||||||
let prepareJobData: any
|
|
||||||
|
|
||||||
let prepareJobOutputFilePath: string
|
|
||||||
let prepareJobOutputData: any
|
let prepareJobOutputData: any
|
||||||
|
|
||||||
|
let runScriptStepDefinition
|
||||||
|
|
||||||
describe('Run script step', () => {
|
describe('Run script step', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const prepareJobJson = fs.readFileSync(prepareJobJsonPath)
|
testHelper = new TestHelper()
|
||||||
prepareJobData = JSON.parse(prepareJobJson.toString())
|
await testHelper.initialize()
|
||||||
console.log(prepareJobData)
|
const prepareJobOutputFilePath = testHelper.createFile(
|
||||||
|
|
||||||
testTempOutput = new TestTempOutput()
|
|
||||||
testTempOutput.initialize()
|
|
||||||
prepareJobOutputFilePath = testTempOutput.createFile(
|
|
||||||
'prepare-job-output.json'
|
'prepare-job-output.json'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const prepareJobData = testHelper.getPrepareJobDefinition()
|
||||||
|
runScriptStepDefinition = testHelper.getRunScriptStepDefinition()
|
||||||
|
|
||||||
await prepareJob(prepareJobData.args, prepareJobOutputFilePath)
|
await prepareJob(prepareJobData.args, prepareJobOutputFilePath)
|
||||||
const outputContent = fs.readFileSync(prepareJobOutputFilePath)
|
const outputContent = fs.readFileSync(prepareJobOutputFilePath)
|
||||||
prepareJobOutputData = JSON.parse(outputContent.toString())
|
prepareJobOutputData = JSON.parse(outputContent.toString())
|
||||||
@@ -33,7 +28,7 @@ describe('Run script step', () => {
|
|||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
await cleanupJob()
|
await cleanupJob()
|
||||||
testTempOutput.cleanup()
|
await testHelper.cleanup()
|
||||||
})
|
})
|
||||||
|
|
||||||
// NOTE: To use this test, do kubectl apply -f podspec.yaml (from podspec examples)
|
// NOTE: To use this test, do kubectl apply -f podspec.yaml (from podspec examples)
|
||||||
@@ -41,21 +36,97 @@ describe('Run script step', () => {
|
|||||||
// npm run test run-script-step
|
// npm run test run-script-step
|
||||||
|
|
||||||
it('should not throw an exception', async () => {
|
it('should not throw an exception', async () => {
|
||||||
const args = {
|
|
||||||
entryPointArgs: ['echo "test"'],
|
|
||||||
entryPoint: '/bin/bash',
|
|
||||||
environmentVariables: {
|
|
||||||
NODE_ENV: 'development'
|
|
||||||
},
|
|
||||||
prependPath: ['/foo/bar', 'bar/foo'],
|
|
||||||
workingDirectory: '/__w/thboop-test2/thboop-test2'
|
|
||||||
}
|
|
||||||
const state = {
|
|
||||||
jobPod: prepareJobOutputData.state.jobPod
|
|
||||||
}
|
|
||||||
const responseFile = null
|
|
||||||
await expect(
|
await expect(
|
||||||
runScriptStep(args, state, responseFile)
|
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)'
|
||||||
|
}
|
||||||
|
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\''
|
||||||
|
]
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
).resolves.not.toThrow()
|
).resolves.not.toThrow()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
18
packages/k8s/tests/test-kind.yaml
Normal file
18
packages/k8s/tests/test-kind.yaml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
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
|
||||||
@@ -1,20 +1,80 @@
|
|||||||
|
import * as k8s from '@kubernetes/client-node'
|
||||||
import * as fs from 'fs'
|
import * as fs from 'fs'
|
||||||
|
import { HookData } from 'hooklib/lib'
|
||||||
|
import * as path from 'path'
|
||||||
import { v4 as uuidv4 } from 'uuid'
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
|
|
||||||
export class TestTempOutput {
|
const kc = new k8s.KubeConfig()
|
||||||
|
|
||||||
|
kc.loadFromDefault()
|
||||||
|
|
||||||
|
const k8sApi = kc.makeApiClient(k8s.CoreV1Api)
|
||||||
|
const k8sStorageApi = kc.makeApiClient(k8s.StorageV1Api)
|
||||||
|
|
||||||
|
export class TestHelper {
|
||||||
private tempDirPath: string
|
private tempDirPath: string
|
||||||
|
private podName: string
|
||||||
constructor() {
|
constructor() {
|
||||||
this.tempDirPath = `${__dirname}/_temp/${uuidv4()}`
|
this.tempDirPath = `${__dirname}/_temp/runner`
|
||||||
|
this.podName = uuidv4().replace(/-/g, '')
|
||||||
}
|
}
|
||||||
|
|
||||||
public initialize(): void {
|
public async initialize(): Promise<void> {
|
||||||
fs.mkdirSync(this.tempDirPath, { recursive: true })
|
process.env['ACTIONS_RUNNER_POD_NAME'] = `${this.podName}`
|
||||||
|
process.env['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 cleanup(): void {
|
public async cleanup(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.cleanupK8sResources()
|
||||||
fs.rmSync(this.tempDirPath, { recursive: true })
|
fs.rmSync(this.tempDirPath, { recursive: true })
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
public async cleanupK8sResources() {
|
||||||
|
await k8sApi
|
||||||
|
.deleteNamespacedPersistentVolumeClaim(
|
||||||
|
`${this.podName}-work`,
|
||||||
|
'default',
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
0
|
||||||
|
)
|
||||||
|
.catch(e => {})
|
||||||
|
await k8sApi.deletePersistentVolume(`${this.podName}-pv`).catch(e => {})
|
||||||
|
await k8sStorageApi.deleteStorageClass('local-storage').catch(e => {})
|
||||||
|
await k8sApi
|
||||||
|
.deleteNamespacedPod(this.podName, 'default', undefined, undefined, 0)
|
||||||
|
.catch(e => {})
|
||||||
|
await k8sApi
|
||||||
|
.deleteNamespacedPod(
|
||||||
|
`${this.podName}-workflow`,
|
||||||
|
'default',
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
0
|
||||||
|
)
|
||||||
|
.catch(e => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
public createFile(fileName?: string): string {
|
public createFile(fileName?: string): string {
|
||||||
const filePath = `${this.tempDirPath}/${fileName || uuidv4()}`
|
const filePath = `${this.tempDirPath}/${fileName || uuidv4()}`
|
||||||
fs.writeFileSync(filePath, '')
|
fs.writeFileSync(filePath, '')
|
||||||
@@ -25,4 +85,112 @@ export class TestTempOutput {
|
|||||||
const filePath = `${this.tempDirPath}/${fileName}`
|
const filePath = `${this.tempDirPath}/${fileName}`
|
||||||
fs.rmSync(filePath)
|
fs.rmSync(filePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async createTestJobPod() {
|
||||||
|
const container = {
|
||||||
|
name: 'nginx',
|
||||||
|
image: 'nginx:latest',
|
||||||
|
imagePullPolicy: 'IfNotPresent'
|
||||||
|
} as k8s.V1Container
|
||||||
|
|
||||||
|
const pod: k8s.V1Pod = {
|
||||||
|
metadata: {
|
||||||
|
name: this.podName
|
||||||
|
},
|
||||||
|
spec: {
|
||||||
|
restartPolicy: 'Never',
|
||||||
|
containers: [container]
|
||||||
|
}
|
||||||
|
} as k8s.V1Pod
|
||||||
|
await k8sApi.createNamespacedPod('default', pod)
|
||||||
|
}
|
||||||
|
|
||||||
|
public async createTestVolume() {
|
||||||
|
var sc: k8s.V1StorageClass = {
|
||||||
|
metadata: {
|
||||||
|
name: 'local-storage'
|
||||||
|
},
|
||||||
|
provisioner: 'kubernetes.io/no-provisioner',
|
||||||
|
volumeBindingMode: 'Immediate'
|
||||||
|
}
|
||||||
|
await k8sStorageApi.createStorageClass(sc)
|
||||||
|
|
||||||
|
var volume: k8s.V1PersistentVolume = {
|
||||||
|
metadata: {
|
||||||
|
name: `${this.podName}-pv`
|
||||||
|
},
|
||||||
|
spec: {
|
||||||
|
storageClassName: 'local-storage',
|
||||||
|
capacity: {
|
||||||
|
storage: '2Gi'
|
||||||
|
},
|
||||||
|
volumeMode: 'Filesystem',
|
||||||
|
accessModes: ['ReadWriteOnce'],
|
||||||
|
hostPath: {
|
||||||
|
path: `${this.tempDirPath}/_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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
## Features
|
<!-- ## Features -->
|
||||||
- Initial Release
|
|
||||||
|
|
||||||
## Bugs
|
## Bugs
|
||||||
|
|
||||||
|
- K8s: Try to get response body message and log entire error response in debug log [#123]
|
||||||
|
- Switch exec pod promise to reject on websocket error [#127]
|
||||||
|
- Fix is alpine check using shlex [#130]
|
||||||
|
|
||||||
## Misc
|
<!-- ## 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>
|
||||||
|
|||||||
Reference in New Issue
Block a user