├── .eslintrc.js ├── .github └── workflows │ ├── ci.yml │ ├── dispatched_example.yml │ ├── link_check.yml │ ├── org_example.yml │ ├── repo_example.yml │ └── tear_down_runners.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── action.yml ├── dist └── index.js ├── git-hooks └── pre-commit ├── package-lock.json ├── package.json ├── src ├── constants.ts ├── generated │ └── inputs-outputs.ts ├── get-runners.ts ├── index.ts ├── install-runner.ts ├── process-inputs.ts ├── types │ ├── kube-executor.ts │ ├── runner-location.ts │ └── types.ts ├── util │ ├── exec.ts │ └── util.ts └── wait-for-pods.ts ├── tsconfig.json └── webpack.config.js /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | "@redhat-actions/eslint-config", 4 | ], 5 | }; 6 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI checks 2 | on: [ push, pull_request_target, workflow_dispatch ] 3 | 4 | jobs: 5 | lint: 6 | name: Run ESLint 7 | runs-on: ubuntu-20.04 8 | 9 | steps: 10 | - uses: actions/checkout@v2 11 | - run: npm ci 12 | - run: npm run lint 13 | 14 | check-dist: 15 | name: Check Distribution 16 | runs-on: ubuntu-20.04 17 | env: 18 | BUNDLE_FILE: "dist/index.js" 19 | BUNDLE_COMMAND: "npm run bundle" 20 | steps: 21 | - uses: actions/checkout@v2 22 | 23 | - name: Install 24 | run: npm ci 25 | 26 | - name: Verify Latest Bundle 27 | uses: redhat-actions/common/bundle-verifier@v1 28 | with: 29 | bundle_file: ${{ env.BUNDLE_FILE }} 30 | bundle_command: ${{ env.BUNDLE_COMMAND }} 31 | 32 | check-inputs-outputs: 33 | name: Check Input and Output enums 34 | runs-on: ubuntu-20.04 35 | env: 36 | IO_FILE: ./src/generated/inputs-outputs.ts 37 | steps: 38 | - uses: actions/checkout@v2 39 | 40 | - name: Install dependencies 41 | run: npm ci 42 | 43 | - name: Verify Input and Output enums 44 | uses: redhat-actions/common/action-io-generator@v1 45 | with: 46 | io_file: ${{ env.IO_FILE }} 47 | -------------------------------------------------------------------------------- /.github/workflows/dispatched_example.yml: -------------------------------------------------------------------------------- 1 | name: Self-hosted dispatched workflow 2 | on: 3 | workflow_dispatch: 4 | repository_dispatch: 5 | types: [ runner_ready ] 6 | 7 | jobs: 8 | test-selfhosted: 9 | runs-on: [ self-hosted ] 10 | 11 | steps: 12 | - run: "hostname" 13 | - run: "ls -lA" 14 | -------------------------------------------------------------------------------- /.github/workflows/link_check.yml: -------------------------------------------------------------------------------- 1 | name: Link checker 2 | on: 3 | push: 4 | paths: 5 | - '**.md' 6 | pull_request: 7 | paths: 8 | -'**.md' 9 | 10 | jobs: 11 | markdown-link-check: 12 | name: Check links in markdown 13 | runs-on: ubuntu-20.04 14 | steps: 15 | - uses: actions/checkout@v2 16 | - uses: gaurav-nelson/github-action-markdown-link-check@v1 17 | with: 18 | use-verbose-mode: true 19 | -------------------------------------------------------------------------------- /.github/workflows/org_example.yml: -------------------------------------------------------------------------------- 1 | name: Install into redhat-actions 2 | on: 3 | push: 4 | workflow_dispatch: 5 | schedule: 6 | # nightly 10pm. note this workflow cleans up after itself 7 | - cron: "0 22 * * *" 8 | 9 | jobs: 10 | install-org-runner: 11 | runs-on: ubuntu-20.04 12 | name: Install runner into organization 13 | outputs: 14 | helm_release_name: ${{ steps.install-runners.outputs.helm_release_name }} 15 | 16 | steps: 17 | - name: Checkout action 18 | uses: actions/checkout@v2 19 | 20 | # Log into our K8s (openshift) cluster 21 | - uses: redhat-actions/oc-login@v1 22 | with: 23 | openshift_server_url: ${{ secrets.OPENSHIFT_SERVER }} 24 | openshift_token: ${{ secrets.OPENSHIFT_TOKEN }} 25 | namespace: ${{ secrets.OPENSHIFT_NAMESPACE }} 26 | insecure_skip_tls_verify: true 27 | 28 | # Install self- 29 | - name: Install self hosted runner into org 30 | id: install-runners 31 | # uses: redhat-actions/openshift-actions-runner-installer@v1 32 | # Test the checked-out version of this runner - a user would need the above 'uses'. 33 | uses: ./ 34 | with: 35 | # This token has 'repo' and 'admin:org' permissions 36 | github_pat: ${{ secrets.ORG_TOKEN }} 37 | 38 | # This runner will be added to the "redhat-actions" organization. 39 | runner_location: redhat-actions 40 | 41 | # Use this container image for the runner. 42 | runner_image: quay.io/redhat-github-actions/java-runner-11 43 | 44 | # Use this tag for the runner_image 45 | runner_tag: v1 46 | 47 | # Give the runner these labels (which are required by the workflow below) 48 | runner_labels: openshift, java 49 | 50 | # Create 2 replicas so we can run jobs in parallel 51 | runner_replicas: 2 52 | 53 | # Instruct the helm chart to use a custom secret name, 54 | # so it doesn't conflict with the secret the repo example uses, 55 | # and inject a custom environment variable into the containers. 56 | helm_extra_args: | 57 | --set-string secretName=github-org-pat 58 | --set runnerEnv[0].name="MY_ENV_VAR" --set runnerEnv[0].value="my_env_value" 59 | 60 | # Refer to the helm chart https://github.com/redhat-actions/openshift-actions-runner-chart 61 | # for values you can override. 62 | 63 | - name: Echo outputs 64 | shell: bash 65 | run: | 66 | echo "${{ toJSON(steps.install-runners.outputs) }}" 67 | 68 | test-org-selfhosted: 69 | name: Self Hosted Quarkus Build and Test 70 | runs-on: [ self-hosted, java ] 71 | needs: install-org-runner 72 | defaults: 73 | run: 74 | working-directory: getting-started 75 | 76 | env: 77 | WORKDIR: getting-started 78 | 79 | steps: 80 | - uses: actions/checkout@v2 81 | with: 82 | repository: redhat-actions/quarkus-quickstarts 83 | 84 | - run: java --version 85 | 86 | # https://github.com/redhat-actions/quarkus-quickstarts/tree/master/getting-started#readme 87 | 88 | # Build, test, and upload our executable jars. 89 | - run: ./mvnw install -ntp 90 | working-directory: ${{ env.WORKDIR }} 91 | 92 | - run: ./mvnw test 93 | working-directory: ${{ env.WORKDIR }} 94 | 95 | - uses: actions/upload-artifact@v2 96 | with: 97 | name: app-jar-files.zip 98 | path: ${{ env.WORKDIR }}/target/*.jar 99 | 100 | teardown-org-runner: 101 | name: Tear down self-hosted runners 102 | runs-on: ubuntu-20.04 103 | needs: [ install-org-runner, test-org-selfhosted ] 104 | if: needs.install-org-runner.outputs.helm_release_name != '' 105 | 106 | steps: 107 | - uses: redhat-actions/oc-login@v1 108 | with: 109 | openshift_server_url: ${{ secrets.OPENSHIFT_SERVER }} 110 | openshift_token: ${{ secrets.OPENSHIFT_TOKEN }} 111 | namespace: ${{ secrets.OPENSHIFT_NAMESPACE }} 112 | insecure_skip_tls_verify: true 113 | 114 | - run: helm ls 115 | 116 | - name: Clean up self-hosted runners 117 | run: helm uninstall ${{ needs.install-org-runner.outputs.helm_release_name }} 118 | -------------------------------------------------------------------------------- /.github/workflows/repo_example.yml: -------------------------------------------------------------------------------- 1 | name: Install into repository 2 | on: 3 | push: 4 | workflow_dispatch: 5 | schedule: 6 | # nightly 10pm and the teardown workflow runs just after 7 | - cron: "0 22 * * *" 8 | 9 | jobs: 10 | install-repo-runner: 11 | runs-on: ubuntu-20.04 12 | name: Install runner into this repository 13 | strategy: 14 | matrix: 15 | runner_tag: [ v1, latest ] 16 | fail-fast: false 17 | steps: 18 | - name: Checkout action 19 | uses: actions/checkout@v2 20 | 21 | - uses: redhat-actions/oc-login@v1 22 | with: 23 | openshift_server_url: ${{ secrets.OPENSHIFT_SERVER }} 24 | openshift_token: ${{ secrets.OPENSHIFT_TOKEN }} 25 | namespace: ${{ secrets.OPENSHIFT_NAMESPACE }} 26 | insecure_skip_tls_verify: true 27 | 28 | - name: Install self hosted runner into repository 29 | id: install-runners 30 | # uses: redhat-actions/openshift-actions-runner-installer@v1 31 | # Test the checked-out version of this runner - a user would need the above 'uses'. 32 | uses: ./ 33 | with: 34 | # This token has 'repo' permissions 35 | github_pat: ${{ secrets.REPO_TOKEN }} 36 | 37 | helm_release_name: node-${{ matrix.runner_tag }}-runner 38 | 39 | # Give the runner these two labels (which are required by the workflow below) 40 | runner_labels: node 41 | 42 | # Use this container image for the runner. 43 | runner_image: quay.io/redhat-github-actions/node-runner-14 44 | 45 | # Use this tag for the runner_image 46 | runner_tag: ${{ matrix.runner_tag }} 47 | 48 | helm_extra_args: | 49 | --set-string secretName=github-pat-repo-${{ matrix.runner_tag }} 50 | 51 | # Create 2 replicas so we can run jobs in parallel 52 | runner_replicas: 2 53 | 54 | - name: Echo outputs 55 | shell: bash 56 | run: | 57 | echo "${{ toJSON(steps.install-runners.outputs) }}" 58 | 59 | dispatch: 60 | name: Dispatch another workflow 61 | runs-on: ubuntu-20.04 62 | needs: install-repo-runner 63 | 64 | steps: 65 | - uses: peter-evans/repository-dispatch@v1.1.3 66 | with: 67 | token: ${{ secrets.REPO_TOKEN }} 68 | # See ./dispatched_example.yml for the type to use. 69 | event-type: runner_ready 70 | 71 | test-selfhosted: 72 | name: Self Hosted Node Workflow 73 | runs-on: [ self-hosted, node ] 74 | needs: install-repo-runner 75 | 76 | steps: 77 | - run: node --version 78 | - run: npm --version 79 | -------------------------------------------------------------------------------- /.github/workflows/tear_down_runners.yml: -------------------------------------------------------------------------------- 1 | name: Tear down repository runners 2 | on: 3 | workflow_dispatch: 4 | repository_dispatch: 5 | types: [ teardown_runners ] 6 | schedule: 7 | # nightly 1030pm since the repo_example runs at 10 8 | - cron: "0 22 30 * *" 9 | 10 | jobs: 11 | tear-down-runners: 12 | name: Tear down repository runners 13 | runs-on: ubuntu-20.04 14 | env: 15 | HELM_RELEASE_NAME: "node-runner" 16 | 17 | steps: 18 | - uses: redhat-actions/oc-login@v1 19 | with: 20 | openshift_server_url: ${{ secrets.OPENSHIFT_SERVER }} 21 | openshift_token: ${{ secrets.OPENSHIFT_TOKEN }} 22 | namespace: ${{ secrets.OPENSHIFT_NAMESPACE }} 23 | insecure_skip_tls_verify: true 24 | 25 | - run: helm ls 26 | 27 | # If we were running in the same workflow as the self-hosted runner installation, 28 | # we could use ${{ .outputs.helm_release_name }} to get the release name 29 | - run: | 30 | if helm status ${{ env.HELM_RELEASE_NAME }}; then 31 | echo "RELEASE_EXISTS=true" >> $GITHUB_ENV 32 | else 33 | echo "Release ${{ env.HELM_RELEASE_NAME }} not found" 34 | fi 35 | 36 | - run: helm uninstall ${{ env.HELM_RELEASE_NAME }} 37 | if: env.RELEASE_EXISTS == 'true' 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | out/ 2 | *.tsbuildinfo 3 | lib/ 4 | node_modules/ 5 | *.js.map 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # openshift-actions-runner-installer Changelog 2 | 3 | ## v1.1 4 | - Update action to run on Node16. https://github.blog/changelog/2022-05-20-actions-can-now-run-in-a-node-js-16-runtime/ 5 | 6 | ## v1.0 7 | Initial marketplace release 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Red Hat. All rights reserved. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OpenShift Actions Runner Installer 2 | 3 | [![Install into repository](https://github.com/redhat-actions/openshift-actions-runner-installer/workflows/Install%20into%20repository/badge.svg)](https://github.com/redhat-actions/openshift-actions-runner-installer/actions) 4 | [![Install into org](https://github.com/redhat-actions/openshift-actions-runner-installer/workflows/Install%20into%20redhat-actions/badge.svg)](https://github.com/redhat-actions/openshift-actions-runner-installer/actions) 5 | [![CI checks](https://github.com/redhat-actions/openshift-actions-runner-installer/workflows/CI%20Checks/badge.svg)](https://github.com/redhat-actions/openshift-actions-runner-installer/actions) 6 | [![Link checker](https://github.com/redhat-actions/openshift-actions-runner-installer/workflows/Link%20checker/badge.svg)](https://github.com/redhat-actions/openshift-actions-runner-installer/actions) 7 | 8 | [![awesome-runners](https://img.shields.io/badge/listed%20on-awesome--runners-blue.svg)](https://github.com/jonico/awesome-runners) 9 | [![tag badge](https://img.shields.io/github/v/tag/redhat-actions/openshift-actions-runner-installer)](https://github.com/redhat-actions/openshift-actions-runner-installer/tags) 10 | [![license badge](https://img.shields.io/github/license/redhat-actions/kn-service-deploy)](./LICENSE) 11 | 12 | The OpenShift Self-Hosted Actions Runner Installer is a GitHub Action to automatically install self-hosted Actions runner containers into a Kubernetes cluster. 13 | 14 | The action uses the [**OpenShift Actions Runner Chart**](https://github.com/redhat-actions/openshift-actions-runner-chart/) to install runners. 15 | 16 | By default, the chart installs the [**OpenShift Actions Runner**](https://github.com/redhat-actions/openshift-actions-runner). The image to use is configurable (see [Inputs](#inputs)). 17 | 18 | This action uses these two projects to make the self-hosted runner installation on Kubernetes as easy as possible. 19 | 20 | If a runner that uses the same image and has any requested labels is already present, the install step will be skipped. This action can be run as a prerequisite step to the "real" workflow to ensure the runner a workflow needs is available. 21 | 22 | While this action, chart and images are developed for and tested on OpenShift, they do not contain any OpenShift specific code. This action should be compatible with any Kubernetes platform. 23 | 24 | ## Prerequisites 25 | You must have access to a Kubernetes cluster. Visit [openshift.com/try](https://www.openshift.com/try) or sign up for our [Developer Sandbox](https://developers.redhat.com/developer-sandbox). 26 | 27 | You must have authenticated to your Kubernetes cluster and set up a Kubernetes config. If you are using OpenShift, you can use [**oc-login**](https://github.com/redhat-actions/oc-login). 28 | 29 | You must have `helm` v3 and either `oc` or `kubectl` installed. You can use the [**OpenShift CLI Installer**](https://github.com/redhat-actions/openshift-cli-installer) to install and cache these tools. 30 | 31 | You do **not** need cluster administrator privileges to deploy the runners and run workloads. However, some images or tools may require special permissions. 32 | 33 | 34 | 35 | ## Example Workflows 36 | Refer to the [**Repository Example**](./.github/workflows/repo_example.yml) and [**Organization Example**](./.github/workflows/org_example.yml). The Repository example is also an example of using a [`repository_dispatch` event](https://docs.github.com/en/actions/reference/events-that-trigger-workflows#repository_dispatch) to trigger a separate workflow, once the runner is ready. 37 | 38 | Remember to [create a secret](https://docs.github.com/en/actions/reference/encrypted-secrets) containing the GitHub PAT as detailed above, and pass it in the `github_pat` input. Below, the secret is named `PAT`. 39 | 40 | All other inputs are optional. 41 | 42 | ### Minimal Example 43 | ```yaml 44 | name: OpenShift Self-Hosted Installer Workflow 45 | on: [ push, workflow_dispatch ] 46 | 47 | jobs: 48 | install-runner: 49 | runs-on: ubuntu-20.04 50 | name: Install runner 51 | steps: 52 | - name: Install self hosted runner into this repository 53 | uses: redhat-actions/openshift-actions-runner-installer@v1 54 | with: 55 | github_pat: ${{ secrets.PAT }} 56 | 57 | self-hosted-workflow: 58 | # Now that the above job has ensured the runner container exists, 59 | # we can run our workflow inside it. 60 | name: OpenShift Self Hosted Workflow 61 | # Add other labels here if you have to filter by a runner type. 62 | runs-on: [ self-hosted ] 63 | needs: install-runner 64 | 65 | steps: 66 | - run: hostname 67 | - run: ls -Al 68 | # ... etc 69 | ``` 70 | 71 | 72 | 73 | ## Inputs 74 | The only required input is the `github_pat`, which is a [Personal Access Token](https://docs.github.com/en/free-pro-team@latest/github/authenticating-to-github/creating-a-personal-access-token), with the appropriate permisions. 75 | 76 | The token must have the `repo` permission scope. For organization runners, the token must also have the `admin:org` scope. Refer to the Runner [README](https://github.com/redhat-actions/openshift-actions-runner#pat-guidelines). 77 | 78 | Note that the default workflow token `secrets.GITHUB_TOKEN` does **not** have the permissions required to check for and install self-hosted runners. Refer to [Permissions for the GITHUB_TOKEN](https://docs.github.com/en/actions/reference/authentication-in-a-workflow#permissions-for-the-github_token). 79 | 80 | | Input Name | Description | Default | 81 | | ---------- | ----------- | ------- | 82 | | github_pat | GitHub Personal access token. Refer to the description above. | **Must be provided** 83 | | runner_image | Container image to use for the runner. | [`quay.io/redhat-github-actions/runner`](https://quay.io/redhat-github-actions/runner) 84 | | runner_tag | Tag to use for the runner container image. | `v1` | 85 | | runner_labels | [Labels](https://docs.github.com/en/actions/hosting-your-own-runners/using-labels-with-self-hosted-runners) to add to the self-hosted runner. Must be comma-separated, spaces after commas optional. | None | 86 | | runner_location | Repository or organization for the self-hosted runner. | Workflow repository | 87 | | runner_replicas | Number of replicas of the container to create. Each replica is its own pod, and its own runner. | 1 88 | | namespace | Optional Kubernetes namespace to pass to all Helm and Kube client comands. | None | 89 | | helm_release_name | The Helm release name to use. | Runner location (repo or org) | 90 | | helm_uninstall_existing | Uninstall any release that matches the `helm_release_name` and `namespace` before running `helm install`. If this is false, and the release exists, the action will fail when the `helm install` fails. | `true` | 91 | | helm_chart_version | Version of our [Helm Chart](https://github.com/redhat-actions/openshift-actions-runner-chart) to install. | Latest release 92 | | helm_extra_args | Arbitrary arguments to append to the `helm` command. Refer to the [Chart README](https://github.com/redhat-actions/openshift-actions-runner-chart).
Separate items by newline. Do not quote the arguments, since `@actions/exec` manages quoting. | None | 93 | 94 | ## Outputs 95 | | Output Name | Description | 96 | | ----------- | ----------- | 97 | | helm_release_name | The name of the Helm release that was installed.
If the runners were present and the install was skipped, this value is undefined. | 98 | | installed | Boolean value indicating if the runners were installed (`true`), or already present (`false`). | 99 | | runners | JSON-parsable array of the matching runners' names, whether they were installed by this action or already present. | 100 | 101 | ## Removing runners 102 | `helm uninstall` is sufficient to remove the runners. As long as the runners terminate gracefully, they will remove themselves from the repository or organization before exiting. 103 | 104 | You can use the `helm_release_name` output to determine the helm release name to uninstall. 105 | 106 | Refer to the [tear down example](./.github/workflows/tear_down_runners.yml) and the [organization workflow](./.github/workflows/org_example.yml) for examples. 107 | 108 | 109 | ## Troubleshooting 110 | 111 | See the Troubleshooting sections of [the chart README](https://github.com/redhat-actions/openshift-actions-runner-chart#Troubleshooting), and [the runner README](https://github.com/redhat-actions/openshift-actions-runner#Troubleshooting). 112 | 113 | The most common errors are due to a missing or misconfigured GitHub PAT. Make sure that: 114 | - The secret was created correctly. 115 | - The secret is referred to by the correct name in the workflow file. 116 | - The PAT in the secret has the correct permissions. 117 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: OpenShift Self Hosted Runner Installer 2 | description: Check for and install self-hosted runners on to your OpenShift cluster 3 | author: Red Hat 4 | branding: 5 | icon: circle 6 | color: red 7 | inputs: 8 | github_pat: 9 | description: | 10 | GitHub Personal access token. The token must have the "repo" permission scope. 11 | For organization runners, the token must also have the "admin:org" scope. 12 | required: true 13 | runner_location: 14 | description: | 15 | Repository or organization for the self-hosted runner. 16 | For example, "redhat-actions/check-self-hosted-runner" for a repository, or "redhat-actions" for an organization. 17 | Defaults to the current repository. 18 | required: false 19 | runner_image: 20 | description: Container image to use for the runner. 21 | required: false 22 | runner_tag: 23 | description: Tag to use for the runner container image. 24 | required: false 25 | runner_labels: 26 | description: | 27 | Labels in the runners to check for. 28 | For multiple labels, separate by comma and an optional space. For example, "label1, label2". 29 | required: false 30 | runner_replicas: 31 | description: Number of runner replicas to create. 32 | required: false 33 | default: "1" 34 | namespace: 35 | description: | 36 | Optional namespace (aka project) to pass to all Helm or Kubernetes commands. 37 | required: false 38 | helm_uninstall_existing: 39 | description: | 40 | Uninstall any release that matches the `helm_release_name` and `namespace` before running `helm install`. 41 | If this is false, and the release exists, the action will fail when the `helm install` fails. 42 | required: false 43 | default: "true" 44 | helm_release_name: 45 | description: The Helm Release name to give the new runner release. Defaults to the repository or org name plus "-runners". 46 | required: false 47 | helm_extra_args: 48 | description: | 49 | Any other arguments to pass to the 'helm install' command. 50 | Separate arguments by newline. Do not use quotes - @actions/exec will do the quoting for you. 51 | required: false 52 | helm_chart_version: 53 | description: Version of our Helm Chart to install. Defaults to the latest. 54 | required: false 55 | outputs: 56 | helm_release_name: 57 | description: | 58 | The name of the Helm release that was created. 59 | If a matching runner was already present, the Helm install is skipped, and this value is undefined. 60 | installed: 61 | description: Boolean value indicating if the runners were installed (installed=true), or already present (installed=false). 62 | runners: 63 | description: JSON-parseable array of the runner names, whether they were installed or not. 64 | runs: 65 | using: 'node16' 66 | main: 'dist/index.js' 67 | -------------------------------------------------------------------------------- /git-hooks/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | ### Copy this into .git/hooks and overwrite the empty one. 4 | ### This will ensure the bundle and ins-outs verification checks won't fail for you. 5 | 6 | echo "----- Pre-commit -----" 7 | set -ex 8 | npx action-io-generator -o src/generated/inputs-outputs.ts 9 | npm run lint 10 | npm run bundle 11 | git add -v dist/ src/generated 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "check-self-hosted-runner", 3 | "version": "1.0.0", 4 | "engines": { 5 | "node": "16" 6 | }, 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/redhat-actions/openshift-actions-runner-installer" 10 | }, 11 | "description": "", 12 | "main": "dist/index.js", 13 | "scripts": { 14 | "compile": "tsc -p .", 15 | "bundle": "npx webpack --mode=production", 16 | "clean": "rm -rf out/ dist/", 17 | "lint": "eslint . --max-warnings=0" 18 | }, 19 | "keywords": [], 20 | "author": "Red Hat Inc.", 21 | "license": "MIT", 22 | "dependencies": { 23 | "@actions/core": "^1.10.0", 24 | "@actions/exec": "^1.0.4", 25 | "@actions/github": "^4.0.0", 26 | "@actions/io": "^1.1.0" 27 | }, 28 | "devDependencies": { 29 | "@redhat-actions/action-io-generator": "^1.5.0", 30 | "@redhat-actions/eslint-config": "^1.3.2", 31 | "@redhat-actions/tsconfig": "^1.1.1", 32 | "@redhat-actions/webpack-config": "^1.2.0", 33 | "@types/node": "^12.20.12", 34 | "@types/terser-webpack-plugin": "^5.0.3", 35 | "@typescript-eslint/eslint-plugin": "^4.22.1", 36 | "@typescript-eslint/parser": "^4.22.1", 37 | "eslint": "^7.26.0", 38 | "terser-webpack-plugin": "^5.1.1", 39 | "ts-loader": "^8.2.0", 40 | "typescript": "^4.2.4", 41 | "webpack": "^5.36.2", 42 | "webpack-cli": "^4.7.0" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | /*************************************************************************************************** 2 | * Copyright (c) Red Hat, Inc. All rights reserved. 3 | * Licensed under the MIT License. See LICENSE file in the project root for license information. 4 | **************************************************************************************************/ 5 | 6 | namespace Constants { 7 | export const DEFAULT_IMG = "quay.io/redhat-github-actions/runner"; 8 | export const DEFAULT_IMG_TAG = "v1"; 9 | 10 | export const CHART_REPO_NAME = "openshift-actions-runner-chart"; 11 | export const CHART_NAME = "actions-runner"; 12 | export const CHART_REPO_URL = `https://redhat-actions.github.io/openshift-actions-runner-chart/`; 13 | export const RELEASE_NAME_LABEL = "app.kubernetes.io/instance"; 14 | } 15 | 16 | export default Constants; 17 | -------------------------------------------------------------------------------- /src/generated/inputs-outputs.ts: -------------------------------------------------------------------------------- 1 | // This file was auto-generated by action-io-generator. Do not edit by hand! 2 | export enum Inputs { 3 | /** 4 | * GitHub Personal access token. The token must have the "repo" permission scope. 5 | * For organization runners, the token must also have the "admin:org" scope. 6 | * Required: true 7 | * Default: None. 8 | */ 9 | GITHUB_PAT = "github_pat", 10 | /** 11 | * Version of our Helm Chart to install. Defaults to the latest. 12 | * Required: false 13 | * Default: None. 14 | */ 15 | HELM_CHART_VERSION = "helm_chart_version", 16 | /** 17 | * Any other arguments to pass to the 'helm install' command. 18 | * Separate arguments by newline. Do not use quotes - @actions/exec will do the quoting for you. 19 | * Required: false 20 | * Default: None. 21 | */ 22 | HELM_EXTRA_ARGS = "helm_extra_args", 23 | /** 24 | * The Helm Release name to give the new runner release. Defaults to the repository or org name plus "-runners". 25 | * Required: false 26 | * Default: None. 27 | */ 28 | HELM_RELEASE_NAME = "helm_release_name", 29 | /** 30 | * Uninstall any release that matches the `helm_release_name` and `namespace` before running `helm install`. 31 | * If this is false, and the release exists, the action will fail when the `helm install` fails. 32 | * Required: false 33 | * Default: "true" 34 | */ 35 | HELM_UNINSTALL_EXISTING = "helm_uninstall_existing", 36 | /** 37 | * Optional namespace (aka project) to pass to all Helm or Kubernetes commands. 38 | * Required: false 39 | * Default: None. 40 | */ 41 | NAMESPACE = "namespace", 42 | /** 43 | * Container image to use for the runner. 44 | * Required: false 45 | * Default: None. 46 | */ 47 | RUNNER_IMAGE = "runner_image", 48 | /** 49 | * Labels in the runners to check for. 50 | * For multiple labels, separate by comma and an optional space. For example, "label1, label2". 51 | * Required: false 52 | * Default: None. 53 | */ 54 | RUNNER_LABELS = "runner_labels", 55 | /** 56 | * Repository or organization for the self-hosted runner. 57 | * For example, "redhat-actions/check-self-hosted-runner" for a repository, or "redhat-actions" for an organization. 58 | * Defaults to the current repository. 59 | * Required: false 60 | * Default: None. 61 | */ 62 | RUNNER_LOCATION = "runner_location", 63 | /** 64 | * Number of runner replicas to create. 65 | * Required: false 66 | * Default: "1" 67 | */ 68 | RUNNER_REPLICAS = "runner_replicas", 69 | /** 70 | * Tag to use for the runner container image. 71 | * Required: false 72 | * Default: None. 73 | */ 74 | RUNNER_TAG = "runner_tag", 75 | } 76 | 77 | export enum Outputs { 78 | /** 79 | * The name of the Helm release that was created. 80 | * If a matching runner was already present, the Helm install is skipped, and this value is undefined. 81 | * Required: false 82 | * Default: None. 83 | */ 84 | HELM_RELEASE_NAME = "helm_release_name", 85 | /** 86 | * Boolean value indicating if the runners were installed (installed=true), or already present (installed=false). 87 | * Required: false 88 | * Default: None. 89 | */ 90 | INSTALLED = "installed", 91 | /** 92 | * JSON-parseable array of the runner names, whether they were installed or not. 93 | * Required: false 94 | * Default: None. 95 | */ 96 | RUNNERS = "runners", 97 | } 98 | -------------------------------------------------------------------------------- /src/get-runners.ts: -------------------------------------------------------------------------------- 1 | /*************************************************************************************************** 2 | * Copyright (c) Red Hat, Inc. All rights reserved. 3 | * Licensed under the MIT License. See LICENSE file in the project root for license information. 4 | **************************************************************************************************/ 5 | 6 | import * as core from "@actions/core"; 7 | import * as github from "@actions/github"; 8 | 9 | import RunnerLocation from "./types/runner-location"; 10 | import { Octokit, SelfHostedRunner, SelfHostedRunnersResponse } from "./types/types"; 11 | import { awaitWithRetry, joinList } from "./util/util"; 12 | 13 | export async function getMatchingOnlineRunners( 14 | githubPat: string, runnerLocation: RunnerLocation, requiredLabels: string[] 15 | ): Promise { 16 | const selfHostedRunnersResponse = await listSelfHostedRunners(githubPat, runnerLocation); 17 | 18 | const noRunners = selfHostedRunnersResponse.total_count; 19 | 20 | core.info(`${runnerLocation.toString()} has ${noRunners} runner${noRunners !== 1 ? "s" : ""}.`); 21 | 22 | if (selfHostedRunnersResponse.total_count === 0) { 23 | return []; 24 | } 25 | 26 | core.info(`Looking for runner with labels: ${joinList(requiredLabels.map((label) => `"${label}"`))}`); 27 | 28 | const matchingOnlineRunners = selfHostedRunnersResponse.runners.filter((runner) => { 29 | const runnerLabels = runner.labels.map((label) => label.name); 30 | core.info(`${runner.name} has labels: ${runnerLabels.map((label) => `"${label}"`).join(", ")}`); 31 | 32 | const matchingLabels: string[] = []; 33 | const missingLabels: string[] = []; 34 | 35 | requiredLabels.forEach((label) => { 36 | if (runnerLabels.includes(label)) { 37 | matchingLabels.push(label); 38 | } 39 | else { 40 | missingLabels.push(label); 41 | } 42 | }); 43 | 44 | if (missingLabels.length === 0) { 45 | // core.info(`${runner.name} has all the required labels`); 46 | const isOnline = runner.status === "online"; 47 | if (isOnline) { 48 | core.info(`${runner.name} has the required labels and is online`); 49 | } 50 | else { 51 | core.info(`${runner.name} has all the required labels, but status is ${runner.status}`); 52 | } 53 | return isOnline; 54 | } 55 | 56 | const missingLabelsNoun = missingLabels.length > 1 ? "labels" : "label"; 57 | core.info( 58 | `${runner.name} is missing the ${missingLabelsNoun} ` 59 | + `${joinList(missingLabels.map((l) => `"${l}"`))}` 60 | ); 61 | 62 | return false; 63 | }); 64 | 65 | return matchingOnlineRunners; 66 | } 67 | 68 | const WAIT_FOR_RUNNERS_TIMEOUT = 60; 69 | 70 | export async function waitForRunnersToBeOnline( 71 | githubPat: string, runnerLocation: RunnerLocation, newRunnerNames: string[] 72 | ): Promise { 73 | const noRunnerErrMsg = `Not all of the new runners were added to ${runnerLocation}, or were not online ` 74 | + `within ${WAIT_FOR_RUNNERS_TIMEOUT}s. Check if the pods failed to start, or exited.`; 75 | 76 | core.info(`⏳ Waiting for the new runners to come up: ${joinList(newRunnerNames, "and")}`); 77 | 78 | const newOnlineRunners: string[] = []; 79 | const newOfflineRunners: string[] = []; 80 | 81 | return awaitWithRetry( 82 | WAIT_FOR_RUNNERS_TIMEOUT, 5, 83 | `Waiting for runners to come online...`, noRunnerErrMsg, 84 | async (resolve) => { 85 | const currentGHRunners = await listSelfHostedRunners(githubPat, runnerLocation); 86 | if (currentGHRunners.runners.length > 0) { 87 | const runnersWithStatus = currentGHRunners.runners.map((runner) => `${runner.name} (${runner.status})`); 88 | core.info(`${runnerLocation} runners are: ${joinList(runnersWithStatus)}`); 89 | } 90 | else { 91 | core.info(`${runnerLocation} has no runners.`); 92 | } 93 | 94 | // const currentGHRunnerNames = currentGHRunners.runners.map((runner) => runner.name); 95 | 96 | // collect the runners that have not yet appeared as online or offline 97 | const unresolvedRunners = newRunnerNames.filter( 98 | (newRunner) => !newOnlineRunners.includes(newRunner) && !newOfflineRunners.includes(newRunner) 99 | ); 100 | 101 | if (unresolvedRunners.length === 0) { 102 | // all runners have been accounted for 103 | resolve(newOnlineRunners); 104 | } 105 | else { 106 | core.info(`Still waiting for ${joinList(unresolvedRunners)}`); 107 | } 108 | 109 | unresolvedRunners.forEach((newRunnerName) => { 110 | // look for one of the new runners to be known by github 111 | const newRunnerIndex = currentGHRunners.runners 112 | .map((runner) => runner.name) 113 | .findIndex((runnerName) => runnerName === newRunnerName); 114 | 115 | if (newRunnerIndex !== -1) { 116 | const newRunner = currentGHRunners.runners[newRunnerIndex]; 117 | // if the runner is online, we are good and we return it 118 | if (newRunner.status === "online") { 119 | core.info(`✅ ${newRunner.name} is online`); 120 | newOnlineRunners.push(newRunner.name); 121 | } 122 | // else, we have to log a warning, because this usually means the runner configured but then crashed 123 | // but, only log one warning per runner. 124 | else if (!newOfflineRunners.includes(newRunner.name)) { 125 | core.warning(`New runner ${newRunner.name} connected to GitHub but is ${newRunner.status}`); 126 | newOfflineRunners.push(newRunner.name); 127 | } 128 | } 129 | }); 130 | } 131 | ); 132 | } 133 | 134 | async function listSelfHostedRunners( 135 | githubPat: string, runnerLocation: RunnerLocation 136 | ): Promise { 137 | const octokit = await getOctokit(githubPat); 138 | 139 | let response; 140 | try { 141 | if (runnerLocation.repository) { 142 | // API Documentation: 143 | // https://docs.github.com/en/free-pro-team@latest/rest/reference/actions#self-hosted-runners 144 | // Octokit Documentation: https://octokit.github.io/rest.js/v17#actions-list-self-hosted-runners-for-repo 145 | response = await octokit.actions.listSelfHostedRunnersForRepo({ 146 | owner: runnerLocation.owner, 147 | repo: runnerLocation.repository, 148 | }); 149 | } 150 | else { 151 | // org only 152 | // Octokit Documentation: https://octokit.github.io/rest.js/v17#actions-list-self-hosted-runners-for-org 153 | response = await octokit.actions.listSelfHostedRunnersForOrg({ 154 | org: runnerLocation.owner, 155 | }); 156 | } 157 | } 158 | catch (err) { 159 | throw getBetterHttpError(err); 160 | } 161 | 162 | core.debug(`Self-hosted runners response: ${JSON.stringify(response.data, undefined, 2)}`); 163 | 164 | return response.data; 165 | } 166 | 167 | let cachedOctokit: Octokit | undefined; 168 | async function getOctokit(githubPat: string): Promise { 169 | if (cachedOctokit) { 170 | return cachedOctokit; 171 | } 172 | 173 | // Get authenticated GitHub client (Ocktokit): https://github.com/actions/toolkit/tree/master/packages/github#usage- 174 | const octokit: Octokit = github.getOctokit(githubPat); 175 | cachedOctokit = octokit; 176 | return octokit; 177 | } 178 | 179 | /** 180 | * The errors messages from octokit HTTP requests can be poor; prepending the status code helps clarify the problem. 181 | */ 182 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 183 | function getBetterHttpError(err: any): Error { 184 | const status = err.status; 185 | if (status && err.message) { 186 | return new Error(`Received status ${status}: ${err.message}`); 187 | } 188 | return err; 189 | } 190 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /*************************************************************************************************** 2 | * Copyright (c) Red Hat, Inc. All rights reserved. 3 | * Licensed under the MIT License. See LICENSE file in the project root for license information. 4 | **************************************************************************************************/ 5 | 6 | import * as core from "@actions/core"; 7 | 8 | import installRunner from "./install-runner"; 9 | import { joinList } from "./util/util"; 10 | import processInputs from "./process-inputs"; 11 | import { getMatchingOnlineRunners, waitForRunnersToBeOnline } from "./get-runners"; 12 | import { Outputs } from "./generated/inputs-outputs"; 13 | 14 | export async function run(): Promise { 15 | const runnerConfig = processInputs(); 16 | core.debug(`INPUTS:`); 17 | core.debug(JSON.stringify(runnerConfig, undefined, 2)); 18 | 19 | const taggedImage = `${runnerConfig.runnerImage}:${runnerConfig.runnerTag}`; 20 | 21 | core.info(`🔎 Fetching self-hosted runners for ${runnerConfig.runnerLocation}`); 22 | 23 | const matchingOnlineRunners = await getMatchingOnlineRunners( 24 | // We label our runners with the taggedImage so that runner using the wrong image are not counted. 25 | runnerConfig.githubPat, runnerConfig.runnerLocation, runnerConfig.runnerLabels.concat(taggedImage), 26 | ); 27 | 28 | if (matchingOnlineRunners.length > 0) { 29 | const runnerNames = matchingOnlineRunners.map((runner) => runner.name); 30 | if (matchingOnlineRunners.length === 1) { 31 | core.info(`✅ Runner ${runnerNames[0]} matches the given labels.`); 32 | } 33 | else { 34 | core.info(`✅ Runners ${joinList(runnerNames)} match the given labels.`); 35 | } 36 | 37 | // Outputs.HELM_RELEASE_NAME is not set here, since we did not do a helm release. 38 | core.setOutput(Outputs.INSTALLED, false); 39 | core.setOutput(Outputs.RUNNERS, JSON.stringify(runnerNames)); 40 | return; 41 | } 42 | 43 | core.info(`❌ No online runner with all the required labels was found.`); 44 | core.info(`Installing a runner now.`); 45 | 46 | const installedRunnerPodnames = await installRunner(runnerConfig); 47 | core.debug(`installedRunnerPodnames are ${installedRunnerPodnames}`); 48 | 49 | // at present, the runner names == their hostnames === their pod names 50 | const newRunnerNames = installedRunnerPodnames; 51 | 52 | const newRunners = await waitForRunnersToBeOnline( 53 | runnerConfig.githubPat, runnerConfig.runnerLocation, newRunnerNames 54 | ); 55 | 56 | const plural = newRunners.length !== 1; 57 | core.info( 58 | `✅ Success: new self-hosted runner${plural ? "s" : ""} ` 59 | + `${joinList(newRunners)} ${plural ? "are" : "is"} up and running.` 60 | ); 61 | 62 | core.setOutput(Outputs.HELM_RELEASE_NAME, runnerConfig.helmReleaseName); 63 | core.setOutput(Outputs.INSTALLED, true); 64 | core.setOutput(Outputs.RUNNERS, JSON.stringify(newRunners)); 65 | } 66 | 67 | run().catch((err) => core.setFailed(err.message)); 68 | -------------------------------------------------------------------------------- /src/install-runner.ts: -------------------------------------------------------------------------------- 1 | /*************************************************************************************************** 2 | * Copyright (c) Red Hat, Inc. All rights reserved. 3 | * Licensed under the MIT License. See LICENSE file in the project root for license information. 4 | **************************************************************************************************/ 5 | 6 | import * as core from "@actions/core"; 7 | import * as io from "@actions/io"; 8 | 9 | import exec from "./util/exec"; 10 | import Constants from "./constants"; 11 | import { RunnerConfiguration } from "./types/types"; 12 | import getAndWaitForPods from "./wait-for-pods"; 13 | import { splitByNewline } from "./util/util"; 14 | 15 | enum HelmValueNames { 16 | RUNNER_IMAGE = "runnerImage", 17 | RUNNER_TAG = "runnerTag", 18 | RUNNER_LABELS = "runnerLabels", 19 | RUNNER_REPLICAS = "replicas", 20 | GITHUB_PAT = "githubPat", 21 | GITHUB_OWNER = "githubOwner", 22 | GITHUB_REPO = "githubRepository", 23 | } 24 | 25 | export default async function runHelmInstall(config: RunnerConfiguration): Promise { 26 | const helmPath = await io.which("helm", true); 27 | 28 | const namespaceArgs = config.namespace ? [ "--namespace", config.namespace ] : []; 29 | await exec(helmPath, [ "ls", ...namespaceArgs ]); 30 | 31 | if (config.helmUninstallIfExists) { 32 | core.info(`🔎 Checking if release ${config.helmReleaseName} already exists...`); 33 | 34 | const releasesStr = await exec(helmPath, [ "ls", "-q", ...namespaceArgs ]); 35 | const releases = splitByNewline(releasesStr.stdout); 36 | 37 | if (releases.includes(config.helmReleaseName)) { 38 | core.info(`ℹ️ Release ${config.helmReleaseName} already exists; removing.`); 39 | await exec(helmPath, [ "uninstall", config.helmReleaseName, ...namespaceArgs ]); 40 | } 41 | else { 42 | core.info(`Release ${config.helmReleaseName} does not exist.`); 43 | } 44 | } 45 | else { 46 | core.info(`Not checking if release already exists`); 47 | } 48 | 49 | await exec(helmPath, [ "repo", "add", Constants.CHART_REPO_NAME, Constants.CHART_REPO_URL ]); 50 | await exec(helmPath, [ "repo", "list" ]); 51 | await exec(helmPath, [ "repo", "update" ]); 52 | await exec(helmPath, [ "search", "repo", Constants.CHART_NAME ]); 53 | 54 | await exec(helmPath, [ "version" ]); 55 | 56 | const versionArgs = config.helmChartVersion ? [ "--version", config.helmChartVersion ] : []; 57 | 58 | const helmInstallCmd: string[] = [ 59 | "install", 60 | // "--debug", 61 | config.helmReleaseName, 62 | Constants.CHART_REPO_NAME + "/" + Constants.CHART_NAME, 63 | ...namespaceArgs, 64 | ...versionArgs, 65 | "--set-string", `${HelmValueNames.RUNNER_IMAGE}=${config.runnerImage}`, 66 | "--set-string", `${HelmValueNames.RUNNER_TAG}=${config.runnerTag}`, 67 | "--set-string", `${HelmValueNames.GITHUB_PAT}=${config.githubPat}`, 68 | "--set-string", `${HelmValueNames.GITHUB_OWNER}=${config.runnerLocation.owner}`, 69 | "--set", `${HelmValueNames.RUNNER_REPLICAS}=${config.runnerReplicas}`, 70 | ]; 71 | 72 | if (config.runnerLocation.repository) { 73 | helmInstallCmd.push( 74 | "--set-string", `${HelmValueNames.GITHUB_REPO}=${config.runnerLocation.repository}` 75 | ); 76 | } 77 | 78 | if (config.runnerLabels.length > 0) { 79 | // the labels are passed using array syntax, which is: "{ label1, label2 }" 80 | // Do not put spaces after the comma - 81 | // it works locally because the chart trims the spaces but it works differently in actions/exec for some reason 82 | const labelsStringified = `{ ${config.runnerLabels.join("\\,")} }`; 83 | helmInstallCmd.push("--set", `${HelmValueNames.RUNNER_LABELS}=${labelsStringified}`); 84 | } 85 | 86 | if (config.helmExtraArgs.length > 0) { 87 | helmInstallCmd.push(...config.helmExtraArgs); 88 | } 89 | 90 | await exec(helmPath, helmInstallCmd); 91 | await exec(helmPath, [ "get", "manifest", config.helmReleaseName, ...namespaceArgs ], { group: true }); 92 | 93 | return getAndWaitForPods(config.helmReleaseName, config.runnerReplicas, config.namespace); 94 | } 95 | -------------------------------------------------------------------------------- /src/process-inputs.ts: -------------------------------------------------------------------------------- 1 | /*************************************************************************************************** 2 | * Copyright (c) Red Hat, Inc. All rights reserved. 3 | * Licensed under the MIT License. See LICENSE file in the project root for license information. 4 | **************************************************************************************************/ 5 | 6 | import * as core from "@actions/core"; 7 | import * as github from "@actions/github"; 8 | 9 | import Constants from "./constants"; 10 | import { Inputs } from "./generated/inputs-outputs"; 11 | import RunnerLocation from "./types/runner-location"; 12 | import { RunnerConfiguration } from "./types/types"; 13 | import { splitByNewline } from "./util/util"; 14 | 15 | export default function processInputs(): RunnerConfiguration { 16 | const githubPat = core.getInput(Inputs.GITHUB_PAT, { required: true }); 17 | const encodedPat = Buffer.from(githubPat).toString("base64"); 18 | core.setSecret(encodedPat); 19 | 20 | const runnerLocInput = core.getInput(Inputs.RUNNER_LOCATION); 21 | core.debug(`Runner location input is ${runnerLocInput}`); 22 | const runnerLocationStr = runnerLocInput || `${github.context.repo.owner}/${github.context.repo.repo}`; 23 | const runnerLocation = getRunnerLocationObj(runnerLocationStr); 24 | 25 | const helmReleaseNameInput = core.getInput(Inputs.HELM_RELEASE_NAME); 26 | 27 | let helmReleaseName; 28 | if (helmReleaseNameInput) { 29 | helmReleaseName = helmReleaseNameInput; 30 | } 31 | else if (runnerLocation.repository) { 32 | helmReleaseName = runnerLocation.repository + "-runner"; 33 | } 34 | else { 35 | helmReleaseName = runnerLocation.owner + "-runner"; 36 | } 37 | helmReleaseName = validateResourceName(helmReleaseName); 38 | 39 | const runnerImage = core.getInput(Inputs.RUNNER_IMAGE) || Constants.DEFAULT_IMG; 40 | const runnerTag = core.getInput(Inputs.RUNNER_TAG) || Constants.DEFAULT_IMG_TAG; 41 | 42 | const inputLabelsStr = core.getInput(Inputs.RUNNER_LABELS); 43 | let runnerLabels: string[] = []; 44 | if (inputLabelsStr) { 45 | runnerLabels = inputLabelsStr.split(",").map((label) => label.trim()); 46 | } 47 | 48 | const inputExtraArgsStr = core.getInput(Inputs.HELM_EXTRA_ARGS); 49 | let helmExtraArgs: string[] = []; 50 | if (inputExtraArgsStr) { 51 | // transform the array of lines into an array of arguments 52 | // by splitting over lines, then over spaces, then trimming. 53 | const lines = splitByNewline(inputExtraArgsStr); 54 | helmExtraArgs = lines.flatMap((line) => line.split(" ")).map((arg) => arg.trim()); 55 | } 56 | 57 | let helmChartVersion: string | undefined = core.getInput(Inputs.HELM_CHART_VERSION); 58 | if (helmChartVersion === "") { 59 | helmChartVersion = undefined; 60 | } 61 | 62 | const helmUninstallIfExists = core.getInput(Inputs.HELM_UNINSTALL_EXISTING) === "true"; 63 | 64 | let namespace: string | undefined = core.getInput(Inputs.NAMESPACE); 65 | if (namespace === "") { 66 | namespace = undefined; 67 | } 68 | 69 | const runnerReplicas = core.getInput(Inputs.RUNNER_REPLICAS) || "1"; 70 | 71 | return { 72 | githubPat, 73 | helmChartVersion, 74 | helmExtraArgs, 75 | helmReleaseName, 76 | helmUninstallIfExists, 77 | runnerImage, 78 | namespace, 79 | runnerLabels, 80 | runnerLocation, 81 | runnerReplicas, 82 | runnerTag, 83 | }; 84 | } 85 | 86 | function getRunnerLocationObj(runnerLocationStr: string): RunnerLocation { 87 | const slashIndex = runnerLocationStr.indexOf("/"); 88 | if (slashIndex >= 0) { 89 | return new RunnerLocation( 90 | runnerLocationStr.substring(0, slashIndex), 91 | runnerLocationStr.substring(slashIndex + 1), 92 | ); 93 | } 94 | 95 | return new RunnerLocation(runnerLocationStr); 96 | } 97 | 98 | export function validateResourceName(name: string): string { 99 | // https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#dns-label-names 100 | 101 | // replace chars that may be in github repo/org name 102 | const nameWithReplacements = name.toLowerCase().replace(/[ _/.]/g, "-"); 103 | 104 | if (!nameWithReplacements.match(/^[a-z0-9-]+$/)) { 105 | throw new Error( 106 | `Helm release name "${name}" contains illegal characters. ` 107 | + `Can only container lowercase letters, numbers, and '-'.` 108 | ); 109 | } 110 | if (!nameWithReplacements.match(/^[a-z]/) || !nameWithReplacements.match(/[a-z]$/)) { 111 | throw new Error(`Helm release name "${name}" must start and end with a lowercase letter.`); 112 | } 113 | if (nameWithReplacements.length > 63) { 114 | throw new Error(`Helm release name "${name}" must be shorter than 64 characters.`); 115 | } 116 | 117 | return nameWithReplacements; 118 | } 119 | -------------------------------------------------------------------------------- /src/types/kube-executor.ts: -------------------------------------------------------------------------------- 1 | /*************************************************************************************************** 2 | * Copyright (c) Red Hat, Inc. All rights reserved. 3 | * Licensed under the MIT License. See LICENSE file in the project root for license information. 4 | **************************************************************************************************/ 5 | 6 | import * as io from "@actions/io"; 7 | 8 | import exec from "../util/exec"; 9 | 10 | export async function getKubeCommandExecutor( 11 | labelSelector?: string | undefined, namespace?: string | undefined 12 | ): Promise { 13 | const kubeClientPath = await getKubeClientPath(); 14 | 15 | return new KubeCommandExecutor(kubeClientPath, labelSelector, namespace); 16 | } 17 | 18 | /** 19 | * @returns The path to oc if it is which-findable, else the path to kubectl if it's findable. 20 | * @throws If neither oc nor kubectl is findable. 21 | */ 22 | async function getKubeClientPath(): Promise { 23 | const ocPath = await io.which("oc"); 24 | if (ocPath) { 25 | return ocPath; 26 | } 27 | 28 | const kubectlPath = await io.which("kubectl"); 29 | if (!kubectlPath) { 30 | throw new Error( 31 | `Neither kubectl nor oc was found. One of these tools must be installed, and added to the PATH.` 32 | ); 33 | } 34 | return kubectlPath; 35 | } 36 | 37 | type KubeResourceType = "all" | "pods" | "replicasets" | "deployments"; // well, these are all we need for now. 38 | 39 | export class KubeCommandExecutor { 40 | private readonly namespaceArg: string[]; 41 | 42 | private readonly labelSelectorArg: string[]; 43 | 44 | constructor( 45 | private readonly kubeClientPath: string, 46 | labelSelector?: string, 47 | namespace?: string 48 | ) { 49 | this.namespaceArg = namespace ? [ "--namespace", namespace ] : []; 50 | this.labelSelectorArg = labelSelector ? [ "--selector", labelSelector ] : []; 51 | } 52 | 53 | /* eslint-disable @typescript-eslint/typedef */ 54 | public async logs(podName: string, containerName?: string, group = false): Promise { 55 | const containerNameArg = containerName ? [ containerName ] : []; 56 | 57 | const result = await exec( 58 | this.kubeClientPath, [ 59 | ...this.namespaceArg, 60 | "logs", 61 | podName, 62 | ...containerNameArg, 63 | ], 64 | { group } 65 | ); 66 | 67 | return result.stdout; 68 | } 69 | 70 | public describe(resourceType: KubeResourceType, outputFormat?: string, group = false): Promise { 71 | return this.view("describe", resourceType, outputFormat, group); 72 | } 73 | 74 | public async get(resourceType: KubeResourceType, outputFormat?: string, group = false): Promise { 75 | return this.view("get", resourceType, outputFormat, group); 76 | } 77 | 78 | private async view( 79 | // eslint-disable-next-line @typescript-eslint/typedef 80 | operation: "get" | "describe", resourceType: KubeResourceType, outputFormat?: string, group = false, 81 | ): Promise { 82 | const outputArg = outputFormat ? [ "--output", outputFormat ] : []; 83 | 84 | const result = await exec( 85 | this.kubeClientPath, [ 86 | ...this.namespaceArg, 87 | operation, 88 | resourceType, 89 | ...this.labelSelectorArg, 90 | ...outputArg, 91 | ], 92 | { group } 93 | ); 94 | 95 | return result.stdout; 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/types/runner-location.ts: -------------------------------------------------------------------------------- 1 | /*************************************************************************************************** 2 | * Copyright (c) Red Hat, Inc. All rights reserved. 3 | * Licensed under the MIT License. See LICENSE file in the project root for license information. 4 | **************************************************************************************************/ 5 | 6 | /** 7 | * Represents a place a self-hosted runner can be located. Either under an org, or under a repository. 8 | */ 9 | export default class RunnerLocation { 10 | // public readonly isRepository; 11 | 12 | constructor( 13 | public readonly owner: string, 14 | public readonly repository?: string, 15 | ) { 16 | // this.isRepository = !!this.repository; 17 | } 18 | 19 | public toString(): string { 20 | if (this.repository) { 21 | return `${this.owner}/${this.repository}`; 22 | } 23 | return this.owner; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/types/types.ts: -------------------------------------------------------------------------------- 1 | /*************************************************************************************************** 2 | * Copyright (c) Red Hat, Inc. All rights reserved. 3 | * Licensed under the MIT License. See LICENSE file in the project root for license information. 4 | **************************************************************************************************/ 5 | 6 | import { GitHub } from "@actions/github/lib/utils"; 7 | import RunnerLocation from "./runner-location"; 8 | 9 | // The return type of getOctokit - copied from node_modules/@actions/github/lib/github.d.ts 10 | export type Octokit = InstanceType; 11 | 12 | interface SelfHostedRunnerLabel { 13 | id: number; 14 | name: string; 15 | type: string; 16 | } 17 | 18 | export interface SelfHostedRunner { 19 | id: number; 20 | name: string; 21 | os: string; 22 | status: string; 23 | busy: boolean; 24 | labels: SelfHostedRunnerLabel[]; 25 | } 26 | 27 | // https://docs.github.com/en/rest/reference/actions#list-self-hosted-runners-for-an-organization 28 | // https://docs.github.com/en/rest/reference/actions#list-self-hosted-runners-for-a-repository 29 | export interface SelfHostedRunnersResponse { 30 | // eslint-disable-next-line camelcase 31 | total_count: number; 32 | runners: SelfHostedRunner[]; 33 | } 34 | 35 | /** 36 | * All the inputs we process from the user and then pass around to the helm commands. 37 | */ 38 | export interface RunnerConfiguration { 39 | namespace?: string | undefined; 40 | helmChartVersion: string | undefined; 41 | helmExtraArgs: string[]; 42 | helmReleaseName: string; 43 | helmUninstallIfExists: boolean; 44 | githubPat: string; 45 | runnerLocation: RunnerLocation; 46 | runnerLabels: string[]; 47 | runnerImage: string; 48 | runnerReplicas: string; 49 | runnerTag: string; 50 | } 51 | -------------------------------------------------------------------------------- /src/util/exec.ts: -------------------------------------------------------------------------------- 1 | /*************************************************************************************************** 2 | * Copyright (c) Red Hat, Inc. All rights reserved. 3 | * Licensed under the MIT License. See LICENSE file in the project root for license information. 4 | **************************************************************************************************/ 5 | 6 | import * as core from "@actions/core"; 7 | import * as exec from "@actions/exec"; 8 | import * as path from "path"; 9 | 10 | export default async function execute( 11 | executable: string, 12 | args: string[], 13 | execOptions: exec.ExecOptions & { group?: boolean } = {}, 14 | ): Promise<{ exitCode: number, stdout: string, stderr: string }> { 15 | let stdout = ""; 16 | let stderr = ""; 17 | 18 | const finalExecOptions = { ...execOptions }; 19 | finalExecOptions.ignoreReturnCode = true; // the return code is processed below 20 | 21 | finalExecOptions.listeners = { 22 | stdout: (data): void => { 23 | stdout += `${data.toString()}`; 24 | }, 25 | stderr: (data): void => { 26 | stderr += `${data.toString()}`; 27 | }, 28 | }; 29 | 30 | if (execOptions.group) { 31 | const groupName = [ executable, ...args ].join(" "); 32 | core.startGroup(groupName); 33 | } 34 | 35 | try { 36 | const exitCode = await exec.exec(executable, args, finalExecOptions); 37 | 38 | if (execOptions.ignoreReturnCode !== true && exitCode !== 0) { 39 | // Throwing the stderr as part of the Error makes the stderr show up in the action outline, 40 | // which saves some clicking when debugging. 41 | let error = `${path.basename(executable)} exited with code ${exitCode}`; 42 | if (stderr) { 43 | error += `\n${stderr}`; 44 | } 45 | throw new Error(error); 46 | } 47 | 48 | return { 49 | exitCode, 50 | stdout: stdout.trim(), 51 | stderr: stderr.trim(), 52 | }; 53 | } 54 | finally { 55 | if (execOptions.group) { 56 | core.endGroup(); 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/util/util.ts: -------------------------------------------------------------------------------- 1 | /*************************************************************************************************** 2 | * Copyright (c) Red Hat, Inc. All rights reserved. 3 | * Licensed under the MIT License. See LICENSE file in the project root for license information. 4 | **************************************************************************************************/ 5 | 6 | import * as core from "@actions/core"; 7 | 8 | /** 9 | * Joins a string array into a user-friendly list. 10 | * Eg, `joinList([ "tim", "erin", "john" ], "and")` => "tim, erin and john" 11 | */ 12 | export function joinList(strings_: readonly string[], andOrOr: "and" | "or" = "and"): string { 13 | // we have to duplicate "strings" here since we modify the array below and it's passed by reference 14 | const strings = Array.from(strings_).filter((s) => { 15 | if (!s) { 16 | return false; 17 | } 18 | return true; 19 | }); 20 | 21 | // separate the last string from the others since we have to prepend andOrOr to it 22 | const lastString = strings.splice(strings.length - 1, 1)[0]; 23 | 24 | let joined = strings.join(", "); 25 | if (strings.length > 0) { 26 | joined = `${joined} ${andOrOr} ${lastString}`; 27 | } 28 | else { 29 | joined = lastString; 30 | } 31 | return joined; 32 | } 33 | 34 | export function splitByNewline(s: string): string[] { 35 | return s.split(/\r?\n/); 36 | } 37 | 38 | export function awaitWithRetry( 39 | timeoutS: number, delayS: number, groupName: string, errMsg: string, 40 | executor: (resolve: (value: T) => void, reject?: (err: Error) => void) => Promise 41 | ): Promise { 42 | let tries = 0; 43 | const maxTries = timeoutS / delayS; 44 | 45 | let interval: NodeJS.Timeout | undefined; 46 | 47 | core.startGroup(groupName); 48 | 49 | return new Promise((resolve, reject) => { 50 | interval = setInterval(async () => { 51 | await executor(resolve, reject); 52 | 53 | if (tries > maxTries) { 54 | reject(new Error(errMsg)); 55 | return; 56 | } 57 | 58 | tries++; 59 | }, 60 | delayS * 1000); 61 | }).finally(() => { 62 | if (interval) { 63 | clearInterval(interval); 64 | } 65 | core.endGroup(); 66 | }); 67 | } 68 | -------------------------------------------------------------------------------- /src/wait-for-pods.ts: -------------------------------------------------------------------------------- 1 | /*************************************************************************************************** 2 | * Copyright (c) Red Hat, Inc. All rights reserved. 3 | * Licensed under the MIT License. See LICENSE file in the project root for license information. 4 | **************************************************************************************************/ 5 | 6 | import * as core from "@actions/core"; 7 | 8 | import { awaitWithRetry, splitByNewline } from "./util/util"; 9 | import { getKubeCommandExecutor } from "./types/kube-executor"; 10 | import Constants from "./constants"; 11 | 12 | // Do not quote the jsonpath curly braces as you normally would - it looks like @actions/exec does some extra escaping. 13 | 14 | const JSONPATH_METADATA_NAME = `jsonpath={.items[*].metadata.name}{"\\n"}`; 15 | // we could also use "replicas" instead of "availableReplicas" to not wait for the container to start 16 | const JSONPATH_DEPLOY_REPLICAS = `jsonpath={.items[*].status.availableReplicas}{"\\n"}`; 17 | 18 | // This outputs a line per pod, " " 19 | // it only looks at the first container since we only have one per pod at this time 20 | // eslint-disable-next-line max-len 21 | const JSONPATH_CONTAINER_READY = `jsonpath={range .items[*]}{"podName="}{.metadata.name}{" containerName="}{.status.containerStatuses[0].name}{" ready="}{.status.containerStatuses[0].ready}{"\\n"}{end}`; 22 | 23 | const DEPLOYMENT_READY_TIMEOUT_S = 120; 24 | 25 | export default async function getAndWaitForPods( 26 | releaseName: string, desiredNoReplicas: string, namespace?: string 27 | ): Promise { 28 | // Helm adds the release name in an anntation by default, but you can't query by annotation. 29 | // I have modified the chart to add it to this label, too, so we can find the pods easily. 30 | const labelSelectorArg = `${Constants.RELEASE_NAME_LABEL}=${releaseName}`; 31 | 32 | const kubeExecutor = await getKubeCommandExecutor(labelSelectorArg, namespace); 33 | 34 | const deploymentName = await kubeExecutor.get( 35 | "deployments", 36 | JSONPATH_METADATA_NAME, 37 | ); 38 | 39 | const deploymentNotReadyMsg = `Deployment ${deploymentName} did not have ${desiredNoReplicas} available replicas ` 40 | + `after ${DEPLOYMENT_READY_TIMEOUT_S}s.`; 41 | 42 | await awaitWithRetry( 43 | DEPLOYMENT_READY_TIMEOUT_S, 5, 44 | `Waiting for deployment ${deploymentName} to come up...`, deploymentNotReadyMsg, 45 | async (resolve) => { 46 | await kubeExecutor.get("all"); 47 | 48 | const availableReplicas = await kubeExecutor.get("deployments", JSONPATH_DEPLOY_REPLICAS); 49 | 50 | if (availableReplicas === desiredNoReplicas) { 51 | core.info(`${deploymentName} has ${desiredNoReplicas} replicas!`); 52 | resolve(); 53 | } 54 | } 55 | ).catch(async (err) => { 56 | core.warning(err); 57 | core.info(`🐞 Running debug commands...`); 58 | 59 | try { 60 | await kubeExecutor.describe("deployments", undefined, true); 61 | await kubeExecutor.describe("replicasets", undefined, true); 62 | await kubeExecutor.describe("pods", undefined, true); 63 | 64 | // See the jsonpath above for what this output looks like 65 | const notReadyPods = splitByNewline(await kubeExecutor.get("pods", JSONPATH_CONTAINER_READY)) 66 | // map the lines to objects containing the podName and pod phase 67 | .map((podStatus) => { 68 | const [ podName, containerName, ready ] = podStatus.split(" ") 69 | .map((item) => item.substring(item.indexOf("=") + 1, item.length)); 70 | 71 | return { 72 | podName, containerName, ready, 73 | }; 74 | }) 75 | // filter out the ones that succeeded 76 | .filter((podStatusObj) => podStatusObj.ready); 77 | 78 | if (notReadyPods.length > 0) { 79 | for (const notReadyPod of notReadyPods) { 80 | // and print the logs for the pods that did not succeed 81 | await kubeExecutor.logs(notReadyPod.podName, notReadyPod.containerName, true); 82 | } 83 | } 84 | else { 85 | core.info(`The first container in all pods is Ready - not printing any container logs.`); 86 | } 87 | } 88 | catch (debugErr) { 89 | core.info(`Failed to print debug info: ${debugErr}`); 90 | } 91 | 92 | throw err; 93 | }); 94 | 95 | core.info(`Deployment ${deploymentName} has successfully come up`); 96 | 97 | const podNamesStr = await kubeExecutor.get( 98 | "pods", 99 | JSONPATH_METADATA_NAME, 100 | ); 101 | 102 | const pods = podNamesStr.split(" "); 103 | // core.info(`Released pod${pods.length !== 1 ? "s are" : " is"} ${joinList(pods)}`); 104 | 105 | // show the resourecs in the familiar format 106 | await kubeExecutor.get("all"); 107 | 108 | return pods; 109 | } 110 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@redhat-actions/tsconfig", 3 | "compilerOptions": { 4 | "rootDir": "src/", 5 | "outDir": "out/" 6 | }, 7 | "include": [ 8 | "src/" 9 | ], 10 | } 11 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | //@ts-check 2 | 3 | module.exports = require("@redhat-actions/webpack-config")(__dirname); 4 | --------------------------------------------------------------------------------