Compare commits

...
Author SHA1 Message Date
Nikola JokicandSisyphus 9e78b267f6 refactor(k8s): invert scheduler env var to make affinity default
Change ACTIONS_RUNNER_USE_KUBE_SCHEDULER to ACTIONS_RUNNER_DISABLE_KUBE_SCHEDULER
to make affinity-based scheduling the first-class (default) implementation.

Breaking Change:
- OLD: Set ACTIONS_RUNNER_USE_KUBE_SCHEDULER=true to enable affinity (opt-in)
- NEW: Affinity is enabled by default, set ACTIONS_RUNNER_DISABLE_KUBE_SCHEDULER=true to disable (opt-out)

Code changes:
- utils.ts: Rename constant and invert useKubeScheduler() logic
- rwo-affinity-test.ts: Update tests to verify default affinity behavior
- ADR 0135: Update to reflect opt-out model
- README: Update guidance to reflect default behavior

Co-authored-by: Sisyphus <[email protected]>
2026-04-23 00:23:47 +02:00
Nikola JokicandSisyphus 51a0a891f6 docs(adr,k8s): clarify RWX enables free scheduling, RWO requires affinity
- RWX volumes allow job pods to be scheduled on any cluster node
- RWO volumes require affinity to pin job pods to runner's node
- Remove ACTIONS_RUNNER_USE_KUBE_SCHEDULER from RWX migration steps
- Emphasize resource utilization benefits of RWX free scheduling

Co-authored-by: Sisyphus <[email protected]>
2026-04-23 00:09:05 +02:00
Nikola Jokic 5d337ff96e lint fmt 2026-04-22 22:59:40 +02:00
Nikola Jokic 4972708208 Ensure read write many first 2026-04-22 22:55:49 +02:00
tfhdddandGitHub 6ecda1d8ea fix: Resolves the issue of inconsistent hash calculations between runner and workflow files. (#316)
CodeQL / Analyze (javascript) (push) Has been cancelled
2026-02-13 22:47:15 +01:00
Nikola JokicandGitHub d21e5b430e Prepare 0.8.1 release (#311)
* Prepare 0.8.1 release

* setup node

* Update releaseNotes.md
2026-02-04 11:13:03 +01:00
Nikola JokicandGitHub d8d2e74810 Bump packages (#304)
* Bump packages

* bump exec
2026-01-15 21:21:58 +01:00
Will HopkinsandGitHub 5f5708a2b8 Overwrite runnner file commands (#298) 2025-12-12 13:57:47 +01:00
f8e1cae677 Reduce the amount of data copied to the workflow pod (#293)
* run script copies back only runner file commands

* wip

* fix

* fmt

* user volume mount

* try doing only file commands

* typo

* remove _temp_pre

* Update packages/k8s/src/hooks/run-script-step.ts

Co-authored-by: Copilot <[email protected]>

* Update packages/k8s/src/hooks/run-script-step.ts

Co-authored-by: Copilot <[email protected]>

* better escape

* no useless escapes

---------

Co-authored-by: Copilot <[email protected]>
2025-12-10 21:49:35 +01:00
Nikola JokicandGitHub 996cc75daf Group dependabot updates (#289) 2025-12-10 21:49:27 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
adf5e34937 Bump js-yaml from 4.1.0 to 4.1.1 (#276)
Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.1.0 to 4.1.1.
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/4.1.0...4.1.1)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 4.1.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-27 16:34:47 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
4041f8648c Bump js-yaml from 4.1.0 to 4.1.1 in /packages/hooklib (#277)
Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.1.0 to 4.1.1.
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/4.1.0...4.1.1)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 4.1.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-27 16:34:17 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
1f60eaf940 Bump glob from 10.4.5 to 10.5.0 in /packages/k8s (#278)
Bumps [glob](https://github.com/isaacs/node-glob) from 10.4.5 to 10.5.0.
- [Changelog](https://github.com/isaacs/node-glob/blob/main/changelog.md)
- [Commits](https://github.com/isaacs/node-glob/compare/v10.4.5...v10.5.0)

---
updated-dependencies:
- dependency-name: glob
  dependency-version: 10.5.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-27 16:34:01 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
c3d8e2ab20 Bump glob from 10.4.5 to 10.5.0 in /packages/docker (#279)
Bumps [glob](https://github.com/isaacs/node-glob) from 10.4.5 to 10.5.0.
- [Changelog](https://github.com/isaacs/node-glob/blob/main/changelog.md)
- [Commits](https://github.com/isaacs/node-glob/compare/v10.4.5...v10.5.0)

---
updated-dependencies:
- dependency-name: glob
  dependency-version: 10.5.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-27 16:33:39 +01:00
zarko-aandGitHub 3f829eef9e Fix event.json not being copied to /github/workflow in kubernetes-novolume mode (#287)
In run-script-step, the _temp directory was being copied to the workflow pod,
but the _github_home and _github_workflow directories were not being moved
from their temporary location to the /github directory structure where they
are expected by GitHub Actions.

This caused event.json to be missing at /github/workflow/event.json, breaking
actions that depend on GITHUB_EVENT_PATH.

The fix adds a setup step that copies _github_home and _github_workflow from
/__w/_temp/ to /github/ after copying the temp directory to the pod, matching
the behavior of run-container-step and prepareJobScript.

Uses cp -r instead of symlinks to avoid symlink validation errors when copying
files back from the pod to the runner.
2025-11-26 11:47:19 +01:00
011ffb284e Fix workingDir permissions issue by creating it within init container (#283)
* Fix workingDir permissions issue by creating it within init container

* Apply suggestion from @Copilot

Co-authored-by: Copilot <[email protected]>

* rework init commands

---------

Co-authored-by: Copilot <[email protected]>
2025-11-26 11:46:47 +01:00
Vincent Van OuytselandGitHub 0951cc73e4 Improve validation checks after copying (#285)
* fix: calculate hash again after failure

The hash from the source is calculated only once. The source hash is
checked with the destination hash, but if the destination hash does not
match, the destination match is calculated again.

The problem is that if the source hash is incorrect, the check will keep
failing because the source hash is never re-calculated.

Now, in the event that the hashes do not match, the hash of the source
and the destination are calculated again.

* fix: use size instead of block size

Previously the %b parameter was used with stat. This displays the block
size of the file. We noticed that in some cases the block size of the
source and the destination file could be slightly different. Since the
source and target run in different containers, they can have different
block sizes defined. If the block size did not match, the hash would also not match, even if
the file content would be exactly the same.

With this change, the block size is no longer used. Instead the actual
size in bytes of the file is listed.
2025-11-24 16:14:02 +01:00
Nikola JokicandGitHub 15e808935c Allow non-root container (#264)
* Allow non-root container

* format

* add lint:fix and fix lint errors

* fix tests and volume mounts
2025-11-21 14:44:29 +01:00
vvanouytsel-trendminerandGitHub ad9cb43c31 feat: check if required binaries are present (#272)
* feat: check if required binaries are present

Previously the necessary binaries were copied over using the runner
container. This lead to issues in case your main container was using the
musl libc implementation.

Instead of copying over any binaries, the initContainer now checks if
the required binaries are present in the main container.

* feat: get rid of the init container

* fix: add _runner_file_commands

* fix: do not fail if _runner_file_commands does not exist

It seems that for container actions this directory does not exist.
2025-11-10 15:01:40 +01:00
zarko-aandGitHub 2934de33f8 Sort 'find' output before hashing for consistency (#267)
* Sort 'find' output before hashing for consistency across different platforms

* fix style issues
2025-11-04 12:06:36 +01:00
Jiang LongandGitHub ea25fd1b3e Change command to remove sudo to fix fs-init inital container (#263)
* Change command to copy externals instead of move

* fix: using only mv, remove sudo
2025-10-21 15:47:08 +02:00
c03a5fb3c1 Prepare 0.8.0 release and bump dependencies once more (#256)
* Prepare 0.8.0 release and bump dependencies once more

* Update releaseNotes.md

Co-authored-by: Copilot <[email protected]>

---------

Co-authored-by: Copilot <[email protected]>
2025-10-04 11:53:55 +02:00
96c35e7cc6 Remove dependency on the runner's volume (#244)
* bump actions

* experiment using init container to prepare working environment

* rm script before continuing

* fix

* Update packages/k8s/src/hooks/run-script-step.ts

Co-authored-by: Copilot <[email protected]>

* leverage exec stat instead of printf

* npm update

* document the new constraint

---------

Co-authored-by: DenisPalnitsky <[email protected]>
2025-10-02 16:23:07 +02:00
Nikola JokicandGitHub c67938c536 bump actions (#254) 2025-10-02 16:20:55 +02:00
464be47642 Separate CI docker and k8s tests (#250)
* Separate tests

* Update .github/workflows/build.yaml

Co-authored-by: Copilot <[email protected]>

---------

Co-authored-by: Copilot <[email protected]>
2025-09-24 19:18:35 +02:00
Nikola JokicandGitHub 74ce64c1d0 Update codeowners to reflect the same team from the ARC (#251) 2025-09-24 12:40:42 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
9a71a3a7e9 Bump brace-expansion from 1.1.11 to 1.1.12 (#238)
Bumps [brace-expansion](https://github.com/juliangruber/brace-expansion) from 1.1.11 to 1.1.12.
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/1.1.11...v1.1.12)

---
updated-dependencies:
- dependency-name: brace-expansion
  dependency-version: 1.1.12
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-29 11:24:23 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
9a858922c8 Bump form-data from 4.0.3 to 4.0.4 in /packages/k8s (#239)
Bumps [form-data](https://github.com/form-data/form-data) from 4.0.3 to 4.0.4.
- [Release notes](https://github.com/form-data/form-data/releases)
- [Changelog](https://github.com/form-data/form-data/blob/master/CHANGELOG.md)
- [Commits](https://github.com/form-data/form-data/compare/v4.0.3...v4.0.4)

---
updated-dependencies:
- dependency-name: form-data
  dependency-version: 4.0.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-29 11:24:01 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
605551ff1c Bump @eslint/plugin-kit from 0.3.3 to 0.3.4 (#240)
Bumps [@eslint/plugin-kit](https://github.com/eslint/rewrite/tree/HEAD/packages/plugin-kit) from 0.3.3 to 0.3.4.
- [Release notes](https://github.com/eslint/rewrite/releases)
- [Changelog](https://github.com/eslint/rewrite/blob/main/packages/plugin-kit/CHANGELOG.md)
- [Commits](https://github.com/eslint/rewrite/commits/plugin-kit-v0.3.4/packages/plugin-kit)

---
updated-dependencies:
- dependency-name: "@eslint/plugin-kit"
  dependency-version: 0.3.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-29 11:23:37 +02:00
name_snrlandGitHub 878781f9c4 docker: fix readOnly volumes in createContainer (#236) 2025-07-29 11:12:01 +02:00
1e051b849b Update codeowners (#237)
* Update codeowners

* Update CODEOWNERS

Co-authored-by: Rocio Montes <[email protected]>

---------

Co-authored-by: Rocio Montes <[email protected]>
2025-07-29 11:11:27 +02:00
Nikola JokicandGitHub 589414ea69 Bump all dependencies (#234)
* Bump all dependencies

* build and reformat

* lint

* format
2025-07-29 11:06:45 +02:00
Ben De St Paer-GotchandGitHub dd4f7dae2c Update README.md (#224) 2025-06-05 14:03:29 +02:00
Nikola JokicandGitHub 7da5474a5d Release 0.7.0 (#218) 2025-04-17 12:34:48 +02:00
Nikola JokicandGitHub 375992cd31 Expose CI=true and GITHUB_ACTIONS env variables (#215)
* Expose CI=true and GITHUB_ACTIONS env variables

* fmt

* revert the prettier and finish this

* revert package-lock.json
2025-04-17 12:08:32 +02:00
Nikola JokicandGitHub aae800a69b bump node in tests to node 22 since node14 is quite old (#216)
* bump node in tests to node 22 since node14 is quite old

* change test contsants
2025-04-16 15:57:59 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
e47f9b8af4 Bump jsonpath-plus from 10.1.0 to 10.3.0 in /packages/k8s (#213)
Bumps [jsonpath-plus](https://github.com/s3u/JSONPath) from 10.1.0 to 10.3.0.
- [Release notes](https://github.com/s3u/JSONPath/releases)
- [Changelog](https://github.com/JSONPath-Plus/JSONPath/blob/main/CHANGES.md)
- [Commits](https://github.com/s3u/JSONPath/compare/v10.1.0...v10.3.0)

---
updated-dependencies:
- dependency-name: jsonpath-plus
  dependency-version: 10.3.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-15 14:25:32 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
54e14cb7f3 Bump braces from 3.0.2 to 3.0.3 in /packages/hooklib (#194)
Bumps [braces](https://github.com/micromatch/braces) from 3.0.2 to 3.0.3.
- [Changelog](https://github.com/micromatch/braces/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/braces/compare/3.0.2...3.0.3)

---
updated-dependencies:
- dependency-name: braces
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-14 14:37:19 +02:00
Grant BuskeyandGitHub ef2229fc0b feat(k8s): add /github/home to containerAction mounts and surface createSecretForEnvs errors #181 (#198)
* feat: add /github/home to containerAction mounts #181

* fix: add debug logging for failed secret creations #181
2025-04-14 14:12:51 +02:00
Andre KlärnerandGitHub 88dc98f8ef k8s: start logging from the beginning (#184) 2025-04-14 14:03:05 +02:00
Joan Miquel LuqueandGitHub b388518d40 feat(k8s): Use pod affinity when KubeScheduler is enabled #201 (#212)
Signed-off-by: Joan Miquel Luque Oliver <[email protected]>
2025-04-14 13:36:21 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
7afb8f9323 Bump cross-spawn from 7.0.3 to 7.0.6 in /packages/k8s (#196)
Bumps [cross-spawn](https://github.com/moxystudio/node-cross-spawn) from 7.0.3 to 7.0.6.
- [Changelog](https://github.com/moxystudio/node-cross-spawn/blob/master/CHANGELOG.md)
- [Commits](https://github.com/moxystudio/node-cross-spawn/compare/v7.0.3...v7.0.6)

---
updated-dependencies:
- dependency-name: cross-spawn
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-24 16:51:12 +01:00
Robin BobbittandGitHub d4c5425b22 support alternative network modes (#209) 2025-03-24 16:33:43 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
120636d3d7 Bump ws from 7.5.8 to 7.5.10 in /packages/k8s (#192)
Bumps [ws](https://github.com/websockets/ws) from 7.5.8 to 7.5.10.
- [Release notes](https://github.com/websockets/ws/releases)
- [Commits](https://github.com/websockets/ws/compare/7.5.8...7.5.10)

---
updated-dependencies:
- dependency-name: ws
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-06 11:41:28 -05:00
Josh GrossandGitHub 5e805a0546 Remove dependency on deprecated release actions (#193)
* Update to the latest available actions

* Remove dependency on deprecated release actions

* Add release workflow fixes from testing
2024-11-06 11:41:09 -05:00
Josh GrossandGitHub 27bae0b2b7 Update to the latest available actions (#191) 2024-11-06 11:18:49 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
8eed1ad1b6 Bump jsonpath-plus and @kubernetes/client-node in /packages/k8s (#187)
Bumps [jsonpath-plus](https://github.com/s3u/JSONPath) to 10.1.0 and updates ancestor dependency [@kubernetes/client-node](https://github.com/kubernetes-client/javascript). These dependencies need to be updated together.


Updates `jsonpath-plus` from 9.0.0 to 10.1.0
- [Release notes](https://github.com/s3u/JSONPath/releases)
- [Changelog](https://github.com/JSONPath-Plus/JSONPath/blob/main/CHANGES.md)
- [Commits](https://github.com/s3u/JSONPath/compare/v9.0.0...v10.1.0)

Updates `@kubernetes/client-node` from 0.22.0 to 0.22.2
- [Release notes](https://github.com/kubernetes-client/javascript/releases)
- [Changelog](https://github.com/kubernetes-client/javascript/blob/master/CHANGELOG.md)
- [Commits](https://github.com/kubernetes-client/javascript/compare/0.22.0...0.22.2)

---
updated-dependencies:
- dependency-name: jsonpath-plus
  dependency-type: indirect
- dependency-name: "@kubernetes/client-node"
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-06 10:34:45 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
7b404841b2 Bump braces from 3.0.2 to 3.0.3 in /packages/k8s (#188)
Bumps [braces](https://github.com/micromatch/braces) from 3.0.2 to 3.0.3.
- [Changelog](https://github.com/micromatch/braces/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/braces/compare/3.0.2...3.0.3)

---
updated-dependencies:
- dependency-name: braces
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-06 10:34:16 -05:00
Josh GrossandGitHub 977d53963d Remove @actions/runner-akvelon from CODEOWNERS (#190) 2024-11-05 18:13:42 -05:00
Josh GrossandGitHub 77b40ac6df Prepare 0.6.2 Release (#189) 2024-11-05 14:36:03 -05:00
Oliver RadwellandGitHub ee10d95fd4 Bump kubernetes/client-node from 0.18.1 to 0.22.0 (#182) 2024-11-05 13:22:04 -05:00
Nikola JokicandGitHub 73655d4639 Release 0.6.1 (#172) 2024-06-19 13:42:23 +02:00
Nikola JokicandGitHub ca4ea17d58 Skip writing extension containers in output context file (#154) 2024-06-19 11:49:43 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
ed70e2f8e0 Bump tar from 6.1.11 to 6.2.1 in /packages/k8s (#156)
Bumps [tar](https://github.com/isaacs/node-tar) from 6.1.11 to 6.2.1.
- [Release notes](https://github.com/isaacs/node-tar/releases)
- [Changelog](https://github.com/isaacs/node-tar/blob/main/CHANGELOG.md)
- [Commits](https://github.com/isaacs/node-tar/compare/v6.1.11...v6.2.1)

---
updated-dependencies:
- dependency-name: tar
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-18 17:35:16 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
aeabaf144a Bump braces from 3.0.2 to 3.0.3 in /packages/docker (#171)
Bumps [braces](https://github.com/micromatch/braces) from 3.0.2 to 3.0.3.
- [Changelog](https://github.com/micromatch/braces/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/braces/compare/3.0.2...3.0.3)

---
updated-dependencies:
- dependency-name: braces
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-18 17:34:53 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
8388a36f44 Bump ws from 7.5.7 to 7.5.10 in /packages/docker (#170)
Bumps [ws](https://github.com/websockets/ws) from 7.5.7 to 7.5.10.
- [Release notes](https://github.com/websockets/ws/releases)
- [Commits](https://github.com/websockets/ws/compare/7.5.7...7.5.10)

---
updated-dependencies:
- dependency-name: ws
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-18 16:19:49 +02:00
Nikola JokicandGitHub 9705deeb08 Release 0.6.0 (#148) 2024-03-14 14:36:02 +01:00
KatarzynaandGitHub 99efdeca99 Mount /github/workflow to docker action pods (#137)
* Mount /github/workflow to docker action pods, the same way as for container job pods

* Adjust tests
2024-03-14 12:36:27 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
bb09a79b22 Bump jose from 4.11.4 to 4.15.5 in /packages/k8s (#142)
Bumps [jose](https://github.com/panva/jose) from 4.11.4 to 4.15.5.
- [Release notes](https://github.com/panva/jose/releases)
- [Changelog](https://github.com/panva/jose/blob/v4.15.5/CHANGELOG.md)
- [Commits](https://github.com/panva/jose/compare/v4.11.4...v4.15.5)

---
updated-dependencies:
- dependency-name: jose
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-14 12:33:36 +01:00
KatarzynaandGitHub 746e644039 ADR-0134 superseding ADR-0096 (#136)
Related to https://github.com/actions/runner-container-hooks/issues/132
2024-03-14 12:33:06 +01:00
7223e1dbb2 Use ACTIONS_RUNNER_CONTAINER_HOOK_TEMPLATE to extend service containers (#134)
https://github.com/actions/runner-container-hooks/issues/132

Co-authored-by: Katarzyna Radkowska <[email protected]>
2024-02-20 16:19:29 +01:00
af27abe1f7 Read logs also from failed child (container job/container action) pod (#135)
Co-authored-by: Katarzyna Radkowska <[email protected]>
2024-02-20 12:01:11 +01:00
Nikola JokicandGitHub 638bd19c9d Release 0.5.1 (#131)
* Release 0.5.1

* Add one more PR that is part of the release
2024-02-05 15:00:35 +01:00
50e14cf868 Switch exec pod promise to reject on websocket error (#127)
* Switch exec pod promise to reject on websocket error

* Fix incorrectly resolved merge conflict

* Apply suggestions from code review

Co-authored-by: Ferenc Hammerl <[email protected]>

---------

Co-authored-by: Ferenc Hammerl <[email protected]>
2024-02-05 14:40:15 +01:00
Nikola JokicandGitHub 921be5b85f Fix is alpine check using shlex (#130) 2024-02-05 09:50:51 +01:00
Nikola JokicandGitHub 0cce49705b Try to get response body message and log entire error response in debug mode (#123) 2023-12-15 13:01:04 +01:00
Nikola JokicandGitHub 46c92fe43e Release 0.5.0 (#119) 2023-11-22 13:15:52 +01:00
Nikola JokicandGitHub 56208347f1 Update 0096-hook-extensions.md (#118) 2023-11-20 15:10:21 +01:00
Nikola JokicandGitHub c093f87779 Docker and K8s: Fix shell arguments when split by the runner (#115)
* Docker: Fix shell arguments when split by the runner

* Add shlex to k8s hook as well
2023-11-20 15:09:36 +01:00
Nikola JokicandGitHub c47c74ad9e Update CODEOWNERS (#114) 2023-11-09 14:11:50 +01:00
Wout Van De WielandGitHub 90a6236466 Add option to use the kubernetes scheduler for workflow pods (#111)
* Add option to use kube scheduler

This should only be used when rwx volumes are supported or when using a single node cluster.

* Add option to set timeout for prepare job

If the kube scheduler is used to hold jobs until sufficient resources are available,
then prepare job needs to wait for a longer period until the workflow pod is running.
This timeout will mostly need an increase in cases where many jobs are triggered
which together exceed the resources available in the cluster.
The workflows can then be gracefully handled later when sufficient resources become available again.

* Skip name override warning when names match or job extension

* Add guard for positive timeouts with a warning

* Write out ReadWriteMany in full
2023-10-31 12:51:09 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
496287d61d Bump @babel/traverse from 7.18.2 to 7.23.2 in /packages/k8s (#110)
Bumps [@babel/traverse](https://github.com/babel/babel/tree/HEAD/packages/babel-traverse) from 7.18.2 to 7.23.2.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.23.2/packages/babel-traverse)

---
updated-dependencies:
- dependency-name: "@babel/traverse"
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-10-18 09:36:40 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
5264b6cd7d Bump @babel/traverse from 7.17.9 to 7.23.2 in /packages/docker (#109)
Bumps [@babel/traverse](https://github.com/babel/babel/tree/HEAD/packages/babel-traverse) from 7.17.9 to 7.23.2.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.23.2/packages/babel-traverse)

---
updated-dependencies:
- dependency-name: "@babel/traverse"
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-10-18 09:36:30 +02:00
Nikola JokicandGitHub b58b13134a import fs in CD workflow (#106) 2023-09-25 14:39:20 +02:00
Nikola JokicandGitHub 8ea7e21dec Fix CD release order (#105) 2023-09-25 14:34:49 +02:00
Nikola JokicandGitHub 64000d716a Release notes for 0.4.0 version (#104) 2023-09-25 13:53:01 +02:00
Nikola JokicandGitHub 4ff4b552a6 [ADR] Hook extensions (#96)
* [ADR] Hook extensions

* Add ADR number

* Add image field to specify that it is going to be ignored

* Update env name, explain that the file is going to be applied to both job and container step pods

* rewphrase job container to job pod

* update name for the job to $job
2023-09-25 13:52:48 +02:00
Nikola JokicandGitHub 4cdcf09c43 Implement yaml extensions overwriting the default pod/container spec (#75)
* Implement yaml extensions overwriting the default pod/container spec

* format files

* Extend specs for container job and include docker and k8s tests in k8s

* Create table tests for docker tests

* included warnings and extracted append logic as generic

* updated merge to allow for file read

* reverted back examples and k8s/tests

* reverted back docker tests

* Tests for extension prepare-job

* Fix lint and format and merge error

* Added basic test for container step

* revert hooklib since new definition for container options is received from a file

* revert docker options since create options are a string

* Fix revert

* Update package locks and deps

* included example of extension.yaml. Added side-car container that was missing

* Ignore spec modification for the service containers, change selector to

* fix lint error

* Add missing image override

* Add comment explaining merge object meta with job and pod

* fix test
2023-09-25 11:49:03 +02:00
Nikola JokicandGitHub 5107bb1d41 Escape backtick in writeEntryPointScript (#101) 2023-08-28 10:27:20 +02:00
Nikola JokicandGitHub 547ed30dc3 Include sha256 checksums in releaseNotes (#98)
* Include sha256 checksums in releaseNotes

* Add ul for sha
2023-08-28 10:15:08 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
17fb66892c Bump word-wrap from 1.2.3 to 1.2.5 in /packages/docker (#95)
Bumps [word-wrap](https://github.com/jonschlinkert/word-wrap) from 1.2.3 to 1.2.5.
- [Release notes](https://github.com/jonschlinkert/word-wrap/releases)
- [Commits](https://github.com/jonschlinkert/word-wrap/compare/1.2.3...1.2.5)

---
updated-dependencies:
- dependency-name: word-wrap
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-07-25 13:30:07 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
9319a8566a Bump word-wrap from 1.2.3 to 1.2.4 in /packages/hooklib (#88)
Bumps [word-wrap](https://github.com/jonschlinkert/word-wrap) from 1.2.3 to 1.2.4.
- [Release notes](https://github.com/jonschlinkert/word-wrap/releases)
- [Commits](https://github.com/jonschlinkert/word-wrap/compare/1.2.3...1.2.4)

---
updated-dependencies:
- dependency-name: word-wrap
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-07-25 13:27:12 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
669ec6f706 Bump word-wrap from 1.2.3 to 1.2.4 in /packages/k8s (#89)
Bumps [word-wrap](https://github.com/jonschlinkert/word-wrap) from 1.2.3 to 1.2.4.
- [Release notes](https://github.com/jonschlinkert/word-wrap/releases)
- [Commits](https://github.com/jonschlinkert/word-wrap/compare/1.2.3...1.2.4)

---
updated-dependencies:
- dependency-name: word-wrap
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-07-25 13:26:57 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
aa658859f8 Bump word-wrap from 1.2.3 to 1.2.4 (#90)
Bumps [word-wrap](https://github.com/jonschlinkert/word-wrap) from 1.2.3 to 1.2.4.
- [Release notes](https://github.com/jonschlinkert/word-wrap/releases)
- [Commits](https://github.com/jonschlinkert/word-wrap/compare/1.2.3...1.2.4)

---
updated-dependencies:
- dependency-name: word-wrap
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-07-25 13:26:27 +02:00
Nikola JokicandGitHub 8b83223a2b Add limitation and throw if an entrypoint is not specified for container step (#77) 2023-07-17 11:02:03 +02:00
Takamasa SaichiandGitHub 586a052286 Do not overwrite entrypoint if it has already been set or if it is Service container (#83) 2023-07-17 10:33:34 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
730509f702 Bump tough-cookie from 4.0.0 to 4.1.3 in /packages/docker (#87)
Bumps [tough-cookie](https://github.com/salesforce/tough-cookie) from 4.0.0 to 4.1.3.
- [Release notes](https://github.com/salesforce/tough-cookie/releases)
- [Changelog](https://github.com/salesforce/tough-cookie/blob/master/CHANGELOG.md)
- [Commits](https://github.com/salesforce/tough-cookie/compare/v4.0.0...v4.1.3)

---
updated-dependencies:
- dependency-name: tough-cookie
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-07-11 10:41:36 +02:00
Marko ZagoženandGitHub 3fc91e4132 Fix argument order for 'docker pull' (#85)
The optional --config option must come *before* the pull argument.
2023-06-30 15:03:01 +02:00
Nikola JokicandGitHub ebbe2bdaff Update package.json version (#79) 2023-05-17 11:18:23 +02:00
Nikola JokicandGitHub 17837d25d2 Release notes for 0.3.2 (#78) 2023-05-17 11:03:05 +02:00
Arthur BaarsandGitHub c37c5ca584 k8s: handle $ symbols in environment variable names and values (#74)
* Add test cases

* Handle $ symbols in environment variable names and values
2023-04-18 15:14:10 +02:00
Bassem DghaidiandGitHub 04b58be49a ADR: using ephemeral containers (#72)
* Add ephemeral containers ADR draft

* Add ADR PR number to filename and title

* Add motivation

* Add evaluation section with details

* Add storage configuration

* Add the remaining sections

* Fix formatting

* Add guidance

* Update ADR status to rejected
2023-04-05 06:48:48 -04:00
Nikola JokicandGitHub 89ff7d1155 Release 0.3.1 (#71)
* Update releaseNotes.md

* updated package.json and package lock
2023-03-20 15:26:23 +01:00
6dbb0b61b7 Ensure response consist no matter having ports or not. (#70)
* Ensure responseconsist no matter having ports or not.

* Update packages/k8s/src/hooks/prepare-job.ts

Co-authored-by: Nikola Jokic <[email protected]>

---------

Co-authored-by: Nikola Jokic <[email protected]>
2023-03-20 10:11:19 +01:00
Bassem DghaidiandGitHub c92bb5544e Fix 0.3.0 release notes (#69) 2023-03-17 05:30:10 -04:00
Nikola JokicandGitHub 26f4a32c30 0.3.0 release notes (#68) 2023-03-17 10:18:56 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
10c6c0aa70 Bump cacheable-request and @kubernetes/client-node in /packages/k8s (#66)
Removes [cacheable-request](https://github.com/jaredwray/cacheable-request). It's no longer used after updating ancestor dependency [@kubernetes/client-node](https://github.com/kubernetes-client/javascript). These dependencies need to be updated together.


Removes `cacheable-request`

Updates `@kubernetes/client-node` from 0.16.3 to 0.18.1
- [Release notes](https://github.com/kubernetes-client/javascript/releases)
- [Changelog](https://github.com/kubernetes-client/javascript/blob/master/CHANGELOG.md)
- [Commits](https://github.com/kubernetes-client/javascript/commits)

---
updated-dependencies:
- dependency-name: cacheable-request
  dependency-type: indirect
- dependency-name: "@kubernetes/client-node"
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-03-02 11:28:06 +01:00
Nikola JokicandGitHub d735152125 Exit from run k8s not allowing promise rejection (#65)
* Exit from run k8s not allowing promise rejection

* Unused case removed k8s
2023-02-14 11:30:16 +01:00
ae31f04223 removed equal sign from env buffer, added defensive guard against the key (#62)
* removed equal sign from env buffer, added defensive guard against the key

* Update packages/k8s/src/k8s/utils.ts

Co-authored-by: John Sudol <[email protected]>

* Update packages/k8s/src/k8s/utils.ts

Co-authored-by: Ferenc Hammerl <[email protected]>

* fix format

---------

Co-authored-by: John Sudol <[email protected]>
Co-authored-by: Ferenc Hammerl <[email protected]>
2023-02-09 17:11:16 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
7754cb80eb Bump http-cache-semantics from 4.1.0 to 4.1.1 in /packages/k8s (#63)
Bumps [http-cache-semantics](https://github.com/kornelski/http-cache-semantics) from 4.1.0 to 4.1.1.
- [Release notes](https://github.com/kornelski/http-cache-semantics/releases)
- [Commits](https://github.com/kornelski/http-cache-semantics/compare/v4.1.0...v4.1.1)

---
updated-dependencies:
- dependency-name: http-cache-semantics
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-02-09 14:54:32 +01:00
Nikola JokicandGitHub ae432db512 docker and k8s: read from stdin inside try catch block (#49)
There might be situation where reading from standard input fails. In
that case, we should encapsulate that exception within the try catch
block to avoid unhandeled Promise rejection exception and provide more
information about the error
2023-01-23 12:46:47 +01:00
4448b61e00 Fix service port mappings when input is undefined, null, or empty (#60)
* fix: service without ports defined

* fix port mappings when ports are undefined,null or empty

* fix

Co-authored-by: Ronald Claveau <[email protected]>
2023-01-06 11:54:52 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
bf39b9bf16 Bump json5 from 1.0.1 to 1.0.2 in /packages/hooklib (#56)
Bumps [json5](https://github.com/json5/json5) from 1.0.1 to 1.0.2.
- [Release notes](https://github.com/json5/json5/releases)
- [Changelog](https://github.com/json5/json5/blob/main/CHANGELOG.md)
- [Commits](https://github.com/json5/json5/compare/v1.0.1...v1.0.2)

---
updated-dependencies:
- dependency-name: json5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-01-06 11:05:26 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
5b597b0fe2 Bump json5 from 2.2.1 to 2.2.3 in /packages/k8s (#57)
Bumps [json5](https://github.com/json5/json5) from 2.2.1 to 2.2.3.
- [Release notes](https://github.com/json5/json5/releases)
- [Changelog](https://github.com/json5/json5/blob/main/CHANGELOG.md)
- [Commits](https://github.com/json5/json5/compare/v2.2.1...v2.2.3)

---
updated-dependencies:
- dependency-name: json5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-01-06 11:05:05 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
0e1ba7bdc8 Bump json5 from 1.0.1 to 1.0.2 in /packages/docker (#58)
Bumps [json5](https://github.com/json5/json5) from 1.0.1 to 1.0.2.
- [Release notes](https://github.com/json5/json5/releases)
- [Changelog](https://github.com/json5/json5/blob/main/CHANGELOG.md)
- [Commits](https://github.com/json5/json5/compare/v1.0.1...v1.0.2)

---
updated-dependencies:
- dependency-name: json5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-01-06 11:04:42 +01:00
73914b840c fix: naming for services & service entrypoint (#53)
* rename to container

* fix container image name bug

* fix entrypoint bug

* bump patch version

* formatting

* fix versions in package-lock

* add test

* revert version bump

* added check + test for args as well

* formatting

* remove cscode launch.json

* expand example json

* wrong version, revert to correct one

* correct lock

* throw error on invalid image definition

* change falsy check

* Update packages/k8s/src/k8s/utils.ts

Co-authored-by: Ferenc Hammerl <[email protected]>

Co-authored-by: Ferenc Hammerl <[email protected]>
2023-01-06 10:22:41 +01:00
Nikola JokicandGitHub b537fd4c92 Upgrade package json5 (#55) 2023-01-05 10:30:51 +01:00
Ferenc HammerlandGitHub 17d2b3b850 Release notes for v0.2.0 (#47)
* Update releaseNotes.md

* Bump version to 0.2.0
2022-12-15 15:29:15 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Ferenc Hammerl
ea011028f5 Bump @actions/core from 1.6.0 to 1.9.1 in /packages/hooklib (#29)
* Bump @actions/core from 1.6.0 to 1.9.1 in /packages/hooklib

Bumps [@actions/core](https://github.com/actions/toolkit/tree/HEAD/packages/core) from 1.6.0 to 1.9.1.
- [Release notes](https://github.com/actions/toolkit/releases)
- [Changelog](https://github.com/actions/toolkit/blob/main/packages/core/RELEASES.md)
- [Commits](https://github.com/actions/toolkit/commits/HEAD/packages/core)

---
updated-dependencies:
- dependency-name: "@actions/core"
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <[email protected]>

* Trigger Build

* Update package lock for docker and k8s

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Ferenc Hammerl <[email protected]>
2022-12-15 14:58:13 +01:00
Nikola JokicandGitHub eaae191ebb k8s: don't overwriting service entrypoint (#45) 2022-12-15 14:13:57 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
418d484160 Bump jose from 2.0.5 to 2.0.6 in /packages/k8s (#31)
Bumps [jose](https://github.com/panva/jose) from 2.0.5 to 2.0.6.
- [Release notes](https://github.com/panva/jose/releases)
- [Changelog](https://github.com/panva/jose/blob/v2.0.6/CHANGELOG.md)
- [Commits](https://github.com/panva/jose/compare/v2.0.5...v2.0.6)

---
updated-dependencies:
- dependency-name: jose
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-12-15 14:04:57 +01:00
Nikola JokicandGitHub ce3c55d086 exposing env variables from runner with DOCKER_ envs to respect docker options set on host (#40)
* exposing env variables from runner with DOCKER_ prefix to respect rootless docker

* Prioritize DOCKER cli over workflow envs

* formatted
2022-12-08 08:09:51 +01:00
Nikola JokicandGitHub d988d965c5 fixing issue related to setting hostPort and containerPort when format is port/proto (#38)
* fixing issue related to setting hostPort and containerPort when format is port/proto

* added one more test case and refactored containerPorts to be without regexp

* added throw on ports outside of (0,65536) range with test

* repaired error message and added tests to multi splits. refactored port checking
2022-11-15 14:23:09 +01:00
Nikola JokicandGitHub 23cc6dda6f fixed substring issue with /github/workspace and /github/file_commands (#35)
* fixed substring issue with /github/workspace and /github/file_commands

* npm run format

* last 3 parts of the path are mounted to /github/workspace and /github/file_commands

* file commands now point to _temp/_runner_file_commands
2022-11-03 14:55:07 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
8986035ca8 Bump @actions/core from 1.8.2 to 1.9.1 in /packages/k8s (#28)
Bumps [@actions/core](https://github.com/actions/toolkit/tree/HEAD/packages/core) from 1.8.2 to 1.9.1.
- [Release notes](https://github.com/actions/toolkit/releases)
- [Changelog](https://github.com/actions/toolkit/blob/main/packages/core/RELEASES.md)
- [Commits](https://github.com/actions/toolkit/commits/HEAD/packages/core)

---
updated-dependencies:
- dependency-name: "@actions/core"
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <[email protected]>

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-10-25 17:12:49 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
e975289683 Bump @actions/core from 1.6.0 to 1.9.1 in /packages/docker (#27)
Bumps [@actions/core](https://github.com/actions/toolkit/tree/HEAD/packages/core) from 1.6.0 to 1.9.1.
- [Release notes](https://github.com/actions/toolkit/releases)
- [Changelog](https://github.com/actions/toolkit/blob/main/packages/core/RELEASES.md)
- [Commits](https://github.com/actions/toolkit/commits/HEAD/packages/core)

---
updated-dependencies:
- dependency-name: "@actions/core"
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <[email protected]>

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-10-25 17:12:17 +02:00
Nikola JokicandGitHub a555151eef repaired env variable name in CONTRIBUTING (HOOK(S)) (#37) 2022-10-25 16:26:59 +02:00
Thomas BoopandGitHub 16eb238caa 0.1.3 release notes (#26) 2022-08-16 15:43:31 +02:00
Nikola JokicandGitHub 8e06496e34 fixing defaulting to docker hub on private registry, and b64 encoding (#25) 2022-08-16 09:30:58 -04:00
Thomas BoopandGitHub e2033b29c7 0.1.2 release (#22)
* 0.1.2 release

* trace the error and show a user readable message
2022-06-23 08:57:14 -04:00
Nikola JokicandGitHub eb47baaf5e Adding more tests and minor changes in code (#21)
* added cleanup job checks, started testing constants file

* added getVolumeClaimName test

* added write entrypoint tests

* added tests around k8s utils

* fixed new regexp

* added tests around runner instance label

* 100% test coverage of constants
2022-06-22 14:15:42 -04:00
Nikola JokicandGitHub 20c19dae27 refactor around job claim name and runner instance labels (#20)
* refactor around job claim name, and runner instance labels

* repaired failing test
2022-06-22 09:32:50 -04:00
Thomas BoopandGitHub 4307828719 Don't use JSON.stringify for errors (#19)
* better error handling

* remove unneeded catch

* Update index.ts
2022-06-22 15:20:48 +02:00
Thomas BoopandGitHub 5c6995dba1 Add Akvelon to codeowners 2022-06-22 09:06:20 -04:00
63 changed files with 18050 additions and 19608 deletions
-4
View File
@@ -1,4 +0,0 @@
dist/
lib/
node_modules/
**/tests/**
-56
View File
@@ -1,56 +0,0 @@
{
"plugins": ["@typescript-eslint"],
"extends": ["plugin:github/recommended"],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": 9,
"sourceType": "module",
"project": "./tsconfig.json"
},
"rules": {
"eslint-comments/no-use": "off",
"import/no-namespace": "off",
"no-constant-condition": "off",
"no-unused-vars": "off",
"i18n-text/no-en": "off",
"@typescript-eslint/no-unused-vars": "error",
"@typescript-eslint/explicit-member-accessibility": ["error", {"accessibility": "no-public"}],
"@typescript-eslint/no-require-imports": "error",
"@typescript-eslint/array-type": "error",
"@typescript-eslint/await-thenable": "error",
"camelcase": "off",
"@typescript-eslint/explicit-function-return-type": ["error", {"allowExpressions": true}],
"@typescript-eslint/func-call-spacing": ["error", "never"],
"@typescript-eslint/no-array-constructor": "error",
"@typescript-eslint/no-empty-interface": "error",
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-extraneous-class": "error",
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/no-for-in-array": "error",
"@typescript-eslint/no-inferrable-types": "error",
"@typescript-eslint/no-misused-new": "error",
"@typescript-eslint/no-namespace": "error",
"@typescript-eslint/no-non-null-assertion": "warn",
"@typescript-eslint/no-unnecessary-qualifier": "error",
"@typescript-eslint/no-unnecessary-type-assertion": "error",
"@typescript-eslint/no-useless-constructor": "error",
"@typescript-eslint/no-var-requires": "error",
"@typescript-eslint/prefer-for-of": "warn",
"@typescript-eslint/prefer-function-type": "warn",
"@typescript-eslint/prefer-includes": "error",
"@typescript-eslint/prefer-string-starts-ends-with": "error",
"@typescript-eslint/promise-function-async": "error",
"@typescript-eslint/require-array-sort-compare": "error",
"@typescript-eslint/restrict-plus-operands": "error",
"semi": "off",
"@typescript-eslint/semi": ["error", "never"],
"@typescript-eslint/type-annotation-spacing": "error",
"@typescript-eslint/unbound-method": "error",
"no-shadow": "off",
"@typescript-eslint/no-shadow": ["error"]
},
"env": {
"node": true,
"es6": true
}
}
+1
View File
@@ -0,0 +1 @@
*.png filter=lfs diff=lfs merge=lfs -text
+28
View File
@@ -0,0 +1,28 @@
version: 2
updates:
# Group updates into a single PR per workspace package
- package-ecosystem: npm
directory: "/packages/docker"
schedule:
interval: weekly
groups:
all-dependencies:
patterns:
- "*"
- package-ecosystem: npm
directory: "/packages/hooklib"
schedule:
interval: weekly
groups:
all-dependencies:
patterns:
- "*"
- package-ecosystem: npm
directory: "/packages/k8s"
schedule:
interval: weekly
groups:
all-dependencies:
patterns:
- "*"
+53 -10
View File
@@ -6,14 +6,62 @@ on:
paths-ignore:
- '**.md'
workflow_dispatch:
jobs:
build:
format-and-lint:
name: Format & Lint Checks
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v5
- uses: actions/setup-node@v6
with:
node-version: 24
cache: npm
- run: npm ci
name: Install dependencies
- run: npm run bootstrap
name: Bootstrap the packages
- run: npm run build-all
name: Build packages
- run: npm run format-check
name: Check formatting
- name: Check linter
run: |
npm run lint
git diff --exit-code -- . ':!packages/k8s/tests/test-kind.yaml'
docker-tests:
name: Docker Hook Tests
runs-on: ubuntu-latest
needs: format-and-lint
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v6
with:
node-version: 24
cache: npm
- run: npm ci
name: Install dependencies
- run: npm run bootstrap
name: Bootstrap the packages
- run: npm run build-all
name: Build packages
- name: Run Docker tests
run: npm run test --prefix packages/docker
k8s-tests:
name: Kubernetes Hook Tests
runs-on: ubuntu-latest
needs: format-and-lint
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v6
with:
node-version: 24
cache: npm
- run: sed -i "s|{{PATHTOREPO}}|$(pwd)|" packages/k8s/tests/test-kind.yaml
name: Setup kind cluster yaml config
- uses: helm/[email protected]
- uses: helm/kind-action@v1.12.0
with:
config: packages/k8s/tests/test-kind.yaml
- run: npm install
@@ -22,10 +70,5 @@ jobs:
name: Bootstrap the packages
- run: npm run build-all
name: Build packages
- run: npm run format-check
- name: Check linter
run: |
npm run lint
git diff --exit-code -- ':!packages/k8s/tests/test-kind.yaml'
- name: Run tests
run: npm run test
- name: Run Kubernetes tests
run: npm run test --prefix packages/k8s
+4 -4
View File
@@ -38,11 +38,11 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v3
uses: actions/checkout@v5
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -56,7 +56,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v2
uses: github/codeql-action/autobuild@v3
# ️ Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
@@ -69,4 +69,4 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2
uses: github/codeql-action/analyze@v3
+60 -43
View File
@@ -1,57 +1,74 @@
name: CD - Release new version
on:
workflow_dispatch:
permissions:
contents: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- run: npm install
name: Install dependencies
- run: npm run bootstrap
name: Bootstrap the packages
- run: npm run build-all
name: Build packages
- uses: actions/github-script@v6
id: releaseNotes
- uses: actions/checkout@v5
- uses: actions/setup-node@v6
with:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
node-version: 24
cache: npm
- name: Install dependencies
run: npm install
- name: Bootstrap the packages
run: npm run bootstrap
- name: Build packages
run: npm run build-all
- uses: actions/github-script@v8
id: releaseVersion
with:
result-encoding: string
script: |
const fs = require('fs');
const hookVersion = require('./package.json').version
var releaseNotes = fs.readFileSync('${{ github.workspace }}/releaseNotes.md', 'utf8').replace(/<HOOK_VERSION>/g, hookVersion)
console.log(releaseNotes)
core.setOutput('version', hookVersion);
core.setOutput('note', releaseNotes);
return require('./package.json').version
- name: Zip up releases
run: |
zip -r -j actions-runner-hooks-docker-${{ steps.releaseNotes.outputs.version }}.zip packages/docker/dist
zip -r -j actions-runner-hooks-k8s-${{ steps.releaseNotes.outputs.version }}.zip packages/k8s/dist
- uses: actions/create-release@v1
id: createRelease
name: Create ${{ steps.releaseNotes.outputs.version }} Hook Release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
zip -r -j actions-runner-hooks-docker-${{ steps.releaseVersion.outputs.result }}.zip packages/docker/dist
zip -r -j actions-runner-hooks-k8s-${{ steps.releaseVersion.outputs.result }}.zip packages/k8s/dist
- name: Calculate SHA
id: sha
shell: bash
run: |
sha_docker=$(sha256sum actions-runner-hooks-docker-${{ steps.releaseVersion.outputs.result }}.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.result }}.zip | awk '{print $1}')
echo "K8s SHA: $sha_k8s"
echo "k8s-sha=$sha_k8s" >> $GITHUB_OUTPUT
- name: Create release notes
id: releaseNotes
uses: actions/github-script@v8
with:
tag_name: "v${{ steps.releaseNotes.outputs.version }}"
release_name: "v${{ steps.releaseNotes.outputs.version }}"
body: |
${{ steps.releaseNotes.outputs.note }}
- name: Upload K8s hooks
uses: actions/upload-release-asset@v1
script: |
const fs = require('fs');
var releaseNotes = fs.readFileSync('${{ github.workspace }}/releaseNotes.md', 'utf8').replace(/<HOOK_VERSION>/g, '${{ steps.releaseVersion.outputs.result }}')
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)
fs.writeFileSync('${{ github.workspace }}/finalReleaseNotes.md', releaseNotes);
- name: Create ${{ steps.releaseVersion.outputs.result }} Hook Release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.createRelease.outputs.upload_url }}
asset_path: ${{ github.workspace }}/actions-runner-hooks-k8s-${{ steps.releaseNotes.outputs.version }}.zip
asset_name: actions-runner-hooks-k8s-${{ steps.releaseNotes.outputs.version }}.zip
asset_content_type: application/octet-stream
- name: Upload docker hooks
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.createRelease.outputs.upload_url }}
asset_path: ${{ github.workspace }}/actions-runner-hooks-docker-${{ steps.releaseNotes.outputs.version }}.zip
asset_name: actions-runner-hooks-docker-${{ steps.releaseNotes.outputs.version }}.zip
asset_content_type: application/octet-stream
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release create v${{ steps.releaseVersion.outputs.result }} \
--title "v${{ steps.releaseVersion.outputs.result }}" \
--repo ${{ github.repository }} \
--notes-file ${{ github.workspace }}/finalReleaseNotes.md \
--latest \
${{ github.workspace }}/actions-runner-hooks-k8s-${{ steps.releaseVersion.outputs.result }}.zip \
${{ github.workspace }}/actions-runner-hooks-docker-${{ steps.releaseVersion.outputs.result }}.zip
+1 -1
View File
@@ -1 +1 @@
* @actions/actions-runtime
* @actions/actions-compute @nikola-jokic
+1 -1
View File
@@ -13,7 +13,7 @@ You'll need a runner compatible with hooks, a repository with container workflow
- You'll need a runner compatible with hooks, a repository with container workflows to which you can register the runner and the hooks from this repository.
- See [the runner contributing.md](../../github/CONTRIBUTING.MD) for how to get started with runner development.
- Build your hook using `npm run build`
- Enable the hooks by setting `ACTIONS_RUNNER_CONTAINER_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
```
+19 -5
View File
@@ -3,6 +3,24 @@ The Runner Container Hooks repo provides a set of packages that implement the co
More information on how to implement your own hooks can be found in the [adr](https://github.com/actions/runner/pull/1891). The `examples` folder provides example inputs for each hook.
### Note
Thank you for your interest in this GitHub action, however, right now we are not taking contributions.
We continue to focus our resources on strategic areas that help our customers be successful while making developers' lives easier. While GitHub Actions remains a key part of this vision, we are allocating resources towards other areas of Actions and are not taking contributions to this repository at this time. The GitHub public roadmap is the best place to follow along for any updates on features were working on and what stage theyre in.
We are taking the following steps to better direct requests related to GitHub Actions, including:
1. We will be directing questions and support requests to our [Community Discussions area](https://github.com/orgs/community/discussions/categories/actions)
2. High Priority bugs can be reported through Community Discussions or you can report these to our support team https://support.github.com/contact/bug-report.
3. Security Issues should be handled as per our [security.md](security.md)
We will still provide security updates for this project and fix major breaking changes during this time.
You are welcome to still raise bugs in this repo.
## Background
Three projects are included in the `packages` folder
@@ -10,10 +28,6 @@ Three projects are included in the `packages` folder
- docker: A hook implementation of the runner's docker implementation. More details can be found in the [readme](./packages/docker/README.md)
- hooklib: a shared library which contains typescript definitions and utilities that the other projects consume
### Requirements
We welcome contributions. See [how to contribute to get started](./CONTRIBUTING.md).
## License
This project is licensed under the terms of the MIT open source license. Please refer to [MIT](./LICENSE.md) for the full terms.
@@ -28,4 +42,4 @@ Find a bug? Please file an issue in this repository using the issue templates.
## Code of Conduct
See our [Code of Conduct](./CODE_OF_CONDUCT.MD)
See our [Code of Conduct](./CODE_OF_CONDUCT.MD)
@@ -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>
![runner->debugger](./images/runner-debugger.png)
</details>
<details>
<summary>2. Debugger -> Runner communication</summary>
![debugger->runner](./images/debugger-runner.png)
</details>
<details>
<summary>3. Debugger2 -> Debugger communication</summary>
![debugger2->debugger](./images/debugger2-debugger.png)
</details>
### Storage
An emptyDir volume can be successfully mounted (read/write) by the runner as well as the ephemeral containers. This means that ephemeral containers can share data with the runner and other ephemeral containers.
<details>
<summary>Configuration</summary>
```yaml
# Extracted from the values.yaml for the gha-runner-scale-set helm chart
spec:
containers:
- name: runner
image: ghcr.io/actions/actions-runner:latest
command: ["/home/runner/run.sh"]
volumeMounts:
- mountPath: /workspace
name: work-volume
volumes:
- name: work-volume
emptyDir:
sizeLimit: 1Gi
```
```bash
# The API call to the Kubernetes API used to create the ephemeral containers
POD_NAME="arc-runner-set-6sfwd-runner-k7qq6"
NAMESPACE="arc-runners"
curl -v "https://<IP>:<PORT>/api/v1/namespaces/$NAMESPACE/pods/$POD_NAME/ephemeralcontainers" \
-X PATCH \
-H 'Content-Type: application/strategic-merge-patch+json' \
--cacert <PATH_TO_CACERT> \
--cert <PATH_TO_CERT> \
--key <PATH_TO_CLIENT_KEY> \
-d '
{
"spec":
{
"ephemeralContainers":
[
{
"name": "debugger",
"command": ["sh"],
"image": "ghcr.io/actions/actions-runner:latest",
"targetContainerName": "runner",
"stdin": true,
"tty": true,
"volumeMounts": [{
"mountPath": "/workspace",
"name": "work-volume",
"readOnly": false
}]
},
{
"name": "debugger2",
"command": ["sh"],
"image": "ghcr.io/actions/actions-runner:latest",
"targetContainerName": "runner",
"stdin": true,
"tty": true,
"volumeMounts": [{
"mountPath": "/workspace",
"name": "work-volume",
"readOnly": false
}]
}
]
}
}'
```
</details>
<details>
<summary>emptyDir volume mount</summary>
![emptyDir volume mount](./images/emptyDir_volume.png)
</details>
### Security
According to the [ephemeral containers API specification](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.26/#ephemeralcontainer-v1-core) the configuration of the `securityContext` field is possible.
Ephemeral containers share the same network namespace as the pod they are attached to. This means that ephemeral containers can access the same network interfaces as the pod and can communicate with other containers in the same pod.
It is also possible for ephemeral containers to [share the process namespace](https://kubernetes.io/docs/tasks/configure-pod-container/share-process-namespace/) with the other containers in the pod. This is disabled by default.
The above could have unpredictable security implications.
### Resource limits
Resources are not allowed for ephemeral containers. Ephemeral containers use spare resources already allocated to the pod. [^1] This is a major drawback as it means that ephemeral containers cannot be configured to have resource limits.
There are no guaranteed resources for ad-hoc troubleshooting. If troubleshooting causes a pod to exceed its resource limit it may be evicted. [^3]
### Logs
Since ephemeral containers can share volumes with the runner container, it's possible to write logs to the same volume and have them available to the runner container.
### Customizability
Ephemeral containers can run any image and tag provided, they can be customized to run any arbitrary job. However, it's important to note that the following are not feasible:
- Lifecycle is not allowed for ephemeral containers
- Ephemeral containers will stop when their command exits, such as exiting a shell, and they will not be restarted. Unlike `kubectl exec`, processes in Ephemeral Containers will not receive an `EOF` if their connections are interrupted, so shells won't automatically exit on disconnect. There is no API support for killing or restarting an ephemeral container. The only way to exit the container is to send it an OS signal. [^4]
- Probes are not allowed for ephemeral containers.
- Ports are not allowed for ephemeral containers.
## Decision
While the evaluation shows that ephemeral containers can be used to run jobs in containers, it's important to acknowledge that ephemeral containers were not designed to handle workloads but rather provide a mechanism to inspect running containers for debugging and troubleshooting purposes.
Given the limitations of ephemeral containers, we decided not to use them outside of their intended purpose.
## Consequences
Proposal rejected, no further action required. This document will be used as a reference for future discussions.
[^1]: https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.26/#ephemeralcontainer-v1-core
[^2]: https://kubernetes.io/docs/concepts/workloads/pods/ephemeral-containers/
[^3]: https://github.com/kubernetes/enhancements/blob/master/keps/sig-node/277-ephemeral-containers/README.md#notesconstraintscaveats
[^4]: https://github.com/kubernetes/enhancements/blob/master/keps/sig-node/277-ephemeral-containers/README.md#ephemeral-container-lifecycle
+34
View File
@@ -0,0 +1,34 @@
# ADR 0096: Hook extensions
**Date:** 3 August 2023
**Status**: Superceded [^1]
## 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.
[^1]: Superseded by [ADR 0134](0134-hook-extensions.md)
+41
View File
@@ -0,0 +1,41 @@
# ADR 0134: Hook extensions
**Date:** 20 February 2024
**Status**: Accepted [^1]
## 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* "$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.
2. If the name of the container *starts with* "$", and matches the name of the [container service](https://docs.github.com/en/actions/using-containerized-services/about-service-containers), the `name` and the `image` fields are going to be ignored and the spec will be applied to that service container, so that `env`, `volumeMounts`, `ports` are appended to the default container spec for service created by the hook, while the rest of the fields are going to be applied to the created container spec.
If there is no container service with such name defined in the workflow, such spec extension will be ignored.
3. If the name of the container *does not start with* "$", the entire spec of the container will be added to the pod definition.
## 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.
[^1]: Supersedes [ADR 0096](0096-hook-extensions.md)
+55
View File
@@ -0,0 +1,55 @@
# ADR 0135: RWX volume strategy and RWO affinity fallback
**Date:** 22 April 2026
**Status**: Accepted
## Context
The Kubernetes hook implementation for GitHub Actions runners requires access to the runner's working directory (`_work`) within the dynamically created job pods. This shared access is typically managed via Persistent Volume Claims (PVCs).
The choice of storage strategy significantly impacts pod scheduling. While ReadWriteOnce (RWO) volumes require job pods to be co-located on the same node as the runner pod, ReadWriteMany (RWX) volumes allow job pods to be scheduled freely across the cluster.
Depending on the storage provider and cluster configuration, operators may choose between `ReadWriteMany` (RWX) or `ReadWriteOnce` (RWO) access modes. RWX is preferred because it allows the Kubernetes scheduler to place job pods on any available node, improving resource utilization and cluster flexibility. RWO restricts volume access to a single node at a time, requiring all pods using the volume to be pinned to that specific node.
## Decision
We have decided to establish `ReadWriteMany` (RWX) as the preferred storage strategy for the Kubernetes hook. RWX provides superior operational flexibility by enabling free scheduling of job pods across the cluster, as the shared volume is accessible from any node. This decoupling of job pods from the runner's node allows for better resource distribution and reduces the risk of node-level resource exhaustion.
For environments where RWX is unavailable or undesirable, we support a `ReadWriteOnce` (RWO) fallback strategy. This fallback is implemented using node affinity to ensure that job pods are scheduled onto the same node as the runner pod that holds the RWO volume.
### Operational Guidance
1. **Preferred Model (RWX):** Operators should configure the runner with a PVC supporting `ReadWriteMany`.
2. **Fallback Model (RWO):** If using `ReadWriteOnce`, the Kubernetes scheduler integration is enabled by default. Operators can optionally disable it by setting `ACTIONS_RUNNER_DISABLE_KUBE_SCHEDULER=true` (not recommended).
3. **Node Selection:** By default, the hook applies a `requiredDuringSchedulingIgnoredDuringExecution` node affinity targeting the runner's current node (`kubernetes.io/hostname`).
4. **Implementation Details:**
- The hook determines the node name via `getCurrentNodeName()` and applies affinity in `packages/k8s/src/k8s/index.ts` (lines 101, 165).
- The scheduler is enabled by default. Setting `ACTIONS_RUNNER_DISABLE_KUBE_SCHEDULER=true` disables it, as defined in `packages/k8s/src/k8s/utils.ts` (line 16).
- The PVC claim name defaults to `${ACTIONS_RUNNER_POD_NAME}-work` unless overridden by `ACTIONS_RUNNER_CLAIM_NAME` (`packages/k8s/src/hooks/constants.ts`, lines 27-33).
### Non-Recommendations
We explicitly do **not** recommend the use of `spec.nodeName` for operator-driven scheduling. While the hook uses `nodeName` as a legacy fallback when `ACTIONS_RUNNER_DISABLE_KUBE_SCHEDULER` is set to `true` (`packages/k8s/src/k8s/index.ts`, lines 103, 167), this bypasses the Kubernetes scheduler and can lead to scheduling failures or resource imbalances. Operators should prefer the default affinity-based approach for RWO volumes.
## Alternatives
- **nodeName Bypass:** Directly setting `nodeName` bypasses the scheduler entirely. This was rejected as a recommendation because it prevents the scheduler from accounting for taints, tolerations, and resource pressure.
- **Local Volumes:** Using local volumes tied to specific nodes. This is a subset of the RWO fallback and is supported via the affinity mechanism.
## Consequences
- **Flexibility:** RWX users benefit from the ability to schedule job pods on any node in the cluster, maximizing resource utilization.
- **Node Coupling:** RWO users remain coupled to the node where the runner pod is running. The hook ensures job pods are scheduled on the same node via affinity (enabled by default) to maintain workspace integrity.
- **Configuration:** Operators using RWO can rely on the default affinity-based scheduling. Setting `ACTIONS_RUNNER_DISABLE_KUBE_SCHEDULER=true` will fall back to legacy `nodeName` pinning (not recommended). RWX configurations do not require any special configuration for basic operation.
## Migration Guidance
Operators migrating from an RWO setup that relied on legacy `nodeName` behavior can continue by setting `ACTIONS_RUNNER_DISABLE_KUBE_SCHEDULER=true`, but should migrate to the default affinity-based scheduling for better scheduler integration:
1. Remove the `ACTIONS_RUNNER_DISABLE_KUBE_SCHEDULER` environment variable to use the default affinity-based scheduling.
2. Verify that the runner's ServiceAccount has the necessary permissions to list pods (to determine its own node).
## Non-Goals
- This ADR does not recommend `nodeName` as a primary or secondary configuration path for operators.
- This ADR does not dictate specific storage providers (e.g., EBS vs. EFS vs. Azure Files), but rather the access mode strategy.
+3
View File
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e871545617a5df08ab48a37a0c0f441aef9e97da0a3bb8f66470be0f2246cfd4
size 61897
+3
View File
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d733bd0d26cded6da8bbcf4e5dd274bb9d700b4fde5e11ccf1e528388147a995
size 64665
+3
View File
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:39b5b4f5bc5419a701512d14ffa9a30e3288fe9403caf6ab8078eb905236205d
size 341558
+3
View File
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:38bfd3f0f3613410245d4fc5a727b8b8203c8da3032f2630945edc7e3470efec
size 59652
+122
View File
@@ -0,0 +1,122 @@
const eslint = require('@eslint/js');
const tseslint = require('@typescript-eslint/eslint-plugin');
const tsparser = require('@typescript-eslint/parser');
const globals = require('globals');
const pluginJest = require('eslint-plugin-jest');
module.exports = [
eslint.configs.recommended,
{
files: ['**/*.ts'],
languageOptions: {
parser: tsparser,
parserOptions: {
ecmaVersion: 2018,
sourceType: 'module',
project: ['./tsconfig.json', './packages/*/tsconfig.json']
},
globals: {
...globals.node,
...globals.es6
}
},
plugins: {
'@typescript-eslint': tseslint,
},
rules: {
// Disabled rules from original config
'eslint-comments/no-use': 'off',
'import/no-namespace': 'off',
'no-constant-condition': 'off',
'no-unused-vars': 'off',
'i18n-text/no-en': 'off',
'camelcase': 'off',
'semi': 'off',
'no-shadow': 'off',
// TypeScript ESLint rules
'@typescript-eslint/no-unused-vars': 'error',
'@typescript-eslint/explicit-member-accessibility': ['error', { accessibility: 'no-public' }],
'@typescript-eslint/no-require-imports': 'error',
'@typescript-eslint/array-type': 'error',
'@typescript-eslint/await-thenable': 'error',
'@typescript-eslint/explicit-function-return-type': ['error', { allowExpressions: true }],
'@typescript-eslint/no-array-constructor': 'error',
'@typescript-eslint/no-empty-interface': 'error',
'@typescript-eslint/no-explicit-any': 'off', // Fixed: removed duplicate and kept only this one
'@typescript-eslint/no-extraneous-class': 'error',
'@typescript-eslint/no-floating-promises': 'error',
'@typescript-eslint/no-for-in-array': 'error',
'@typescript-eslint/no-inferrable-types': 'error',
'@typescript-eslint/no-misused-new': 'error',
'@typescript-eslint/no-namespace': 'error',
'@typescript-eslint/no-non-null-assertion': 'warn',
'@typescript-eslint/no-unnecessary-qualifier': 'error',
'@typescript-eslint/no-unnecessary-type-assertion': 'error',
'@typescript-eslint/no-useless-constructor': 'error',
'@typescript-eslint/no-var-requires': 'error',
'@typescript-eslint/prefer-for-of': 'warn',
'@typescript-eslint/prefer-function-type': 'warn',
'@typescript-eslint/prefer-includes': 'error',
'@typescript-eslint/prefer-string-starts-ends-with': 'error',
'@typescript-eslint/promise-function-async': 'error',
'@typescript-eslint/require-array-sort-compare': 'error',
'@typescript-eslint/restrict-plus-operands': 'error',
'@typescript-eslint/unbound-method': 'error',
'@typescript-eslint/no-shadow': ['error']
}
},
{
// Test files configuration - Fixed file pattern to match .ts files
files: ['**/*test*.ts', '**/*spec*.ts', '**/tests/**/*.ts'],
languageOptions: {
parser: tsparser,
parserOptions: {
ecmaVersion: 2018,
sourceType: 'module',
project: ['./tsconfig.json', './packages/*/tsconfig.json']
},
globals: {
...globals.node,
...globals.es6,
// Fixed Jest globals
describe: 'readonly',
it: 'readonly',
test: 'readonly',
expect: 'readonly',
beforeEach: 'readonly',
afterEach: 'readonly',
beforeAll: 'readonly',
afterAll: 'readonly',
jest: 'readonly'
}
},
plugins: {
'@typescript-eslint': tseslint,
jest: pluginJest
},
rules: {
// Disable no-undef for test files since Jest globals are handled above
'no-undef': 'off',
// Relax some rules for test files
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/explicit-function-return-type': 'off'
}
},
{
files: ['**/jest.config.js', '**/jest.setup.js'],
languageOptions: {
globals: {
...globals.node,
jest: 'readonly',
module: 'writable'
}
},
rules: {
'@typescript-eslint/no-require-imports': 'off',
'@typescript-eslint/no-var-requires': 'off',
'import/no-commonjs': 'off'
}
}
];
+38
View File
@@ -0,0 +1,38 @@
metadata:
annotations:
annotated-by: "extension"
labels:
labeled-by: "extension"
spec:
restartPolicy: Never
containers:
- name: $job # overwrites job container
env:
- name: ENV1
value: "value1"
imagePullPolicy: Always
image: "busybox:1.28" # Ignored
command:
- sh
args:
- -c
- sleep 50
- name: $redis # overwrites redis service
env:
- name: ENV2
value: "value2"
image: "busybox:1.28" # Ignored
resources:
requests:
memory: "1Mi"
cpu: "1"
limits:
memory: "1Gi"
cpu: "2"
- name: side-car
image: "ubuntu:latest" # required
command:
- sh
args:
- -c
- sleep 60
+3 -1
View File
@@ -4,7 +4,7 @@
"state": {},
"args": {
"container": {
"image": "node:14.16",
"image": "node:22",
"workingDirectory": "/__w/repo/repo",
"createOptions": "--cpus 1",
"environmentVariables": {
@@ -73,6 +73,8 @@
"contextName": "redis",
"image": "redis",
"createOptions": "--cpus 1",
"entrypoint": null,
"entryPointArgs": [],
"environmentVariables": {},
"userMountVolumes": [
{
+1 -1
View File
@@ -9,7 +9,7 @@
}
},
"args": {
"image": "node:14.16",
"image": "node:22",
"dockerfile": null,
"entryPointArgs": [
"-e",
+3156 -3071
View File
File diff suppressed because it is too large Load Diff
+16 -9
View File
@@ -1,6 +1,6 @@
{
"name": "hooks",
"version": "0.1.1",
"version": "0.8.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",
"main": "",
"directories": {
@@ -8,10 +8,11 @@
},
"scripts": {
"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 ci --prefix packages/k8s && npm ci --prefix packages/docker",
"format": "prettier --write '**/*.ts'",
"format-check": "prettier --check '**/*.ts'",
"lint": "eslint packages/**/*.ts",
"lint:fix": "eslint packages/**/*.ts --fix",
"build-all": "npm run build --prefix packages/hooklib && npm run build --prefix packages/k8s && npm run build --prefix packages/docker"
},
"repository": {
@@ -25,12 +26,18 @@
},
"homepage": "https://github.com/actions/runner-container-hooks#readme",
"devDependencies": {
"@types/jest": "^27.5.1",
"@types/node": "^17.0.23",
"@typescript-eslint/parser": "^5.18.0",
"eslint": "^8.12.0",
"eslint-plugin-github": "^4.3.6",
"prettier": "^2.6.2",
"typescript": "^4.6.3"
"@eslint/js": "^9.31.0",
"@types/jest": "^30.0.0",
"@types/node": "^24.0.14",
"@typescript-eslint/eslint-plugin": "^8.37.0",
"@typescript-eslint/parser": "^8.37.0",
"eslint": "^9.31.0",
"eslint-plugin-github": "^6.0.0",
"globals": "^15.12.0",
"prettier": "^3.6.2",
"typescript": "^5.8.3"
},
"dependencies": {
"eslint-plugin-jest": "^29.0.1"
}
}
+17 -4
View File
@@ -1,13 +1,26 @@
// eslint-disable-next-line import/no-commonjs
module.exports = {
clearMocks: true,
preset: 'ts-jest',
moduleFileExtensions: ['js', 'ts'],
testEnvironment: 'node',
testMatch: ['**/*-test.ts'],
testRunner: 'jest-circus/runner',
verbose: true,
transform: {
'^.+\\.ts$': 'ts-jest'
'^.+\\.ts$': [
'ts-jest',
{
tsconfig: 'tsconfig.test.json'
}
],
// Transform ESM modules to CommonJS
'^.+\\.(js|mjs)$': ['babel-jest', {
presets: [['@babel/preset-env', { targets: { node: 'current' } }]]
}]
},
setupFilesAfterEnv: ['./jest.setup.js'],
verbose: true
transformIgnorePatterns: [
// Transform these ESM packages
'node_modules/(?!(shlex|@kubernetes/client-node|openid-client|oauth4webapi|jose|uuid)/)'
],
setupFilesAfterEnv: ['./jest.setup.js']
}
+4686 -6430
View File
File diff suppressed because it is too large Load Diff
+19 -13
View File
@@ -5,25 +5,31 @@
"main": "lib/index.js",
"scripts": {
"test": "jest --runInBand",
"build": "npx tsc && npx ncc build"
"build": "npx tsc && npx ncc build",
"format": "prettier --write '**/*.ts'",
"format-check": "prettier --check '**/*.ts'",
"lint": "eslint src/**/*.ts"
},
"author": "",
"license": "MIT",
"dependencies": {
"@actions/core": "^1.6.0",
"@actions/exec": "^1.1.1",
"@actions/core": "^2.0.2",
"@actions/exec": "^2.0.0",
"hooklib": "file:../hooklib",
"uuid": "^8.3.2"
"shlex": "^3.0.0",
"uuid": "^11.1.0"
},
"devDependencies": {
"@types/jest": "^27.4.1",
"@types/node": "^17.0.23",
"@typescript-eslint/parser": "^5.18.0",
"@vercel/ncc": "^0.33.4",
"jest": "^27.5.1",
"ts-jest": "^27.1.4",
"ts-node": "^10.7.0",
"tsconfig-paths": "^3.14.1",
"typescript": "^4.6.3"
"@babel/core": "^7.25.2",
"@babel/preset-env": "^7.25.4",
"@types/jest": "^30.0.0",
"@types/node": "^24.0.14",
"@typescript-eslint/parser": "^8.37.0",
"@vercel/ncc": "^0.38.3",
"jest": "^30.0.4",
"ts-jest": "^29.4.0",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.8.3"
}
}
@@ -43,18 +43,25 @@ export async function createContainer(
if (args.environmentVariables) {
for (const [key] of Object.entries(args.environmentVariables)) {
dockerArgs.push('-e')
dockerArgs.push(key)
dockerArgs.push('-e', key)
}
}
dockerArgs.push('-e', 'GITHUB_ACTIONS=true')
// Use same behavior as the runner https://github.com/actions/runner/blob/27d9c886ab9a45e0013cb462529ac85d581f8c41/src/Runner.Worker/Container/DockerCommandManager.cs#L150
if (!('CI' in (args.environmentVariables ?? {}))) {
dockerArgs.push('-e', 'CI=true')
}
const mountVolumes = [
...(args.userMountVolumes || []),
...(args.systemMountVolumes || [])
]
for (const mountVolume of mountVolumes) {
dockerArgs.push(
`-v=${mountVolume.sourceVolumePath}:${mountVolume.targetVolumePath}`
`-v=${mountVolume.sourceVolumePath}:${mountVolume.targetVolumePath}${
mountVolume.readOnly ? ':ro' : ''
}`
)
}
if (args.entryPoint) {
@@ -91,11 +98,12 @@ export async function containerPull(
image: string,
configLocation: string
): Promise<void> {
const dockerArgs: string[] = ['pull']
const dockerArgs: string[] = []
if (configLocation) {
dockerArgs.push('--config')
dockerArgs.push(configLocation)
}
dockerArgs.push('pull')
dockerArgs.push(image)
for (let i = 0; i < 3; i++) {
try {
@@ -402,11 +410,16 @@ export async function containerRun(
}
if (args.environmentVariables) {
for (const [key] of Object.entries(args.environmentVariables)) {
dockerArgs.push('-e')
dockerArgs.push(key)
dockerArgs.push('-e', key)
}
}
dockerArgs.push('-e', 'GITHUB_ACTIONS=true')
// Use same behavior as the runner https://github.com/actions/runner/blob/27d9c886ab9a45e0013cb462529ac85d581f8c41/src/Runner.Worker/Container/DockerCommandManager.cs#L150
if (!('CI' in (args.environmentVariables ?? {}))) {
dockerArgs.push('-e', 'CI=true')
}
const mountVolumes = [
...(args.userMountVolumes || []),
...(args.systemMountVolumes || [])
@@ -427,6 +440,9 @@ export async function containerRun(
dockerArgs.push(args.image)
if (args.entryPointArgs) {
for (const entryPointArg of args.entryPointArgs) {
if (!entryPointArg) {
continue
}
dockerArgs.push(entryPointArg)
}
}
@@ -440,7 +456,7 @@ export async function isContainerAlpine(containerId: string): Promise<boolean> {
containerId,
'sh',
'-c',
"[ $(cat /etc/*release* | grep -i -e '^ID=*alpine*' -c) != 0 ] || exit 1"
`'[ $(cat /etc/*release* | grep -i -e "^ID=*alpine*" -c) != 0 ] || exit 1'`
]
try {
await runDockerCommand(dockerArgs)
+13 -7
View File
@@ -31,16 +31,20 @@ export async function prepareJob(
core.info('No containers exist, skipping hook invocation')
exit(0)
}
const networkName = generateNetworkName()
// Create network
await networkCreate(networkName)
let networkName = process.env.ACTIONS_RUNNER_NETWORK_DRIVER
if (!networkName) {
networkName = generateNetworkName()
// Create network
await networkCreate(networkName)
}
// Create Job Container
let containerMetadata: ContainerMetadata | undefined = undefined
if (!container?.image) {
core.info('No job container provided, skipping')
} else {
setupContainer(container)
setupContainer(container, true)
const configLocation = await registryLogin(container.registry)
try {
@@ -174,9 +178,11 @@ function generateResponseFile(
writeToResponseFile(responseFile, JSON.stringify(response))
}
function setupContainer(container): void {
container.entryPointArgs = [`-f`, `/dev/null`]
container.entryPoint = 'tail'
function setupContainer(container, jobContainer = false): void {
if (!container.entryPoint && jobContainer) {
container.entryPointArgs = [`-f`, `/dev/null`]
container.entryPoint = 'tail'
}
}
function generateNetworkName(): string {
+6 -7
View File
@@ -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)
+46 -1
View File
@@ -1,10 +1,10 @@
/* eslint-disable @typescript-eslint/no-var-requires */
/* eslint-disable @typescript-eslint/no-require-imports */
/* eslint-disable import/no-commonjs */
import * as core from '@actions/core'
import { env } from 'process'
// Import this way otherwise typescript has errors
const exec = require('@actions/exec')
const shlex = require('shlex')
export interface RunDockerCommandOptions {
workingDir?: string
@@ -16,6 +16,8 @@ export async function runDockerCommand(
args: string[],
options?: RunDockerCommandOptions
): Promise<string> {
options = optionsWithDockerEnvs(options)
args = fixArgs(args)
const pipes = await exec.getExecOutput('docker', args, options)
if (pipes.exitCode !== 0) {
core.error(`Docker failed with exit code ${pipes.exitCode}`)
@@ -24,6 +26,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 ''
@@ -44,6 +85,10 @@ export function sanitize(val: string): string {
return newNameBuilder.join('')
}
export function fixArgs(args: string[]): string[] {
return shlex.split(args.join(' '))
}
export function checkEnvironment(): void {
if (!env.GITHUB_WORKSPACE) {
throw new Error('GITHUB_WORKSPACE is not set')
+37 -2
View File
@@ -40,19 +40,54 @@ 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}:"* ]]; 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}:"* ]]; 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)
).resolves.not.toThrow()
})
it('Should confirm that CI and GITHUB_ACTIONS are set', async () => {
definitions.runScriptStep.args.entryPoint = '/bin/bash'
definitions.runScriptStep.args.entryPointArgs = [
'-c',
`'if [[ ! $(env | grep "^CI=") = "CI=true" ]]; then exit 1; fi'`
]
await expect(
runScriptStep(definitions.runScriptStep.args, prepareJobResponse.state)
).resolves.not.toThrow()
definitions.runScriptStep.args.entryPointArgs = [
'-c',
`'if [[ ! $(env | grep "^GITHUB_ACTIONS=") = "GITHUB_ACTIONS=true" ]]; then exit 1; fi'`
]
await expect(
runScriptStep(definitions.runScriptStep.args, prepareJobResponse.state)
+12 -13
View File
@@ -31,7 +31,7 @@ export default class TestSetup {
private get allTestDirectories() {
const resp = [this.testdir, this.runnerMockDir, this.runnerOutputDir]
for (const [key, value] of Object.entries(this.runnerMockSubdirs)) {
for (const [, value] of Object.entries(this.runnerMockSubdirs)) {
resp.push(`${this.runnerMockDir}/${value}`)
}
@@ -42,12 +42,11 @@ export default class TestSetup {
return resp
}
public initialize(): void {
initialize(): void {
env['GITHUB_WORKSPACE'] = this.workingDirectory
env['RUNNER_NAME'] = 'test'
env[
'RUNNER_TEMP'
] = `${this.runnerMockDir}/${this.runnerMockSubdirs.workTemp}`
env['RUNNER_TEMP'] =
`${this.runnerMockDir}/${this.runnerMockSubdirs.workTemp}`
for (const dir of this.allTestDirectories) {
fs.mkdirSync(dir, { recursive: true })
@@ -59,7 +58,7 @@ export default class TestSetup {
)
}
public teardown(): void {
teardown(): void {
fs.rmdirSync(this.testdir, { recursive: true })
}
@@ -108,21 +107,21 @@ export default class TestSetup {
]
}
public createOutputFile(name: string): string {
createOutputFile(name: string): string {
let filePath = path.join(this.runnerOutputDir, name || `${uuidv4()}.json`)
fs.writeFileSync(filePath, '')
return filePath
}
public get workingDirectory(): string {
get workingDirectory(): string {
return `${this.runnerMockDir}/_work/${this.projectName}/${this.projectName}`
}
public get containerWorkingDirectory(): string {
get containerWorkingDirectory(): string {
return `/__w/${this.projectName}/${this.projectName}`
}
public initializeDockerAction(): string {
initializeDockerAction(): string {
const actionPath = `${this.testdir}/_actions/example-handle/example-repo/example-branch/mock-directory`
fs.mkdirSync(actionPath, { recursive: true })
this.writeDockerfile(actionPath)
@@ -147,7 +146,7 @@ echo "::set-output name=time::$time"`
fs.chmodSync(entryPointPath, 0o755)
}
public getPrepareJobDefinition(): HookData {
getPrepareJobDefinition(): HookData {
const prepareJob = JSON.parse(
fs.readFileSync(
path.resolve(__dirname + '/../../../examples/prepare-job.json'),
@@ -166,7 +165,7 @@ echo "::set-output name=time::$time"`
return prepareJob
}
public getRunScriptStepDefinition(): HookData {
getRunScriptStepDefinition(): HookData {
const runScriptStep = JSON.parse(
fs.readFileSync(
path.resolve(__dirname + '/../../../examples/run-script-step.json'),
@@ -178,7 +177,7 @@ echo "::set-output name=time::$time"`
return runScriptStep
}
public getRunContainerStepDefinition(): HookData {
getRunContainerStepDefinition(): HookData {
const runContainerStep = JSON.parse(
fs.readFileSync(
path.resolve(__dirname + '/../../../examples/run-container-step.json'),
+69 -1
View File
@@ -1,4 +1,4 @@
import { sanitize } from '../src/utils'
import { optionsWithDockerEnvs, sanitize, fixArgs } from '../src/utils'
describe('Utilities', () => {
it('should return sanitized image name', () => {
@@ -9,4 +9,72 @@ describe('Utilities', () => {
const validStr = 'teststr8_one'
expect(sanitize(validStr)).toBe(validStr)
})
test.each([
[['"Hello', 'World"'], ['Hello World']],
[
[
'sh',
'-c',
`'[ $(cat /etc/*release* | grep -i -e "^ID=*alpine*" -c) != 0 ] || exit 1'`
],
[
'sh',
'-c',
`[ $(cat /etc/*release* | grep -i -e "^ID=*alpine*" -c) != 0 ] || exit 1`
]
],
[
[
'sh',
'-c',
`'[ $(cat /etc/*release* | grep -i -e '\\''^ID=*alpine*'\\'' -c) != 0 ] || exit 1'`
],
[
'sh',
'-c',
`[ $(cat /etc/*release* | grep -i -e '^ID=*alpine*' -c) != 0 ] || exit 1`
]
]
])('should fix split arguments(%p, %p)', (args, expected) => {
const got = fixArgs(args)
expect(got).toStrictEqual(expected)
})
describe('with docker options', () => {
it('should augment options with docker environment variables', () => {
process.env.DOCKER_HOST = 'unix:///run/user/1001/docker.sock'
process.env.DOCKER_NOTEXIST = 'notexist'
const optionDefinitions: any = [
undefined,
{},
{ env: {} },
{ env: { DOCKER_HOST: 'unix://var/run/docker.sock' } }
]
for (const opt of optionDefinitions) {
let options = optionsWithDockerEnvs(opt)
expect(options).toBeDefined()
expect(options?.env).toBeDefined()
expect(options?.env?.DOCKER_HOST).toBe(process.env.DOCKER_HOST)
expect(options?.env?.DOCKER_NOTEXIST).toBeUndefined()
}
})
it('should not overwrite other options', () => {
process.env.DOCKER_HOST = 'unix:///run/user/1001/docker.sock'
const opt = {
workingDir: 'test',
input: Buffer.from('test')
}
const options = optionsWithDockerEnvs(opt)
expect(options).toBeDefined()
expect(options?.workingDir).toBe(opt.workingDir)
expect(options?.input).toBe(opt.input)
expect(options?.env).toStrictEqual({
DOCKER_HOST: process.env.DOCKER_HOST
})
})
})
})
+6
View File
@@ -0,0 +1,6 @@
{
"compilerOptions": {
"allowJs": true
},
"extends": "./tsconfig.json"
}
+2614 -2927
View File
File diff suppressed because it is too large Load Diff
+7 -8
View File
@@ -3,7 +3,7 @@
"version": "0.1.0",
"description": "",
"main": "lib/index.js",
"types": "index.d.ts",
"types": "lib/index.d.ts",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"build": "tsc",
@@ -14,15 +14,14 @@
"author": "",
"license": "MIT",
"devDependencies": {
"@types/node": "^17.0.23",
"@typescript-eslint/parser": "^5.18.0",
"@types/node": "^24.0.14",
"@zeit/ncc": "^0.22.3",
"eslint": "^8.12.0",
"eslint-plugin-github": "^4.3.6",
"prettier": "^2.6.2",
"typescript": "^4.6.3"
"eslint": "^9.31.0",
"eslint-plugin-github": "^6.0.0",
"prettier": "^3.6.2",
"typescript": "^5.8.3"
},
"dependencies": {
"@actions/core": "^1.6.0"
"@actions/core": "^2.0.2"
}
}
+23 -3
View File
@@ -22,9 +22,6 @@ rules:
- 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"]
@@ -33,6 +30,27 @@ rules:
- The `ACTIONS_RUNNER_REQUIRE_JOB_CONTAINER` env should be set to true to prevent the runner from running any jobs outside of a container
- The runner pod should map a persistent volume claim into the `_work` directory
- The `ACTIONS_RUNNER_CLAIM_NAME` env should be set to the persistent volume claim that contains the runner's working directory, otherwise it defaults to `${ACTIONS_RUNNER_POD_NAME}-work`
- By default, the hook uses the Kubernetes scheduler with `nodeAffinity` to ensure job pods are scheduled correctly (essential for `ReadWriteOnce` volumes). Setting `ACTIONS_RUNNER_DISABLE_KUBE_SCHEDULER=true` will fall back to legacy `nodeName` pinning (not recommended).
## Storage Guidance
The K8s hooks require a shared volume between the runner pod and the job pods to share the workspace and other internal directories.
### RWX (Recommended)
The preferred way to configure storage is using a `ReadWriteMany` (RWX) Persistent Volume Claim. RWX allows the Kubernetes scheduler to place job pods on any node in the cluster, maximizing resource availability and flexibility.
To migrate from RWO to RWX:
1. Provision a new `ReadWriteMany` StorageClass if one is not available.
2. Update your PVC definition to use `accessModes: [ReadWriteMany]`.
3. No additional environment variables are needed - affinity-based scheduling is the default.
### RWO Fallback (Affinity-based)
If `ReadWriteMany` storage is not available, you can use `ReadWriteOnce` (RWO) storage. In this mode, all job pods must be scheduled on the same node as the runner pod that owns the PVC.
To enable this safely:
1. The default affinity-based scheduling works automatically. Do not set `ACTIONS_RUNNER_DISABLE_KUBE_SCHEDULER`.
2. The hooks will automatically add a `nodeAffinity` to the job pods, ensuring they are scheduled on the same node as the runner pod (`kubernetes.io/hostname` match).
> **Note:** We do not recommend manually setting `nodeName` in the pod template, as the hooks handle node placement automatically via affinity when the scheduler is enabled.
- 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
@@ -43,3 +61,5 @@ rules:
- 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
- Container actions will have to specify the entrypoint, since the default entrypoint will be overridden to run the commands from the workflow.
- Container actions need to have the following binaries in their container image: `sh`, `env`, `tail`.
+17 -4
View File
@@ -1,13 +1,26 @@
// eslint-disable-next-line import/no-commonjs
module.exports = {
clearMocks: true,
preset: 'ts-jest',
moduleFileExtensions: ['js', 'ts'],
testEnvironment: 'node',
testMatch: ['**/*-test.ts'],
testRunner: 'jest-circus/runner',
verbose: true,
transform: {
'^.+\\.ts$': 'ts-jest'
'^.+\\.ts$': [
'ts-jest',
{
tsconfig: 'tsconfig.test.json'
}
],
// Transform ESM modules to CommonJS
'^.+\\.(js|mjs)$': ['babel-jest', {
presets: [['@babel/preset-env', { targets: { node: 'current' } }]]
}]
},
setupFilesAfterEnv: ['./jest.setup.js'],
verbose: true
transformIgnorePatterns: [
// Transform these ESM packages
'node_modules/(?!(shlex|@kubernetes/client-node|openid-client|oauth4webapi|jose|uuid)/)'
],
setupFilesAfterEnv: ['./jest.setup.js']
}
+1
View File
@@ -1 +1,2 @@
// eslint-disable-next-line filenames/match-regex, no-undef
jest.setTimeout(500000)
+4462 -6598
View File
File diff suppressed because it is too large Load Diff
+18 -11
View File
@@ -13,18 +13,25 @@
"author": "",
"license": "MIT",
"dependencies": {
"@actions/core": "^1.6.0",
"@actions/exec": "^1.1.1",
"@actions/io": "^1.1.2",
"@kubernetes/client-node": "^0.16.3",
"hooklib": "file:../hooklib"
"@actions/core": "^2.0.2",
"@actions/exec": "^2.0.0",
"@actions/io": "^2.0.0",
"@kubernetes/client-node": "^1.3.0",
"hooklib": "file:../hooklib",
"js-yaml": "^4.1.0",
"shlex": "^3.0.0",
"tar-fs": "^3.1.0",
"uuid": "^11.1.0"
},
"devDependencies": {
"@types/jest": "^27.4.1",
"@types/node": "^17.0.23",
"@vercel/ncc": "^0.33.4",
"jest": "^27.5.1",
"ts-jest": "^27.1.4",
"typescript": "^4.6.3"
"@babel/core": "^7.28.3",
"@babel/preset-env": "^7.28.3",
"@types/jest": "^30.0.0",
"@types/node": "^24.3.0",
"@vercel/ncc": "^0.38.3",
"babel-jest": "^30.1.1",
"jest": "^30.1.1",
"ts-jest": "^29.4.1",
"typescript": "^5.9.2"
}
}
+8 -6
View File
@@ -39,14 +39,16 @@ 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 CONTAINER_EXTENSION_PREFIX = '$'
export const JOB_CONTAINER_NAME = 'job'
export const JOB_CONTAINER_EXTENSION_NAME = '$job'
export class RunnerInstanceLabel {
runnerhook: string
private podName: string
constructor() {
this.runnerhook = process.env.ACTIONS_RUNNER_POD_NAME as string
this.podName = getRunnerPodName()
}
get key(): string {
@@ -54,10 +56,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}`
}
}
+119 -46
View File
@@ -1,25 +1,36 @@
import * as core from '@actions/core'
import * as io from '@actions/io'
import * as k8s from '@kubernetes/client-node'
import { ContextPorts, prepareJobArgs, writeToResponseFile } from 'hooklib'
import {
JobContainerInfo,
ContextPorts,
PrepareJobArgs,
writeToResponseFile,
ServiceContainerInfo
} from 'hooklib'
import path from 'path'
import {
containerPorts,
createPod,
isPodContainerAlpine,
prunePods,
waitForPodPhases
waitForPodPhases,
getPrepareJobTimeoutSeconds
} from '../k8s'
import {
containerVolumes,
DEFAULT_CONTAINER_ENTRY_POINT,
DEFAULT_CONTAINER_ENTRY_POINT_ARGS,
PodPhase
generateContainerName,
mergeContainerWithOptions,
readExtensionFromFile,
PodPhase,
fixArgs
} from '../k8s/utils'
import { JOB_CONTAINER_NAME } from './constants'
import { CONTAINER_EXTENSION_PREFIX, JOB_CONTAINER_NAME } from './constants'
export async function prepareJob(
args: prepareJobArgs,
args: PrepareJobArgs,
responseFile
): Promise<void> {
if (!args.container) {
@@ -27,29 +38,49 @@ export async function prepareJob(
}
await prunePods()
const extension = readExtensionFromFile()
await copyExternalsToRoot()
let container: k8s.V1Container | undefined = undefined
if (args.container?.image) {
core.debug(`Using image '${args.container.image}' for job image`)
container = createPodSpec(args.container, JOB_CONTAINER_NAME, true)
container = createContainerSpec(
args.container,
JOB_CONTAINER_NAME,
true,
extension
)
}
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),
false,
extension
)
})
}
if (!container && !services?.length) {
throw new Error('No containers exist, skipping hook invocation')
}
let createdPod: k8s.V1Pod | undefined = undefined
try {
createdPod = await createPod(container, services, args.registry)
createdPod = await createPod(
container,
services,
args.container.registry,
extension
)
} catch (err) {
await prunePods()
throw new Error(`failed to create job pod: ${JSON.stringify(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) {
@@ -63,11 +94,12 @@ export async function prepareJob(
await waitForPodPhases(
createdPod.metadata.name,
new Set([PodPhase.RUNNING]),
new Set([PodPhase.PENDING])
new Set([PodPhase.PENDING]),
getPrepareJobTimeoutSeconds()
)
} catch (err) {
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.debug('Job pod is ready for traffic')
@@ -79,16 +111,21 @@ export async function prepareJob(
JOB_CONTAINER_NAME
)
} 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, args, createdPod, isAlpine)
}
function generateResponseFile(
responseFile: string,
args: PrepareJobArgs,
appPod: k8s.V1Pod,
isAlpine
isAlpine: boolean
): void {
if (!appPod.metadata?.name) {
throw new Error('app pod must have metadata.name specified')
@@ -108,8 +145,10 @@ function generateResponseFile(
const mainContainerContextPorts: ContextPorts = {}
if (mainContainer?.ports) {
for (const port of mainContainer.ports) {
mainContainerContextPorts[port.containerPort] =
mainContainerContextPorts.hostPort
if (port.containerPort && port.hostPort) {
mainContainerContextPorts[port.containerPort.toString()] =
port.hostPort.toString()
}
}
}
@@ -119,26 +158,29 @@ function generateResponseFile(
}
}
const serviceContainers = appPod.spec?.containers.filter(
c => c.name !== JOB_CONTAINER_NAME
)
if (serviceContainers?.length) {
response.context['services'] = serviceContainers.map(c => {
if (!c.ports) {
return
}
if (args.services?.length) {
const serviceContainerNames =
args.services?.map(s => generateContainerName(s.image)) || []
const ctxPorts: ContextPorts = {}
for (const port of c.ports) {
ctxPorts[port.containerPort] = port.hostPort
}
response.context['services'] = appPod?.spec?.containers
?.filter(c => serviceContainerNames.includes(c.name))
.map(c => {
const ctxPorts: ContextPorts = {}
if (c.ports?.length) {
for (const port of c.ports) {
if (port.containerPort && port.hostPort) {
ctxPorts[port.containerPort.toString()] = port.hostPort.toString()
}
}
}
return {
image: c.image,
ports: ctxPorts
}
})
return {
image: c.image,
ports: ctxPorts
}
})
}
writeToResponseFile(responseFile, JSON.stringify(response))
}
@@ -153,12 +195,13 @@ async function copyExternalsToRoot(): Promise<void> {
}
}
function createPodSpec(
container,
export function createContainerSpec(
container: JobContainerInfo | ServiceContainerInfo,
name: string,
jobContainer = false
jobContainer = false,
extension?: k8s.V1PodTemplateSpec
): k8s.V1Container {
if (!container.entryPoint) {
if (!container.entryPoint && jobContainer) {
container.entryPoint = DEFAULT_CONTAINER_ENTRY_POINT
container.entryPointArgs = DEFAULT_CONTAINER_ENTRY_POINT_ARGS
}
@@ -166,27 +209,57 @@ 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['workingDirectory']) {
podContainer.workingDir = container['workingDirectory']
}
if (container.entryPoint) {
podContainer.command = [container.entryPoint]
}
if (container.entryPointArgs && container.entryPointArgs.length > 0) {
podContainer.args = fixArgs(container.entryPointArgs)
}
podContainer.env = []
for (const [key, value] of Object.entries(
container['environmentVariables']
container['environmentVariables'] || {}
)) {
if (value && key !== 'HOME') {
podContainer.env.push({ name: key, value: value as string })
podContainer.env.push({ name: key, value: value })
}
}
podContainer.env.push({
name: 'GITHUB_ACTIONS',
value: 'true'
})
if (!('CI' in (container['environmentVariables'] || {}))) {
podContainer.env.push({
name: 'CI',
value: 'true'
})
}
podContainer.volumeMounts = containerVolumes(
container.userMountVolumes,
container['userMountVolumes'],
jobContainer
)
if (!extension) {
return podContainer
}
const from = extension.spec?.containers?.find(
c => c.name === CONTAINER_EXTENSION_PREFIX + name
)
if (from) {
mergeContainerWithOptions(podContainer, from)
}
return podContainer
}
+67 -23
View File
@@ -12,12 +12,12 @@ import {
} from '../k8s'
import {
containerVolumes,
DEFAULT_CONTAINER_ENTRY_POINT,
DEFAULT_CONTAINER_ENTRY_POINT_ARGS,
fixArgs,
mergeContainerWithOptions,
PodPhase,
writeEntryPointScript
readExtensionFromFile
} from '../k8s/utils'
import { JOB_CONTAINER_NAME } from './constants'
import { JOB_CONTAINER_EXTENSION_NAME, JOB_CONTAINER_NAME } from './constants'
export async function runContainerStep(
stepContainer: RunContainerStepArgs
@@ -28,13 +28,36 @@ export async function runContainerStep(
let secretName: string | undefined = undefined
if (stepContainer.environmentVariables) {
secretName = await createSecretForEnvs(stepContainer.environmentVariables)
try {
const envs = JSON.parse(
JSON.stringify(stepContainer.environmentVariables)
)
envs['GITHUB_ACTIONS'] = 'true'
if (!('CI' in envs)) {
envs.CI = 'true'
}
secretName = await createSecretForEnvs(envs)
} catch (err) {
core.debug(`createSecretForEnvs failed: ${JSON.stringify(err)}`)
const message = (err as any)?.response?.body?.message || err
throw new Error(`failed to create script environment: ${message}`)
}
}
const extension = readExtensionFromFile()
core.debug(`Created secret ${secretName} for container job envs`)
const container = createPodSpec(stepContainer, secretName)
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}`)
}
const job = await createJob(container)
if (!job.metadata?.name) {
throw new Error(
`Expected job ${JSON.stringify(
@@ -44,10 +67,23 @@ export async function runContainerStep(
}
core.debug(`Job created, waiting for pod to start: ${job.metadata?.name}`)
const podName = await getContainerJobPodName(job.metadata.name)
let podName: string
try {
podName = await getContainerJobPodName(job.metadata.name)
} catch (err) {
core.debug(`getContainerJobPodName failed: ${JSON.stringify(err)}`)
const message = (err as any)?.response?.body?.message || err
throw new Error(`failed to get container job pod name: ${message}`)
}
await waitForPodPhases(
podName,
new Set([PodPhase.COMPLETED, PodPhase.RUNNING, PodPhase.SUCCEEDED]),
new Set([
PodPhase.COMPLETED,
PodPhase.RUNNING,
PodPhase.SUCCEEDED,
PodPhase.FAILED
]),
new Set([PodPhase.PENDING, PodPhase.UNKNOWN])
)
core.debug('Container step is running or complete, pulling logs')
@@ -56,7 +92,7 @@ export async function runContainerStep(
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
@@ -75,24 +111,21 @@ export async function runContainerStep(
return Number(exitCode) || 1
}
function createPodSpec(
function createContainerSpec(
container: RunContainerStepArgs,
secretName?: string
secretName?: string,
extension?: k8s.V1PodTemplateSpec
): k8s.V1Container {
const podContainer = new k8s.V1Container()
podContainer.name = JOB_CONTAINER_NAME
podContainer.image = container.image
const { entryPoint, entryPointArgs } = container
container.entryPoint = 'sh'
const { containerPath } = writeEntryPointScript(
container.workingDirectory,
entryPoint || DEFAULT_CONTAINER_ENTRY_POINT,
entryPoint ? entryPointArgs || [] : DEFAULT_CONTAINER_ENTRY_POINT_ARGS
)
container.entryPointArgs = ['-e', containerPath]
podContainer.command = [container.entryPoint, ...container.entryPointArgs]
podContainer.workingDir = container.workingDirectory
podContainer.command = container.entryPoint
? [container.entryPoint]
: undefined
podContainer.args = container.entryPointArgs?.length
? fixArgs(container.entryPointArgs)
: undefined
if (secretName) {
podContainer.envFrom = [
@@ -106,5 +139,16 @@ function createPodSpec(
}
podContainer.volumeMounts = containerVolumes(undefined, false, true)
if (!extension) {
return podContainer
}
const from = extension.spec?.containers?.find(
c => c.name === JOB_CONTAINER_EXTENSION_NAME
)
if (from) {
mergeContainerWithOptions(podContainer, from)
}
return podContainer
}
+5 -2
View File
@@ -1,5 +1,6 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import * as fs from 'fs'
import * as core from '@actions/core'
import { RunScriptStepArgs } from 'hooklib'
import { execPodStep } from '../k8s'
import { writeEntryPointScript } from '../k8s/utils'
@@ -8,7 +9,7 @@ import { JOB_CONTAINER_NAME } from './constants'
export async function runScriptStep(
args: RunScriptStepArgs,
state,
responseFile
responseFile?
): Promise<void> {
const { entryPoint, entryPointArgs, environmentVariables } = args
const { containerPath, runnerPath } = writeEntryPointScript(
@@ -28,7 +29,9 @@ export async function runScriptStep(
JOB_CONTAINER_NAME
)
} catch (err) {
throw new Error(`failed to run script step: ${JSON.stringify(err)}`)
core.debug(`execPodStep failed: ${JSON.stringify(err)}`)
const message = (err as any)?.response?.body?.message || err
throw new Error(`failed to run script step: ${message}`)
} finally {
fs.rmSync(runnerPath)
}
+24 -20
View File
@@ -1,5 +1,11 @@
import * as core from '@actions/core'
import { Command, getInputFromStdin, prepareJobArgs } from 'hooklib'
import {
Command,
getInputFromStdin,
PrepareJobArgs,
RunContainerStepArgs,
RunScriptStepArgs
} from 'hooklib'
import {
cleanupJob,
prepareJob,
@@ -9,44 +15,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
await prepareJob(args as PrepareJobArgs, responseFile)
return process.exit(0)
case Command.CleanupJob:
await cleanupJob()
break
return process.exit(0)
case Command.RunScriptStep:
await runScriptStep(args, state, null)
break
await runScriptStep(args as RunScriptStepArgs, state)
return process.exit(0)
case Command.RunContainerStep:
exitCode = await runContainerStep(args)
break
case Command.runContainerStep:
exitCode = await runContainerStep(args as RunContainerStepArgs)
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()
+249 -133
View File
@@ -1,6 +1,7 @@
import * as core from '@actions/core'
import * as k8s from '@kubernetes/client-node'
import { ContainerInfo, Registry } from 'hooklib'
import * as stream from 'stream'
import type { ContainerInfo, Registry } from 'hooklib'
import {
getJobPodName,
getRunnerPodName,
@@ -9,7 +10,13 @@ import {
getVolumeClaimName,
RunnerInstanceLabel
} from '../hooks/constants'
import { PodPhase } from './utils'
import {
PodPhase,
mergePodSpecWithOptions,
mergeObjectMeta,
useKubeScheduler,
fixArgs
} from './utils'
const kc = new k8s.KubeConfig()
@@ -19,6 +26,8 @@ const k8sApi = kc.makeApiClient(k8s.CoreV1Api)
const k8sBatchV1Api = kc.makeApiClient(k8s.BatchV1Api)
const k8sAuthorizationV1Api = kc.makeApiClient(k8s.AuthorizationV1Api)
const DEFAULT_WAIT_FOR_POD_TIME_SECONDS = 10 * 60 // 10 min
export const POD_VOLUME_NAME = 'work'
export const requiredPermissions = [
@@ -57,7 +66,8 @@ export const requiredPermissions = [
export async function createPod(
jobContainer?: k8s.V1Container,
services?: k8s.V1Container[],
registry?: Registry
registry?: Registry,
extension?: k8s.V1PodTemplateSpec
): Promise<k8s.V1Pod> {
const containers: k8s.V1Container[] = []
if (jobContainer) {
@@ -79,15 +89,22 @@ export async function createPod(
appPod.metadata.labels = {
[instanceLabel.key]: instanceLabel.value
}
appPod.metadata.annotations = {}
appPod.spec = new k8s.V1PodSpec()
appPod.spec.containers = containers
appPod.spec.restartPolicy = 'Never'
appPod.spec.nodeName = await getCurrentNodeName()
const nodeName = await getCurrentNodeName()
if (useKubeScheduler()) {
appPod.spec.affinity = await getPodAffinity(nodeName)
} else {
appPod.spec.nodeName = nodeName
}
const claimName = getVolumeClaimName()
appPod.spec.volumes = [
{
name: 'work',
name: POD_VOLUME_NAME,
persistentVolumeClaim: { claimName }
}
]
@@ -102,20 +119,33 @@ export async function createPod(
appPod.spec.imagePullSecrets = [secretReference]
}
const { body } = await k8sApi.createNamespacedPod(namespace(), appPod)
return body
if (extension?.metadata) {
mergeObjectMeta(appPod, extension.metadata)
}
if (extension?.spec) {
mergePodSpecWithOptions(appPod.spec, extension.spec)
}
return await k8sApi.createNamespacedPod({
namespace: namespace(),
body: appPod
})
}
export async function createJob(
container: k8s.V1Container
container: k8s.V1Container,
extension?: k8s.V1PodTemplateSpec
): 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.metadata.annotations = {}
job.spec = new k8s.V1JobSpec()
job.spec.ttlSecondsAfterFinished = 300
@@ -123,58 +153,73 @@ export async function createJob(
job.spec.template = new k8s.V1PodTemplateSpec()
job.spec.template.spec = new k8s.V1PodSpec()
job.spec.template.metadata = new k8s.V1ObjectMeta()
job.spec.template.metadata.labels = {}
job.spec.template.metadata.annotations = {}
job.spec.template.spec.containers = [container]
job.spec.template.spec.restartPolicy = 'Never'
job.spec.template.spec.nodeName = await getCurrentNodeName()
const claimName = `${runnerName()}-work`
const nodeName = await getCurrentNodeName()
if (useKubeScheduler()) {
job.spec.template.spec.affinity = await getPodAffinity(nodeName)
} else {
job.spec.template.spec.nodeName = nodeName
}
const claimName = getVolumeClaimName()
job.spec.template.spec.volumes = [
{
name: 'work',
name: POD_VOLUME_NAME,
persistentVolumeClaim: { claimName }
}
]
const { body } = await k8sBatchV1Api.createNamespacedJob(namespace(), job)
return body
if (extension) {
if (extension.metadata) {
mergeObjectMeta(job, extension.metadata)
mergeObjectMeta(job.spec.template, extension.metadata)
}
if (extension.spec) {
mergePodSpecWithOptions(job.spec.template.spec, extension.spec)
}
}
return await k8sBatchV1Api.createNamespacedJob({
namespace: namespace(),
body: job
})
}
export async function getContainerJobPodName(jobName: string): Promise<string> {
const selector = `job-name=${jobName}`
const backOffManager = new BackOffManager(60)
while (true) {
const podList = await k8sApi.listNamespacedPod(
namespace(),
undefined,
undefined,
undefined,
undefined,
selector,
1
)
const podList = await k8sApi.listNamespacedPod({
namespace: namespace(),
labelSelector: selector,
limit: 1
})
if (!podList.body.items?.length) {
if (!podList.items?.length) {
await backOffManager.backOff()
continue
}
if (!podList.body.items[0].metadata?.name) {
if (!podList.items[0].metadata?.name) {
throw new Error(
`Failed to determine the name of the pod for job ${jobName}`
)
}
return podList.body.items[0].metadata.name
return podList.items[0].metadata.name
}
}
export async function deletePod(podName: string): Promise<void> {
await k8sApi.deleteNamespacedPod(
podName,
namespace(),
undefined,
undefined,
0
)
await k8sApi.deleteNamespacedPod({
name: podName,
namespace: namespace(),
gracePeriodSeconds: 0
})
}
export async function execPodStep(
@@ -182,11 +227,13 @@ export async function execPodStep(
podName: string,
containerName: string,
stdin?: stream.Readable
): Promise<void> {
): Promise<number> {
const exec = new k8s.Exec(kc)
await new Promise(async function (resolve, reject) {
try {
await exec.exec(
command = fixArgs(command)
return await new Promise(function (resolve, reject) {
exec
.exec(
namespace(),
podName,
containerName,
@@ -196,22 +243,20 @@ export async function execPodStep(
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)
resolve(resp.code || 0)
} else {
reject(
core.debug(
JSON.stringify({
message: resp?.message,
details: resp?.details
})
)
reject(new Error(resp?.message || 'execPodStep failed'))
}
}
)
} catch (error) {
reject(JSON.stringify(error))
}
.catch(e => reject(e))
})
}
@@ -223,7 +268,7 @@ export async function waitForJobToComplete(jobName: string): Promise<void> {
return
}
} catch (error) {
throw new Error(`job ${jobName} has failed`)
throw new Error(`job ${jobName} has failed: ${JSON.stringify(error)}`)
}
await backOffManager.backOff()
}
@@ -234,75 +279,90 @@ 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)
return body
return await k8sApi.createNamespacedSecret({
namespace: namespace(),
body: secret
})
}
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)) {
secret.data[key] = Buffer.from(value).toString('base64')
}
await k8sApi.createNamespacedSecret(namespace(), secret)
await k8sApi.createNamespacedSecret({
namespace: namespace(),
body: secret
})
return secretName
}
export async function deleteSecret(secretName: string): Promise<void> {
await k8sApi.deleteNamespacedSecret(secretName, namespace())
await k8sApi.deleteNamespacedSecret({
name: secretName,
namespace: 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) {
const secretList = await k8sApi.listNamespacedSecret({
namespace: namespace(),
labelSelector: new RunnerInstanceLabel().toString()
})
if (!secretList.items.length) {
return
}
await Promise.all(
secretList.body.items.map(
secret => secret.metadata?.name && deleteSecret(secret.metadata.name)
secretList.items.map(
async secret =>
secret.metadata?.name && deleteSecret(secret.metadata.name)
)
)
}
@@ -311,7 +371,7 @@ export async function waitForPodPhases(
podName: string,
awaitingPhases: Set<PodPhase>,
backOffPhases: Set<PodPhase>,
maxTimeSeconds = 10 * 60 // 10 min
maxTimeSeconds = DEFAULT_WAIT_FOR_POD_TIME_SECONDS
): Promise<void> {
const backOffManager = new BackOffManager(maxTimeSeconds)
let phase: PodPhase = PodPhase.UNKNOWN
@@ -330,11 +390,32 @@ export async function waitForPodPhases(
await backOffManager.backOff()
}
} catch (error) {
throw new Error(`Pod ${podName} is unhealthy with phase status ${phase}`)
throw new Error(
`Pod ${podName} is unhealthy with phase status ${phase}: ${JSON.stringify(error)}`
)
}
}
async function getPodPhase(podName: string): Promise<PodPhase> {
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(name: string): Promise<PodPhase> {
const podPhaseLookup = new Set<string>([
PodPhase.PENDING,
PodPhase.RUNNING,
@@ -342,8 +423,10 @@ async function getPodPhase(podName: string): Promise<PodPhase> {
PodPhase.FAILED,
PodPhase.UNKNOWN
])
const { body } = await k8sApi.readNamespacedPod(podName, namespace())
const pod = body
const pod = await k8sApi.readNamespacedPod({
name,
namespace: namespace()
})
if (!pod.status?.phase || !podPhaseLookup.has(pod.status.phase)) {
return PodPhase.UNKNOWN
@@ -351,11 +434,13 @@ async function getPodPhase(podName: string): Promise<PodPhase> {
return pod.status?.phase as PodPhase
}
async function isJobSucceeded(jobName: string): Promise<boolean> {
const { body } = await k8sBatchV1Api.readNamespacedJob(jobName, namespace())
const job = body
async function isJobSucceeded(name: string): Promise<boolean> {
const job = await k8sBatchV1Api.readNamespacedJob({
name,
namespace: namespace()
})
if (job.status?.failed) {
throw new Error(`job ${jobName} has failed`)
throw new Error(`job ${name} has failed`)
}
return !!job.status?.succeeded
}
@@ -372,34 +457,29 @@ 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, {
await log.log(namespace(), podName, containerName, logStream, {
follow: true,
tailLines: 50,
pretty: false,
timestamps: false
})
await new Promise(resolve => r.on('close', () => resolve(null)))
await new Promise(resolve => logStream.on('end', () => resolve(null)))
}
export async function prunePods(): Promise<void> {
const podList = await k8sApi.listNamespacedPod(
namespace(),
undefined,
undefined,
undefined,
undefined,
new RunnerInstanceLabel().toString()
)
if (!podList.body.items.length) {
const podList = await k8sApi.listNamespacedPod({
namespace: namespace(),
labelSelector: new RunnerInstanceLabel().toString()
})
if (!podList.items.length) {
return
}
await Promise.all(
podList.body.items.map(
pod => pod.metadata?.name && deletePod(pod.metadata.name)
podList.items.map(
async pod => pod.metadata?.name && deletePod(pod.metadata.name)
)
)
}
@@ -407,16 +487,16 @@ export async function prunePods(): Promise<void> {
export async function getPodStatus(
name: string
): Promise<k8s.V1PodStatus | undefined> {
const { body } = await k8sApi.readNamespacedPod(name, namespace())
return body.status
const pod = await k8sApi.readNamespacedPod({
name,
namespace: namespace()
})
return pod.status
}
export async function isAuthPermissionsOK(): Promise<boolean> {
const sar = new k8s.V1SelfSubjectAccessReview()
const asyncs: Promise<{
response: unknown
body: k8s.V1SelfSubjectAccessReview
}>[] = []
const asyncs: Promise<k8s.V1SelfSubjectAccessReview>[] = []
for (const resource of requiredPermissions) {
for (const verb of resource.verbs) {
sar.spec = new k8s.V1SelfSubjectAccessReviewSpec()
@@ -426,11 +506,13 @@ export async function isAuthPermissionsOK(): Promise<boolean> {
sar.spec.resourceAttributes.group = resource.group
sar.spec.resourceAttributes.resource = resource.resource
sar.spec.resourceAttributes.subresource = resource.subresource
asyncs.push(k8sAuthorizationV1Api.createSelfSubjectAccessReview(sar))
asyncs.push(
k8sAuthorizationV1Api.createSelfSubjectAccessReview({ body: sar })
)
}
}
const responses = await Promise.all(asyncs)
return responses.every(resp => resp.body.status?.allowed)
return responses.every(resp => resp.status?.allowed)
}
export async function isPodContainerAlpine(
@@ -443,27 +525,18 @@ export async function isPodContainerAlpine(
[
'sh',
'-c',
"[ $(cat /etc/*release* | grep -i -e '^ID=*alpine*' -c) != 0 ] || exit 1"
`'[ $(cat /etc/*release* | grep -i -e "^ID=*alpine*" -c) != 0 ] || exit 1'`
],
podName,
containerName
)
} catch (err) {
} catch {
isAlpine = false
}
return isAlpine
}
async function getCurrentNodeName(): Promise<string> {
const resp = await k8sApi.readNamespacedPod(getRunnerPodName(), namespace())
const nodeName = resp.body.spec?.nodeName
if (!nodeName) {
throw new Error('Failed to determine node name')
}
return nodeName
}
export function namespace(): string {
if (process.env['ACTIONS_RUNNER_KUBERNETES_NAMESPACE']) {
return process.env['ACTIONS_RUNNER_KUBERNETES_NAMESPACE']
@@ -478,14 +551,37 @@ 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.'
)
async function getCurrentNodeName(): Promise<string> {
const resp = await k8sApi.readNamespacedPod({
name: getRunnerPodName(),
namespace: namespace()
})
const nodeName = resp.spec?.nodeName
if (!nodeName) {
throw new Error('Failed to determine node name')
}
return name
return nodeName
}
async function getPodAffinity(nodeName: string): Promise<k8s.V1Affinity> {
const affinity = new k8s.V1Affinity()
affinity.nodeAffinity = new k8s.V1NodeAffinity()
affinity.nodeAffinity.requiredDuringSchedulingIgnoredDuringExecution =
new k8s.V1NodeSelector()
affinity.nodeAffinity.requiredDuringSchedulingIgnoredDuringExecution.nodeSelectorTerms =
[
{
matchExpressions: [
{
key: 'kubernetes.io/hostname',
operator: 'In',
values: [nodeName]
}
]
}
]
return affinity
}
class BackOffManager {
@@ -517,28 +613,48 @@ 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
}
export async function getPodByName(name: string): Promise<k8s.V1Pod> {
return await k8sApi.readNamespacedPod({
name,
namespace: namespace()
})
}
+274 -19
View File
@@ -1,13 +1,21 @@
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 { v1 as uuidv4 } from 'uuid'
import { CONTAINER_EXTENSION_PREFIX } from '../hooks/constants'
import * as shlex from 'shlex'
import { Mount } from 'hooklib'
import * as path from 'path'
import { v1 as uuidv4 } from 'uuid'
import { POD_VOLUME_NAME } from './index'
export const DEFAULT_CONTAINER_ENTRY_POINT_ARGS = [`-f`, `/dev/null`]
export const DEFAULT_CONTAINER_ENTRY_POINT = 'tail'
export const ENV_HOOK_TEMPLATE_PATH = 'ACTIONS_RUNNER_CONTAINER_HOOK_TEMPLATE'
export const ENV_DISABLE_KUBE_SCHEDULER =
'ACTIONS_RUNNER_DISABLE_KUBE_SCHEDULER'
export function containerVolumes(
userMountVolumes: Mount[] = [],
jobContainer = true,
@@ -20,18 +28,30 @@ 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'
},
{
name: POD_VOLUME_NAME,
mountPath: '/github/home',
subPath: '_temp/_github_home'
},
{
name: POD_VOLUME_NAME,
mountPath: '/github/workflow',
subPath: '_temp/_github_workflow'
}
)
return mounts
@@ -63,7 +83,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)) {
@@ -72,7 +91,6 @@ export function containerVolumes(
'Volume mounts outside of the work folder are not supported'
)
}
// source volume path should be relative path
sourceVolumePath = userVolume.sourceVolumePath.slice(
workspacePath.length + 1
)
@@ -91,12 +109,66 @@ export function containerVolumes(
return mounts
}
export function prepareJobScript(userVolumeMounts: Mount[]): {
containerPath: string
runnerPath: string
} {
let mountDirs = userVolumeMounts.map(m => m.targetVolumePath).join(' ')
const content = `#!/bin/sh -l
set -e
cp -R /__w/_temp/_github_home /github/home
cp -R /__w/_temp/_github_workflow /github/workflow
mkdir -p ${mountDirs}
`
const filename = `${uuidv4()}.sh`
const entryPointPath = `${process.env.RUNNER_TEMP}/${filename}`
fs.writeFileSync(entryPointPath, content)
return {
containerPath: `/__w/_temp/${filename}`,
runnerPath: entryPointPath
}
}
export function writeEntryPointScript(
workingDirectory: string,
entryPoint: string,
entryPointArgs?: string[],
prependPath?: string[],
environmentVariables?: { [key: string]: string }
): { containerPath: string; runnerPath: string } {
let exportPath = ''
if (prependPath?.length) {
const prepend =
typeof prependPath === 'string' ? prependPath : prependPath.join(':')
exportPath = `export PATH=${prepend}:$PATH`
}
const environmentPrefix = scriptEnv(environmentVariables)
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 writeRunScript(
workingDirectory: string,
entryPoint: string,
entryPointArgs?: string[],
prependPath?: string[],
environmentVariables?: { [key: string]: string }
): { containerPath: string; runnerPath: string } {
let exportPath = ''
if (prependPath?.length) {
@@ -105,22 +177,12 @@ export function writeEntryPointScript(
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)) {
envBuffer.push(
`"${key}=${value
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"')
.replace(/=/g, '\\=')}"`
)
}
environmentPrefix = `env ${envBuffer.join(' ')} `
}
let environmentPrefix = scriptEnv(environmentVariables)
const content = `#!/bin/sh -l
set -e
rm "$0" # remove script after running
${exportPath}
cd ${workingDirectory} && \
exec ${environmentPrefix} ${entryPoint} ${
@@ -136,6 +198,186 @@ exec ${environmentPrefix} ${entryPoint} ${
}
}
export function writeContainerStepScript(
dst: string,
workingDirectory: string,
entryPoint: string,
entryPointArgs?: string[],
environmentVariables?: { [key: string]: string }
): { containerPath: string; runnerPath: string } {
let environmentPrefix = scriptEnv(environmentVariables)
const parts = workingDirectory.split('/').slice(-2)
if (parts.length !== 2) {
throw new Error(`Invalid working directory: ${workingDirectory}`)
}
const content = `#!/bin/sh -l
rm "$0" # remove script after running
mv /__w/_temp/_github_home /github/home && \
mv /__w/_temp/_github_workflow /github/workflow && \
mv /__w/_temp/_runner_file_commands /github/file_commands || true && \
mv /__w/${parts.join('/')}/ /github/workspace && \
cd /github/workspace && \
exec ${environmentPrefix} ${entryPoint} ${
entryPointArgs?.length ? entryPointArgs.join(' ') : ''
}
`
const filename = `${uuidv4()}.sh`
const entryPointPath = `${dst}/${filename}`
core.debug(`Writing container step script to ${entryPointPath}`)
fs.writeFileSync(entryPointPath, content)
return {
containerPath: `/__w/_temp/${filename}`,
runnerPath: entryPointPath
}
}
function scriptEnv(envs?: { [key: string]: string }): string {
if (!envs || !Object.entries(envs).length) {
return ''
}
const envBuffer: string[] = []
for (const [key, value] of Object.entries(envs)) {
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, '\\`')}"`
)
}
if (!envBuffer?.length) {
return ''
}
return `env ${envBuffer.join(' ')} `
}
export function generateContainerName(image: string): string {
const nameWithTag = image.split('/').pop()
const name = nameWithTag?.split(':')[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 !== CONTAINER_EXTENSION_PREFIX + base.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(CONTAINER_EXTENSION_PREFIX)
)
)
} 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_DISABLE_KUBE_SCHEDULER] !== 'true'
}
export enum PodPhase {
PENDING = 'Pending',
RUNNING = 'Running',
@@ -144,3 +386,16 @@ export enum PodPhase {
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(' '))
}
+43 -4
View File
@@ -1,5 +1,9 @@
import * as k8s from '@kubernetes/client-node'
import { cleanupJob, prepareJob } from '../src/hooks'
import { RunnerInstanceLabel } from '../src/hooks/constants'
import { namespace } from '../src/k8s'
import { TestHelper } from './test-setup'
import { PrepareJobArgs } from 'hooklib'
let testHelper: TestHelper
@@ -11,12 +15,47 @@ describe('Cleanup Job', () => {
const prepareJobOutputFilePath = testHelper.createFile(
'prepare-job-output.json'
)
await prepareJob(prepareJobData.args, prepareJobOutputFilePath)
})
it('should not throw', async () => {
await expect(cleanupJob()).resolves.not.toThrow()
await prepareJob(
prepareJobData.args as PrepareJobArgs,
prepareJobOutputFilePath
)
})
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: namespace(),
labelSelector: new RunnerInstanceLabel().toString()
})
expect(podList.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: namespace(),
labelSelector: new RunnerInstanceLabel().toString()
})
expect(secretList.items.length).toBe(0)
})
})
+182
View 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)
})
})
})
+7 -2
View File
@@ -6,6 +6,7 @@ import {
runScriptStep
} from '../src/hooks'
import { TestHelper } from './test-setup'
import { RunContainerStepArgs, RunScriptStepArgs } from 'hooklib'
jest.useRealTimers()
@@ -25,6 +26,7 @@ describe('e2e', () => {
afterEach(async () => {
await testHelper.cleanup()
})
it('should prepare job, run script step, run container step then cleanup without errors', async () => {
await expect(
prepareJob(prepareJobData.args, prepareJobOutputFilePath)
@@ -36,13 +38,16 @@ describe('e2e', () => {
const prepareJobOutputData = JSON.parse(prepareJobOutputJson.toString())
await expect(
runScriptStep(scriptStepData.args, prepareJobOutputData.state, null)
runScriptStep(
scriptStepData.args as RunScriptStepArgs,
prepareJobOutputData.state
)
).resolves.not.toThrow()
const runContainerStepData = testHelper.getRunContainerStepDefinition()
await expect(
runContainerStep(runContainerStepData.args)
runContainerStep(runContainerStepData.args as RunContainerStepArgs)
).resolves.not.toThrow()
await expect(cleanupJob()).resolves.not.toThrow()
+409
View File
@@ -0,0 +1,409 @@
import * as fs from 'fs'
import { containerPorts } from '../src/k8s'
import {
generateContainerName,
writeRunScript,
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(() =>
writeRunScript('/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(() =>
writeRunScript('/test', 'sh', ['-e', 'script.sh'], ['/prepend/path'], {
SOME_ENV: 'SOME_VALUE'
})
).toThrow()
})
it('should throw if environment variable name contains double quote', () => {
expect(() =>
writeRunScript('/test', 'sh', ['-e', 'script.sh'], ['/prepend/path'], {
'SOME"_ENV': 'SOME_VALUE'
})
).toThrow()
})
it('should throw if environment variable name contains =', () => {
expect(() =>
writeRunScript('/test', 'sh', ['-e', 'script.sh'], ['/prepend/path'], {
'SOME=ENV': 'SOME_VALUE'
})
).toThrow()
})
it('should throw if environment variable name contains single quote', () => {
expect(() =>
writeRunScript('/test', 'sh', ['-e', 'script.sh'], ['/prepend/path'], {
"SOME'_ENV": 'SOME_VALUE'
})
).toThrow()
})
it('should throw if environment variable name contains dollar', () => {
expect(() =>
writeRunScript('/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 } = writeRunScript(
'/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 } = writeRunScript(
'/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 } = writeRunScript(
'/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 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:22
- 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:22',
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:22',
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)
})
})
+189 -17
View File
@@ -1,8 +1,12 @@
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 { ENV_HOOK_TEMPLATE_PATH, generateContainerName } from '../src/k8s/utils'
import { execPodStep, getPodByName } from '../src/k8s'
import { V1Container } from '@kubernetes/client-node'
import { JOB_CONTAINER_NAME } from '../src/hooks/constants'
jest.useRealTimers()
@@ -37,32 +41,82 @@ describe('Prepare job', () => {
})
it('should prepare job with absolute path for userVolumeMount', async () => {
const userVolumeMount = path.join(
process.env.GITHUB_WORKSPACE as string,
'myvolume'
)
fs.mkdirSync(userVolumeMount, { recursive: true })
fs.writeFileSync(path.join(userVolumeMount, 'file.txt'), 'hello')
prepareJobData.args.container.userMountVolumes = [
{
sourceVolumePath: path.join(
process.env.GITHUB_WORKSPACE as string,
'/myvolume'
),
targetVolumePath: '/volume_mount',
sourceVolumePath: userVolumeMount,
targetVolumePath: '/__w/myvolume',
readOnly: false
}
]
await expect(
prepareJob(prepareJobData.args, prepareJobOutputFilePath)
).resolves.not.toThrow()
const content = JSON.parse(
fs.readFileSync(prepareJobOutputFilePath).toString()
)
await execPodStep(
['sh', '-c', '[ "$(cat /__w/myvolume/file.txt)" = "hello" ] || exit 5'],
content!.state!.jobPod,
JOB_CONTAINER_NAME
).then(output => {
expect(output).toBe(0)
})
})
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 prepare job with envs CI and GITHUB_ACTIONS', async () => {
await prepareJob(prepareJobData.args, prepareJobOutputFilePath)
const content = JSON.parse(
fs.readFileSync(prepareJobOutputFilePath).toString()
)
const got = await getPodByName(content.state.jobPod)
expect(got.spec?.containers[0].env).toEqual(
expect.arrayContaining([
{ name: 'CI', value: 'true' },
{ name: 'GITHUB_ACTIONS', value: 'true' }
])
)
expect(got.spec?.containers[1].env).toEqual(
expect.arrayContaining([
{ name: 'CI', value: 'true' },
{ name: 'GITHUB_ACTIONS', value: 'true' }
])
)
})
it('should not override CI env var if already set', async () => {
prepareJobData.args.container.environmentVariables = {
CI: 'false'
}
await prepareJob(prepareJobData.args, prepareJobOutputFilePath)
const content = JSON.parse(
fs.readFileSync(prepareJobOutputFilePath).toString()
)
const got = await getPodByName(content.state.jobPod)
expect(got.spec?.containers[0].env).toEqual(
expect.arrayContaining([
{ name: 'CI', value: 'false' },
{ name: 'GITHUB_ACTIONS', value: 'true' }
])
)
expect(got.spec?.containers[1].env).toEqual(
expect.arrayContaining([
{ name: 'CI', value: 'true' },
{ name: 'GITHUB_ACTIONS', value: 'true' }
])
)
})
it('should not run prepare job without the job container', async () => {
@@ -71,4 +125,122 @@ 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)
})
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?.restartPolicy).toBe('Never')
// job container
expect(got.spec?.containers[0].name).toBe(JOB_CONTAINER_NAME)
expect(got.spec?.containers[0].image).toBe('node:22')
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()
expect(got.spec?.containers[1].env).toEqual(
expect.arrayContaining([
{ name: 'CI', value: 'true' },
{ name: 'GITHUB_ACTIONS', value: 'true' },
{ name: 'ENV2', value: 'value2' }
])
)
expect(got.spec?.containers[1].resources).toEqual({
requests: { memory: '1Mi', cpu: '1' },
limits: { memory: '1Gi', cpu: '2' }
})
// 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 put only job and services in output context file', async () => {
process.env[ENV_HOOK_TEMPLATE_PATH] = path.join(
__dirname,
'../../../examples/extension.yaml'
)
await expect(
prepareJob(prepareJobData.args, prepareJobOutputFilePath)
).resolves.not.toThrow()
const content = JSON.parse(
fs.readFileSync(prepareJobOutputFilePath).toString()
)
expect(content.state.jobPod).toBeTruthy()
expect(content.context.container).toBeTruthy()
expect(content.context.services).toBeTruthy()
expect(content.context.services.length).toBe(1)
})
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()
}
)
it('should prepare job with container with non-root user', async () => {
prepareJobData.args!.container!.image =
'ghcr.io/actions/actions-runner:latest' // known to use user 1001
await expect(
prepareJob(prepareJobData.args, prepareJobOutputFilePath)
).resolves.not.toThrow()
const content = JSON.parse(
fs.readFileSync(prepareJobOutputFilePath).toString()
)
expect(content.state.jobPod).toBeTruthy()
expect(content.context.container.image).toBe(
'ghcr.io/actions/actions-runner:latest'
)
})
})
+55 -8
View File
@@ -1,16 +1,25 @@
import { runContainerStep } from '../src/hooks'
import { prepareJob, runContainerStep } from '../src/hooks'
import { TestHelper } from './test-setup'
import { ENV_HOOK_TEMPLATE_PATH } from '../src/k8s/utils'
import * as fs from 'fs'
import * as yaml from 'js-yaml'
import { JOB_CONTAINER_EXTENSION_NAME } from '../src/hooks/constants'
jest.useRealTimers()
let testHelper: TestHelper
let runContainerStepData: any
let prepareJobData: any
let prepareJobOutputFilePath: string
describe('Run container step', () => {
beforeEach(async () => {
testHelper = new TestHelper()
await testHelper.initialize()
prepareJobData = testHelper.getPrepareJobDefinition()
prepareJobOutputFilePath = testHelper.createFile('prepare-job-output.json')
await prepareJob(prepareJobData.args, prepareJobOutputFilePath)
runContainerStepData = testHelper.getRunContainerStepDefinition()
})
@@ -18,14 +27,41 @@ describe('Run container step', () => {
await testHelper.cleanup()
})
it('should not throw', async () => {
const exitCode = await runContainerStep(runContainerStepData.args)
expect(exitCode).toBe(0)
})
it('should run pod with extensions applied', async () => {
const extension = {
metadata: {
annotations: {
foo: 'bar'
},
labels: {
bar: 'baz'
}
},
spec: {
containers: [
{
name: JOB_CONTAINER_EXTENSION_NAME,
command: ['sh'],
args: ['-c', 'sleep 10000']
},
{
name: 'side-container',
image: 'ubuntu:latest',
command: ['sh'],
args: ['-c', 'echo test']
}
],
restartPolicy: 'Never'
}
}
it('should fail if the working directory does not exist', async () => {
runContainerStepData.args.workingDirectory = '/foo/bar'
await expect(runContainerStep(runContainerStepData.args)).rejects.toThrow()
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 () => {
@@ -38,4 +74,15 @@ describe('Run container step', () => {
runContainerStep(runContainerStepData.args)
).resolves.not.toThrow()
})
it('should run container step with envs CI and GITHUB_ACTIONS', async () => {
runContainerStepData.args.entryPoint = 'bash'
runContainerStepData.args.entryPointArgs = [
'-c',
"'if [[ -z $GITHUB_ACTIONS ]] || [[ -z $CI ]]; then exit 1; fi'"
]
await expect(
runContainerStep(runContainerStepData.args)
).resolves.not.toThrow()
})
})
+38 -30
View File
@@ -1,6 +1,7 @@
import * as fs from 'fs'
import { cleanupJob, prepareJob, runScriptStep } from '../src/hooks'
import { TestHelper } from './test-setup'
import { PrepareJobArgs, RunScriptStepArgs } from 'hooklib'
jest.useRealTimers()
@@ -8,7 +9,9 @@ let testHelper: TestHelper
let prepareJobOutputData: any
let runScriptStepDefinition
let runScriptStepDefinition: {
args: RunScriptStepArgs
}
describe('Run script step', () => {
beforeEach(async () => {
@@ -19,9 +22,14 @@ describe('Run script step', () => {
)
const prepareJobData = testHelper.getPrepareJobDefinition()
runScriptStepDefinition = testHelper.getRunScriptStepDefinition()
runScriptStepDefinition = testHelper.getRunScriptStepDefinition() as {
args: RunScriptStepArgs
}
await prepareJob(prepareJobData.args, prepareJobOutputFilePath)
await prepareJob(
prepareJobData.args as PrepareJobArgs,
prepareJobOutputFilePath
)
const outputContent = fs.readFileSync(prepareJobOutputFilePath)
prepareJobOutputData = JSON.parse(outputContent.toString())
})
@@ -37,22 +45,14 @@ describe('Run script step', () => {
it('should not throw an exception', async () => {
await expect(
runScriptStep(
runScriptStepDefinition.args,
prepareJobOutputData.state,
null
)
runScriptStep(runScriptStepDefinition.args, prepareJobOutputData.state)
).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
)
runScriptStep(runScriptStepDefinition.args, prepareJobOutputData.state)
).rejects.toThrow()
})
@@ -64,16 +64,12 @@ describe('Run script step', () => {
"'if [[ -z $NODE_ENV ]]; then exit 1; fi'"
]
await expect(
runScriptStep(
runScriptStepDefinition.args,
prepareJobOutputData.state,
null
)
runScriptStep(runScriptStepDefinition.args, prepareJobOutputData.state)
).resolves.not.toThrow()
})
it('Should have path variable changed in container with prepend path string', async () => {
runScriptStepDefinition.args.prependPath = '/some/path'
runScriptStepDefinition.args.prependPath = ['/some/path']
runScriptStepDefinition.args.entryPoint = '/bin/bash'
runScriptStepDefinition.args.entryPointArgs = [
'-c',
@@ -81,11 +77,25 @@ describe('Run script step', () => {
]
await expect(
runScriptStep(
runScriptStepDefinition.args,
prepareJobOutputData.state,
null
)
runScriptStep(runScriptStepDefinition.args, prepareJobOutputData.state)
).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)
).resolves.not.toThrow()
})
@@ -94,15 +104,13 @@ describe('Run script step', () => {
runScriptStepDefinition.args.entryPoint = '/bin/bash'
runScriptStepDefinition.args.entryPointArgs = [
'-c',
`'if [[ ! $(env | grep "^PATH=") = "PATH=${runScriptStepDefinition.args.prependPath}:"* ]]; then exit 1; fi'`
`'if [[ ! $(env | grep "^PATH=") = "PATH=${runScriptStepDefinition.args.prependPath.join(
':'
)}:"* ]]; then exit 1; fi'`
]
await expect(
runScriptStep(
runScriptStepDefinition.args,
prepareJobOutputData.state,
null
)
runScriptStep(runScriptStepDefinition.args, prepareJobOutputData.state)
).resolves.not.toThrow()
})
})
+137
View File
@@ -0,0 +1,137 @@
import * as fs from 'fs'
import { cleanupJob } from '../src/hooks'
import { prepareJob } from '../src/hooks/prepare-job'
import { TestHelper } from './test-setup'
import { getPodByName } from '../src/k8s'
import { ENV_DISABLE_KUBE_SCHEDULER } from '../src/k8s/utils'
jest.useRealTimers()
let testHelper: TestHelper
let prepareJobData: any
let prepareJobOutputFilePath: string
describe('RWO Affinity Behavior (Scheduler Mode)', () => {
beforeEach(async () => {
testHelper = new TestHelper()
await testHelper.initialize()
prepareJobData = testHelper.getPrepareJobDefinition()
prepareJobOutputFilePath = testHelper.createFile('prepare-job-output.json')
})
afterEach(async () => {
await cleanupJob()
await testHelper.cleanup()
delete process.env[ENV_DISABLE_KUBE_SCHEDULER]
})
it('should add nodeAffinity with hostname selector by default', async () => {
await prepareJob(prepareJobData.args, prepareJobOutputFilePath)
const content = JSON.parse(
fs.readFileSync(prepareJobOutputFilePath).toString()
)
const pod = await getPodByName(content.state.jobPod)
expect(pod.spec?.affinity).toBeDefined()
expect(pod.spec?.affinity?.nodeAffinity).toBeDefined()
const nodeAffinity = pod.spec?.affinity?.nodeAffinity
expect(
nodeAffinity?.requiredDuringSchedulingIgnoredDuringExecution
).toBeDefined()
const nodeSelectorTerms =
nodeAffinity?.requiredDuringSchedulingIgnoredDuringExecution
?.nodeSelectorTerms
expect(nodeSelectorTerms).toBeDefined()
expect(nodeSelectorTerms?.length).toBeGreaterThan(0)
const matchExpressions = nodeSelectorTerms?.[0].matchExpressions
expect(matchExpressions).toBeDefined()
expect(matchExpressions?.length).toBeGreaterThan(0)
const hostnameExpression = matchExpressions?.[0]
expect(hostnameExpression?.key).toBe('kubernetes.io/hostname')
expect(hostnameExpression?.operator).toBe('In')
expect(hostnameExpression?.values).toBeDefined()
expect(hostnameExpression?.values?.length).toBeGreaterThan(0)
expect(hostnameExpression?.values?.[0]).toBeTruthy()
})
it('should NOT add nodeAffinity when scheduler mode is disabled', async () => {
process.env[ENV_DISABLE_KUBE_SCHEDULER] = 'true'
await prepareJob(prepareJobData.args, prepareJobOutputFilePath)
const content = JSON.parse(
fs.readFileSync(prepareJobOutputFilePath).toString()
)
const pod = await getPodByName(content.state.jobPod)
if (pod.spec?.affinity) {
expect(pod.spec.affinity.nodeAffinity).toBeUndefined()
}
expect(pod.spec?.nodeName).toBeDefined()
})
it('should fail assertion if affinity block is missing by default', async () => {
await prepareJob(prepareJobData.args, prepareJobOutputFilePath)
const content = JSON.parse(
fs.readFileSync(prepareJobOutputFilePath).toString()
)
const pod = await getPodByName(content.state.jobPod)
expect(pod.spec?.affinity).toBeDefined()
expect(pod.spec?.affinity?.nodeAffinity).toBeDefined()
expect(
pod.spec?.affinity?.nodeAffinity
?.requiredDuringSchedulingIgnoredDuringExecution
).toBeDefined()
const nodeSelectorTerms =
pod.spec?.affinity?.nodeAffinity
?.requiredDuringSchedulingIgnoredDuringExecution?.nodeSelectorTerms
expect(nodeSelectorTerms?.[0]?.matchExpressions?.[0]?.key).toBe(
'kubernetes.io/hostname'
)
expect(nodeSelectorTerms?.[0]?.matchExpressions?.[0]?.operator).toBe('In')
expect(
nodeSelectorTerms?.[0]?.matchExpressions?.[0]?.values?.length
).toBeGreaterThan(0)
})
it('should use correct node name from runner pod in affinity values by default', async () => {
const runnerPodName = process.env.ACTIONS_RUNNER_POD_NAME
await prepareJob(prepareJobData.args, prepareJobOutputFilePath)
const content = JSON.parse(
fs.readFileSync(prepareJobOutputFilePath).toString()
)
const jobPod = await getPodByName(content.state.jobPod)
const runnerPod = await getPodByName(runnerPodName!)
const affinityValues =
jobPod.spec?.affinity?.nodeAffinity
?.requiredDuringSchedulingIgnoredDuringExecution?.nodeSelectorTerms?.[0]
?.matchExpressions?.[0]?.values
expect(affinityValues).toBeDefined()
expect(affinityValues?.length).toBeGreaterThan(0)
if (runnerPod.spec?.nodeName) {
expect(affinityValues).toContain(runnerPod.spec.nodeName)
}
})
})
@@ -0,0 +1,28 @@
import {
isRWXTestEnabled,
getRWXStorageClass,
RWX_SKIP_MESSAGE
} from './test-setup'
describe('RWX Test Contract Demo', () => {
const describeOrSkip = isRWXTestEnabled() ? describe : describe.skip
describeOrSkip('RWX volume tests', () => {
it('should use RWX storage class when enabled', () => {
const storageClass = getRWXStorageClass()
expect(storageClass).toBeDefined()
expect(typeof storageClass).toBe('string')
})
it('should verify both env vars are required', () => {
expect(process.env.ACTIONS_RUNNER_K8S_TEST_ENABLE_RWX).toBe('true')
expect(
process.env.ACTIONS_RUNNER_K8S_TEST_RWX_STORAGE_CLASS
).toBeDefined()
})
})
if (!isRWXTestEnabled()) {
it(RWX_SKIP_MESSAGE, () => {})
}
})
+119
View File
@@ -0,0 +1,119 @@
import * as k8s from '@kubernetes/client-node'
import * as fs from 'fs'
import { cleanupJob, prepareJob, runScriptStep } from '../src/hooks'
import {
TestHelper,
isRWXTestEnabled,
getRWXStorageClass,
RWX_SKIP_MESSAGE
} from './test-setup'
import { RunScriptStepArgs } from 'hooklib'
jest.useRealTimers()
const kc = new k8s.KubeConfig()
kc.loadFromDefault()
const k8sApi = kc.makeApiClient(k8s.CoreV1Api)
describe('RWX Volume Tests', () => {
const describeOrSkip = isRWXTestEnabled() ? describe : describe.skip
describeOrSkip('RWX volume integration', () => {
let testHelper: TestHelper
let rwxPvcName: string
let prepareJobData: any
let prepareJobOutputFilePath: string
beforeEach(async () => {
testHelper = new TestHelper()
await testHelper.initialize()
const podName = process.env.ACTIONS_RUNNER_POD_NAME
rwxPvcName = `${podName}-work-rwx`
const volumeClaim: k8s.V1PersistentVolumeClaim = {
metadata: {
name: rwxPvcName
},
spec: {
accessModes: ['ReadWriteMany'],
volumeMode: 'Filesystem',
storageClassName: getRWXStorageClass(),
resources: {
requests: {
storage: '1Gi'
}
}
}
}
await k8sApi.createNamespacedPersistentVolumeClaim({
namespace: 'default',
body: volumeClaim
})
process.env.ACTIONS_RUNNER_CLAIM_NAME = rwxPvcName
prepareJobData = testHelper.getPrepareJobDefinition()
prepareJobOutputFilePath = testHelper.createFile(
'prepare-job-output.json'
)
})
afterAll(async () => {
if (rwxPvcName) {
try {
await k8sApi.deleteNamespacedPersistentVolumeClaim({
name: rwxPvcName,
namespace: 'default'
})
} catch {
// Ignore cleanup errors - PVC may not exist
}
}
})
afterEach(async () => {
await testHelper.cleanup()
})
it('should successfully run hook flow with RWX volume', async () => {
await expect(
prepareJob(prepareJobData.args, prepareJobOutputFilePath)
).resolves.not.toThrow()
const prepareJobOutputJson = fs.readFileSync(prepareJobOutputFilePath)
const prepareJobOutputData = JSON.parse(prepareJobOutputJson.toString())
const scriptStepData = testHelper.getRunScriptStepDefinition()
await expect(
runScriptStep(
scriptStepData.args as RunScriptStepArgs,
prepareJobOutputData.state
)
).resolves.not.toThrow()
await expect(cleanupJob()).resolves.not.toThrow()
})
it('should verify RWX PVC was created with correct access mode', async () => {
const pvc = await k8sApi.readNamespacedPersistentVolumeClaim({
name: rwxPvcName,
namespace: 'default'
})
expect(pvc.spec?.accessModes).toContain('ReadWriteMany')
expect(pvc.spec?.storageClassName).toBe(getRWXStorageClass())
expect(pvc.spec?.volumeMode).toBe('Filesystem')
})
it('should verify RWX claim name is set correctly', () => {
expect(process.env.ACTIONS_RUNNER_CLAIM_NAME).toBe(rwxPvcName)
})
})
if (!isRWXTestEnabled()) {
it(RWX_SKIP_MESSAGE, () => {})
}
})
+162 -51
View File
@@ -14,25 +14,35 @@ const k8sStorageApi = kc.makeApiClient(k8s.StorageV1Api)
export class TestHelper {
private tempDirPath: string
private podName: string
private runnerWorkdir: string
private runnerTemp: string
constructor() {
this.tempDirPath = `${__dirname}/_temp/runner`
this.runnerWorkdir = `${this.tempDirPath}/_work`
this.runnerTemp = `${this.tempDirPath}/_work/_temp`
this.podName = uuidv4().replace(/-/g, '')
}
public async initialize(): Promise<void> {
async initialize(): Promise<void> {
process.env['ACTIONS_RUNNER_POD_NAME'] = `${this.podName}`
process.env['RUNNER_WORKSPACE'] = `${this.tempDirPath}/_work/repo`
process.env['RUNNER_TEMP'] = `${this.tempDirPath}/_work/_temp`
process.env['GITHUB_WORKSPACE'] = `${this.tempDirPath}/_work/repo/repo`
process.env['RUNNER_WORKSPACE'] = `${this.runnerWorkdir}/repo`
process.env['RUNNER_TEMP'] = `${this.runnerTemp}`
process.env['GITHUB_WORKSPACE'] = `${this.runnerWorkdir}/repo/repo`
process.env['ACTIONS_RUNNER_KUBERNETES_NAMESPACE'] = 'default'
fs.mkdirSync(`${this.tempDirPath}/_work/repo/repo`, { recursive: true })
fs.mkdirSync(`${this.runnerWorkdir}/repo/repo`, { recursive: true })
fs.mkdirSync(`${this.tempDirPath}/externals`, { recursive: true })
fs.mkdirSync(process.env.RUNNER_TEMP, { recursive: true })
fs.mkdirSync(this.runnerTemp, { recursive: true })
fs.mkdirSync(`${this.runnerTemp}/_github_workflow`, { recursive: true })
fs.mkdirSync(`${this.runnerTemp}/_github_home`, { recursive: true })
fs.mkdirSync(`${this.runnerTemp}/_runner_file_commands`, {
recursive: true
})
fs.copyFileSync(
path.resolve(`${__dirname}/../../../examples/example-script.sh`),
`${process.env.RUNNER_TEMP}/example-script.sh`
`${this.runnerTemp}/example-script.sh`
)
await this.cleanupK8sResources()
@@ -40,56 +50,84 @@ export class TestHelper {
await this.createTestVolume()
await this.createTestJobPod()
} catch (e) {
console.log(JSON.stringify(e))
console.log(e)
}
}
public async cleanup(): Promise<void> {
async cleanup(): Promise<void> {
try {
await this.cleanupK8sResources()
fs.rmSync(this.tempDirPath, { recursive: true })
} catch {}
} catch {
// Ignore errors during cleanup
}
}
public async cleanupK8sResources() {
async cleanupK8sResources(): Promise<void> {
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 => {})
.deleteNamespacedPersistentVolumeClaim({
name: `${this.podName}-work`,
namespace: 'default',
gracePeriodSeconds: 0
})
.catch((e: k8s.ApiException<any>) => {
if (e.code !== 404) {
console.error(JSON.stringify(e))
}
})
await k8sApi
.deleteNamespacedPod(this.podName, 'default', undefined, undefined, 0)
.catch(e => {})
.deletePersistentVolume({ name: `${this.podName}-pv` })
.catch((e: k8s.ApiException<any>) => {
if (e.code !== 404) {
console.error(JSON.stringify(e))
}
})
await k8sApi
.deleteNamespacedPod(
`${this.podName}-workflow`,
'default',
undefined,
undefined,
0
)
.catch(e => {})
.deleteNamespacedPod({
name: this.podName,
namespace: 'default',
gracePeriodSeconds: 0
})
.catch((e: k8s.ApiException<any>) => {
if (e.code !== 404) {
console.error(JSON.stringify(e))
}
})
await k8sApi
.deleteNamespacedPod({
name: `${this.podName}-workflow`,
namespace: 'default',
gracePeriodSeconds: 0
})
.catch((e: k8s.ApiException<any>) => {
if (e.code !== 404) {
console.error(JSON.stringify(e))
}
})
await k8sStorageApi
.deleteStorageClass({ name: `${this.podName}-storage` })
.catch((e: k8s.ApiException<any>) => {
if (e.code !== 404) {
console.error(JSON.stringify(e))
}
})
}
public createFile(fileName?: string): string {
createFile(fileName?: string): string {
const filePath = `${this.tempDirPath}/${fileName || uuidv4()}`
fs.writeFileSync(filePath, '')
return filePath
}
public removeFile(fileName: string): void {
removeFile(fileName: string): void {
const filePath = `${this.tempDirPath}/${fileName}`
fs.rmSync(filePath)
}
public async createTestJobPod() {
async createTestJobPod(): Promise<void> {
const container = {
name: 'nginx',
image: 'nginx:latest',
name: 'runner',
image: 'ghcr.io/actions/actions-runner:latest',
imagePullPolicy: 'IfNotPresent'
} as k8s.V1Container
@@ -99,28 +137,35 @@ export class TestHelper {
},
spec: {
restartPolicy: 'Never',
containers: [container]
containers: [container],
securityContext: {
runAsUser: 1001,
runAsGroup: 1001,
fsGroup: 1001
}
}
} as k8s.V1Pod
await k8sApi.createNamespacedPod('default', pod)
await k8sApi.createNamespacedPod({ namespace: 'default', body: pod })
}
public async createTestVolume() {
var sc: k8s.V1StorageClass = {
async createTestVolume(): Promise<void> {
const storageClassName = `${this.podName}-storage`
const sc: k8s.V1StorageClass = {
metadata: {
name: 'local-storage'
name: storageClassName
},
provisioner: 'kubernetes.io/no-provisioner',
volumeBindingMode: 'Immediate'
}
await k8sStorageApi.createStorageClass(sc)
await k8sStorageApi.createStorageClass({ body: sc })
var volume: k8s.V1PersistentVolume = {
const volume: k8s.V1PersistentVolume = {
metadata: {
name: `${this.podName}-pv`
},
spec: {
storageClassName: 'local-storage',
storageClassName,
capacity: {
storage: '2Gi'
},
@@ -131,15 +176,16 @@ export class TestHelper {
}
}
}
await k8sApi.createPersistentVolume(volume)
var volumeClaim: k8s.V1PersistentVolumeClaim = {
await k8sApi.createPersistentVolume({ body: volume })
const volumeClaim: k8s.V1PersistentVolumeClaim = {
metadata: {
name: `${this.podName}-work`
},
spec: {
accessModes: ['ReadWriteOnce'],
volumeMode: 'Filesystem',
storageClassName: 'local-storage',
storageClassName,
volumeName: `${this.podName}-pv`,
resources: {
requests: {
@@ -148,10 +194,13 @@ export class TestHelper {
}
}
}
await k8sApi.createNamespacedPersistentVolumeClaim('default', volumeClaim)
await k8sApi.createNamespacedPersistentVolumeClaim({
namespace: 'default',
body: volumeClaim
})
}
public getPrepareJobDefinition(): HookData {
getPrepareJobDefinition(): HookData {
const prepareJob = JSON.parse(
fs.readFileSync(
path.resolve(__dirname + '/../../../examples/prepare-job.json'),
@@ -168,7 +217,7 @@ export class TestHelper {
return prepareJob
}
public getRunScriptStepDefinition(): HookData {
getRunScriptStepDefinition(): HookData {
const runScriptStep = JSON.parse(
fs.readFileSync(
path.resolve(__dirname + '/../../../examples/run-script-step.json'),
@@ -180,7 +229,7 @@ export class TestHelper {
return runScriptStep
}
public getRunContainerStepDefinition(): HookData {
getRunContainerStepDefinition(): HookData {
const runContainerStep = JSON.parse(
fs.readFileSync(
path.resolve(__dirname + '/../../../examples/run-container-step.json'),
@@ -194,3 +243,65 @@ export class TestHelper {
return runContainerStep
}
}
/**
* RWX Test Contract:
*
* Tests requiring ReadWriteMany (RWX) volumes MUST be gated by TWO environment variables:
* 1. ACTIONS_RUNNER_K8S_TEST_ENABLE_RWX=true (explicit opt-in)
* 2. ACTIONS_RUNNER_K8S_TEST_RWX_STORAGE_CLASS=<name> (storage class that supports RWX)
*
* If either variable is missing or ACTIONS_RUNNER_K8S_TEST_ENABLE_RWX is not "true",
* the test MUST be skipped with the exact message defined in this contract.
*
* This contract ensures:
* - RWX tests do not fail on clusters without RWX provisioners
* - Test requirements are explicit and documented
* - RWO affinity tests remain independent and always runnable
* - Skip behavior is deterministic (no dynamic cluster probing)
*
* Usage example:
* ```typescript
* import { isRWXTestEnabled, getRWXStorageClass, RWX_SKIP_MESSAGE } from './test-setup'
*
* describe('RWX Test Suite', () => {
* const describeOrSkip = isRWXTestEnabled() ? describe : describe.skip
*
* describeOrSkip('RWX volume tests', () => {
* it('should test RWX functionality', async () => {
* const storageClass = getRWXStorageClass()
* // ... test code using storageClass
* })
* })
*
* if (!isRWXTestEnabled()) {
* it(RWX_SKIP_MESSAGE, () => {})
* }
* })
* ```
*/
/**
* Checks if RWX tests should run based on environment variables.
* @returns true if both ACTIONS_RUNNER_K8S_TEST_ENABLE_RWX=true and ACTIONS_RUNNER_K8S_TEST_RWX_STORAGE_CLASS are set
*/
export function isRWXTestEnabled(): boolean {
const enabled = process.env.ACTIONS_RUNNER_K8S_TEST_ENABLE_RWX === 'true'
const storageClass = process.env.ACTIONS_RUNNER_K8S_TEST_RWX_STORAGE_CLASS
return enabled && !!storageClass
}
/**
* Gets the RWX storage class name from environment variable.
* @returns The storage class name, or undefined if not set
*/
export function getRWXStorageClass(): string | undefined {
return process.env.ACTIONS_RUNNER_K8S_TEST_RWX_STORAGE_CLASS
}
/**
* Skip message constant - DO NOT MODIFY
* This exact message must be used when skipping RWX tests
*/
export const RWX_SKIP_MESSAGE =
'RWX tests skipped: set ACTIONS_RUNNER_K8S_TEST_ENABLE_RWX=true and ACTIONS_RUNNER_K8S_TEST_RWX_STORAGE_CLASS'
+3 -2
View File
@@ -5,7 +5,8 @@
"outDir": "./lib",
"rootDir": "./src"
},
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
"include": [
"./src"
"src/**/*",
]
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"compilerOptions": {
"allowJs": true
},
"extends": "./tsconfig.json"
}
+21 -3
View File
@@ -1,7 +1,25 @@
## Features
- Loosened the restriction on `ACTIONS_RUNNER_CLAIM_NAME` to be optional, not required for k8s hooks
<!-- ## Features -->
## Bugs
- Change command to remove sudo to fix fs-init initial container [#263]
- Sort 'find' output before hashing for consistency [#267]
- feat: check if required binaries are present [#272]
- Allow non-root container [#264]
- Improve validation checks after copying [#285]
- Fix workingDir permissions issue by creating it within init container [#283]
- Fix event.json not being copied to /github/workflow in kubernetes-novolume mode [#287]
- Reduce the amount of data copied to the workflow pod [#293]
- Overwrite runner file commands [#298]
## Misc
## Misc
- Dependency updates [#276] [#277] [#278] [#279] [#304]
- Group dependabot updates [#289]
## 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>