Compare commits
88
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e78b267f6 | ||
|
|
51a0a891f6 | ||
|
|
5d337ff96e | ||
|
|
4972708208 | ||
|
|
6ecda1d8ea | ||
|
|
d21e5b430e | ||
|
|
d8d2e74810 | ||
|
|
5f5708a2b8 | ||
|
|
f8e1cae677 | ||
|
|
996cc75daf | ||
|
|
adf5e34937 | ||
|
|
4041f8648c | ||
|
|
1f60eaf940 | ||
|
|
c3d8e2ab20 | ||
|
|
3f829eef9e | ||
|
|
011ffb284e | ||
|
|
0951cc73e4 | ||
|
|
15e808935c | ||
|
|
ad9cb43c31 | ||
|
|
2934de33f8 | ||
|
|
ea25fd1b3e | ||
|
|
c03a5fb3c1 | ||
|
|
96c35e7cc6 | ||
|
|
c67938c536 | ||
|
|
464be47642 | ||
|
|
74ce64c1d0 | ||
|
|
9a71a3a7e9 | ||
|
|
9a858922c8 | ||
|
|
605551ff1c | ||
|
|
878781f9c4 | ||
|
|
1e051b849b | ||
|
|
589414ea69 | ||
|
|
dd4f7dae2c | ||
|
|
7da5474a5d | ||
|
|
375992cd31 | ||
|
|
aae800a69b | ||
|
|
e47f9b8af4 | ||
|
|
54e14cb7f3 | ||
|
|
ef2229fc0b | ||
|
|
88dc98f8ef | ||
|
|
b388518d40 | ||
|
|
7afb8f9323 | ||
|
|
d4c5425b22 | ||
|
|
120636d3d7 | ||
|
|
5e805a0546 | ||
|
|
27bae0b2b7 | ||
|
|
8eed1ad1b6 | ||
|
|
7b404841b2 | ||
|
|
977d53963d | ||
|
|
77b40ac6df | ||
|
|
ee10d95fd4 | ||
|
|
73655d4639 | ||
|
|
ca4ea17d58 | ||
|
|
ed70e2f8e0 | ||
|
|
aeabaf144a | ||
|
|
8388a36f44 | ||
|
|
9705deeb08 | ||
|
|
99efdeca99 | ||
|
|
bb09a79b22 | ||
|
|
746e644039 | ||
|
|
7223e1dbb2 | ||
|
|
af27abe1f7 | ||
|
|
638bd19c9d | ||
|
|
50e14cf868 | ||
|
|
921be5b85f | ||
|
|
0cce49705b | ||
|
|
46c92fe43e | ||
|
|
56208347f1 | ||
|
|
c093f87779 | ||
|
|
c47c74ad9e | ||
|
|
90a6236466 | ||
|
|
496287d61d | ||
|
|
5264b6cd7d | ||
|
|
b58b13134a | ||
|
|
8ea7e21dec | ||
|
|
64000d716a | ||
|
|
4ff4b552a6 | ||
|
|
4cdcf09c43 | ||
|
|
5107bb1d41 | ||
|
|
547ed30dc3 | ||
|
|
17fb66892c | ||
|
|
9319a8566a | ||
|
|
669ec6f706 | ||
|
|
aa658859f8 | ||
|
|
8b83223a2b | ||
|
|
586a052286 | ||
|
|
730509f702 | ||
|
|
3fc91e4132 |
@@ -1,4 +0,0 @@
|
|||||||
dist/
|
|
||||||
lib/
|
|
||||||
node_modules/
|
|
||||||
**/tests/**
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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:
|
||||||
|
- "*"
|
||||||
@@ -6,14 +6,62 @@ on:
|
|||||||
paths-ignore:
|
paths-ignore:
|
||||||
- '**.md'
|
- '**.md'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
format-and-lint:
|
||||||
|
name: Format & Lint Checks
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
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
|
- run: sed -i "s|{{PATHTOREPO}}|$(pwd)|" packages/k8s/tests/test-kind.yaml
|
||||||
name: Setup kind cluster yaml config
|
name: Setup kind cluster yaml config
|
||||||
- uses: helm/[email protected]
|
- uses: helm/kind-action@v1.12.0
|
||||||
with:
|
with:
|
||||||
config: packages/k8s/tests/test-kind.yaml
|
config: packages/k8s/tests/test-kind.yaml
|
||||||
- run: npm install
|
- run: npm install
|
||||||
@@ -22,10 +70,5 @@ jobs:
|
|||||||
name: Bootstrap the packages
|
name: Bootstrap the packages
|
||||||
- run: npm run build-all
|
- run: npm run build-all
|
||||||
name: Build packages
|
name: Build packages
|
||||||
- run: npm run format-check
|
- name: Run Kubernetes tests
|
||||||
- name: Check linter
|
run: npm run test --prefix packages/k8s
|
||||||
run: |
|
|
||||||
npm run lint
|
|
||||||
git diff --exit-code -- ':!packages/k8s/tests/test-kind.yaml'
|
|
||||||
- name: Run tests
|
|
||||||
run: npm run test
|
|
||||||
@@ -38,11 +38,11 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
# Initializes the CodeQL tools for scanning.
|
# Initializes the CodeQL tools for scanning.
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@v2
|
uses: github/codeql-action/init@v3
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
# 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).
|
# 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)
|
# If this step fails, then you should remove it and run the build manually (see below)
|
||||||
- name: Autobuild
|
- name: Autobuild
|
||||||
uses: github/codeql-action/autobuild@v2
|
uses: github/codeql-action/autobuild@v3
|
||||||
|
|
||||||
# ℹ️ Command-line programs to run using the OS shell.
|
# ℹ️ 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
|
# 📚 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
|
# ./location_of_script_within_repo/buildscript.sh
|
||||||
|
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@v2
|
uses: github/codeql-action/analyze@v3
|
||||||
|
|||||||
@@ -1,57 +1,74 @@
|
|||||||
name: CD - Release new version
|
name: CD - Release new version
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v5
|
||||||
- run: npm install
|
|
||||||
name: Install dependencies
|
- uses: actions/setup-node@v6
|
||||||
- run: npm run bootstrap
|
|
||||||
name: Bootstrap the packages
|
|
||||||
- run: npm run build-all
|
|
||||||
name: Build packages
|
|
||||||
- uses: actions/github-script@v6
|
|
||||||
id: releaseNotes
|
|
||||||
with:
|
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: |
|
script: |
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const hookVersion = require('./package.json').version
|
return require('./package.json').version
|
||||||
var releaseNotes = fs.readFileSync('${{ github.workspace }}/releaseNotes.md', 'utf8').replace(/<HOOK_VERSION>/g, hookVersion)
|
|
||||||
console.log(releaseNotes)
|
|
||||||
core.setOutput('version', hookVersion);
|
|
||||||
core.setOutput('note', releaseNotes);
|
|
||||||
- name: Zip up releases
|
- name: Zip up releases
|
||||||
run: |
|
run: |
|
||||||
zip -r -j actions-runner-hooks-docker-${{ steps.releaseNotes.outputs.version }}.zip packages/docker/dist
|
zip -r -j actions-runner-hooks-docker-${{ steps.releaseVersion.outputs.result }}.zip packages/docker/dist
|
||||||
zip -r -j actions-runner-hooks-k8s-${{ steps.releaseNotes.outputs.version }}.zip packages/k8s/dist
|
zip -r -j actions-runner-hooks-k8s-${{ steps.releaseVersion.outputs.result }}.zip packages/k8s/dist
|
||||||
- uses: actions/create-release@v1
|
|
||||||
id: createRelease
|
- name: Calculate SHA
|
||||||
name: Create ${{ steps.releaseNotes.outputs.version }} Hook Release
|
id: sha
|
||||||
env:
|
shell: bash
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
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:
|
with:
|
||||||
tag_name: "v${{ steps.releaseNotes.outputs.version }}"
|
script: |
|
||||||
release_name: "v${{ steps.releaseNotes.outputs.version }}"
|
const fs = require('fs');
|
||||||
body: |
|
var releaseNotes = fs.readFileSync('${{ github.workspace }}/releaseNotes.md', 'utf8').replace(/<HOOK_VERSION>/g, '${{ steps.releaseVersion.outputs.result }}')
|
||||||
${{ steps.releaseNotes.outputs.note }}
|
releaseNotes = releaseNotes.replace(/<DOCKER_SHA>/g, '${{ steps.sha.outputs.docker-sha }}')
|
||||||
- name: Upload K8s hooks
|
releaseNotes = releaseNotes.replace(/<K8S_SHA>/g, '${{ steps.sha.outputs.k8s-sha }}')
|
||||||
uses: actions/upload-release-asset@v1
|
console.log(releaseNotes)
|
||||||
|
fs.writeFileSync('${{ github.workspace }}/finalReleaseNotes.md', releaseNotes);
|
||||||
|
|
||||||
|
- name: Create ${{ steps.releaseVersion.outputs.result }} Hook Release
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
with:
|
run: |
|
||||||
upload_url: ${{ steps.createRelease.outputs.upload_url }}
|
gh release create v${{ steps.releaseVersion.outputs.result }} \
|
||||||
asset_path: ${{ github.workspace }}/actions-runner-hooks-k8s-${{ steps.releaseNotes.outputs.version }}.zip
|
--title "v${{ steps.releaseVersion.outputs.result }}" \
|
||||||
asset_name: actions-runner-hooks-k8s-${{ steps.releaseNotes.outputs.version }}.zip
|
--repo ${{ github.repository }} \
|
||||||
asset_content_type: application/octet-stream
|
--notes-file ${{ github.workspace }}/finalReleaseNotes.md \
|
||||||
- name: Upload docker hooks
|
--latest \
|
||||||
uses: actions/upload-release-asset@v1
|
${{ github.workspace }}/actions-runner-hooks-k8s-${{ steps.releaseVersion.outputs.result }}.zip \
|
||||||
env:
|
${{ github.workspace }}/actions-runner-hooks-docker-${{ steps.releaseVersion.outputs.result }}.zip
|
||||||
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
|
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
* @actions/actions-runtime @actions/runner-akvelon
|
* @actions/actions-compute @nikola-jokic
|
||||||
|
|||||||
@@ -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.
|
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 we’re working on and what stage they’re 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
|
## Background
|
||||||
|
|
||||||
Three projects are included in the `packages` folder
|
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)
|
- 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
|
- 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
|
## License
|
||||||
|
|
||||||
This project is licensed under the terms of the MIT open source license. Please refer to [MIT](./LICENSE.md) for the full terms.
|
This project is licensed under the terms of the MIT open source license. Please refer to [MIT](./LICENSE.md) for the full terms.
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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.
|
||||||
@@ -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'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
@@ -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
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
"state": {},
|
"state": {},
|
||||||
"args": {
|
"args": {
|
||||||
"container": {
|
"container": {
|
||||||
"image": "node:14.16",
|
"image": "node:22",
|
||||||
"workingDirectory": "/__w/repo/repo",
|
"workingDirectory": "/__w/repo/repo",
|
||||||
"createOptions": "--cpus 1",
|
"createOptions": "--cpus 1",
|
||||||
"environmentVariables": {
|
"environmentVariables": {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"args": {
|
"args": {
|
||||||
"image": "node:14.16",
|
"image": "node:22",
|
||||||
"dockerfile": null,
|
"dockerfile": null,
|
||||||
"entryPointArgs": [
|
"entryPointArgs": [
|
||||||
"-e",
|
"-e",
|
||||||
|
|||||||
Generated
+3150
-3065
File diff suppressed because it is too large
Load Diff
+16
-9
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hooks",
|
"name": "hooks",
|
||||||
"version": "0.3.2",
|
"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",
|
"description": "Three projects are included - k8s: a kubernetes hook implementation that spins up pods dynamically to run a job - docker: A hook implementation of the runner's docker implementation - A hook lib, which contains shared typescript definitions and utilities that the other packages consume",
|
||||||
"main": "",
|
"main": "",
|
||||||
"directories": {
|
"directories": {
|
||||||
@@ -8,10 +8,11 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "npm run test --prefix packages/docker && npm run test --prefix packages/k8s",
|
"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": "prettier --write '**/*.ts'",
|
||||||
"format-check": "prettier --check '**/*.ts'",
|
"format-check": "prettier --check '**/*.ts'",
|
||||||
"lint": "eslint packages/**/*.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"
|
"build-all": "npm run build --prefix packages/hooklib && npm run build --prefix packages/k8s && npm run build --prefix packages/docker"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
@@ -25,12 +26,18 @@
|
|||||||
},
|
},
|
||||||
"homepage": "https://github.com/actions/runner-container-hooks#readme",
|
"homepage": "https://github.com/actions/runner-container-hooks#readme",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/jest": "^27.5.1",
|
"@eslint/js": "^9.31.0",
|
||||||
"@types/node": "^17.0.23",
|
"@types/jest": "^30.0.0",
|
||||||
"@typescript-eslint/parser": "^5.18.0",
|
"@types/node": "^24.0.14",
|
||||||
"eslint": "^8.12.0",
|
"@typescript-eslint/eslint-plugin": "^8.37.0",
|
||||||
"eslint-plugin-github": "^4.3.6",
|
"@typescript-eslint/parser": "^8.37.0",
|
||||||
"prettier": "^2.6.2",
|
"eslint": "^9.31.0",
|
||||||
"typescript": "^4.6.3"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,26 @@
|
|||||||
// eslint-disable-next-line import/no-commonjs
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
clearMocks: true,
|
clearMocks: true,
|
||||||
|
preset: 'ts-jest',
|
||||||
moduleFileExtensions: ['js', 'ts'],
|
moduleFileExtensions: ['js', 'ts'],
|
||||||
testEnvironment: 'node',
|
testEnvironment: 'node',
|
||||||
testMatch: ['**/*-test.ts'],
|
testMatch: ['**/*-test.ts'],
|
||||||
testRunner: 'jest-circus/runner',
|
testRunner: 'jest-circus/runner',
|
||||||
|
verbose: true,
|
||||||
transform: {
|
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'],
|
transformIgnorePatterns: [
|
||||||
verbose: true
|
// Transform these ESM packages
|
||||||
|
'node_modules/(?!(shlex|@kubernetes/client-node|openid-client|oauth4webapi|jose|uuid)/)'
|
||||||
|
],
|
||||||
|
setupFilesAfterEnv: ['./jest.setup.js']
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+4677
-6423
File diff suppressed because it is too large
Load Diff
@@ -5,25 +5,31 @@
|
|||||||
"main": "lib/index.js",
|
"main": "lib/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "jest --runInBand",
|
"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": "",
|
"author": "",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/core": "^1.9.1",
|
"@actions/core": "^2.0.2",
|
||||||
"@actions/exec": "^1.1.1",
|
"@actions/exec": "^2.0.0",
|
||||||
"hooklib": "file:../hooklib",
|
"hooklib": "file:../hooklib",
|
||||||
"uuid": "^8.3.2"
|
"shlex": "^3.0.0",
|
||||||
|
"uuid": "^11.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/jest": "^27.4.1",
|
"@babel/core": "^7.25.2",
|
||||||
"@types/node": "^17.0.23",
|
"@babel/preset-env": "^7.25.4",
|
||||||
"@typescript-eslint/parser": "^5.18.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@vercel/ncc": "^0.33.4",
|
"@types/node": "^24.0.14",
|
||||||
"jest": "^27.5.1",
|
"@typescript-eslint/parser": "^8.37.0",
|
||||||
"ts-jest": "^27.1.4",
|
"@vercel/ncc": "^0.38.3",
|
||||||
"ts-node": "^10.7.0",
|
"jest": "^30.0.4",
|
||||||
"tsconfig-paths": "^3.14.1",
|
"ts-jest": "^29.4.0",
|
||||||
"typescript": "^4.6.3"
|
"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) {
|
if (args.environmentVariables) {
|
||||||
for (const [key] of Object.entries(args.environmentVariables)) {
|
for (const [key] of Object.entries(args.environmentVariables)) {
|
||||||
dockerArgs.push('-e')
|
dockerArgs.push('-e', key)
|
||||||
dockerArgs.push(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 = [
|
const mountVolumes = [
|
||||||
...(args.userMountVolumes || []),
|
...(args.userMountVolumes || []),
|
||||||
...(args.systemMountVolumes || [])
|
...(args.systemMountVolumes || [])
|
||||||
]
|
]
|
||||||
for (const mountVolume of mountVolumes) {
|
for (const mountVolume of mountVolumes) {
|
||||||
dockerArgs.push(
|
dockerArgs.push(
|
||||||
`-v=${mountVolume.sourceVolumePath}:${mountVolume.targetVolumePath}`
|
`-v=${mountVolume.sourceVolumePath}:${mountVolume.targetVolumePath}${
|
||||||
|
mountVolume.readOnly ? ':ro' : ''
|
||||||
|
}`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (args.entryPoint) {
|
if (args.entryPoint) {
|
||||||
@@ -91,11 +98,12 @@ export async function containerPull(
|
|||||||
image: string,
|
image: string,
|
||||||
configLocation: string
|
configLocation: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const dockerArgs: string[] = ['pull']
|
const dockerArgs: string[] = []
|
||||||
if (configLocation) {
|
if (configLocation) {
|
||||||
dockerArgs.push('--config')
|
dockerArgs.push('--config')
|
||||||
dockerArgs.push(configLocation)
|
dockerArgs.push(configLocation)
|
||||||
}
|
}
|
||||||
|
dockerArgs.push('pull')
|
||||||
dockerArgs.push(image)
|
dockerArgs.push(image)
|
||||||
for (let i = 0; i < 3; i++) {
|
for (let i = 0; i < 3; i++) {
|
||||||
try {
|
try {
|
||||||
@@ -402,11 +410,16 @@ export async function containerRun(
|
|||||||
}
|
}
|
||||||
if (args.environmentVariables) {
|
if (args.environmentVariables) {
|
||||||
for (const [key] of Object.entries(args.environmentVariables)) {
|
for (const [key] of Object.entries(args.environmentVariables)) {
|
||||||
dockerArgs.push('-e')
|
dockerArgs.push('-e', key)
|
||||||
dockerArgs.push(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 = [
|
const mountVolumes = [
|
||||||
...(args.userMountVolumes || []),
|
...(args.userMountVolumes || []),
|
||||||
...(args.systemMountVolumes || [])
|
...(args.systemMountVolumes || [])
|
||||||
@@ -443,7 +456,7 @@ export async function isContainerAlpine(containerId: string): Promise<boolean> {
|
|||||||
containerId,
|
containerId,
|
||||||
'sh',
|
'sh',
|
||||||
'-c',
|
'-c',
|
||||||
"[ $(cat /etc/*release* | grep -i -e '^ID=*alpine*' -c) != 0 ] || exit 1"
|
`'[ $(cat /etc/*release* | grep -i -e "^ID=*alpine*" -c) != 0 ] || exit 1'`
|
||||||
]
|
]
|
||||||
try {
|
try {
|
||||||
await runDockerCommand(dockerArgs)
|
await runDockerCommand(dockerArgs)
|
||||||
|
|||||||
@@ -31,16 +31,20 @@ export async function prepareJob(
|
|||||||
core.info('No containers exist, skipping hook invocation')
|
core.info('No containers exist, skipping hook invocation')
|
||||||
exit(0)
|
exit(0)
|
||||||
}
|
}
|
||||||
const networkName = generateNetworkName()
|
|
||||||
|
let networkName = process.env.ACTIONS_RUNNER_NETWORK_DRIVER
|
||||||
|
if (!networkName) {
|
||||||
|
networkName = generateNetworkName()
|
||||||
// Create network
|
// Create network
|
||||||
await networkCreate(networkName)
|
await networkCreate(networkName)
|
||||||
|
}
|
||||||
|
|
||||||
// Create Job Container
|
// Create Job Container
|
||||||
let containerMetadata: ContainerMetadata | undefined = undefined
|
let containerMetadata: ContainerMetadata | undefined = undefined
|
||||||
if (!container?.image) {
|
if (!container?.image) {
|
||||||
core.info('No job container provided, skipping')
|
core.info('No job container provided, skipping')
|
||||||
} else {
|
} else {
|
||||||
setupContainer(container)
|
setupContainer(container, true)
|
||||||
|
|
||||||
const configLocation = await registryLogin(container.registry)
|
const configLocation = await registryLogin(container.registry)
|
||||||
try {
|
try {
|
||||||
@@ -174,9 +178,11 @@ function generateResponseFile(
|
|||||||
writeToResponseFile(responseFile, JSON.stringify(response))
|
writeToResponseFile(responseFile, JSON.stringify(response))
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupContainer(container): void {
|
function setupContainer(container, jobContainer = false): void {
|
||||||
|
if (!container.entryPoint && jobContainer) {
|
||||||
container.entryPointArgs = [`-f`, `/dev/null`]
|
container.entryPointArgs = [`-f`, `/dev/null`]
|
||||||
container.entryPoint = 'tail'
|
container.entryPoint = 'tail'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateNetworkName(): string {
|
function generateNetworkName(): string {
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||||
/* eslint-disable import/no-commonjs */
|
|
||||||
import * as core from '@actions/core'
|
import * as core from '@actions/core'
|
||||||
import { env } from 'process'
|
import { env } from 'process'
|
||||||
// Import this way otherwise typescript has errors
|
// Import this way otherwise typescript has errors
|
||||||
const exec = require('@actions/exec')
|
const exec = require('@actions/exec')
|
||||||
|
const shlex = require('shlex')
|
||||||
|
|
||||||
export interface RunDockerCommandOptions {
|
export interface RunDockerCommandOptions {
|
||||||
workingDir?: string
|
workingDir?: string
|
||||||
@@ -17,6 +17,7 @@ export async function runDockerCommand(
|
|||||||
options?: RunDockerCommandOptions
|
options?: RunDockerCommandOptions
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
options = optionsWithDockerEnvs(options)
|
options = optionsWithDockerEnvs(options)
|
||||||
|
args = fixArgs(args)
|
||||||
const pipes = await exec.getExecOutput('docker', args, options)
|
const pipes = await exec.getExecOutput('docker', args, options)
|
||||||
if (pipes.exitCode !== 0) {
|
if (pipes.exitCode !== 0) {
|
||||||
core.error(`Docker failed with exit code ${pipes.exitCode}`)
|
core.error(`Docker failed with exit code ${pipes.exitCode}`)
|
||||||
@@ -84,6 +85,10 @@ export function sanitize(val: string): string {
|
|||||||
return newNameBuilder.join('')
|
return newNameBuilder.join('')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function fixArgs(args: string[]): string[] {
|
||||||
|
return shlex.split(args.join(' '))
|
||||||
|
}
|
||||||
|
|
||||||
export function checkEnvironment(): void {
|
export function checkEnvironment(): void {
|
||||||
if (!env.GITHUB_WORKSPACE) {
|
if (!env.GITHUB_WORKSPACE) {
|
||||||
throw new Error('GITHUB_WORKSPACE is not set')
|
throw new Error('GITHUB_WORKSPACE is not set')
|
||||||
|
|||||||
@@ -40,21 +40,54 @@ describe('run script step', () => {
|
|||||||
definitions.runScriptStep.args.entryPoint = '/bin/bash'
|
definitions.runScriptStep.args.entryPoint = '/bin/bash'
|
||||||
definitions.runScriptStep.args.entryPointArgs = [
|
definitions.runScriptStep.args.entryPointArgs = [
|
||||||
'-c',
|
'-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(
|
await expect(
|
||||||
runScriptStep(definitions.runScriptStep.args, prepareJobResponse.state)
|
runScriptStep(definitions.runScriptStep.args, prepareJobResponse.state)
|
||||||
).resolves.not.toThrow()
|
).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 () => {
|
it('Should have path variable changed in container with prepend path string array', async () => {
|
||||||
definitions.runScriptStep.args.prependPath = ['/some/other/path']
|
definitions.runScriptStep.args.prependPath = ['/some/other/path']
|
||||||
definitions.runScriptStep.args.entryPoint = '/bin/bash'
|
definitions.runScriptStep.args.entryPoint = '/bin/bash'
|
||||||
definitions.runScriptStep.args.entryPointArgs = [
|
definitions.runScriptStep.args.entryPointArgs = [
|
||||||
'-c',
|
'-c',
|
||||||
`if [[ ! $(env | grep "^PATH=") = "PATH=${definitions.runScriptStep.args.prependPath.join(
|
`'if [[ ! $(env | grep "^PATH=") = "PATH=${definitions.runScriptStep.args.prependPath.join(
|
||||||
':'
|
':'
|
||||||
)}:"* ]]; then exit 1; fi`
|
)}:"* ]]; 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(
|
await expect(
|
||||||
runScriptStep(definitions.runScriptStep.args, prepareJobResponse.state)
|
runScriptStep(definitions.runScriptStep.args, prepareJobResponse.state)
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export default class TestSetup {
|
|||||||
private get allTestDirectories() {
|
private get allTestDirectories() {
|
||||||
const resp = [this.testdir, this.runnerMockDir, this.runnerOutputDir]
|
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}`)
|
resp.push(`${this.runnerMockDir}/${value}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,12 +42,11 @@ export default class TestSetup {
|
|||||||
return resp
|
return resp
|
||||||
}
|
}
|
||||||
|
|
||||||
public initialize(): void {
|
initialize(): void {
|
||||||
env['GITHUB_WORKSPACE'] = this.workingDirectory
|
env['GITHUB_WORKSPACE'] = this.workingDirectory
|
||||||
env['RUNNER_NAME'] = 'test'
|
env['RUNNER_NAME'] = 'test'
|
||||||
env[
|
env['RUNNER_TEMP'] =
|
||||||
'RUNNER_TEMP'
|
`${this.runnerMockDir}/${this.runnerMockSubdirs.workTemp}`
|
||||||
] = `${this.runnerMockDir}/${this.runnerMockSubdirs.workTemp}`
|
|
||||||
|
|
||||||
for (const dir of this.allTestDirectories) {
|
for (const dir of this.allTestDirectories) {
|
||||||
fs.mkdirSync(dir, { recursive: true })
|
fs.mkdirSync(dir, { recursive: true })
|
||||||
@@ -59,7 +58,7 @@ export default class TestSetup {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
public teardown(): void {
|
teardown(): void {
|
||||||
fs.rmdirSync(this.testdir, { recursive: true })
|
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`)
|
let filePath = path.join(this.runnerOutputDir, name || `${uuidv4()}.json`)
|
||||||
fs.writeFileSync(filePath, '')
|
fs.writeFileSync(filePath, '')
|
||||||
return filePath
|
return filePath
|
||||||
}
|
}
|
||||||
|
|
||||||
public get workingDirectory(): string {
|
get workingDirectory(): string {
|
||||||
return `${this.runnerMockDir}/_work/${this.projectName}/${this.projectName}`
|
return `${this.runnerMockDir}/_work/${this.projectName}/${this.projectName}`
|
||||||
}
|
}
|
||||||
|
|
||||||
public get containerWorkingDirectory(): string {
|
get containerWorkingDirectory(): string {
|
||||||
return `/__w/${this.projectName}/${this.projectName}`
|
return `/__w/${this.projectName}/${this.projectName}`
|
||||||
}
|
}
|
||||||
|
|
||||||
public initializeDockerAction(): string {
|
initializeDockerAction(): string {
|
||||||
const actionPath = `${this.testdir}/_actions/example-handle/example-repo/example-branch/mock-directory`
|
const actionPath = `${this.testdir}/_actions/example-handle/example-repo/example-branch/mock-directory`
|
||||||
fs.mkdirSync(actionPath, { recursive: true })
|
fs.mkdirSync(actionPath, { recursive: true })
|
||||||
this.writeDockerfile(actionPath)
|
this.writeDockerfile(actionPath)
|
||||||
@@ -147,7 +146,7 @@ echo "::set-output name=time::$time"`
|
|||||||
fs.chmodSync(entryPointPath, 0o755)
|
fs.chmodSync(entryPointPath, 0o755)
|
||||||
}
|
}
|
||||||
|
|
||||||
public getPrepareJobDefinition(): HookData {
|
getPrepareJobDefinition(): HookData {
|
||||||
const prepareJob = JSON.parse(
|
const prepareJob = JSON.parse(
|
||||||
fs.readFileSync(
|
fs.readFileSync(
|
||||||
path.resolve(__dirname + '/../../../examples/prepare-job.json'),
|
path.resolve(__dirname + '/../../../examples/prepare-job.json'),
|
||||||
@@ -166,7 +165,7 @@ echo "::set-output name=time::$time"`
|
|||||||
return prepareJob
|
return prepareJob
|
||||||
}
|
}
|
||||||
|
|
||||||
public getRunScriptStepDefinition(): HookData {
|
getRunScriptStepDefinition(): HookData {
|
||||||
const runScriptStep = JSON.parse(
|
const runScriptStep = JSON.parse(
|
||||||
fs.readFileSync(
|
fs.readFileSync(
|
||||||
path.resolve(__dirname + '/../../../examples/run-script-step.json'),
|
path.resolve(__dirname + '/../../../examples/run-script-step.json'),
|
||||||
@@ -178,7 +177,7 @@ echo "::set-output name=time::$time"`
|
|||||||
return runScriptStep
|
return runScriptStep
|
||||||
}
|
}
|
||||||
|
|
||||||
public getRunContainerStepDefinition(): HookData {
|
getRunContainerStepDefinition(): HookData {
|
||||||
const runContainerStep = JSON.parse(
|
const runContainerStep = JSON.parse(
|
||||||
fs.readFileSync(
|
fs.readFileSync(
|
||||||
path.resolve(__dirname + '/../../../examples/run-container-step.json'),
|
path.resolve(__dirname + '/../../../examples/run-container-step.json'),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { optionsWithDockerEnvs, sanitize } from '../src/utils'
|
import { optionsWithDockerEnvs, sanitize, fixArgs } from '../src/utils'
|
||||||
|
|
||||||
describe('Utilities', () => {
|
describe('Utilities', () => {
|
||||||
it('should return sanitized image name', () => {
|
it('should return sanitized image name', () => {
|
||||||
@@ -10,6 +10,37 @@ describe('Utilities', () => {
|
|||||||
expect(sanitize(validStr)).toBe(validStr)
|
expect(sanitize(validStr)).toBe(validStr)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
[['"Hello', 'World"'], ['Hello World']],
|
||||||
|
[
|
||||||
|
[
|
||||||
|
'sh',
|
||||||
|
'-c',
|
||||||
|
`'[ $(cat /etc/*release* | grep -i -e "^ID=*alpine*" -c) != 0 ] || exit 1'`
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'sh',
|
||||||
|
'-c',
|
||||||
|
`[ $(cat /etc/*release* | grep -i -e "^ID=*alpine*" -c) != 0 ] || exit 1`
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
[
|
||||||
|
'sh',
|
||||||
|
'-c',
|
||||||
|
`'[ $(cat /etc/*release* | grep -i -e '\\''^ID=*alpine*'\\'' -c) != 0 ] || exit 1'`
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'sh',
|
||||||
|
'-c',
|
||||||
|
`[ $(cat /etc/*release* | grep -i -e '^ID=*alpine*' -c) != 0 ] || exit 1`
|
||||||
|
]
|
||||||
|
]
|
||||||
|
])('should fix split arguments(%p, %p)', (args, expected) => {
|
||||||
|
const got = fixArgs(args)
|
||||||
|
expect(got).toStrictEqual(expected)
|
||||||
|
})
|
||||||
|
|
||||||
describe('with docker options', () => {
|
describe('with docker options', () => {
|
||||||
it('should augment options with docker environment variables', () => {
|
it('should augment options with docker environment variables', () => {
|
||||||
process.env.DOCKER_HOST = 'unix:///run/user/1001/docker.sock'
|
process.env.DOCKER_HOST = 'unix:///run/user/1001/docker.sock'
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"allowJs": true
|
||||||
|
},
|
||||||
|
"extends": "./tsconfig.json"
|
||||||
|
}
|
||||||
Generated
+2610
-2938
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "lib/index.js",
|
"main": "lib/index.js",
|
||||||
"types": "index.d.ts",
|
"types": "lib/index.d.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "echo \"Error: no test specified\" && exit 1",
|
"test": "echo \"Error: no test specified\" && exit 1",
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
@@ -14,15 +14,14 @@
|
|||||||
"author": "",
|
"author": "",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^17.0.23",
|
"@types/node": "^24.0.14",
|
||||||
"@typescript-eslint/parser": "^5.18.0",
|
|
||||||
"@zeit/ncc": "^0.22.3",
|
"@zeit/ncc": "^0.22.3",
|
||||||
"eslint": "^8.12.0",
|
"eslint": "^9.31.0",
|
||||||
"eslint-plugin-github": "^4.3.6",
|
"eslint-plugin-github": "^6.0.0",
|
||||||
"prettier": "^2.6.2",
|
"prettier": "^3.6.2",
|
||||||
"typescript": "^4.6.3"
|
"typescript": "^5.8.3"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/core": "^1.9.1"
|
"@actions/core": "^2.0.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-3
@@ -22,9 +22,6 @@ rules:
|
|||||||
- apiGroups: [""]
|
- apiGroups: [""]
|
||||||
resources: ["pods/log"]
|
resources: ["pods/log"]
|
||||||
verbs: ["get", "list", "watch",]
|
verbs: ["get", "list", "watch",]
|
||||||
- apiGroups: ["batch"]
|
|
||||||
resources: ["jobs"]
|
|
||||||
verbs: ["get", "list", "create", "delete"]
|
|
||||||
- apiGroups: [""]
|
- apiGroups: [""]
|
||||||
resources: ["secrets"]
|
resources: ["secrets"]
|
||||||
verbs: ["get", "list", "create", "delete"]
|
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 `ACTIONS_RUNNER_REQUIRE_JOB_CONTAINER` env should be set to true to prevent the runner from running any jobs outside of a container
|
||||||
- The runner pod should map a persistent volume claim into the `_work` directory
|
- The runner pod should map a persistent volume claim into the `_work` directory
|
||||||
- The `ACTIONS_RUNNER_CLAIM_NAME` 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`
|
- 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.
|
- 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
|
- `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
|
- `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
|
- 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
|
- 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
|
- 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`.
|
||||||
|
|||||||
@@ -1,13 +1,26 @@
|
|||||||
// eslint-disable-next-line import/no-commonjs
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
clearMocks: true,
|
clearMocks: true,
|
||||||
|
preset: 'ts-jest',
|
||||||
moduleFileExtensions: ['js', 'ts'],
|
moduleFileExtensions: ['js', 'ts'],
|
||||||
testEnvironment: 'node',
|
testEnvironment: 'node',
|
||||||
testMatch: ['**/*-test.ts'],
|
testMatch: ['**/*-test.ts'],
|
||||||
testRunner: 'jest-circus/runner',
|
testRunner: 'jest-circus/runner',
|
||||||
|
verbose: true,
|
||||||
transform: {
|
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'],
|
transformIgnorePatterns: [
|
||||||
verbose: true
|
// Transform these ESM packages
|
||||||
|
'node_modules/(?!(shlex|@kubernetes/client-node|openid-client|oauth4webapi|jose|uuid)/)'
|
||||||
|
],
|
||||||
|
setupFilesAfterEnv: ['./jest.setup.js']
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
|
// eslint-disable-next-line filenames/match-regex, no-undef
|
||||||
jest.setTimeout(500000)
|
jest.setTimeout(500000)
|
||||||
|
|||||||
Generated
+4462
-5994
File diff suppressed because it is too large
Load Diff
+18
-11
@@ -13,18 +13,25 @@
|
|||||||
"author": "",
|
"author": "",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/core": "^1.9.1",
|
"@actions/core": "^2.0.2",
|
||||||
"@actions/exec": "^1.1.1",
|
"@actions/exec": "^2.0.0",
|
||||||
"@actions/io": "^1.1.2",
|
"@actions/io": "^2.0.0",
|
||||||
"@kubernetes/client-node": "^0.18.1",
|
"@kubernetes/client-node": "^1.3.0",
|
||||||
"hooklib": "file:../hooklib"
|
"hooklib": "file:../hooklib",
|
||||||
|
"js-yaml": "^4.1.0",
|
||||||
|
"shlex": "^3.0.0",
|
||||||
|
"tar-fs": "^3.1.0",
|
||||||
|
"uuid": "^11.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/jest": "^27.4.1",
|
"@babel/core": "^7.28.3",
|
||||||
"@types/node": "^17.0.23",
|
"@babel/preset-env": "^7.28.3",
|
||||||
"@vercel/ncc": "^0.33.4",
|
"@types/jest": "^30.0.0",
|
||||||
"jest": "^27.5.1",
|
"@types/node": "^24.3.0",
|
||||||
"ts-jest": "^27.1.4",
|
"@vercel/ncc": "^0.38.3",
|
||||||
"typescript": "^4.6.3"
|
"babel-jest": "^30.1.1",
|
||||||
|
"jest": "^30.1.1",
|
||||||
|
"ts-jest": "^29.4.1",
|
||||||
|
"typescript": "^5.9.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,9 @@ export function getSecretName(): string {
|
|||||||
|
|
||||||
export const MAX_POD_NAME_LENGTH = 63
|
export const MAX_POD_NAME_LENGTH = 63
|
||||||
export const STEP_POD_NAME_SUFFIX_LENGTH = 8
|
export const STEP_POD_NAME_SUFFIX_LENGTH = 8
|
||||||
|
export const CONTAINER_EXTENSION_PREFIX = '$'
|
||||||
export const JOB_CONTAINER_NAME = 'job'
|
export const JOB_CONTAINER_NAME = 'job'
|
||||||
|
export const JOB_CONTAINER_EXTENSION_NAME = '$job'
|
||||||
|
|
||||||
export class RunnerInstanceLabel {
|
export class RunnerInstanceLabel {
|
||||||
private podName: string
|
private podName: string
|
||||||
|
|||||||
@@ -1,26 +1,36 @@
|
|||||||
import * as core from '@actions/core'
|
import * as core from '@actions/core'
|
||||||
import * as io from '@actions/io'
|
import * as io from '@actions/io'
|
||||||
import * as k8s from '@kubernetes/client-node'
|
import * as k8s from '@kubernetes/client-node'
|
||||||
import { ContextPorts, prepareJobArgs, writeToResponseFile } from 'hooklib'
|
import {
|
||||||
|
JobContainerInfo,
|
||||||
|
ContextPorts,
|
||||||
|
PrepareJobArgs,
|
||||||
|
writeToResponseFile,
|
||||||
|
ServiceContainerInfo
|
||||||
|
} from 'hooklib'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import {
|
import {
|
||||||
containerPorts,
|
containerPorts,
|
||||||
createPod,
|
createPod,
|
||||||
isPodContainerAlpine,
|
isPodContainerAlpine,
|
||||||
prunePods,
|
prunePods,
|
||||||
waitForPodPhases
|
waitForPodPhases,
|
||||||
|
getPrepareJobTimeoutSeconds
|
||||||
} from '../k8s'
|
} from '../k8s'
|
||||||
import {
|
import {
|
||||||
containerVolumes,
|
containerVolumes,
|
||||||
DEFAULT_CONTAINER_ENTRY_POINT,
|
DEFAULT_CONTAINER_ENTRY_POINT,
|
||||||
DEFAULT_CONTAINER_ENTRY_POINT_ARGS,
|
DEFAULT_CONTAINER_ENTRY_POINT_ARGS,
|
||||||
generateContainerName,
|
generateContainerName,
|
||||||
PodPhase
|
mergeContainerWithOptions,
|
||||||
|
readExtensionFromFile,
|
||||||
|
PodPhase,
|
||||||
|
fixArgs
|
||||||
} from '../k8s/utils'
|
} from '../k8s/utils'
|
||||||
import { JOB_CONTAINER_NAME } from './constants'
|
import { CONTAINER_EXTENSION_PREFIX, JOB_CONTAINER_NAME } from './constants'
|
||||||
|
|
||||||
export async function prepareJob(
|
export async function prepareJob(
|
||||||
args: prepareJobArgs,
|
args: PrepareJobArgs,
|
||||||
responseFile
|
responseFile
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!args.container) {
|
if (!args.container) {
|
||||||
@@ -28,29 +38,49 @@ export async function prepareJob(
|
|||||||
}
|
}
|
||||||
|
|
||||||
await prunePods()
|
await prunePods()
|
||||||
|
|
||||||
|
const extension = readExtensionFromFile()
|
||||||
await copyExternalsToRoot()
|
await copyExternalsToRoot()
|
||||||
|
|
||||||
let container: k8s.V1Container | undefined = undefined
|
let container: k8s.V1Container | undefined = undefined
|
||||||
if (args.container?.image) {
|
if (args.container?.image) {
|
||||||
core.debug(`Using image '${args.container.image}' for job image`)
|
container = createContainerSpec(
|
||||||
container = createContainerSpec(args.container, JOB_CONTAINER_NAME, true)
|
args.container,
|
||||||
|
JOB_CONTAINER_NAME,
|
||||||
|
true,
|
||||||
|
extension
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
let services: k8s.V1Container[] = []
|
let services: k8s.V1Container[] = []
|
||||||
if (args.services?.length) {
|
if (args.services?.length) {
|
||||||
services = args.services.map(service => {
|
services = args.services.map(service => {
|
||||||
core.debug(`Adding service '${service.image}' to pod definition`)
|
return createContainerSpec(
|
||||||
return createContainerSpec(service, generateContainerName(service.image))
|
service,
|
||||||
|
generateContainerName(service.image),
|
||||||
|
false,
|
||||||
|
extension
|
||||||
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!container && !services?.length) {
|
if (!container && !services?.length) {
|
||||||
throw new Error('No containers exist, skipping hook invocation')
|
throw new Error('No containers exist, skipping hook invocation')
|
||||||
}
|
}
|
||||||
|
|
||||||
let createdPod: k8s.V1Pod | undefined = undefined
|
let createdPod: k8s.V1Pod | undefined = undefined
|
||||||
try {
|
try {
|
||||||
createdPod = await createPod(container, services, args.container.registry)
|
createdPod = await createPod(
|
||||||
|
container,
|
||||||
|
services,
|
||||||
|
args.container.registry,
|
||||||
|
extension
|
||||||
|
)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await prunePods()
|
await prunePods()
|
||||||
throw new Error(`failed to create job pod: ${err}`)
|
core.debug(`createPod failed: ${JSON.stringify(err)}`)
|
||||||
|
const message = (err as any)?.response?.body?.message || err
|
||||||
|
throw new Error(`failed to create job pod: ${message}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!createdPod?.metadata?.name) {
|
if (!createdPod?.metadata?.name) {
|
||||||
@@ -64,11 +94,12 @@ export async function prepareJob(
|
|||||||
await waitForPodPhases(
|
await waitForPodPhases(
|
||||||
createdPod.metadata.name,
|
createdPod.metadata.name,
|
||||||
new Set([PodPhase.RUNNING]),
|
new Set([PodPhase.RUNNING]),
|
||||||
new Set([PodPhase.PENDING])
|
new Set([PodPhase.PENDING]),
|
||||||
|
getPrepareJobTimeoutSeconds()
|
||||||
)
|
)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await prunePods()
|
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')
|
core.debug('Job pod is ready for traffic')
|
||||||
@@ -80,16 +111,21 @@ export async function prepareJob(
|
|||||||
JOB_CONTAINER_NAME
|
JOB_CONTAINER_NAME
|
||||||
)
|
)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new Error(`Failed to determine if the pod is alpine: ${err}`)
|
core.debug(
|
||||||
|
`Failed to determine if the pod is alpine: ${JSON.stringify(err)}`
|
||||||
|
)
|
||||||
|
const message = (err as any)?.response?.body?.message || err
|
||||||
|
throw new Error(`failed to determine if the pod is alpine: ${message}`)
|
||||||
}
|
}
|
||||||
core.debug(`Setting isAlpine to ${isAlpine}`)
|
core.debug(`Setting isAlpine to ${isAlpine}`)
|
||||||
generateResponseFile(responseFile, createdPod, isAlpine)
|
generateResponseFile(responseFile, args, createdPod, isAlpine)
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateResponseFile(
|
function generateResponseFile(
|
||||||
responseFile: string,
|
responseFile: string,
|
||||||
|
args: PrepareJobArgs,
|
||||||
appPod: k8s.V1Pod,
|
appPod: k8s.V1Pod,
|
||||||
isAlpine
|
isAlpine: boolean
|
||||||
): void {
|
): void {
|
||||||
if (!appPod.metadata?.name) {
|
if (!appPod.metadata?.name) {
|
||||||
throw new Error('app pod must have metadata.name specified')
|
throw new Error('app pod must have metadata.name specified')
|
||||||
@@ -109,8 +145,10 @@ function generateResponseFile(
|
|||||||
const mainContainerContextPorts: ContextPorts = {}
|
const mainContainerContextPorts: ContextPorts = {}
|
||||||
if (mainContainer?.ports) {
|
if (mainContainer?.ports) {
|
||||||
for (const port of mainContainer.ports) {
|
for (const port of mainContainer.ports) {
|
||||||
mainContainerContextPorts[port.containerPort] =
|
if (port.containerPort && port.hostPort) {
|
||||||
mainContainerContextPorts.hostPort
|
mainContainerContextPorts[port.containerPort.toString()] =
|
||||||
|
port.hostPort.toString()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,15 +158,19 @@ function generateResponseFile(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const serviceContainers = appPod.spec?.containers.filter(
|
if (args.services?.length) {
|
||||||
c => c.name !== JOB_CONTAINER_NAME
|
const serviceContainerNames =
|
||||||
)
|
args.services?.map(s => generateContainerName(s.image)) || []
|
||||||
if (serviceContainers?.length) {
|
|
||||||
response.context['services'] = serviceContainers.map(c => {
|
response.context['services'] = appPod?.spec?.containers
|
||||||
|
?.filter(c => serviceContainerNames.includes(c.name))
|
||||||
|
.map(c => {
|
||||||
const ctxPorts: ContextPorts = {}
|
const ctxPorts: ContextPorts = {}
|
||||||
if (c.ports?.length) {
|
if (c.ports?.length) {
|
||||||
for (const port of c.ports) {
|
for (const port of c.ports) {
|
||||||
ctxPorts[port.containerPort] = port.hostPort
|
if (port.containerPort && port.hostPort) {
|
||||||
|
ctxPorts[port.containerPort.toString()] = port.hostPort.toString()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,6 +180,7 @@ function generateResponseFile(
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
writeToResponseFile(responseFile, JSON.stringify(response))
|
writeToResponseFile(responseFile, JSON.stringify(response))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,9 +196,10 @@ async function copyExternalsToRoot(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createContainerSpec(
|
export function createContainerSpec(
|
||||||
container,
|
container: JobContainerInfo | ServiceContainerInfo,
|
||||||
name: string,
|
name: string,
|
||||||
jobContainer = false
|
jobContainer = false,
|
||||||
|
extension?: k8s.V1PodTemplateSpec
|
||||||
): k8s.V1Container {
|
): k8s.V1Container {
|
||||||
if (!container.entryPoint && jobContainer) {
|
if (!container.entryPoint && jobContainer) {
|
||||||
container.entryPoint = DEFAULT_CONTAINER_ENTRY_POINT
|
container.entryPoint = DEFAULT_CONTAINER_ENTRY_POINT
|
||||||
@@ -167,31 +211,55 @@ export function createContainerSpec(
|
|||||||
image: container.image,
|
image: container.image,
|
||||||
ports: containerPorts(container)
|
ports: containerPorts(container)
|
||||||
} as k8s.V1Container
|
} as k8s.V1Container
|
||||||
if (container.workingDirectory) {
|
if (container['workingDirectory']) {
|
||||||
podContainer.workingDir = container.workingDirectory
|
podContainer.workingDir = container['workingDirectory']
|
||||||
}
|
}
|
||||||
|
|
||||||
if (container.entryPoint) {
|
if (container.entryPoint) {
|
||||||
podContainer.command = [container.entryPoint]
|
podContainer.command = [container.entryPoint]
|
||||||
}
|
}
|
||||||
|
|
||||||
if (container.entryPointArgs?.length > 0) {
|
if (container.entryPointArgs && container.entryPointArgs.length > 0) {
|
||||||
podContainer.args = container.entryPointArgs
|
podContainer.args = fixArgs(container.entryPointArgs)
|
||||||
}
|
}
|
||||||
|
|
||||||
podContainer.env = []
|
podContainer.env = []
|
||||||
for (const [key, value] of Object.entries(
|
for (const [key, value] of Object.entries(
|
||||||
container['environmentVariables']
|
container['environmentVariables'] || {}
|
||||||
)) {
|
)) {
|
||||||
if (value && key !== 'HOME') {
|
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(
|
podContainer.volumeMounts = containerVolumes(
|
||||||
container.userMountVolumes,
|
container['userMountVolumes'],
|
||||||
jobContainer
|
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
|
return podContainer
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,12 +12,12 @@ import {
|
|||||||
} from '../k8s'
|
} from '../k8s'
|
||||||
import {
|
import {
|
||||||
containerVolumes,
|
containerVolumes,
|
||||||
DEFAULT_CONTAINER_ENTRY_POINT,
|
fixArgs,
|
||||||
DEFAULT_CONTAINER_ENTRY_POINT_ARGS,
|
mergeContainerWithOptions,
|
||||||
PodPhase,
|
PodPhase,
|
||||||
writeEntryPointScript
|
readExtensionFromFile
|
||||||
} from '../k8s/utils'
|
} from '../k8s/utils'
|
||||||
import { JOB_CONTAINER_NAME } from './constants'
|
import { JOB_CONTAINER_EXTENSION_NAME, JOB_CONTAINER_NAME } from './constants'
|
||||||
|
|
||||||
export async function runContainerStep(
|
export async function runContainerStep(
|
||||||
stepContainer: RunContainerStepArgs
|
stepContainer: RunContainerStepArgs
|
||||||
@@ -28,13 +28,36 @@ export async function runContainerStep(
|
|||||||
|
|
||||||
let secretName: string | undefined = undefined
|
let secretName: string | undefined = undefined
|
||||||
if (stepContainer.environmentVariables) {
|
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`)
|
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) {
|
if (!job.metadata?.name) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Expected job ${JSON.stringify(
|
`Expected job ${JSON.stringify(
|
||||||
@@ -44,10 +67,23 @@ export async function runContainerStep(
|
|||||||
}
|
}
|
||||||
core.debug(`Job created, waiting for pod to start: ${job.metadata?.name}`)
|
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(
|
await waitForPodPhases(
|
||||||
podName,
|
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])
|
new Set([PodPhase.PENDING, PodPhase.UNKNOWN])
|
||||||
)
|
)
|
||||||
core.debug('Container step is running or complete, pulling logs')
|
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')
|
core.debug('Waiting for container job to complete')
|
||||||
await waitForJobToComplete(job.metadata.name)
|
await waitForJobToComplete(job.metadata.name)
|
||||||
// pod has failed so pull the status code from the container
|
|
||||||
const status = await getPodStatus(podName)
|
const status = await getPodStatus(podName)
|
||||||
if (status?.phase === 'Succeeded') {
|
if (status?.phase === 'Succeeded') {
|
||||||
return 0
|
return 0
|
||||||
@@ -75,24 +111,21 @@ export async function runContainerStep(
|
|||||||
return Number(exitCode) || 1
|
return Number(exitCode) || 1
|
||||||
}
|
}
|
||||||
|
|
||||||
function createPodSpec(
|
function createContainerSpec(
|
||||||
container: RunContainerStepArgs,
|
container: RunContainerStepArgs,
|
||||||
secretName?: string
|
secretName?: string,
|
||||||
|
extension?: k8s.V1PodTemplateSpec
|
||||||
): k8s.V1Container {
|
): k8s.V1Container {
|
||||||
const podContainer = new k8s.V1Container()
|
const podContainer = new k8s.V1Container()
|
||||||
podContainer.name = JOB_CONTAINER_NAME
|
podContainer.name = JOB_CONTAINER_NAME
|
||||||
podContainer.image = container.image
|
podContainer.image = container.image
|
||||||
|
podContainer.workingDir = container.workingDirectory
|
||||||
const { entryPoint, entryPointArgs } = container
|
podContainer.command = container.entryPoint
|
||||||
container.entryPoint = 'sh'
|
? [container.entryPoint]
|
||||||
|
: undefined
|
||||||
const { containerPath } = writeEntryPointScript(
|
podContainer.args = container.entryPointArgs?.length
|
||||||
container.workingDirectory,
|
? fixArgs(container.entryPointArgs)
|
||||||
entryPoint || DEFAULT_CONTAINER_ENTRY_POINT,
|
: undefined
|
||||||
entryPoint ? entryPointArgs || [] : DEFAULT_CONTAINER_ENTRY_POINT_ARGS
|
|
||||||
)
|
|
||||||
container.entryPointArgs = ['-e', containerPath]
|
|
||||||
podContainer.command = [container.entryPoint, ...container.entryPointArgs]
|
|
||||||
|
|
||||||
if (secretName) {
|
if (secretName) {
|
||||||
podContainer.envFrom = [
|
podContainer.envFrom = [
|
||||||
@@ -106,5 +139,16 @@ function createPodSpec(
|
|||||||
}
|
}
|
||||||
podContainer.volumeMounts = containerVolumes(undefined, false, true)
|
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
|
return podContainer
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||||
import * as fs from 'fs'
|
import * as fs from 'fs'
|
||||||
|
import * as core from '@actions/core'
|
||||||
import { RunScriptStepArgs } from 'hooklib'
|
import { RunScriptStepArgs } from 'hooklib'
|
||||||
import { execPodStep } from '../k8s'
|
import { execPodStep } from '../k8s'
|
||||||
import { writeEntryPointScript } from '../k8s/utils'
|
import { writeEntryPointScript } from '../k8s/utils'
|
||||||
@@ -8,7 +9,7 @@ import { JOB_CONTAINER_NAME } from './constants'
|
|||||||
export async function runScriptStep(
|
export async function runScriptStep(
|
||||||
args: RunScriptStepArgs,
|
args: RunScriptStepArgs,
|
||||||
state,
|
state,
|
||||||
responseFile
|
responseFile?
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { entryPoint, entryPointArgs, environmentVariables } = args
|
const { entryPoint, entryPointArgs, environmentVariables } = args
|
||||||
const { containerPath, runnerPath } = writeEntryPointScript(
|
const { containerPath, runnerPath } = writeEntryPointScript(
|
||||||
@@ -28,7 +29,9 @@ export async function runScriptStep(
|
|||||||
JOB_CONTAINER_NAME
|
JOB_CONTAINER_NAME
|
||||||
)
|
)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new Error(`failed to run script step: ${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 {
|
} finally {
|
||||||
fs.rmSync(runnerPath)
|
fs.rmSync(runnerPath)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import * as core from '@actions/core'
|
import * as core from '@actions/core'
|
||||||
import { Command, getInputFromStdin, prepareJobArgs } from 'hooklib'
|
import {
|
||||||
|
Command,
|
||||||
|
getInputFromStdin,
|
||||||
|
PrepareJobArgs,
|
||||||
|
RunContainerStepArgs,
|
||||||
|
RunScriptStepArgs
|
||||||
|
} from 'hooklib'
|
||||||
import {
|
import {
|
||||||
cleanupJob,
|
cleanupJob,
|
||||||
prepareJob,
|
prepareJob,
|
||||||
@@ -27,16 +33,16 @@ async function run(): Promise<void> {
|
|||||||
let exitCode = 0
|
let exitCode = 0
|
||||||
switch (command) {
|
switch (command) {
|
||||||
case Command.PrepareJob:
|
case Command.PrepareJob:
|
||||||
await prepareJob(args as prepareJobArgs, responseFile)
|
await prepareJob(args as PrepareJobArgs, responseFile)
|
||||||
return process.exit(0)
|
return process.exit(0)
|
||||||
case Command.CleanupJob:
|
case Command.CleanupJob:
|
||||||
await cleanupJob()
|
await cleanupJob()
|
||||||
return process.exit(0)
|
return process.exit(0)
|
||||||
case Command.RunScriptStep:
|
case Command.RunScriptStep:
|
||||||
await runScriptStep(args, state, null)
|
await runScriptStep(args as RunScriptStepArgs, state)
|
||||||
return process.exit(0)
|
return process.exit(0)
|
||||||
case Command.RunContainerStep:
|
case Command.RunContainerStep:
|
||||||
exitCode = await runContainerStep(args)
|
exitCode = await runContainerStep(args as RunContainerStepArgs)
|
||||||
return process.exit(exitCode)
|
return process.exit(exitCode)
|
||||||
default:
|
default:
|
||||||
throw new Error(`Command not recognized: ${command}`)
|
throw new Error(`Command not recognized: ${command}`)
|
||||||
|
|||||||
+199
-95
@@ -1,7 +1,7 @@
|
|||||||
import * as core from '@actions/core'
|
import * as core from '@actions/core'
|
||||||
import * as k8s from '@kubernetes/client-node'
|
import * as k8s from '@kubernetes/client-node'
|
||||||
import { ContainerInfo, Registry } from 'hooklib'
|
|
||||||
import * as stream from 'stream'
|
import * as stream from 'stream'
|
||||||
|
import type { ContainerInfo, Registry } from 'hooklib'
|
||||||
import {
|
import {
|
||||||
getJobPodName,
|
getJobPodName,
|
||||||
getRunnerPodName,
|
getRunnerPodName,
|
||||||
@@ -10,7 +10,13 @@ import {
|
|||||||
getVolumeClaimName,
|
getVolumeClaimName,
|
||||||
RunnerInstanceLabel
|
RunnerInstanceLabel
|
||||||
} from '../hooks/constants'
|
} from '../hooks/constants'
|
||||||
import { PodPhase } from './utils'
|
import {
|
||||||
|
PodPhase,
|
||||||
|
mergePodSpecWithOptions,
|
||||||
|
mergeObjectMeta,
|
||||||
|
useKubeScheduler,
|
||||||
|
fixArgs
|
||||||
|
} from './utils'
|
||||||
|
|
||||||
const kc = new k8s.KubeConfig()
|
const kc = new k8s.KubeConfig()
|
||||||
|
|
||||||
@@ -20,6 +26,8 @@ const k8sApi = kc.makeApiClient(k8s.CoreV1Api)
|
|||||||
const k8sBatchV1Api = kc.makeApiClient(k8s.BatchV1Api)
|
const k8sBatchV1Api = kc.makeApiClient(k8s.BatchV1Api)
|
||||||
const k8sAuthorizationV1Api = kc.makeApiClient(k8s.AuthorizationV1Api)
|
const k8sAuthorizationV1Api = kc.makeApiClient(k8s.AuthorizationV1Api)
|
||||||
|
|
||||||
|
const DEFAULT_WAIT_FOR_POD_TIME_SECONDS = 10 * 60 // 10 min
|
||||||
|
|
||||||
export const POD_VOLUME_NAME = 'work'
|
export const POD_VOLUME_NAME = 'work'
|
||||||
|
|
||||||
export const requiredPermissions = [
|
export const requiredPermissions = [
|
||||||
@@ -58,7 +66,8 @@ export const requiredPermissions = [
|
|||||||
export async function createPod(
|
export async function createPod(
|
||||||
jobContainer?: k8s.V1Container,
|
jobContainer?: k8s.V1Container,
|
||||||
services?: k8s.V1Container[],
|
services?: k8s.V1Container[],
|
||||||
registry?: Registry
|
registry?: Registry,
|
||||||
|
extension?: k8s.V1PodTemplateSpec
|
||||||
): Promise<k8s.V1Pod> {
|
): Promise<k8s.V1Pod> {
|
||||||
const containers: k8s.V1Container[] = []
|
const containers: k8s.V1Container[] = []
|
||||||
if (jobContainer) {
|
if (jobContainer) {
|
||||||
@@ -80,15 +89,22 @@ export async function createPod(
|
|||||||
appPod.metadata.labels = {
|
appPod.metadata.labels = {
|
||||||
[instanceLabel.key]: instanceLabel.value
|
[instanceLabel.key]: instanceLabel.value
|
||||||
}
|
}
|
||||||
|
appPod.metadata.annotations = {}
|
||||||
|
|
||||||
appPod.spec = new k8s.V1PodSpec()
|
appPod.spec = new k8s.V1PodSpec()
|
||||||
appPod.spec.containers = containers
|
appPod.spec.containers = containers
|
||||||
appPod.spec.restartPolicy = 'Never'
|
appPod.spec.restartPolicy = 'Never'
|
||||||
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()
|
const claimName = getVolumeClaimName()
|
||||||
appPod.spec.volumes = [
|
appPod.spec.volumes = [
|
||||||
{
|
{
|
||||||
name: 'work',
|
name: POD_VOLUME_NAME,
|
||||||
persistentVolumeClaim: { claimName }
|
persistentVolumeClaim: { claimName }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -103,12 +119,23 @@ export async function createPod(
|
|||||||
appPod.spec.imagePullSecrets = [secretReference]
|
appPod.spec.imagePullSecrets = [secretReference]
|
||||||
}
|
}
|
||||||
|
|
||||||
const { body } = await k8sApi.createNamespacedPod(namespace(), appPod)
|
if (extension?.metadata) {
|
||||||
return body
|
mergeObjectMeta(appPod, extension.metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extension?.spec) {
|
||||||
|
mergePodSpecWithOptions(appPod.spec, extension.spec)
|
||||||
|
}
|
||||||
|
|
||||||
|
return await k8sApi.createNamespacedPod({
|
||||||
|
namespace: namespace(),
|
||||||
|
body: appPod
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createJob(
|
export async function createJob(
|
||||||
container: k8s.V1Container
|
container: k8s.V1Container,
|
||||||
|
extension?: k8s.V1PodTemplateSpec
|
||||||
): Promise<k8s.V1Job> {
|
): Promise<k8s.V1Job> {
|
||||||
const runnerInstanceLabel = new RunnerInstanceLabel()
|
const runnerInstanceLabel = new RunnerInstanceLabel()
|
||||||
|
|
||||||
@@ -118,6 +145,7 @@ export async function createJob(
|
|||||||
job.metadata = new k8s.V1ObjectMeta()
|
job.metadata = new k8s.V1ObjectMeta()
|
||||||
job.metadata.name = getStepPodName()
|
job.metadata.name = getStepPodName()
|
||||||
job.metadata.labels = { [runnerInstanceLabel.key]: runnerInstanceLabel.value }
|
job.metadata.labels = { [runnerInstanceLabel.key]: runnerInstanceLabel.value }
|
||||||
|
job.metadata.annotations = {}
|
||||||
|
|
||||||
job.spec = new k8s.V1JobSpec()
|
job.spec = new k8s.V1JobSpec()
|
||||||
job.spec.ttlSecondsAfterFinished = 300
|
job.spec.ttlSecondsAfterFinished = 300
|
||||||
@@ -125,58 +153,73 @@ export async function createJob(
|
|||||||
job.spec.template = new k8s.V1PodTemplateSpec()
|
job.spec.template = new k8s.V1PodTemplateSpec()
|
||||||
|
|
||||||
job.spec.template.spec = new k8s.V1PodSpec()
|
job.spec.template.spec = new k8s.V1PodSpec()
|
||||||
|
job.spec.template.metadata = new k8s.V1ObjectMeta()
|
||||||
|
job.spec.template.metadata.labels = {}
|
||||||
|
job.spec.template.metadata.annotations = {}
|
||||||
job.spec.template.spec.containers = [container]
|
job.spec.template.spec.containers = [container]
|
||||||
job.spec.template.spec.restartPolicy = 'Never'
|
job.spec.template.spec.restartPolicy = 'Never'
|
||||||
job.spec.template.spec.nodeName = await getCurrentNodeName()
|
|
||||||
|
const nodeName = await getCurrentNodeName()
|
||||||
|
if (useKubeScheduler()) {
|
||||||
|
job.spec.template.spec.affinity = await getPodAffinity(nodeName)
|
||||||
|
} else {
|
||||||
|
job.spec.template.spec.nodeName = nodeName
|
||||||
|
}
|
||||||
|
|
||||||
const claimName = getVolumeClaimName()
|
const claimName = getVolumeClaimName()
|
||||||
job.spec.template.spec.volumes = [
|
job.spec.template.spec.volumes = [
|
||||||
{
|
{
|
||||||
name: 'work',
|
name: POD_VOLUME_NAME,
|
||||||
persistentVolumeClaim: { claimName }
|
persistentVolumeClaim: { claimName }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
const { body } = await k8sBatchV1Api.createNamespacedJob(namespace(), job)
|
if (extension) {
|
||||||
return body
|
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> {
|
export async function getContainerJobPodName(jobName: string): Promise<string> {
|
||||||
const selector = `job-name=${jobName}`
|
const selector = `job-name=${jobName}`
|
||||||
const backOffManager = new BackOffManager(60)
|
const backOffManager = new BackOffManager(60)
|
||||||
while (true) {
|
while (true) {
|
||||||
const podList = await k8sApi.listNamespacedPod(
|
const podList = await k8sApi.listNamespacedPod({
|
||||||
namespace(),
|
namespace: namespace(),
|
||||||
undefined,
|
labelSelector: selector,
|
||||||
undefined,
|
limit: 1
|
||||||
undefined,
|
})
|
||||||
undefined,
|
|
||||||
selector,
|
|
||||||
1
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!podList.body.items?.length) {
|
if (!podList.items?.length) {
|
||||||
await backOffManager.backOff()
|
await backOffManager.backOff()
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!podList.body.items[0].metadata?.name) {
|
if (!podList.items[0].metadata?.name) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Failed to determine the name of the pod for job ${jobName}`
|
`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> {
|
export async function deletePod(podName: string): Promise<void> {
|
||||||
await k8sApi.deleteNamespacedPod(
|
await k8sApi.deleteNamespacedPod({
|
||||||
podName,
|
name: podName,
|
||||||
namespace(),
|
namespace: namespace(),
|
||||||
undefined,
|
gracePeriodSeconds: 0
|
||||||
undefined,
|
})
|
||||||
0
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function execPodStep(
|
export async function execPodStep(
|
||||||
@@ -184,10 +227,13 @@ export async function execPodStep(
|
|||||||
podName: string,
|
podName: string,
|
||||||
containerName: string,
|
containerName: string,
|
||||||
stdin?: stream.Readable
|
stdin?: stream.Readable
|
||||||
): Promise<void> {
|
): Promise<number> {
|
||||||
const exec = new k8s.Exec(kc)
|
const exec = new k8s.Exec(kc)
|
||||||
await new Promise(async function (resolve, reject) {
|
|
||||||
await exec.exec(
|
command = fixArgs(command)
|
||||||
|
return await new Promise(function (resolve, reject) {
|
||||||
|
exec
|
||||||
|
.exec(
|
||||||
namespace(),
|
namespace(),
|
||||||
podName,
|
podName,
|
||||||
containerName,
|
containerName,
|
||||||
@@ -197,9 +243,8 @@ export async function execPodStep(
|
|||||||
stdin ?? null,
|
stdin ?? null,
|
||||||
false /* tty */,
|
false /* tty */,
|
||||||
resp => {
|
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') {
|
if (resp.status === 'Success') {
|
||||||
resolve(resp.code)
|
resolve(resp.code || 0)
|
||||||
} else {
|
} else {
|
||||||
core.debug(
|
core.debug(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
@@ -207,10 +252,11 @@ export async function execPodStep(
|
|||||||
details: resp?.details
|
details: resp?.details
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
reject(resp?.message)
|
reject(new Error(resp?.message || 'execPodStep failed'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
.catch(e => reject(e))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,7 +268,7 @@ export async function waitForJobToComplete(jobName: string): Promise<void> {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(`job ${jobName} has failed`)
|
throw new Error(`job ${jobName} has failed: ${JSON.stringify(error)}`)
|
||||||
}
|
}
|
||||||
await backOffManager.backOff()
|
await backOffManager.backOff()
|
||||||
}
|
}
|
||||||
@@ -263,8 +309,10 @@ export async function createDockerSecret(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const { body } = await k8sApi.createNamespacedSecret(namespace(), secret)
|
return await k8sApi.createNamespacedSecret({
|
||||||
return body
|
namespace: namespace(),
|
||||||
|
body: secret
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createSecretForEnvs(envs: {
|
export async function createSecretForEnvs(envs: {
|
||||||
@@ -288,30 +336,33 @@ export async function createSecretForEnvs(envs: {
|
|||||||
secret.data[key] = Buffer.from(value).toString('base64')
|
secret.data[key] = Buffer.from(value).toString('base64')
|
||||||
}
|
}
|
||||||
|
|
||||||
await k8sApi.createNamespacedSecret(namespace(), secret)
|
await k8sApi.createNamespacedSecret({
|
||||||
|
namespace: namespace(),
|
||||||
|
body: secret
|
||||||
|
})
|
||||||
return secretName
|
return secretName
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteSecret(secretName: string): Promise<void> {
|
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> {
|
export async function pruneSecrets(): Promise<void> {
|
||||||
const secretList = await k8sApi.listNamespacedSecret(
|
const secretList = await k8sApi.listNamespacedSecret({
|
||||||
namespace(),
|
namespace: namespace(),
|
||||||
undefined,
|
labelSelector: new RunnerInstanceLabel().toString()
|
||||||
undefined,
|
})
|
||||||
undefined,
|
if (!secretList.items.length) {
|
||||||
undefined,
|
|
||||||
new RunnerInstanceLabel().toString()
|
|
||||||
)
|
|
||||||
if (!secretList.body.items.length) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
secretList.body.items.map(
|
secretList.items.map(
|
||||||
secret => secret.metadata?.name && deleteSecret(secret.metadata.name)
|
async secret =>
|
||||||
|
secret.metadata?.name && deleteSecret(secret.metadata.name)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -320,7 +371,7 @@ export async function waitForPodPhases(
|
|||||||
podName: string,
|
podName: string,
|
||||||
awaitingPhases: Set<PodPhase>,
|
awaitingPhases: Set<PodPhase>,
|
||||||
backOffPhases: Set<PodPhase>,
|
backOffPhases: Set<PodPhase>,
|
||||||
maxTimeSeconds = 10 * 60 // 10 min
|
maxTimeSeconds = DEFAULT_WAIT_FOR_POD_TIME_SECONDS
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const backOffManager = new BackOffManager(maxTimeSeconds)
|
const backOffManager = new BackOffManager(maxTimeSeconds)
|
||||||
let phase: PodPhase = PodPhase.UNKNOWN
|
let phase: PodPhase = PodPhase.UNKNOWN
|
||||||
@@ -339,11 +390,32 @@ export async function waitForPodPhases(
|
|||||||
await backOffManager.backOff()
|
await backOffManager.backOff()
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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>([
|
const podPhaseLookup = new Set<string>([
|
||||||
PodPhase.PENDING,
|
PodPhase.PENDING,
|
||||||
PodPhase.RUNNING,
|
PodPhase.RUNNING,
|
||||||
@@ -351,8 +423,10 @@ async function getPodPhase(podName: string): Promise<PodPhase> {
|
|||||||
PodPhase.FAILED,
|
PodPhase.FAILED,
|
||||||
PodPhase.UNKNOWN
|
PodPhase.UNKNOWN
|
||||||
])
|
])
|
||||||
const { body } = await k8sApi.readNamespacedPod(podName, namespace())
|
const pod = await k8sApi.readNamespacedPod({
|
||||||
const pod = body
|
name,
|
||||||
|
namespace: namespace()
|
||||||
|
})
|
||||||
|
|
||||||
if (!pod.status?.phase || !podPhaseLookup.has(pod.status.phase)) {
|
if (!pod.status?.phase || !podPhaseLookup.has(pod.status.phase)) {
|
||||||
return PodPhase.UNKNOWN
|
return PodPhase.UNKNOWN
|
||||||
@@ -360,11 +434,13 @@ async function getPodPhase(podName: string): Promise<PodPhase> {
|
|||||||
return pod.status?.phase as PodPhase
|
return pod.status?.phase as PodPhase
|
||||||
}
|
}
|
||||||
|
|
||||||
async function isJobSucceeded(jobName: string): Promise<boolean> {
|
async function isJobSucceeded(name: string): Promise<boolean> {
|
||||||
const { body } = await k8sBatchV1Api.readNamespacedJob(jobName, namespace())
|
const job = await k8sBatchV1Api.readNamespacedJob({
|
||||||
const job = body
|
name,
|
||||||
|
namespace: namespace()
|
||||||
|
})
|
||||||
if (job.status?.failed) {
|
if (job.status?.failed) {
|
||||||
throw new Error(`job ${jobName} has failed`)
|
throw new Error(`job ${name} has failed`)
|
||||||
}
|
}
|
||||||
return !!job.status?.succeeded
|
return !!job.status?.succeeded
|
||||||
}
|
}
|
||||||
@@ -384,31 +460,26 @@ export async function getPodLogs(
|
|||||||
process.stderr.write(err.message)
|
process.stderr.write(err.message)
|
||||||
})
|
})
|
||||||
|
|
||||||
const r = await log.log(namespace(), podName, containerName, logStream, {
|
await log.log(namespace(), podName, containerName, logStream, {
|
||||||
follow: true,
|
follow: true,
|
||||||
tailLines: 50,
|
|
||||||
pretty: false,
|
pretty: false,
|
||||||
timestamps: 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> {
|
export async function prunePods(): Promise<void> {
|
||||||
const podList = await k8sApi.listNamespacedPod(
|
const podList = await k8sApi.listNamespacedPod({
|
||||||
namespace(),
|
namespace: namespace(),
|
||||||
undefined,
|
labelSelector: new RunnerInstanceLabel().toString()
|
||||||
undefined,
|
})
|
||||||
undefined,
|
if (!podList.items.length) {
|
||||||
undefined,
|
|
||||||
new RunnerInstanceLabel().toString()
|
|
||||||
)
|
|
||||||
if (!podList.body.items.length) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
podList.body.items.map(
|
podList.items.map(
|
||||||
pod => pod.metadata?.name && deletePod(pod.metadata.name)
|
async pod => pod.metadata?.name && deletePod(pod.metadata.name)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -416,16 +487,16 @@ export async function prunePods(): Promise<void> {
|
|||||||
export async function getPodStatus(
|
export async function getPodStatus(
|
||||||
name: string
|
name: string
|
||||||
): Promise<k8s.V1PodStatus | undefined> {
|
): Promise<k8s.V1PodStatus | undefined> {
|
||||||
const { body } = await k8sApi.readNamespacedPod(name, namespace())
|
const pod = await k8sApi.readNamespacedPod({
|
||||||
return body.status
|
name,
|
||||||
|
namespace: namespace()
|
||||||
|
})
|
||||||
|
return pod.status
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function isAuthPermissionsOK(): Promise<boolean> {
|
export async function isAuthPermissionsOK(): Promise<boolean> {
|
||||||
const sar = new k8s.V1SelfSubjectAccessReview()
|
const sar = new k8s.V1SelfSubjectAccessReview()
|
||||||
const asyncs: Promise<{
|
const asyncs: Promise<k8s.V1SelfSubjectAccessReview>[] = []
|
||||||
response: unknown
|
|
||||||
body: k8s.V1SelfSubjectAccessReview
|
|
||||||
}>[] = []
|
|
||||||
for (const resource of requiredPermissions) {
|
for (const resource of requiredPermissions) {
|
||||||
for (const verb of resource.verbs) {
|
for (const verb of resource.verbs) {
|
||||||
sar.spec = new k8s.V1SelfSubjectAccessReviewSpec()
|
sar.spec = new k8s.V1SelfSubjectAccessReviewSpec()
|
||||||
@@ -435,11 +506,13 @@ export async function isAuthPermissionsOK(): Promise<boolean> {
|
|||||||
sar.spec.resourceAttributes.group = resource.group
|
sar.spec.resourceAttributes.group = resource.group
|
||||||
sar.spec.resourceAttributes.resource = resource.resource
|
sar.spec.resourceAttributes.resource = resource.resource
|
||||||
sar.spec.resourceAttributes.subresource = resource.subresource
|
sar.spec.resourceAttributes.subresource = resource.subresource
|
||||||
asyncs.push(k8sAuthorizationV1Api.createSelfSubjectAccessReview(sar))
|
asyncs.push(
|
||||||
|
k8sAuthorizationV1Api.createSelfSubjectAccessReview({ body: sar })
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const responses = await Promise.all(asyncs)
|
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(
|
export async function isPodContainerAlpine(
|
||||||
@@ -452,27 +525,18 @@ export async function isPodContainerAlpine(
|
|||||||
[
|
[
|
||||||
'sh',
|
'sh',
|
||||||
'-c',
|
'-c',
|
||||||
"[ $(cat /etc/*release* | grep -i -e '^ID=*alpine*' -c) != 0 ] || exit 1"
|
`'[ $(cat /etc/*release* | grep -i -e "^ID=*alpine*" -c) != 0 ] || exit 1'`
|
||||||
],
|
],
|
||||||
podName,
|
podName,
|
||||||
containerName
|
containerName
|
||||||
)
|
)
|
||||||
} catch (err) {
|
} catch {
|
||||||
isAlpine = false
|
isAlpine = false
|
||||||
}
|
}
|
||||||
|
|
||||||
return isAlpine
|
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 {
|
export function namespace(): string {
|
||||||
if (process.env['ACTIONS_RUNNER_KUBERNETES_NAMESPACE']) {
|
if (process.env['ACTIONS_RUNNER_KUBERNETES_NAMESPACE']) {
|
||||||
return process.env['ACTIONS_RUNNER_KUBERNETES_NAMESPACE']
|
return process.env['ACTIONS_RUNNER_KUBERNETES_NAMESPACE']
|
||||||
@@ -487,6 +551,39 @@ export function namespace(): string {
|
|||||||
return context.namespace
|
return context.namespace
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 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 {
|
class BackOffManager {
|
||||||
private backOffSeconds = 1
|
private backOffSeconds = 1
|
||||||
totalTime = 0
|
totalTime = 0
|
||||||
@@ -554,3 +651,10 @@ export function containerPorts(
|
|||||||
}
|
}
|
||||||
return ports
|
return ports
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getPodByName(name: string): Promise<k8s.V1Pod> {
|
||||||
|
return await k8sApi.readNamespacedPod({
|
||||||
|
name,
|
||||||
|
namespace: namespace()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
+259
-26
@@ -1,13 +1,21 @@
|
|||||||
import * as k8s from '@kubernetes/client-node'
|
import * as k8s from '@kubernetes/client-node'
|
||||||
import * as fs from 'fs'
|
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 { Mount } from 'hooklib'
|
||||||
import * as path from 'path'
|
import * as path from 'path'
|
||||||
import { v1 as uuidv4 } from 'uuid'
|
|
||||||
import { POD_VOLUME_NAME } from './index'
|
import { POD_VOLUME_NAME } from './index'
|
||||||
|
|
||||||
export const DEFAULT_CONTAINER_ENTRY_POINT_ARGS = [`-f`, `/dev/null`]
|
export const DEFAULT_CONTAINER_ENTRY_POINT_ARGS = [`-f`, `/dev/null`]
|
||||||
export const DEFAULT_CONTAINER_ENTRY_POINT = 'tail'
|
export const DEFAULT_CONTAINER_ENTRY_POINT = 'tail'
|
||||||
|
|
||||||
|
export const ENV_HOOK_TEMPLATE_PATH = 'ACTIONS_RUNNER_CONTAINER_HOOK_TEMPLATE'
|
||||||
|
export const ENV_DISABLE_KUBE_SCHEDULER =
|
||||||
|
'ACTIONS_RUNNER_DISABLE_KUBE_SCHEDULER'
|
||||||
|
|
||||||
export function containerVolumes(
|
export function containerVolumes(
|
||||||
userMountVolumes: Mount[] = [],
|
userMountVolumes: Mount[] = [],
|
||||||
jobContainer = true,
|
jobContainer = true,
|
||||||
@@ -34,6 +42,16 @@ export function containerVolumes(
|
|||||||
name: POD_VOLUME_NAME,
|
name: POD_VOLUME_NAME,
|
||||||
mountPath: '/github/file_commands',
|
mountPath: '/github/file_commands',
|
||||||
subPath: '_temp/_runner_file_commands'
|
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
|
return mounts
|
||||||
@@ -73,7 +91,6 @@ export function containerVolumes(
|
|||||||
'Volume mounts outside of the work folder are not supported'
|
'Volume mounts outside of the work folder are not supported'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// source volume path should be relative path
|
|
||||||
sourceVolumePath = userVolume.sourceVolumePath.slice(
|
sourceVolumePath = userVolume.sourceVolumePath.slice(
|
||||||
workspacePath.length + 1
|
workspacePath.length + 1
|
||||||
)
|
)
|
||||||
@@ -92,12 +109,66 @@ export function containerVolumes(
|
|||||||
return mounts
|
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(
|
export function writeEntryPointScript(
|
||||||
workingDirectory: string,
|
workingDirectory: string,
|
||||||
entryPoint: string,
|
entryPoint: string,
|
||||||
entryPointArgs?: string[],
|
entryPointArgs?: string[],
|
||||||
prependPath?: string[],
|
prependPath?: string[],
|
||||||
environmentVariables?: { [key: string]: 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 } {
|
): { containerPath: string; runnerPath: string } {
|
||||||
let exportPath = ''
|
let exportPath = ''
|
||||||
if (prependPath?.length) {
|
if (prependPath?.length) {
|
||||||
@@ -106,32 +177,12 @@ export function writeEntryPointScript(
|
|||||||
typeof prependPath === 'string' ? prependPath : prependPath.join(':')
|
typeof prependPath === 'string' ? prependPath : prependPath.join(':')
|
||||||
exportPath = `export PATH=${prepend}:$PATH`
|
exportPath = `export PATH=${prepend}:$PATH`
|
||||||
}
|
}
|
||||||
let environmentPrefix = ''
|
|
||||||
|
|
||||||
if (environmentVariables && Object.entries(environmentVariables).length) {
|
let environmentPrefix = scriptEnv(environmentVariables)
|
||||||
const envBuffer: string[] = []
|
|
||||||
for (const [key, value] of Object.entries(environmentVariables)) {
|
|
||||||
if (
|
|
||||||
key.includes(`=`) ||
|
|
||||||
key.includes(`'`) ||
|
|
||||||
key.includes(`"`) ||
|
|
||||||
key.includes(`$`)
|
|
||||||
) {
|
|
||||||
throw new Error(
|
|
||||||
`environment key ${key} is invalid - the key must not contain =, $, ', or "`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
envBuffer.push(
|
|
||||||
`"${key}=${value
|
|
||||||
.replace(/\\/g, '\\\\')
|
|
||||||
.replace(/"/g, '\\"')
|
|
||||||
.replace(/\$/g, '\\$')}"`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
environmentPrefix = `env ${envBuffer.join(' ')} `
|
|
||||||
}
|
|
||||||
|
|
||||||
const content = `#!/bin/sh -l
|
const content = `#!/bin/sh -l
|
||||||
|
set -e
|
||||||
|
rm "$0" # remove script after running
|
||||||
${exportPath}
|
${exportPath}
|
||||||
cd ${workingDirectory} && \
|
cd ${workingDirectory} && \
|
||||||
exec ${environmentPrefix} ${entryPoint} ${
|
exec ${environmentPrefix} ${entryPoint} ${
|
||||||
@@ -147,9 +198,76 @@ 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 {
|
export function generateContainerName(image: string): string {
|
||||||
const nameWithTag = image.split('/').pop()
|
const nameWithTag = image.split('/').pop()
|
||||||
const name = nameWithTag?.split(':').at(0)
|
const name = nameWithTag?.split(':')[0]
|
||||||
|
|
||||||
if (!name) {
|
if (!name) {
|
||||||
throw new Error(`Image definition '${image}' is invalid`)
|
throw new Error(`Image definition '${image}' is invalid`)
|
||||||
@@ -158,6 +276,108 @@ export function generateContainerName(image: string): string {
|
|||||||
return name
|
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 {
|
export enum PodPhase {
|
||||||
PENDING = 'Pending',
|
PENDING = 'Pending',
|
||||||
RUNNING = 'Running',
|
RUNNING = 'Running',
|
||||||
@@ -166,3 +386,16 @@ export enum PodPhase {
|
|||||||
UNKNOWN = 'Unknown',
|
UNKNOWN = 'Unknown',
|
||||||
COMPLETED = 'Completed'
|
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(' '))
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { cleanupJob, prepareJob } from '../src/hooks'
|
|||||||
import { RunnerInstanceLabel } from '../src/hooks/constants'
|
import { RunnerInstanceLabel } from '../src/hooks/constants'
|
||||||
import { namespace } from '../src/k8s'
|
import { namespace } from '../src/k8s'
|
||||||
import { TestHelper } from './test-setup'
|
import { TestHelper } from './test-setup'
|
||||||
|
import { PrepareJobArgs } from 'hooklib'
|
||||||
|
|
||||||
let testHelper: TestHelper
|
let testHelper: TestHelper
|
||||||
|
|
||||||
@@ -14,7 +15,10 @@ describe('Cleanup Job', () => {
|
|||||||
const prepareJobOutputFilePath = testHelper.createFile(
|
const prepareJobOutputFilePath = testHelper.createFile(
|
||||||
'prepare-job-output.json'
|
'prepare-job-output.json'
|
||||||
)
|
)
|
||||||
await prepareJob(prepareJobData.args, prepareJobOutputFilePath)
|
await prepareJob(
|
||||||
|
prepareJobData.args as PrepareJobArgs,
|
||||||
|
prepareJobOutputFilePath
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
@@ -32,16 +36,12 @@ describe('Cleanup Job', () => {
|
|||||||
kc.loadFromDefault()
|
kc.loadFromDefault()
|
||||||
const k8sApi = kc.makeApiClient(k8s.CoreV1Api)
|
const k8sApi = kc.makeApiClient(k8s.CoreV1Api)
|
||||||
|
|
||||||
const podList = await k8sApi.listNamespacedPod(
|
const podList = await k8sApi.listNamespacedPod({
|
||||||
namespace(),
|
namespace: namespace(),
|
||||||
undefined,
|
labelSelector: new RunnerInstanceLabel().toString()
|
||||||
undefined,
|
})
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
new RunnerInstanceLabel().toString()
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(podList.body.items.length).toBe(0)
|
expect(podList.items.length).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should have no runner linked secrets', async () => {
|
it('should have no runner linked secrets', async () => {
|
||||||
@@ -51,15 +51,11 @@ describe('Cleanup Job', () => {
|
|||||||
kc.loadFromDefault()
|
kc.loadFromDefault()
|
||||||
const k8sApi = kc.makeApiClient(k8s.CoreV1Api)
|
const k8sApi = kc.makeApiClient(k8s.CoreV1Api)
|
||||||
|
|
||||||
const secretList = await k8sApi.listNamespacedSecret(
|
const secretList = await k8sApi.listNamespacedSecret({
|
||||||
namespace(),
|
namespace: namespace(),
|
||||||
undefined,
|
labelSelector: new RunnerInstanceLabel().toString()
|
||||||
undefined,
|
})
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
new RunnerInstanceLabel().toString()
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(secretList.body.items.length).toBe(0)
|
expect(secretList.items.length).toBe(0)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
runScriptStep
|
runScriptStep
|
||||||
} from '../src/hooks'
|
} from '../src/hooks'
|
||||||
import { TestHelper } from './test-setup'
|
import { TestHelper } from './test-setup'
|
||||||
|
import { RunContainerStepArgs, RunScriptStepArgs } from 'hooklib'
|
||||||
|
|
||||||
jest.useRealTimers()
|
jest.useRealTimers()
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ describe('e2e', () => {
|
|||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
await testHelper.cleanup()
|
await testHelper.cleanup()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should prepare job, run script step, run container step then cleanup without errors', async () => {
|
it('should prepare job, run script step, run container step then cleanup without errors', async () => {
|
||||||
await expect(
|
await expect(
|
||||||
prepareJob(prepareJobData.args, prepareJobOutputFilePath)
|
prepareJob(prepareJobData.args, prepareJobOutputFilePath)
|
||||||
@@ -36,13 +38,16 @@ describe('e2e', () => {
|
|||||||
const prepareJobOutputData = JSON.parse(prepareJobOutputJson.toString())
|
const prepareJobOutputData = JSON.parse(prepareJobOutputJson.toString())
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
runScriptStep(scriptStepData.args, prepareJobOutputData.state, null)
|
runScriptStep(
|
||||||
|
scriptStepData.args as RunScriptStepArgs,
|
||||||
|
prepareJobOutputData.state
|
||||||
|
)
|
||||||
).resolves.not.toThrow()
|
).resolves.not.toThrow()
|
||||||
|
|
||||||
const runContainerStepData = testHelper.getRunContainerStepDefinition()
|
const runContainerStepData = testHelper.getRunContainerStepDefinition()
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
runContainerStep(runContainerStepData.args)
|
runContainerStep(runContainerStepData.args as RunContainerStepArgs)
|
||||||
).resolves.not.toThrow()
|
).resolves.not.toThrow()
|
||||||
|
|
||||||
await expect(cleanupJob()).resolves.not.toThrow()
|
await expect(cleanupJob()).resolves.not.toThrow()
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import * as fs from 'fs'
|
import * as fs from 'fs'
|
||||||
import { containerPorts, POD_VOLUME_NAME } from '../src/k8s'
|
import { containerPorts } from '../src/k8s'
|
||||||
import {
|
import {
|
||||||
containerVolumes,
|
|
||||||
generateContainerName,
|
generateContainerName,
|
||||||
writeEntryPointScript
|
writeRunScript,
|
||||||
|
mergePodSpecWithOptions,
|
||||||
|
mergeContainerWithOptions,
|
||||||
|
readExtensionFromFile,
|
||||||
|
ENV_HOOK_TEMPLATE_PATH
|
||||||
} from '../src/k8s/utils'
|
} from '../src/k8s/utils'
|
||||||
|
import * as k8s from '@kubernetes/client-node'
|
||||||
import { TestHelper } from './test-setup'
|
import { TestHelper } from './test-setup'
|
||||||
|
|
||||||
let testHelper: TestHelper
|
let testHelper: TestHelper
|
||||||
@@ -22,91 +26,55 @@ describe('k8s utils', () => {
|
|||||||
|
|
||||||
it('should not throw', () => {
|
it('should not throw', () => {
|
||||||
expect(() =>
|
expect(() =>
|
||||||
writeEntryPointScript(
|
writeRunScript('/test', 'sh', ['-e', 'script.sh'], ['/prepend/path'], {
|
||||||
'/test',
|
|
||||||
'sh',
|
|
||||||
['-e', 'script.sh'],
|
|
||||||
['/prepend/path'],
|
|
||||||
{
|
|
||||||
SOME_ENV: 'SOME_VALUE'
|
SOME_ENV: 'SOME_VALUE'
|
||||||
}
|
})
|
||||||
)
|
|
||||||
).not.toThrow()
|
).not.toThrow()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should throw if RUNNER_TEMP is not set', () => {
|
it('should throw if RUNNER_TEMP is not set', () => {
|
||||||
delete process.env.RUNNER_TEMP
|
delete process.env.RUNNER_TEMP
|
||||||
expect(() =>
|
expect(() =>
|
||||||
writeEntryPointScript(
|
writeRunScript('/test', 'sh', ['-e', 'script.sh'], ['/prepend/path'], {
|
||||||
'/test',
|
|
||||||
'sh',
|
|
||||||
['-e', 'script.sh'],
|
|
||||||
['/prepend/path'],
|
|
||||||
{
|
|
||||||
SOME_ENV: 'SOME_VALUE'
|
SOME_ENV: 'SOME_VALUE'
|
||||||
}
|
})
|
||||||
)
|
|
||||||
).toThrow()
|
).toThrow()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should throw if environment variable name contains double quote', () => {
|
it('should throw if environment variable name contains double quote', () => {
|
||||||
expect(() =>
|
expect(() =>
|
||||||
writeEntryPointScript(
|
writeRunScript('/test', 'sh', ['-e', 'script.sh'], ['/prepend/path'], {
|
||||||
'/test',
|
|
||||||
'sh',
|
|
||||||
['-e', 'script.sh'],
|
|
||||||
['/prepend/path'],
|
|
||||||
{
|
|
||||||
'SOME"_ENV': 'SOME_VALUE'
|
'SOME"_ENV': 'SOME_VALUE'
|
||||||
}
|
})
|
||||||
)
|
|
||||||
).toThrow()
|
).toThrow()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should throw if environment variable name contains =', () => {
|
it('should throw if environment variable name contains =', () => {
|
||||||
expect(() =>
|
expect(() =>
|
||||||
writeEntryPointScript(
|
writeRunScript('/test', 'sh', ['-e', 'script.sh'], ['/prepend/path'], {
|
||||||
'/test',
|
|
||||||
'sh',
|
|
||||||
['-e', 'script.sh'],
|
|
||||||
['/prepend/path'],
|
|
||||||
{
|
|
||||||
'SOME=ENV': 'SOME_VALUE'
|
'SOME=ENV': 'SOME_VALUE'
|
||||||
}
|
})
|
||||||
)
|
|
||||||
).toThrow()
|
).toThrow()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should throw if environment variable name contains single quote', () => {
|
it('should throw if environment variable name contains single quote', () => {
|
||||||
expect(() =>
|
expect(() =>
|
||||||
writeEntryPointScript(
|
writeRunScript('/test', 'sh', ['-e', 'script.sh'], ['/prepend/path'], {
|
||||||
'/test',
|
|
||||||
'sh',
|
|
||||||
['-e', 'script.sh'],
|
|
||||||
['/prepend/path'],
|
|
||||||
{
|
|
||||||
"SOME'_ENV": 'SOME_VALUE'
|
"SOME'_ENV": 'SOME_VALUE'
|
||||||
}
|
})
|
||||||
)
|
|
||||||
).toThrow()
|
).toThrow()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should throw if environment variable name contains dollar', () => {
|
it('should throw if environment variable name contains dollar', () => {
|
||||||
expect(() =>
|
expect(() =>
|
||||||
writeEntryPointScript(
|
writeRunScript('/test', 'sh', ['-e', 'script.sh'], ['/prepend/path'], {
|
||||||
'/test',
|
|
||||||
'sh',
|
|
||||||
['-e', 'script.sh'],
|
|
||||||
['/prepend/path'],
|
|
||||||
{
|
|
||||||
SOME_$_ENV: 'SOME_VALUE'
|
SOME_$_ENV: 'SOME_VALUE'
|
||||||
}
|
})
|
||||||
)
|
|
||||||
).toThrow()
|
).toThrow()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should escape double quote, dollar and backslash in environment variable values', () => {
|
it('should escape double quote, dollar and backslash in environment variable values', () => {
|
||||||
const { runnerPath } = writeEntryPointScript(
|
const { runnerPath } = writeRunScript(
|
||||||
'/test',
|
'/test',
|
||||||
'sh',
|
'sh',
|
||||||
['-e', 'script.sh'],
|
['-e', 'script.sh'],
|
||||||
@@ -125,7 +93,7 @@ describe('k8s utils', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should return object with containerPath and runnerPath', () => {
|
it('should return object with containerPath and runnerPath', () => {
|
||||||
const { containerPath, runnerPath } = writeEntryPointScript(
|
const { containerPath, runnerPath } = writeRunScript(
|
||||||
'/test',
|
'/test',
|
||||||
'sh',
|
'sh',
|
||||||
['-e', 'script.sh'],
|
['-e', 'script.sh'],
|
||||||
@@ -140,7 +108,7 @@ describe('k8s utils', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should write entrypoint path and the file should exist', () => {
|
it('should write entrypoint path and the file should exist', () => {
|
||||||
const { runnerPath } = writeEntryPointScript(
|
const { runnerPath } = writeRunScript(
|
||||||
'/test',
|
'/test',
|
||||||
'sh',
|
'sh',
|
||||||
['-e', 'script.sh'],
|
['-e', 'script.sh'],
|
||||||
@@ -163,75 +131,6 @@ describe('k8s utils', () => {
|
|||||||
await testHelper.cleanup()
|
await testHelper.cleanup()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should throw if container action and GITHUB_WORKSPACE env is not set', () => {
|
|
||||||
delete process.env.GITHUB_WORKSPACE
|
|
||||||
expect(() => containerVolumes([], true, true)).toThrow()
|
|
||||||
expect(() => containerVolumes([], false, true)).toThrow()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should always have work mount', () => {
|
|
||||||
let volumes = containerVolumes([], true, true)
|
|
||||||
expect(volumes.find(e => e.mountPath === '/__w')).toBeTruthy()
|
|
||||||
volumes = containerVolumes([], true, false)
|
|
||||||
expect(volumes.find(e => e.mountPath === '/__w')).toBeTruthy()
|
|
||||||
volumes = containerVolumes([], false, true)
|
|
||||||
expect(volumes.find(e => e.mountPath === '/__w')).toBeTruthy()
|
|
||||||
volumes = containerVolumes([], false, false)
|
|
||||||
expect(volumes.find(e => e.mountPath === '/__w')).toBeTruthy()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should have container action volumes', () => {
|
|
||||||
let volumes = containerVolumes([], true, true)
|
|
||||||
let workspace = volumes.find(e => e.mountPath === '/github/workspace')
|
|
||||||
let fileCommands = volumes.find(
|
|
||||||
e => e.mountPath === '/github/file_commands'
|
|
||||||
)
|
|
||||||
expect(workspace).toBeTruthy()
|
|
||||||
expect(workspace?.subPath).toBe('repo/repo')
|
|
||||||
expect(fileCommands).toBeTruthy()
|
|
||||||
expect(fileCommands?.subPath).toBe('_temp/_runner_file_commands')
|
|
||||||
|
|
||||||
volumes = containerVolumes([], false, true)
|
|
||||||
workspace = volumes.find(e => e.mountPath === '/github/workspace')
|
|
||||||
fileCommands = volumes.find(e => e.mountPath === '/github/file_commands')
|
|
||||||
expect(workspace).toBeTruthy()
|
|
||||||
expect(workspace?.subPath).toBe('repo/repo')
|
|
||||||
expect(fileCommands).toBeTruthy()
|
|
||||||
expect(fileCommands?.subPath).toBe('_temp/_runner_file_commands')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should have externals, github home and github workflow mounts if job container', () => {
|
|
||||||
const volumes = containerVolumes()
|
|
||||||
expect(volumes.find(e => e.mountPath === '/__e')).toBeTruthy()
|
|
||||||
expect(volumes.find(e => e.mountPath === '/github/home')).toBeTruthy()
|
|
||||||
expect(volumes.find(e => e.mountPath === '/github/workflow')).toBeTruthy()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should throw if user volume source volume path is not in workspace', () => {
|
|
||||||
expect(() =>
|
|
||||||
containerVolumes(
|
|
||||||
[
|
|
||||||
{
|
|
||||||
sourceVolumePath: '/outside/of/workdir'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
true,
|
|
||||||
false
|
|
||||||
)
|
|
||||||
).toThrow()
|
|
||||||
})
|
|
||||||
|
|
||||||
it(`all volumes should have name ${POD_VOLUME_NAME}`, () => {
|
|
||||||
let volumes = containerVolumes([], true, true)
|
|
||||||
expect(volumes.every(e => e.name === POD_VOLUME_NAME)).toBeTruthy()
|
|
||||||
volumes = containerVolumes([], true, false)
|
|
||||||
expect(volumes.every(e => e.name === POD_VOLUME_NAME)).toBeTruthy()
|
|
||||||
volumes = containerVolumes([], false, true)
|
|
||||||
expect(volumes.every(e => e.name === POD_VOLUME_NAME)).toBeTruthy()
|
|
||||||
volumes = containerVolumes([], false, false)
|
|
||||||
expect(volumes.every(e => e.name === POD_VOLUME_NAME)).toBeTruthy()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should parse container ports', () => {
|
it('should parse container ports', () => {
|
||||||
const tt = [
|
const tt = [
|
||||||
{
|
{
|
||||||
@@ -328,4 +227,183 @@ describe('k8s utils', () => {
|
|||||||
expect(() => generateContainerName(':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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ import * as path from 'path'
|
|||||||
import { cleanupJob } from '../src/hooks'
|
import { cleanupJob } from '../src/hooks'
|
||||||
import { createContainerSpec, prepareJob } from '../src/hooks/prepare-job'
|
import { createContainerSpec, prepareJob } from '../src/hooks/prepare-job'
|
||||||
import { TestHelper } from './test-setup'
|
import { TestHelper } from './test-setup'
|
||||||
import { generateContainerName } from '../src/k8s/utils'
|
import { ENV_HOOK_TEMPLATE_PATH, generateContainerName } from '../src/k8s/utils'
|
||||||
|
import { execPodStep, getPodByName } from '../src/k8s'
|
||||||
import { V1Container } from '@kubernetes/client-node'
|
import { V1Container } from '@kubernetes/client-node'
|
||||||
|
import { JOB_CONTAINER_NAME } from '../src/hooks/constants'
|
||||||
|
|
||||||
jest.useRealTimers()
|
jest.useRealTimers()
|
||||||
|
|
||||||
@@ -39,32 +41,82 @@ describe('Prepare job', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should prepare job with absolute path for userVolumeMount', async () => {
|
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 = [
|
prepareJobData.args.container.userMountVolumes = [
|
||||||
{
|
{
|
||||||
sourceVolumePath: path.join(
|
sourceVolumePath: userVolumeMount,
|
||||||
process.env.GITHUB_WORKSPACE as string,
|
targetVolumePath: '/__w/myvolume',
|
||||||
'/myvolume'
|
|
||||||
),
|
|
||||||
targetVolumePath: '/volume_mount',
|
|
||||||
readOnly: false
|
readOnly: false
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
await expect(
|
await expect(
|
||||||
prepareJob(prepareJobData.args, prepareJobOutputFilePath)
|
prepareJob(prepareJobData.args, prepareJobOutputFilePath)
|
||||||
).resolves.not.toThrow()
|
).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 () => {
|
it('should prepare job with envs CI and GITHUB_ACTIONS', async () => {
|
||||||
prepareJobData.args.container.userMountVolumes = [
|
await prepareJob(prepareJobData.args, prepareJobOutputFilePath)
|
||||||
{
|
|
||||||
sourceVolumePath: '/somewhere/not/in/gh-workspace',
|
const content = JSON.parse(
|
||||||
targetVolumePath: '/containermount',
|
fs.readFileSync(prepareJobOutputFilePath).toString()
|
||||||
readOnly: false
|
)
|
||||||
|
|
||||||
|
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 expect(
|
await prepareJob(prepareJobData.args, prepareJobOutputFilePath)
|
||||||
prepareJob(prepareJobData.args, prepareJobOutputFilePath)
|
|
||||||
).rejects.toThrow()
|
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 () => {
|
it('should not run prepare job without the job container', async () => {
|
||||||
@@ -83,6 +135,85 @@ describe('Prepare job', () => {
|
|||||||
expect(services[0].args).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, []])(
|
test.each([undefined, null, []])(
|
||||||
'should not throw exception when portMapping=%p',
|
'should not throw exception when portMapping=%p',
|
||||||
async pm => {
|
async pm => {
|
||||||
@@ -96,4 +227,20 @@ describe('Prepare job', () => {
|
|||||||
expect(() => content.context.services[0].image).not.toThrow()
|
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'
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,16 +1,25 @@
|
|||||||
import { runContainerStep } from '../src/hooks'
|
import { prepareJob, runContainerStep } from '../src/hooks'
|
||||||
import { TestHelper } from './test-setup'
|
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()
|
jest.useRealTimers()
|
||||||
|
|
||||||
let testHelper: TestHelper
|
let testHelper: TestHelper
|
||||||
|
|
||||||
let runContainerStepData: any
|
let runContainerStepData: any
|
||||||
|
let prepareJobData: any
|
||||||
|
let prepareJobOutputFilePath: string
|
||||||
|
|
||||||
describe('Run container step', () => {
|
describe('Run container step', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
testHelper = new TestHelper()
|
testHelper = new TestHelper()
|
||||||
await testHelper.initialize()
|
await testHelper.initialize()
|
||||||
|
prepareJobData = testHelper.getPrepareJobDefinition()
|
||||||
|
prepareJobOutputFilePath = testHelper.createFile('prepare-job-output.json')
|
||||||
|
await prepareJob(prepareJobData.args, prepareJobOutputFilePath)
|
||||||
runContainerStepData = testHelper.getRunContainerStepDefinition()
|
runContainerStepData = testHelper.getRunContainerStepDefinition()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -18,14 +27,41 @@ describe('Run container step', () => {
|
|||||||
await testHelper.cleanup()
|
await testHelper.cleanup()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should not throw', async () => {
|
it('should run pod with extensions applied', async () => {
|
||||||
const exitCode = await runContainerStep(runContainerStepData.args)
|
const extension = {
|
||||||
expect(exitCode).toBe(0)
|
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 () => {
|
let filePath = testHelper.createFile()
|
||||||
runContainerStepData.args.workingDirectory = '/foo/bar'
|
fs.writeFileSync(filePath, yaml.dump(extension))
|
||||||
await expect(runContainerStep(runContainerStepData.args)).rejects.toThrow()
|
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 () => {
|
it('should shold have env variables available', async () => {
|
||||||
@@ -38,4 +74,15 @@ describe('Run container step', () => {
|
|||||||
runContainerStep(runContainerStepData.args)
|
runContainerStep(runContainerStepData.args)
|
||||||
).resolves.not.toThrow()
|
).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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import * as fs from 'fs'
|
import * as fs from 'fs'
|
||||||
import { cleanupJob, prepareJob, runScriptStep } from '../src/hooks'
|
import { cleanupJob, prepareJob, runScriptStep } from '../src/hooks'
|
||||||
import { TestHelper } from './test-setup'
|
import { TestHelper } from './test-setup'
|
||||||
|
import { PrepareJobArgs, RunScriptStepArgs } from 'hooklib'
|
||||||
|
|
||||||
jest.useRealTimers()
|
jest.useRealTimers()
|
||||||
|
|
||||||
@@ -8,7 +9,9 @@ let testHelper: TestHelper
|
|||||||
|
|
||||||
let prepareJobOutputData: any
|
let prepareJobOutputData: any
|
||||||
|
|
||||||
let runScriptStepDefinition
|
let runScriptStepDefinition: {
|
||||||
|
args: RunScriptStepArgs
|
||||||
|
}
|
||||||
|
|
||||||
describe('Run script step', () => {
|
describe('Run script step', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@@ -19,9 +22,14 @@ describe('Run script step', () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const prepareJobData = testHelper.getPrepareJobDefinition()
|
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)
|
const outputContent = fs.readFileSync(prepareJobOutputFilePath)
|
||||||
prepareJobOutputData = JSON.parse(outputContent.toString())
|
prepareJobOutputData = JSON.parse(outputContent.toString())
|
||||||
})
|
})
|
||||||
@@ -37,22 +45,14 @@ describe('Run script step', () => {
|
|||||||
|
|
||||||
it('should not throw an exception', async () => {
|
it('should not throw an exception', async () => {
|
||||||
await expect(
|
await expect(
|
||||||
runScriptStep(
|
runScriptStep(runScriptStepDefinition.args, prepareJobOutputData.state)
|
||||||
runScriptStepDefinition.args,
|
|
||||||
prepareJobOutputData.state,
|
|
||||||
null
|
|
||||||
)
|
|
||||||
).resolves.not.toThrow()
|
).resolves.not.toThrow()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should fail if the working directory does not exist', async () => {
|
it('should fail if the working directory does not exist', async () => {
|
||||||
runScriptStepDefinition.args.workingDirectory = '/foo/bar'
|
runScriptStepDefinition.args.workingDirectory = '/foo/bar'
|
||||||
await expect(
|
await expect(
|
||||||
runScriptStep(
|
runScriptStep(runScriptStepDefinition.args, prepareJobOutputData.state)
|
||||||
runScriptStepDefinition.args,
|
|
||||||
prepareJobOutputData.state,
|
|
||||||
null
|
|
||||||
)
|
|
||||||
).rejects.toThrow()
|
).rejects.toThrow()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -64,16 +64,12 @@ describe('Run script step', () => {
|
|||||||
"'if [[ -z $NODE_ENV ]]; then exit 1; fi'"
|
"'if [[ -z $NODE_ENV ]]; then exit 1; fi'"
|
||||||
]
|
]
|
||||||
await expect(
|
await expect(
|
||||||
runScriptStep(
|
runScriptStep(runScriptStepDefinition.args, prepareJobOutputData.state)
|
||||||
runScriptStepDefinition.args,
|
|
||||||
prepareJobOutputData.state,
|
|
||||||
null
|
|
||||||
)
|
|
||||||
).resolves.not.toThrow()
|
).resolves.not.toThrow()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should have path variable changed in container with prepend path string', async () => {
|
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.entryPoint = '/bin/bash'
|
||||||
runScriptStepDefinition.args.entryPointArgs = [
|
runScriptStepDefinition.args.entryPointArgs = [
|
||||||
'-c',
|
'-c',
|
||||||
@@ -81,11 +77,7 @@ describe('Run script step', () => {
|
|||||||
]
|
]
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
runScriptStep(
|
runScriptStep(runScriptStepDefinition.args, prepareJobOutputData.state)
|
||||||
runScriptStepDefinition.args,
|
|
||||||
prepareJobOutputData.state,
|
|
||||||
null
|
|
||||||
)
|
|
||||||
).resolves.not.toThrow()
|
).resolves.not.toThrow()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -103,11 +95,7 @@ describe('Run script step', () => {
|
|||||||
]
|
]
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
runScriptStep(
|
runScriptStep(runScriptStepDefinition.args, prepareJobOutputData.state)
|
||||||
runScriptStepDefinition.args,
|
|
||||||
prepareJobOutputData.state,
|
|
||||||
null
|
|
||||||
)
|
|
||||||
).resolves.not.toThrow()
|
).resolves.not.toThrow()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -122,11 +110,7 @@ describe('Run script step', () => {
|
|||||||
]
|
]
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
runScriptStep(
|
runScriptStep(runScriptStepDefinition.args, prepareJobOutputData.state)
|
||||||
runScriptStepDefinition.args,
|
|
||||||
prepareJobOutputData.state,
|
|
||||||
null
|
|
||||||
)
|
|
||||||
).resolves.not.toThrow()
|
).resolves.not.toThrow()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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, () => {})
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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, () => {})
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -14,25 +14,35 @@ const k8sStorageApi = kc.makeApiClient(k8s.StorageV1Api)
|
|||||||
export class TestHelper {
|
export class TestHelper {
|
||||||
private tempDirPath: string
|
private tempDirPath: string
|
||||||
private podName: string
|
private podName: string
|
||||||
|
private runnerWorkdir: string
|
||||||
|
private runnerTemp: string
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.tempDirPath = `${__dirname}/_temp/runner`
|
this.tempDirPath = `${__dirname}/_temp/runner`
|
||||||
|
this.runnerWorkdir = `${this.tempDirPath}/_work`
|
||||||
|
this.runnerTemp = `${this.tempDirPath}/_work/_temp`
|
||||||
this.podName = uuidv4().replace(/-/g, '')
|
this.podName = uuidv4().replace(/-/g, '')
|
||||||
}
|
}
|
||||||
|
|
||||||
public async initialize(): Promise<void> {
|
async initialize(): Promise<void> {
|
||||||
process.env['ACTIONS_RUNNER_POD_NAME'] = `${this.podName}`
|
process.env['ACTIONS_RUNNER_POD_NAME'] = `${this.podName}`
|
||||||
process.env['RUNNER_WORKSPACE'] = `${this.tempDirPath}/_work/repo`
|
process.env['RUNNER_WORKSPACE'] = `${this.runnerWorkdir}/repo`
|
||||||
process.env['RUNNER_TEMP'] = `${this.tempDirPath}/_work/_temp`
|
process.env['RUNNER_TEMP'] = `${this.runnerTemp}`
|
||||||
process.env['GITHUB_WORKSPACE'] = `${this.tempDirPath}/_work/repo/repo`
|
process.env['GITHUB_WORKSPACE'] = `${this.runnerWorkdir}/repo/repo`
|
||||||
process.env['ACTIONS_RUNNER_KUBERNETES_NAMESPACE'] = 'default'
|
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(`${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(
|
fs.copyFileSync(
|
||||||
path.resolve(`${__dirname}/../../../examples/example-script.sh`),
|
path.resolve(`${__dirname}/../../../examples/example-script.sh`),
|
||||||
`${process.env.RUNNER_TEMP}/example-script.sh`
|
`${this.runnerTemp}/example-script.sh`
|
||||||
)
|
)
|
||||||
|
|
||||||
await this.cleanupK8sResources()
|
await this.cleanupK8sResources()
|
||||||
@@ -44,52 +54,80 @@ export class TestHelper {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async cleanup(): Promise<void> {
|
async cleanup(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await this.cleanupK8sResources()
|
await this.cleanupK8sResources()
|
||||||
fs.rmSync(this.tempDirPath, { recursive: true })
|
fs.rmSync(this.tempDirPath, { recursive: true })
|
||||||
} catch {}
|
} catch {
|
||||||
|
// Ignore errors during cleanup
|
||||||
}
|
}
|
||||||
public async cleanupK8sResources() {
|
|
||||||
await k8sApi
|
|
||||||
.deleteNamespacedPersistentVolumeClaim(
|
|
||||||
`${this.podName}-work`,
|
|
||||||
'default',
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
0
|
|
||||||
)
|
|
||||||
.catch(e => {})
|
|
||||||
await k8sApi.deletePersistentVolume(`${this.podName}-pv`).catch(e => {})
|
|
||||||
await k8sStorageApi.deleteStorageClass('local-storage').catch(e => {})
|
|
||||||
await k8sApi
|
|
||||||
.deleteNamespacedPod(this.podName, 'default', undefined, undefined, 0)
|
|
||||||
.catch(e => {})
|
|
||||||
await k8sApi
|
|
||||||
.deleteNamespacedPod(
|
|
||||||
`${this.podName}-workflow`,
|
|
||||||
'default',
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
0
|
|
||||||
)
|
|
||||||
.catch(e => {})
|
|
||||||
}
|
}
|
||||||
public createFile(fileName?: string): string {
|
|
||||||
|
async cleanupK8sResources(): Promise<void> {
|
||||||
|
await k8sApi
|
||||||
|
.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
|
||||||
|
.deletePersistentVolume({ name: `${this.podName}-pv` })
|
||||||
|
.catch((e: k8s.ApiException<any>) => {
|
||||||
|
if (e.code !== 404) {
|
||||||
|
console.error(JSON.stringify(e))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await k8sApi
|
||||||
|
.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))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
createFile(fileName?: string): string {
|
||||||
const filePath = `${this.tempDirPath}/${fileName || uuidv4()}`
|
const filePath = `${this.tempDirPath}/${fileName || uuidv4()}`
|
||||||
fs.writeFileSync(filePath, '')
|
fs.writeFileSync(filePath, '')
|
||||||
return filePath
|
return filePath
|
||||||
}
|
}
|
||||||
|
|
||||||
public removeFile(fileName: string): void {
|
removeFile(fileName: string): void {
|
||||||
const filePath = `${this.tempDirPath}/${fileName}`
|
const filePath = `${this.tempDirPath}/${fileName}`
|
||||||
fs.rmSync(filePath)
|
fs.rmSync(filePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
public async createTestJobPod() {
|
async createTestJobPod(): Promise<void> {
|
||||||
const container = {
|
const container = {
|
||||||
name: 'nginx',
|
name: 'runner',
|
||||||
image: 'nginx:latest',
|
image: 'ghcr.io/actions/actions-runner:latest',
|
||||||
imagePullPolicy: 'IfNotPresent'
|
imagePullPolicy: 'IfNotPresent'
|
||||||
} as k8s.V1Container
|
} as k8s.V1Container
|
||||||
|
|
||||||
@@ -99,28 +137,35 @@ export class TestHelper {
|
|||||||
},
|
},
|
||||||
spec: {
|
spec: {
|
||||||
restartPolicy: 'Never',
|
restartPolicy: 'Never',
|
||||||
containers: [container]
|
containers: [container],
|
||||||
|
securityContext: {
|
||||||
|
runAsUser: 1001,
|
||||||
|
runAsGroup: 1001,
|
||||||
|
fsGroup: 1001
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} as k8s.V1Pod
|
} as k8s.V1Pod
|
||||||
await k8sApi.createNamespacedPod('default', pod)
|
await k8sApi.createNamespacedPod({ namespace: 'default', body: pod })
|
||||||
}
|
}
|
||||||
|
|
||||||
public async createTestVolume() {
|
async createTestVolume(): Promise<void> {
|
||||||
var sc: k8s.V1StorageClass = {
|
const storageClassName = `${this.podName}-storage`
|
||||||
|
|
||||||
|
const sc: k8s.V1StorageClass = {
|
||||||
metadata: {
|
metadata: {
|
||||||
name: 'local-storage'
|
name: storageClassName
|
||||||
},
|
},
|
||||||
provisioner: 'kubernetes.io/no-provisioner',
|
provisioner: 'kubernetes.io/no-provisioner',
|
||||||
volumeBindingMode: 'Immediate'
|
volumeBindingMode: 'Immediate'
|
||||||
}
|
}
|
||||||
await k8sStorageApi.createStorageClass(sc)
|
await k8sStorageApi.createStorageClass({ body: sc })
|
||||||
|
|
||||||
var volume: k8s.V1PersistentVolume = {
|
const volume: k8s.V1PersistentVolume = {
|
||||||
metadata: {
|
metadata: {
|
||||||
name: `${this.podName}-pv`
|
name: `${this.podName}-pv`
|
||||||
},
|
},
|
||||||
spec: {
|
spec: {
|
||||||
storageClassName: 'local-storage',
|
storageClassName,
|
||||||
capacity: {
|
capacity: {
|
||||||
storage: '2Gi'
|
storage: '2Gi'
|
||||||
},
|
},
|
||||||
@@ -131,15 +176,16 @@ export class TestHelper {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await k8sApi.createPersistentVolume(volume)
|
await k8sApi.createPersistentVolume({ body: volume })
|
||||||
var volumeClaim: k8s.V1PersistentVolumeClaim = {
|
|
||||||
|
const volumeClaim: k8s.V1PersistentVolumeClaim = {
|
||||||
metadata: {
|
metadata: {
|
||||||
name: `${this.podName}-work`
|
name: `${this.podName}-work`
|
||||||
},
|
},
|
||||||
spec: {
|
spec: {
|
||||||
accessModes: ['ReadWriteOnce'],
|
accessModes: ['ReadWriteOnce'],
|
||||||
volumeMode: 'Filesystem',
|
volumeMode: 'Filesystem',
|
||||||
storageClassName: 'local-storage',
|
storageClassName,
|
||||||
volumeName: `${this.podName}-pv`,
|
volumeName: `${this.podName}-pv`,
|
||||||
resources: {
|
resources: {
|
||||||
requests: {
|
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(
|
const prepareJob = JSON.parse(
|
||||||
fs.readFileSync(
|
fs.readFileSync(
|
||||||
path.resolve(__dirname + '/../../../examples/prepare-job.json'),
|
path.resolve(__dirname + '/../../../examples/prepare-job.json'),
|
||||||
@@ -168,7 +217,7 @@ export class TestHelper {
|
|||||||
return prepareJob
|
return prepareJob
|
||||||
}
|
}
|
||||||
|
|
||||||
public getRunScriptStepDefinition(): HookData {
|
getRunScriptStepDefinition(): HookData {
|
||||||
const runScriptStep = JSON.parse(
|
const runScriptStep = JSON.parse(
|
||||||
fs.readFileSync(
|
fs.readFileSync(
|
||||||
path.resolve(__dirname + '/../../../examples/run-script-step.json'),
|
path.resolve(__dirname + '/../../../examples/run-script-step.json'),
|
||||||
@@ -180,7 +229,7 @@ export class TestHelper {
|
|||||||
return runScriptStep
|
return runScriptStep
|
||||||
}
|
}
|
||||||
|
|
||||||
public getRunContainerStepDefinition(): HookData {
|
getRunContainerStepDefinition(): HookData {
|
||||||
const runContainerStep = JSON.parse(
|
const runContainerStep = JSON.parse(
|
||||||
fs.readFileSync(
|
fs.readFileSync(
|
||||||
path.resolve(__dirname + '/../../../examples/run-container-step.json'),
|
path.resolve(__dirname + '/../../../examples/run-container-step.json'),
|
||||||
@@ -194,3 +243,65 @@ export class TestHelper {
|
|||||||
return runContainerStep
|
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'
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
"outDir": "./lib",
|
"outDir": "./lib",
|
||||||
"rootDir": "./src"
|
"rootDir": "./src"
|
||||||
},
|
},
|
||||||
|
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
|
||||||
"include": [
|
"include": [
|
||||||
"./src"
|
"src/**/*",
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"allowJs": true
|
||||||
|
},
|
||||||
|
"extends": "./tsconfig.json"
|
||||||
|
}
|
||||||
+21
-2
@@ -1,6 +1,25 @@
|
|||||||
<!-- ## Features -->
|
<!-- ## Features -->
|
||||||
|
|
||||||
## Bugs
|
## Bugs
|
||||||
|
|
||||||
- Handle `$` symbols in environment variable names and values in k8s [#74]
|
- 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>
|
||||||
|
|||||||
Reference in New Issue
Block a user