mirror of
https://github.com/actions/runner-container-hooks.git
synced 2025-12-16 17:56:44 +00:00
Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
||||
*.png filter=lfs diff=lfs merge=lfs -text
|
||||
@@ -1 +1 @@
|
||||
* @actions/actions-runtime
|
||||
* @actions/actions-runtime @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.
|
||||
- See [the runner contributing.md](../../github/CONTRIBUTING.MD) for how to get started with runner development.
|
||||
- Build your hook using `npm run build`
|
||||
- Enable the hooks by setting `ACTIONS_RUNNER_CONTAINER_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
|
||||
- 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
|
||||
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.
@@ -73,6 +73,8 @@
|
||||
"contextName": "redis",
|
||||
"image": "redis",
|
||||
"createOptions": "--cpus 1",
|
||||
"entrypoint": null,
|
||||
"entryPointArgs": [],
|
||||
"environmentVariables": {},
|
||||
"userMountVolumes": [
|
||||
{
|
||||
|
||||
16
package-lock.json
generated
16
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hooks",
|
||||
"version": "0.1.0",
|
||||
"version": "0.3.2",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hooks",
|
||||
"version": "0.1.0",
|
||||
"version": "0.3.2",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^27.5.1",
|
||||
@@ -1800,9 +1800,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/json5": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz",
|
||||
"integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==",
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz",
|
||||
"integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.0"
|
||||
@@ -3926,9 +3926,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"json5": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz",
|
||||
"integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==",
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz",
|
||||
"integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"minimist": "^1.2.0"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hooks",
|
||||
"version": "0.1.0",
|
||||
"version": "0.3.2",
|
||||
"description": "Three projects are included - k8s: a kubernetes hook implementation that spins up pods dynamically to run a job - docker: A hook implementation of the runner's docker implementation - A hook lib, which contains shared typescript definitions and utilities that the other packages consume",
|
||||
"main": "",
|
||||
"directories": {
|
||||
|
||||
64
packages/docker/package-lock.json
generated
64
packages/docker/package-lock.json
generated
@@ -9,7 +9,7 @@
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@actions/core": "^1.6.0",
|
||||
"@actions/core": "^1.9.1",
|
||||
"@actions/exec": "^1.1.1",
|
||||
"hooklib": "file:../hooklib",
|
||||
"uuid": "^8.3.2"
|
||||
@@ -30,7 +30,7 @@
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@actions/core": "^1.6.0"
|
||||
"@actions/core": "^1.9.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^17.0.23",
|
||||
@@ -43,11 +43,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@actions/core": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@actions/core/-/core-1.6.0.tgz",
|
||||
"integrity": "sha512-NB1UAZomZlCV/LmJqkLhNTqtKfFXJZAUPcfl/zqG7EfsQdeUJtaWO98SGbuQ3pydJ3fHl2CvI/51OKYlCYYcaw==",
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@actions/core/-/core-1.9.1.tgz",
|
||||
"integrity": "sha512-5ad+U2YGrmmiw6du20AQW5XuWo7UKN2052FjSV7MX+Wfjf8sCqcsZe62NfgHys4QI4/Y+vQvLKYL8jWtA1ZBTA==",
|
||||
"dependencies": {
|
||||
"@actions/http-client": "^1.0.11"
|
||||
"@actions/http-client": "^2.0.1",
|
||||
"uuid": "^8.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@actions/exec": {
|
||||
@@ -59,11 +60,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@actions/http-client": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-1.0.11.tgz",
|
||||
"integrity": "sha512-VRYHGQV1rqnROJqdMvGUbY/Kn8vriQe/F9HR2AlYHzmKuM/p3kjNuXhmdBfcVgsvRWTz5C5XW5xvndZrVBuAYg==",
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-2.0.1.tgz",
|
||||
"integrity": "sha512-PIXiMVtz6VvyaRsGY268qvj57hXQEpsYogYOu2nrQhlf+XCGmZstmuZBbAybUl1nQGnvS1k1eEsQ69ZoD7xlSw==",
|
||||
"dependencies": {
|
||||
"tunnel": "0.0.6"
|
||||
"tunnel": "^0.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@actions/io": {
|
||||
@@ -3778,9 +3779,9 @@
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/json5": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.1.tgz",
|
||||
"integrity": "sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA==",
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
||||
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"json5": "lib/cli.js"
|
||||
@@ -4902,9 +4903,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tsconfig-paths/node_modules/json5": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz",
|
||||
"integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==",
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz",
|
||||
"integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.0"
|
||||
@@ -5279,11 +5280,12 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/core": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@actions/core/-/core-1.6.0.tgz",
|
||||
"integrity": "sha512-NB1UAZomZlCV/LmJqkLhNTqtKfFXJZAUPcfl/zqG7EfsQdeUJtaWO98SGbuQ3pydJ3fHl2CvI/51OKYlCYYcaw==",
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@actions/core/-/core-1.9.1.tgz",
|
||||
"integrity": "sha512-5ad+U2YGrmmiw6du20AQW5XuWo7UKN2052FjSV7MX+Wfjf8sCqcsZe62NfgHys4QI4/Y+vQvLKYL8jWtA1ZBTA==",
|
||||
"requires": {
|
||||
"@actions/http-client": "^1.0.11"
|
||||
"@actions/http-client": "^2.0.1",
|
||||
"uuid": "^8.3.2"
|
||||
}
|
||||
},
|
||||
"@actions/exec": {
|
||||
@@ -5295,11 +5297,11 @@
|
||||
}
|
||||
},
|
||||
"@actions/http-client": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-1.0.11.tgz",
|
||||
"integrity": "sha512-VRYHGQV1rqnROJqdMvGUbY/Kn8vriQe/F9HR2AlYHzmKuM/p3kjNuXhmdBfcVgsvRWTz5C5XW5xvndZrVBuAYg==",
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-2.0.1.tgz",
|
||||
"integrity": "sha512-PIXiMVtz6VvyaRsGY268qvj57hXQEpsYogYOu2nrQhlf+XCGmZstmuZBbAybUl1nQGnvS1k1eEsQ69ZoD7xlSw==",
|
||||
"requires": {
|
||||
"tunnel": "0.0.6"
|
||||
"tunnel": "^0.0.6"
|
||||
}
|
||||
},
|
||||
"@actions/io": {
|
||||
@@ -7376,7 +7378,7 @@
|
||||
"hooklib": {
|
||||
"version": "file:../hooklib",
|
||||
"requires": {
|
||||
"@actions/core": "^1.6.0",
|
||||
"@actions/core": "^1.9.1",
|
||||
"@types/node": "^17.0.23",
|
||||
"@typescript-eslint/parser": "^5.18.0",
|
||||
"@zeit/ncc": "^0.22.3",
|
||||
@@ -8174,9 +8176,9 @@
|
||||
"peer": true
|
||||
},
|
||||
"json5": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.1.tgz",
|
||||
"integrity": "sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA==",
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
||||
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
|
||||
"dev": true
|
||||
},
|
||||
"kleur": {
|
||||
@@ -8983,9 +8985,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"json5": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz",
|
||||
"integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==",
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz",
|
||||
"integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"minimist": "^1.2.0"
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@actions/core": "^1.6.0",
|
||||
"@actions/core": "^1.9.1",
|
||||
"@actions/exec": "^1.1.1",
|
||||
"hooklib": "file:../hooklib",
|
||||
"uuid": "^8.3.2"
|
||||
|
||||
@@ -427,6 +427,9 @@ export async function containerRun(
|
||||
dockerArgs.push(args.image)
|
||||
if (args.entryPointArgs) {
|
||||
for (const entryPointArg of args.entryPointArgs) {
|
||||
if (!entryPointArg) {
|
||||
continue
|
||||
}
|
||||
dockerArgs.push(entryPointArg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,15 +16,14 @@ import {
|
||||
import { checkEnvironment } from './utils'
|
||||
|
||||
async function run(): Promise<void> {
|
||||
const input = await getInputFromStdin()
|
||||
|
||||
const args = input['args']
|
||||
const command = input['command']
|
||||
const responseFile = input['responseFile']
|
||||
const state = input['state']
|
||||
|
||||
try {
|
||||
checkEnvironment()
|
||||
const input = await getInputFromStdin()
|
||||
|
||||
const args = input['args']
|
||||
const command = input['command']
|
||||
const responseFile = input['responseFile']
|
||||
const state = input['state']
|
||||
switch (command) {
|
||||
case Command.PrepareJob:
|
||||
await prepareJob(args as PrepareJobArgs, responseFile)
|
||||
|
||||
@@ -16,6 +16,7 @@ export async function runDockerCommand(
|
||||
args: string[],
|
||||
options?: RunDockerCommandOptions
|
||||
): Promise<string> {
|
||||
options = optionsWithDockerEnvs(options)
|
||||
const pipes = await exec.getExecOutput('docker', args, options)
|
||||
if (pipes.exitCode !== 0) {
|
||||
core.error(`Docker failed with exit code ${pipes.exitCode}`)
|
||||
@@ -24,6 +25,45 @@ export async function runDockerCommand(
|
||||
return Promise.resolve(pipes.stdout)
|
||||
}
|
||||
|
||||
export function optionsWithDockerEnvs(
|
||||
options?: RunDockerCommandOptions
|
||||
): RunDockerCommandOptions | undefined {
|
||||
// From https://docs.docker.com/engine/reference/commandline/cli/#environment-variables
|
||||
const dockerCliEnvs = new Set([
|
||||
'DOCKER_API_VERSION',
|
||||
'DOCKER_CERT_PATH',
|
||||
'DOCKER_CONFIG',
|
||||
'DOCKER_CONTENT_TRUST_SERVER',
|
||||
'DOCKER_CONTENT_TRUST',
|
||||
'DOCKER_CONTEXT',
|
||||
'DOCKER_DEFAULT_PLATFORM',
|
||||
'DOCKER_HIDE_LEGACY_COMMANDS',
|
||||
'DOCKER_HOST',
|
||||
'DOCKER_STACK_ORCHESTRATOR',
|
||||
'DOCKER_TLS_VERIFY',
|
||||
'BUILDKIT_PROGRESS'
|
||||
])
|
||||
const dockerEnvs = {}
|
||||
for (const key in process.env) {
|
||||
if (dockerCliEnvs.has(key)) {
|
||||
dockerEnvs[key] = process.env[key]
|
||||
}
|
||||
}
|
||||
|
||||
const newOptions = {
|
||||
workingDir: options?.workingDir,
|
||||
input: options?.input,
|
||||
env: options?.env || {}
|
||||
}
|
||||
|
||||
// Set docker envs or overwrite provided ones
|
||||
for (const [key, value] of Object.entries(dockerEnvs)) {
|
||||
newOptions.env[key] = value as string
|
||||
}
|
||||
|
||||
return newOptions
|
||||
}
|
||||
|
||||
export function sanitize(val: string): string {
|
||||
if (!val || typeof val !== 'string') {
|
||||
return ''
|
||||
|
||||
@@ -52,7 +52,9 @@ describe('run script step', () => {
|
||||
definitions.runScriptStep.args.entryPoint = '/bin/bash'
|
||||
definitions.runScriptStep.args.entryPointArgs = [
|
||||
'-c',
|
||||
`if [[ ! $(env | grep "^PATH=") = "PATH=${definitions.runScriptStep.args.prependPath}:"* ]]; then exit 1; fi`
|
||||
`if [[ ! $(env | grep "^PATH=") = "PATH=${definitions.runScriptStep.args.prependPath.join(
|
||||
':'
|
||||
)}:"* ]]; then exit 1; fi`
|
||||
]
|
||||
await expect(
|
||||
runScriptStep(definitions.runScriptStep.args, prepareJobResponse.state)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { sanitize } from '../src/utils'
|
||||
import { optionsWithDockerEnvs, sanitize } from '../src/utils'
|
||||
|
||||
describe('Utilities', () => {
|
||||
it('should return sanitized image name', () => {
|
||||
@@ -9,4 +9,41 @@ describe('Utilities', () => {
|
||||
const validStr = 'teststr8_one'
|
||||
expect(sanitize(validStr)).toBe(validStr)
|
||||
})
|
||||
|
||||
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
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
61
packages/hooklib/package-lock.json
generated
61
packages/hooklib/package-lock.json
generated
@@ -9,7 +9,7 @@
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@actions/core": "^1.6.0"
|
||||
"@actions/core": "^1.9.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^17.0.23",
|
||||
@@ -22,19 +22,20 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@actions/core": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@actions/core/-/core-1.6.0.tgz",
|
||||
"integrity": "sha512-NB1UAZomZlCV/LmJqkLhNTqtKfFXJZAUPcfl/zqG7EfsQdeUJtaWO98SGbuQ3pydJ3fHl2CvI/51OKYlCYYcaw==",
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@actions/core/-/core-1.9.1.tgz",
|
||||
"integrity": "sha512-5ad+U2YGrmmiw6du20AQW5XuWo7UKN2052FjSV7MX+Wfjf8sCqcsZe62NfgHys4QI4/Y+vQvLKYL8jWtA1ZBTA==",
|
||||
"dependencies": {
|
||||
"@actions/http-client": "^1.0.11"
|
||||
"@actions/http-client": "^2.0.1",
|
||||
"uuid": "^8.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@actions/http-client": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-1.0.11.tgz",
|
||||
"integrity": "sha512-VRYHGQV1rqnROJqdMvGUbY/Kn8vriQe/F9HR2AlYHzmKuM/p3kjNuXhmdBfcVgsvRWTz5C5XW5xvndZrVBuAYg==",
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-2.0.1.tgz",
|
||||
"integrity": "sha512-PIXiMVtz6VvyaRsGY268qvj57hXQEpsYogYOu2nrQhlf+XCGmZstmuZBbAybUl1nQGnvS1k1eEsQ69ZoD7xlSw==",
|
||||
"dependencies": {
|
||||
"tunnel": "0.0.6"
|
||||
"tunnel": "^0.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/eslintrc": {
|
||||
@@ -1741,9 +1742,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/json5": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz",
|
||||
"integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==",
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz",
|
||||
"integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.0"
|
||||
@@ -2485,6 +2486,14 @@
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "8.3.2",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/v8-compile-cache": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz",
|
||||
@@ -2546,19 +2555,20 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/core": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@actions/core/-/core-1.6.0.tgz",
|
||||
"integrity": "sha512-NB1UAZomZlCV/LmJqkLhNTqtKfFXJZAUPcfl/zqG7EfsQdeUJtaWO98SGbuQ3pydJ3fHl2CvI/51OKYlCYYcaw==",
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@actions/core/-/core-1.9.1.tgz",
|
||||
"integrity": "sha512-5ad+U2YGrmmiw6du20AQW5XuWo7UKN2052FjSV7MX+Wfjf8sCqcsZe62NfgHys4QI4/Y+vQvLKYL8jWtA1ZBTA==",
|
||||
"requires": {
|
||||
"@actions/http-client": "^1.0.11"
|
||||
"@actions/http-client": "^2.0.1",
|
||||
"uuid": "^8.3.2"
|
||||
}
|
||||
},
|
||||
"@actions/http-client": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-1.0.11.tgz",
|
||||
"integrity": "sha512-VRYHGQV1rqnROJqdMvGUbY/Kn8vriQe/F9HR2AlYHzmKuM/p3kjNuXhmdBfcVgsvRWTz5C5XW5xvndZrVBuAYg==",
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-2.0.1.tgz",
|
||||
"integrity": "sha512-PIXiMVtz6VvyaRsGY268qvj57hXQEpsYogYOu2nrQhlf+XCGmZstmuZBbAybUl1nQGnvS1k1eEsQ69ZoD7xlSw==",
|
||||
"requires": {
|
||||
"tunnel": "0.0.6"
|
||||
"tunnel": "^0.0.6"
|
||||
}
|
||||
},
|
||||
"@eslint/eslintrc": {
|
||||
@@ -3779,9 +3789,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"json5": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz",
|
||||
"integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==",
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz",
|
||||
"integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"minimist": "^1.2.0"
|
||||
@@ -4300,6 +4310,11 @@
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"uuid": {
|
||||
"version": "8.3.2",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="
|
||||
},
|
||||
"v8-compile-cache": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz",
|
||||
|
||||
@@ -23,6 +23,6 @@
|
||||
"typescript": "^4.6.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/core": "^1.6.0"
|
||||
"@actions/core": "^1.9.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ rules:
|
||||
- The `ACTIONS_RUNNER_POD_NAME` env should be set to the name of the pod
|
||||
- The `ACTIONS_RUNNER_REQUIRE_JOB_CONTAINER` env should be set to true to prevent the runner from running any jobs outside of a container
|
||||
- The runner pod should map a persistent volume claim into the `_work` directory
|
||||
- The `ACTIONS_RUNNER_CLAIM_NAME` env should be set to the persistent volume claim that contains the runner's working directory
|
||||
- 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
|
||||
|
||||
1024
packages/k8s/package-lock.json
generated
1024
packages/k8s/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -13,10 +13,10 @@
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@actions/core": "^1.6.0",
|
||||
"@actions/core": "^1.9.1",
|
||||
"@actions/exec": "^1.1.1",
|
||||
"@actions/io": "^1.1.2",
|
||||
"@kubernetes/client-node": "^0.16.3",
|
||||
"@kubernetes/client-node": "^0.18.1",
|
||||
"hooklib": "file:../hooklib"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -27,9 +27,7 @@ export function getStepPodName(): string {
|
||||
export function getVolumeClaimName(): string {
|
||||
const name = process.env.ACTIONS_RUNNER_CLAIM_NAME
|
||||
if (!name) {
|
||||
throw new Error(
|
||||
"'ACTIONS_RUNNER_CLAIM_NAME' is required, please contact your self hosted runner administrator"
|
||||
)
|
||||
return `${getRunnerPodName()}-work`
|
||||
}
|
||||
return name
|
||||
}
|
||||
@@ -41,14 +39,14 @@ export function getSecretName(): string {
|
||||
)}-secret-${uuidv4().substring(0, STEP_POD_NAME_SUFFIX_LENGTH)}`
|
||||
}
|
||||
|
||||
const MAX_POD_NAME_LENGTH = 63
|
||||
const STEP_POD_NAME_SUFFIX_LENGTH = 8
|
||||
export const MAX_POD_NAME_LENGTH = 63
|
||||
export const STEP_POD_NAME_SUFFIX_LENGTH = 8
|
||||
export const JOB_CONTAINER_NAME = 'job'
|
||||
|
||||
export class RunnerInstanceLabel {
|
||||
runnerhook: string
|
||||
private podName: string
|
||||
constructor() {
|
||||
this.runnerhook = process.env.ACTIONS_RUNNER_POD_NAME as string
|
||||
this.podName = getRunnerPodName()
|
||||
}
|
||||
|
||||
get key(): string {
|
||||
@@ -56,10 +54,10 @@ export class RunnerInstanceLabel {
|
||||
}
|
||||
|
||||
get value(): string {
|
||||
return this.runnerhook
|
||||
return this.podName
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return `runner-pod=${this.runnerhook}`
|
||||
return `runner-pod=${this.podName}`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
containerVolumes,
|
||||
DEFAULT_CONTAINER_ENTRY_POINT,
|
||||
DEFAULT_CONTAINER_ENTRY_POINT_ARGS,
|
||||
generateContainerName,
|
||||
PodPhase
|
||||
} from '../k8s/utils'
|
||||
import { JOB_CONTAINER_NAME } from './constants'
|
||||
@@ -31,14 +32,14 @@ export async function prepareJob(
|
||||
let container: k8s.V1Container | undefined = undefined
|
||||
if (args.container?.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)
|
||||
}
|
||||
|
||||
let services: k8s.V1Container[] = []
|
||||
if (args.services?.length) {
|
||||
services = args.services.map(service => {
|
||||
core.debug(`Adding service '${service.image}' to pod definition`)
|
||||
return createPodSpec(service, service.image.split(':')[0])
|
||||
return createContainerSpec(service, generateContainerName(service.image))
|
||||
})
|
||||
}
|
||||
if (!container && !services?.length) {
|
||||
@@ -46,10 +47,10 @@ export async function prepareJob(
|
||||
}
|
||||
let createdPod: k8s.V1Pod | undefined = undefined
|
||||
try {
|
||||
createdPod = await createPod(container, services, args.registry)
|
||||
createdPod = await createPod(container, services, args.container.registry)
|
||||
} catch (err) {
|
||||
await prunePods()
|
||||
throw new Error(`failed to create job pod: ${JSON.stringify(err)}`)
|
||||
throw new Error(`failed to create job pod: ${err}`)
|
||||
}
|
||||
|
||||
if (!createdPod?.metadata?.name) {
|
||||
@@ -124,13 +125,11 @@ function generateResponseFile(
|
||||
)
|
||||
if (serviceContainers?.length) {
|
||||
response.context['services'] = serviceContainers.map(c => {
|
||||
if (!c.ports) {
|
||||
return
|
||||
}
|
||||
|
||||
const ctxPorts: ContextPorts = {}
|
||||
for (const port of c.ports) {
|
||||
ctxPorts[port.containerPort] = port.hostPort
|
||||
if (c.ports?.length) {
|
||||
for (const port of c.ports) {
|
||||
ctxPorts[port.containerPort] = port.hostPort
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -153,12 +152,12 @@ async function copyExternalsToRoot(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
function createPodSpec(
|
||||
export function createContainerSpec(
|
||||
container,
|
||||
name: string,
|
||||
jobContainer = false
|
||||
): k8s.V1Container {
|
||||
if (!container.entryPoint) {
|
||||
if (!container.entryPoint && jobContainer) {
|
||||
container.entryPoint = DEFAULT_CONTAINER_ENTRY_POINT
|
||||
container.entryPointArgs = DEFAULT_CONTAINER_ENTRY_POINT_ARGS
|
||||
}
|
||||
@@ -166,14 +165,20 @@ function createPodSpec(
|
||||
const podContainer = {
|
||||
name,
|
||||
image: container.image,
|
||||
command: [container.entryPoint],
|
||||
args: container.entryPointArgs,
|
||||
ports: containerPorts(container)
|
||||
} as k8s.V1Container
|
||||
if (container.workingDirectory) {
|
||||
podContainer.workingDir = container.workingDirectory
|
||||
}
|
||||
|
||||
if (container.entryPoint) {
|
||||
podContainer.command = [container.entryPoint]
|
||||
}
|
||||
|
||||
if (container.entryPointArgs?.length > 0) {
|
||||
podContainer.args = container.entryPointArgs
|
||||
}
|
||||
|
||||
podContainer.env = []
|
||||
for (const [key, value] of Object.entries(
|
||||
container['environmentVariables']
|
||||
|
||||
@@ -28,7 +28,7 @@ export async function runScriptStep(
|
||||
JOB_CONTAINER_NAME
|
||||
)
|
||||
} catch (err) {
|
||||
throw new Error(`failed to run script step: ${JSON.stringify(err)}`)
|
||||
throw new Error(`failed to run script step: ${err}`)
|
||||
} finally {
|
||||
fs.rmSync(runnerPath)
|
||||
}
|
||||
|
||||
@@ -9,44 +9,42 @@ import {
|
||||
import { isAuthPermissionsOK, namespace, requiredPermissions } from './k8s'
|
||||
|
||||
async function run(): Promise<void> {
|
||||
const input = await getInputFromStdin()
|
||||
|
||||
const args = input['args']
|
||||
const command = input['command']
|
||||
const responseFile = input['responseFile']
|
||||
const state = input['state']
|
||||
|
||||
let exitCode = 0
|
||||
try {
|
||||
const input = await getInputFromStdin()
|
||||
|
||||
const args = input['args']
|
||||
const command = input['command']
|
||||
const responseFile = input['responseFile']
|
||||
const state = input['state']
|
||||
if (!(await isAuthPermissionsOK())) {
|
||||
throw new Error(
|
||||
`The Service account needs the following permissions ${JSON.stringify(
|
||||
requiredPermissions
|
||||
)} on the pod resource in the '${namespace}' namespace. Please contact your self hosted runner administrator.`
|
||||
)} on the pod resource in the '${namespace()}' namespace. Please contact your self hosted runner administrator.`
|
||||
)
|
||||
}
|
||||
|
||||
let exitCode = 0
|
||||
switch (command) {
|
||||
case Command.PrepareJob:
|
||||
await prepareJob(args as prepareJobArgs, responseFile)
|
||||
break
|
||||
return process.exit(0)
|
||||
case Command.CleanupJob:
|
||||
await cleanupJob()
|
||||
break
|
||||
return process.exit(0)
|
||||
case Command.RunScriptStep:
|
||||
await runScriptStep(args, state, null)
|
||||
break
|
||||
return process.exit(0)
|
||||
case Command.RunContainerStep:
|
||||
exitCode = await runContainerStep(args)
|
||||
break
|
||||
case Command.runContainerStep:
|
||||
return process.exit(exitCode)
|
||||
default:
|
||||
throw new Error(`Command not recognized: ${command}`)
|
||||
}
|
||||
} catch (error) {
|
||||
core.error(error as Error)
|
||||
exitCode = 1
|
||||
process.exit(1)
|
||||
}
|
||||
process.exitCode = exitCode
|
||||
}
|
||||
|
||||
void run()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import * as core from '@actions/core'
|
||||
import * as k8s from '@kubernetes/client-node'
|
||||
import { ContainerInfo, Registry } from 'hooklib'
|
||||
import * as stream from 'stream'
|
||||
@@ -109,13 +110,14 @@ export async function createPod(
|
||||
export async function createJob(
|
||||
container: k8s.V1Container
|
||||
): Promise<k8s.V1Job> {
|
||||
const job = new k8s.V1Job()
|
||||
const runnerInstanceLabel = new RunnerInstanceLabel()
|
||||
|
||||
const job = new k8s.V1Job()
|
||||
job.apiVersion = 'batch/v1'
|
||||
job.kind = 'Job'
|
||||
job.metadata = new k8s.V1ObjectMeta()
|
||||
job.metadata.name = getStepPodName()
|
||||
job.metadata.labels = { 'runner-pod': getRunnerPodName() }
|
||||
job.metadata.labels = { [runnerInstanceLabel.key]: runnerInstanceLabel.value }
|
||||
|
||||
job.spec = new k8s.V1JobSpec()
|
||||
job.spec.ttlSecondsAfterFinished = 300
|
||||
@@ -127,7 +129,7 @@ export async function createJob(
|
||||
job.spec.template.spec.restartPolicy = 'Never'
|
||||
job.spec.template.spec.nodeName = await getCurrentNodeName()
|
||||
|
||||
const claimName = `${runnerName()}-work`
|
||||
const claimName = getVolumeClaimName()
|
||||
job.spec.template.spec.volumes = [
|
||||
{
|
||||
name: 'work',
|
||||
@@ -185,33 +187,30 @@ export async function execPodStep(
|
||||
): Promise<void> {
|
||||
const exec = new k8s.Exec(kc)
|
||||
await new Promise(async function (resolve, reject) {
|
||||
try {
|
||||
await exec.exec(
|
||||
namespace(),
|
||||
podName,
|
||||
containerName,
|
||||
command,
|
||||
process.stdout,
|
||||
process.stderr,
|
||||
stdin ?? null,
|
||||
false /* tty */,
|
||||
resp => {
|
||||
// kube.exec returns an error if exit code is not 0, but we can't actually get the exit code
|
||||
if (resp.status === 'Success') {
|
||||
resolve(resp.code)
|
||||
} else {
|
||||
reject(
|
||||
JSON.stringify({
|
||||
message: resp?.message,
|
||||
details: resp?.details
|
||||
})
|
||||
)
|
||||
}
|
||||
await exec.exec(
|
||||
namespace(),
|
||||
podName,
|
||||
containerName,
|
||||
command,
|
||||
process.stdout,
|
||||
process.stderr,
|
||||
stdin ?? null,
|
||||
false /* tty */,
|
||||
resp => {
|
||||
// kube.exec returns an error if exit code is not 0, but we can't actually get the exit code
|
||||
if (resp.status === 'Success') {
|
||||
resolve(resp.code)
|
||||
} else {
|
||||
core.debug(
|
||||
JSON.stringify({
|
||||
message: resp?.message,
|
||||
details: resp?.details
|
||||
})
|
||||
)
|
||||
reject(resp?.message)
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
reject(JSON.stringify(error))
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -234,29 +233,34 @@ export async function createDockerSecret(
|
||||
): Promise<k8s.V1Secret> {
|
||||
const authContent = {
|
||||
auths: {
|
||||
[registry.serverUrl]: {
|
||||
[registry.serverUrl || 'https://index.docker.io/v1/']: {
|
||||
username: registry.username,
|
||||
password: registry.password,
|
||||
auth: Buffer.from(
|
||||
`${registry.username}:${registry.password}`,
|
||||
auth: Buffer.from(`${registry.username}:${registry.password}`).toString(
|
||||
'base64'
|
||||
).toString()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const runnerInstanceLabel = new RunnerInstanceLabel()
|
||||
|
||||
const secretName = getSecretName()
|
||||
const secret = new k8s.V1Secret()
|
||||
secret.immutable = true
|
||||
secret.apiVersion = 'v1'
|
||||
secret.metadata = new k8s.V1ObjectMeta()
|
||||
secret.metadata.name = secretName
|
||||
secret.metadata.labels = { 'runner-pod': getRunnerPodName() }
|
||||
secret.metadata.namespace = namespace()
|
||||
secret.metadata.labels = {
|
||||
[runnerInstanceLabel.key]: runnerInstanceLabel.value
|
||||
}
|
||||
secret.type = 'kubernetes.io/dockerconfigjson'
|
||||
secret.kind = 'Secret'
|
||||
secret.data = {
|
||||
'.dockerconfigjson': Buffer.from(
|
||||
JSON.stringify(authContent),
|
||||
'.dockerconfigjson': Buffer.from(JSON.stringify(authContent)).toString(
|
||||
'base64'
|
||||
).toString()
|
||||
)
|
||||
}
|
||||
|
||||
const { body } = await k8sApi.createNamespacedSecret(namespace(), secret)
|
||||
@@ -266,13 +270,18 @@ export async function createDockerSecret(
|
||||
export async function createSecretForEnvs(envs: {
|
||||
[key: string]: string
|
||||
}): Promise<string> {
|
||||
const runnerInstanceLabel = new RunnerInstanceLabel()
|
||||
|
||||
const secret = new k8s.V1Secret()
|
||||
const secretName = getSecretName()
|
||||
secret.immutable = true
|
||||
secret.apiVersion = 'v1'
|
||||
secret.metadata = new k8s.V1ObjectMeta()
|
||||
secret.metadata.name = secretName
|
||||
secret.metadata.labels = { 'runner-pod': getRunnerPodName() }
|
||||
|
||||
secret.metadata.labels = {
|
||||
[runnerInstanceLabel.key]: runnerInstanceLabel.value
|
||||
}
|
||||
secret.kind = 'Secret'
|
||||
secret.data = {}
|
||||
for (const [key, value] of Object.entries(envs)) {
|
||||
@@ -372,7 +381,7 @@ export async function getPodLogs(
|
||||
})
|
||||
|
||||
logStream.on('error', err => {
|
||||
process.stderr.write(JSON.stringify(err))
|
||||
process.stderr.write(err.message)
|
||||
})
|
||||
|
||||
const r = await log.log(namespace(), podName, containerName, logStream, {
|
||||
@@ -478,16 +487,6 @@ export function namespace(): string {
|
||||
return context.namespace
|
||||
}
|
||||
|
||||
function runnerName(): string {
|
||||
const name = process.env.ACTIONS_RUNNER_POD_NAME
|
||||
if (!name) {
|
||||
throw new Error(
|
||||
'Failed to determine runner name. "ACTIONS_RUNNER_POD_NAME" env variables should be set.'
|
||||
)
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
class BackOffManager {
|
||||
private backOffSeconds = 1
|
||||
totalTime = 0
|
||||
@@ -517,27 +516,40 @@ class BackOffManager {
|
||||
export function containerPorts(
|
||||
container: ContainerInfo
|
||||
): k8s.V1ContainerPort[] {
|
||||
// 8080:8080/tcp
|
||||
const portFormat = /(\d{1,5})(:(\d{1,5}))?(\/(tcp|udp))?/
|
||||
|
||||
const ports: k8s.V1ContainerPort[] = []
|
||||
if (!container.portMappings?.length) {
|
||||
return ports
|
||||
}
|
||||
for (const portDefinition of container.portMappings) {
|
||||
const submatches = portFormat.exec(portDefinition)
|
||||
if (!submatches) {
|
||||
throw new Error(
|
||||
`Port definition "${portDefinition}" is in incorrect format`
|
||||
)
|
||||
const portProtoSplit = portDefinition.split('/')
|
||||
if (portProtoSplit.length > 2) {
|
||||
throw new Error(`Unexpected port format: ${portDefinition}`)
|
||||
}
|
||||
|
||||
const port = new k8s.V1ContainerPort()
|
||||
port.hostPort = Number(submatches[1])
|
||||
if (submatches[3]) {
|
||||
port.containerPort = Number(submatches[3])
|
||||
port.protocol =
|
||||
portProtoSplit.length === 2 ? portProtoSplit[1].toUpperCase() : 'TCP'
|
||||
|
||||
const portSplit = portProtoSplit[0].split(':')
|
||||
if (portSplit.length > 2) {
|
||||
throw new Error('ports should have at most one ":" separator')
|
||||
}
|
||||
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 {
|
||||
port.protocol = 'TCP'
|
||||
port.hostPort = parsePort(portSplit[0])
|
||||
port.containerPort = parsePort(portSplit[1])
|
||||
}
|
||||
|
||||
ports.push(port)
|
||||
}
|
||||
return ports
|
||||
|
||||
@@ -20,18 +20,20 @@ export function containerVolumes(
|
||||
}
|
||||
]
|
||||
|
||||
const workspacePath = process.env.GITHUB_WORKSPACE as string
|
||||
if (containerAction) {
|
||||
const workspace = process.env.GITHUB_WORKSPACE as string
|
||||
const i = workspacePath.lastIndexOf('_work/')
|
||||
const workspaceRelativePath = workspacePath.slice(i + '_work/'.length)
|
||||
mounts.push(
|
||||
{
|
||||
name: POD_VOLUME_NAME,
|
||||
mountPath: '/github/workspace',
|
||||
subPath: workspace.substring(workspace.indexOf('work/') + 1)
|
||||
subPath: workspaceRelativePath
|
||||
},
|
||||
{
|
||||
name: POD_VOLUME_NAME,
|
||||
mountPath: '/github/file_commands',
|
||||
subPath: workspace.substring(workspace.indexOf('work/') + 1)
|
||||
subPath: '_temp/_runner_file_commands'
|
||||
}
|
||||
)
|
||||
return mounts
|
||||
@@ -63,7 +65,6 @@ export function containerVolumes(
|
||||
return mounts
|
||||
}
|
||||
|
||||
const workspacePath = process.env.GITHUB_WORKSPACE as string
|
||||
for (const userVolume of userMountVolumes) {
|
||||
let sourceVolumePath = ''
|
||||
if (path.isAbsolute(userVolume.sourceVolumePath)) {
|
||||
@@ -110,11 +111,21 @@ export function writeEntryPointScript(
|
||||
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(' ')} `
|
||||
@@ -136,6 +147,17 @@ exec ${environmentPrefix} ${entryPoint} ${
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
export enum PodPhase {
|
||||
PENDING = 'Pending',
|
||||
RUNNING = 'Running',
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import * as k8s from '@kubernetes/client-node'
|
||||
import { cleanupJob, prepareJob } from '../src/hooks'
|
||||
import { RunnerInstanceLabel } from '../src/hooks/constants'
|
||||
import { namespace } from '../src/k8s'
|
||||
import { TestHelper } from './test-setup'
|
||||
|
||||
let testHelper: TestHelper
|
||||
@@ -13,10 +16,50 @@ describe('Cleanup Job', () => {
|
||||
)
|
||||
await prepareJob(prepareJobData.args, prepareJobOutputFilePath)
|
||||
})
|
||||
it('should not throw', async () => {
|
||||
await expect(cleanupJob()).resolves.not.toThrow()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await testHelper.cleanup()
|
||||
})
|
||||
|
||||
it('should not throw', async () => {
|
||||
await expect(cleanupJob()).resolves.not.toThrow()
|
||||
})
|
||||
|
||||
it('should have no runner linked pods running', async () => {
|
||||
await cleanupJob()
|
||||
const kc = new k8s.KubeConfig()
|
||||
|
||||
kc.loadFromDefault()
|
||||
const k8sApi = kc.makeApiClient(k8s.CoreV1Api)
|
||||
|
||||
const podList = await k8sApi.listNamespacedPod(
|
||||
namespace(),
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
new RunnerInstanceLabel().toString()
|
||||
)
|
||||
|
||||
expect(podList.body.items.length).toBe(0)
|
||||
})
|
||||
|
||||
it('should have no runner linked secrets', async () => {
|
||||
await cleanupJob()
|
||||
const kc = new k8s.KubeConfig()
|
||||
|
||||
kc.loadFromDefault()
|
||||
const k8sApi = kc.makeApiClient(k8s.CoreV1Api)
|
||||
|
||||
const secretList = await k8sApi.listNamespacedSecret(
|
||||
namespace(),
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
new RunnerInstanceLabel().toString()
|
||||
)
|
||||
|
||||
expect(secretList.body.items.length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
331
packages/k8s/tests/k8s-utils-test.ts
Normal file
331
packages/k8s/tests/k8s-utils-test.ts
Normal file
@@ -0,0 +1,331 @@
|
||||
import * as fs from 'fs'
|
||||
import { containerPorts, POD_VOLUME_NAME } from '../src/k8s'
|
||||
import {
|
||||
containerVolumes,
|
||||
generateContainerName,
|
||||
writeEntryPointScript
|
||||
} from '../src/k8s/utils'
|
||||
import { TestHelper } from './test-setup'
|
||||
|
||||
let testHelper: TestHelper
|
||||
|
||||
describe('k8s utils', () => {
|
||||
describe('write entrypoint', () => {
|
||||
beforeEach(async () => {
|
||||
testHelper = new TestHelper()
|
||||
await testHelper.initialize()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await testHelper.cleanup()
|
||||
})
|
||||
|
||||
it('should not throw', () => {
|
||||
expect(() =>
|
||||
writeEntryPointScript(
|
||||
'/test',
|
||||
'sh',
|
||||
['-e', 'script.sh'],
|
||||
['/prepend/path'],
|
||||
{
|
||||
SOME_ENV: 'SOME_VALUE'
|
||||
}
|
||||
)
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
it('should throw if RUNNER_TEMP is not set', () => {
|
||||
delete process.env.RUNNER_TEMP
|
||||
expect(() =>
|
||||
writeEntryPointScript(
|
||||
'/test',
|
||||
'sh',
|
||||
['-e', 'script.sh'],
|
||||
['/prepend/path'],
|
||||
{
|
||||
SOME_ENV: 'SOME_VALUE'
|
||||
}
|
||||
)
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
it('should 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,10 @@
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
import { cleanupJob } from '../src/hooks'
|
||||
import { prepareJob } from '../src/hooks/prepare-job'
|
||||
import { createContainerSpec, prepareJob } from '../src/hooks/prepare-job'
|
||||
import { TestHelper } from './test-setup'
|
||||
import { generateContainerName } from '../src/k8s/utils'
|
||||
import { V1Container } from '@kubernetes/client-node'
|
||||
|
||||
jest.useRealTimers()
|
||||
|
||||
@@ -71,4 +73,27 @@ describe('Prepare job', () => {
|
||||
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)
|
||||
})
|
||||
|
||||
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()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -89,12 +89,36 @@ describe('Run script step', () => {
|
||||
).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}:"* ]]; then exit 1; fi'`
|
||||
`'if [[ ! $(env | grep "^PATH=") = "PATH=${runScriptStepDefinition.args.prependPath.join(
|
||||
':'
|
||||
)}:"* ]]; then exit 1; fi'`
|
||||
]
|
||||
|
||||
await expect(
|
||||
|
||||
@@ -21,7 +21,6 @@ export class TestHelper {
|
||||
|
||||
public async initialize(): Promise<void> {
|
||||
process.env['ACTIONS_RUNNER_POD_NAME'] = `${this.podName}`
|
||||
process.env['ACTIONS_RUNNER_CLAIM_NAME'] = `${this.podName}-work`
|
||||
process.env['RUNNER_WORKSPACE'] = `${this.tempDirPath}/_work/repo`
|
||||
process.env['RUNNER_TEMP'] = `${this.tempDirPath}/_work/_temp`
|
||||
process.env['GITHUB_WORKSPACE'] = `${this.tempDirPath}/_work/repo/repo`
|
||||
@@ -41,7 +40,7 @@ export class TestHelper {
|
||||
await this.createTestVolume()
|
||||
await this.createTestJobPod()
|
||||
} catch (e) {
|
||||
console.log(JSON.stringify(e))
|
||||
console.log(e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
## Features
|
||||
- Initial Release
|
||||
|
||||
<!-- ## Features -->
|
||||
## Bugs
|
||||
|
||||
- Handle `$` symbols in environment variable names and values in k8s [#74]
|
||||
|
||||
## Misc
|
||||
<!-- ## Misc -->
|
||||
|
||||
Reference in New Issue
Block a user