From 7bfd12714f7df5afc80d57590ca83a2ae9ea666e Mon Sep 17 00:00:00 2001 From: kurok <22548029+kurok@users.noreply.github.com> Date: Thu, 2 Jul 2026 20:33:00 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20pluggable=20bootstrap=20=E2=80=94=20pre?= =?UTF-8?q?-runner-script=20+=20user-data-template=20(unfork=20Ubuntu=20et?= =?UTF-8?q?=20al.)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Fork and replace the userData array" was a maintenance sentence nobody should serve. Keep the built-in yum bootstrap as the only supported path, but make the bootstrap pluggable so any distro/extra-setup need is a template away. - pre-runner-script (the 80% case): a root snippet injected into the built-in bootstrap before runner configuration (install docker, mount caches, certs). Runs under set -euo pipefail with its own phase tag, so a failure surfaces as failed:pre-runner-script in the diagnostics flow. - user-data-template (full control): replaces the bootstrap entirely. Repo- relative file path or inline string. The action substitutes documented placeholders — {{RUNNER_VERSION}}, {{RUNNER_CHECKSUM_X64/ARM64}}, {{REGISTRATION_TOKEN}}, {{REPO_URL}}, {{LABEL}}, {{TTL_MINUTES}} — and submits the result. Unknown {{...}} tokens error at render (typo protection). - The two inputs are mutually exclusive (config error). Rendered payload is size-guarded against the EC2 16 KB limit. Built-in path with neither input is byte-identical to today (regression). - examples/user-data/ubuntu.sh.tpl (community-maintained) + examples/user-data/ README stating the support boundary — converts the perennial Ubuntu ask from "fork" to "copy one file". Tests: renderUserDataTemplate (substitution, repeats, unknown-token error, token rendered), assertUserDataSize (limit + multibyte), pre-runner-script injection + default-absent, config mutual-exclusion (203 tests total). README support- boundary section replaces the "fork and replace" paragraph; action.yml inputs. Closes #46 Signed-off-by: kurok <22548029+kurok@users.noreply.github.com> --- README.md | 49 +++++++++++- action.yml | 20 +++++ dist/index.js | 129 ++++++++++++++++++++++++++++++- examples/user-data/README.md | 41 ++++++++++ examples/user-data/ubuntu.sh.tpl | 45 +++++++++++ src/aws.js | 53 ++++++++++++- src/config.js | 11 +++ src/template.js | 58 ++++++++++++++ tests/config.test.js | 19 +++++ tests/template.test.js | 55 +++++++++++++ tests/userdata.test.js | 17 ++++ 11 files changed, 492 insertions(+), 5 deletions(-) create mode 100644 examples/user-data/README.md create mode 100644 examples/user-data/ubuntu.sh.tpl create mode 100644 src/template.js create mode 100644 tests/template.test.js diff --git a/README.md b/README.md index 6644b2e5..20c7643b 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ And all this automatically as a part of your GitHub Actions workflow. > > The bootstrap script that this action injects as EC2 `user-data` is hardcoded to use `yum`, `useradd`, `sudo`, `bash`, and a `tmpfs` `/tmp`. That means the AMI you pass via `ec2-image-id` **must** be a yum-based distribution — Amazon Linux 2023 (the tested baseline), Amazon Linux 2, or a RHEL-family image (RHEL / CentOS Stream / Rocky / Alma) whose `/tmp` is mounted as tmpfs. > -> **Debian, Ubuntu, Alpine, and any other non-yum distributions are not supported.** If you launch this action against such an AMI, the EC2 instance will boot but the runner bootstrap will fail. The action now surfaces this quickly: it fails fast naming the failing step and prints the instance's console output (see [Troubleshooting a failed start](#troubleshooting-a-failed-start)). Cross-distro support is not on the roadmap — if you need it, fork and replace the `userData` bootstrap in `src/aws.js`. +> **The built-in bootstrap targets yum-based Linux (Amazon Linux 2023) only.** Launching the built-in bootstrap against a non-yum AMI (Debian, Ubuntu, Alpine, …) fails — the action surfaces this fast, naming the failing step and printing the console output (see [Troubleshooting a failed start](#troubleshooting-a-failed-start)). You **don't need to fork** for other distros or extra setup: use [`pre-runner-script`](#custom-bootstrap-pre-runner-script--user-data-template) to inject steps into the built-in bootstrap, or [`user-data-template`](#custom-bootstrap-pre-runner-script--user-data-template) to replace it entirely (an Ubuntu example ships in [`examples/user-data/`](examples/user-data/)). Custom templates are unsupported by design — the boundary *is* the feature. ![GitHub Actions self-hosted EC2 runner](docs/images/github-actions-runner.gif) @@ -323,6 +323,8 @@ Now you're ready to go! | `eip-allocation-id` | Optional. Used only with the `start` mode. | Allocation Id of an Elastic IP to associate with the runner instance once it is running. | | `runner-version` | Optional. Used only with the `start` mode. | Version of the `actions/runner` binary to download and register (default `2.335.1`).

Must have a matching entry in `src/runner-checksums.js`; the action verifies the downloaded tarball's SHA-256 against that table before extraction. | | `architecture` | Optional. Used only with the `start` mode. | Runner CPU architecture: `x64` (default) or `arm64` (Graviton). Must match the AMI (validated at start). All types in an `ec2-instance-type` fallback list must share this arch. See [Running on Graviton (arm64)](#running-on-graviton-arm64). | +| `pre-runner-script` | Optional. Used only with the `start` mode. | Shell snippet run as root by the built-in bootstrap **before** runner config (install docker, mount caches, add certs). Fail-fast, tagged `failed:pre-runner-script`. Mutually exclusive with `user-data-template`. See [Custom bootstrap](#custom-bootstrap-pre-runner-script--user-data-template). | +| `user-data-template` | Optional. Used only with the `start` mode. | Full bootstrap override — a repo-relative file path or inline string with `{{PLACEHOLDERS}}`. Replaces the built-in bootstrap (unsupported by design). Mutually exclusive with `pre-runner-script`. See [Custom bootstrap](#custom-bootstrap-pre-runner-script--user-data-template). | | `http-tokens` | Optional. Used only with the `start` mode. | Instance Metadata Service (IMDS) token mode (default `required`).

- `required` — IMDSv2 only; mitigates SSRF-style credential theft.
- `optional` — also allows IMDSv1; set only if a workload on the runner needs it. | | `encrypt-ebs` | Optional. Used only with the `start` mode. | When `true`, the root EBS volume is created with SSE-EBS encryption using the account's default AWS-managed key (default `false`). Volume size / type / IOPS are preserved from the AMI unless overridden by the `volume-*` inputs below. | | `volume-size` | Optional. Used only with the `start` mode. | Root EBS volume size in GiB. Omitted = AMI default (Amazon Linux 2023: 8 GiB). Must be ≥ the AMI snapshot size. See [Disk space for Docker workloads](#disk-space-for-docker-workloads). | @@ -524,6 +526,51 @@ A single `subnet-id` + `ec2-instance-type` means a single point of failure: when The `instance-type-used` and `subnet-id-used` outputs report what actually launched. Single values keep the original single-attempt behavior. +## Custom bootstrap (`pre-runner-script` / `user-data-template`) + +Need an extra package or a different distro? Two escape hatches mean you never have to fork. + +### `pre-runner-script` — the 80% case + +Inject shell into the built-in (supported) bootstrap, run as root before the runner registers: + +```yml +- uses: namecheap/ec2-github-runner@v3 + with: + mode: start + # ... other inputs ... + pre-runner-script: | + yum install -y docker + systemctl start docker +``` + +It runs under `set -euo pipefail` and a failure is tagged `failed:pre-runner-script`, so it shows up in the [fast-fail diagnostics](#troubleshooting-a-failed-start) like any other phase. + +### `user-data-template` — full control + +Replace the bootstrap entirely with your own script (repo-relative path or inline string). The action substitutes documented placeholders and submits the result: + +```yml +- uses: namecheap/ec2-github-runner@v3 + with: + mode: start + # ... other inputs ... + user-data-template: ./examples/user-data/ubuntu.sh.tpl +``` + +| Placeholder | Value | +| --- | --- | +| `{{RUNNER_VERSION}}` | Pinned `actions/runner` version | +| `{{RUNNER_CHECKSUM_X64}}` / `{{RUNNER_CHECKSUM_ARM64}}` | Tarball SHA-256 per arch | +| `{{REGISTRATION_TOKEN}}` | Ephemeral registration token (secret) | +| `{{REPO_URL}}` | `https://github.com//` | +| `{{LABEL}}` | The unique runner label | +| `{{TTL_MINUTES}}` | `max-lifetime-minutes` (`0` = disabled) | + +Unknown `{{...}}` tokens fail the run (typo protection); the rendered payload must stay under the EC2 16 KB limit. + +**Support boundary:** the built-in yum bootstrap is the only *supported* path. With a custom template, the action renders your placeholders and gives you the diagnostics tooling — but the script is yours. See [`examples/user-data/`](examples/user-data/) (an Ubuntu 24.04 template ships there as a community-maintained starting point). The two inputs are mutually exclusive. + ## Running on Graviton (arm64) Graviton instances (c7g/m7g/r7g/…) deliver ~20–40% better price/performance for the compile/test workloads CI runs, and Go/Rust/Node/Java toolchains are all arm64-native. Set `architecture: arm64` and point at an arm64 AMI: diff --git a/action.yml b/action.yml index ab03f7ad..876ec3e3 100644 --- a/action.yml +++ b/action.yml @@ -118,6 +118,26 @@ inputs: change is needed to run on Graviton. required: false default: 'x64' + pre-runner-script: + description: >- + Used only with the 'start' mode. A shell snippet run as root by the + built-in bootstrap BEFORE runner configuration (e.g. install docker, + mount caches, add corporate certs). Runs under set -euo pipefail; a + failure is tagged 'failed:pre-runner-script'. Mutually exclusive with + user-data-template. + required: false + user-data-template: + description: >- + Used only with the 'start' mode. Full user-data bootstrap override — + a repo-relative file path or an inline template string. The action + substitutes the documented placeholders ({{RUNNER_VERSION}}, + {{RUNNER_CHECKSUM_X64}}, {{RUNNER_CHECKSUM_ARM64}}, + {{REGISTRATION_TOKEN}}, {{REPO_URL}}, {{LABEL}}, {{TTL_MINUTES}}) and + submits the result as-is. The template MUST register the runner. + Custom templates are unsupported (the built-in bootstrap is the + supported path); see examples/user-data/. Mutually exclusive with + pre-runner-script. + required: false encrypt-ebs: description: >- When 'true', the root EBS volume is created with SSE-EBS diff --git a/dist/index.js b/dist/index.js index c2f7f35f..d7d8401f 100644 --- a/dist/index.js +++ b/dist/index.js @@ -104659,11 +104659,13 @@ const { AssociateAddressCommand, waitUntilInstanceRunning, } = __nccwpck_require__(5193); +const fs = __nccwpck_require__(9896); const core = __nccwpck_require__(7484); const config = __nccwpck_require__(1283); const log = __nccwpck_require__(7223); const { withRetry } = __nccwpck_require__(6759); const { sortByCreationDate, parseCsv } = __nccwpck_require__(5804); +const { renderUserDataTemplate, assertUserDataSize } = __nccwpck_require__(9275); const checksums = __nccwpck_require__(2874); // RunInstances error classification for the capacity-fallback chain. @@ -104994,7 +104996,21 @@ function buildRootDeviceMapping(image, opts = {}) { // registration timeout. Tagging is best-effort: it needs // `ec2:CreateTags` on the instance profile and degrades to // timeout-only detection when absent (every write is `|| true`). -function buildUserData({ runnerVersion, owner, repo, label, githubRegistrationToken, shaX64, shaArm64, maxLifetimeMinutes }) { +function buildUserData({ runnerVersion, owner, repo, label, githubRegistrationToken, shaX64, shaArm64, maxLifetimeMinutes, preRunnerScript }) { + // User-supplied pre-runner-script (#46): injected verbatim into the outer + // (root) shell before runner configuration, under the same set -euo + // pipefail + ERR trap, tagged as its own phase so a failure surfaces as + // failed:pre-runner-script. + const preRunnerLines = preRunnerScript && preRunnerScript.trim() + ? [ + '# User-supplied pre-runner-script (runs as root before runner config).', + 'GH_RUNNER_STEP=pre-runner-script', + 'gh_runner_phone_home pre-runner-script', + ...preRunnerScript.replace(/\r\n/g, '\n').split('\n'), + '', + ] + : []; + // TTL self-destruct (#42): arm a shutdown timer as an absolute upper // bound on instance lifetime. Combined with // InstanceInitiatedShutdownBehavior: terminate on RunInstances (set in @@ -105053,6 +105069,7 @@ function buildUserData({ runnerVersion, owner, repo, label, githubRegistrationTo ' useradd -m -s /bin/bash runner', 'fi', '', + ...preRunnerLines, '# The runner-user shell owns the download/configure/register phases and', '# reports them itself; drop the outer ERR trap so it does not overwrite', '# the inner shell\'s more specific failed: tag.', @@ -105127,6 +105144,15 @@ function buildTagSpecifications(label, startedAtIso) { ]; } +// Resolve the user-data-template input: a repo-relative path is read from +// disk; anything else is treated as an inline template string. +function resolveUserDataTemplate(templateInput) { + if (fs.existsSync(templateInput)) { + return fs.readFileSync(templateInput, 'utf8'); + } + return templateInput; +} + async function startEc2Instance(label, githubRegistrationToken) { const client = ec2Client(); @@ -105144,7 +105170,30 @@ async function startEc2Instance(label, githubRegistrationToken) { } const maxLifetimeMinutes = config.input.maxLifetimeMinutes; - const userData = buildUserData({ runnerVersion, owner, repo, label, githubRegistrationToken, shaX64, shaArm64, maxLifetimeMinutes }); + + // Bootstrap source (#46): a full user-data-template override renders the + // documented placeholders; otherwise the built-in yum bootstrap is used, + // optionally with an injected pre-runner-script. Either way the rendered + // payload is size-checked against the EC2 16 KB limit. + let userData; + if (config.input.userDataTemplate) { + const template = resolveUserDataTemplate(config.input.userDataTemplate); + userData = renderUserDataTemplate(template, { + RUNNER_VERSION: runnerVersion, + RUNNER_CHECKSUM_X64: shaX64, + RUNNER_CHECKSUM_ARM64: shaArm64, + REGISTRATION_TOKEN: githubRegistrationToken, + REPO_URL: `https://github.com/${owner}/${repo}`, + LABEL: label, + TTL_MINUTES: maxLifetimeMinutes, + }); + } else { + userData = buildUserData({ + runnerVersion, owner, repo, label, githubRegistrationToken, shaX64, shaArm64, maxLifetimeMinutes, + preRunnerScript: config.input.preRunnerScript, + }); + } + assertUserDataSize(userData); const resolved = await resolveImage(client); config.input.ec2ImageId = resolved.id; @@ -105682,6 +105731,8 @@ class Config { ec2InstanceIds: core.getInput('ec2-instance-ids') ? JSON.parse(core.getInput('ec2-instance-ids')) : null, count: core.getInput('count') || '1', allowPartial: core.getInput('allow-partial') || 'false', + preRunnerScript: core.getInput('pre-runner-script'), + userDataTemplate: core.getInput('user-data-template'), iamRoleName: core.getInput('iam-role-name'), runnerVersion: core.getInput('runner-version') || '2.335.1', architecture: core.getInput('architecture') || 'x64', @@ -105735,6 +105786,7 @@ class Config { this.validateMarketInputs(); this.validateArchitectureInputs(); this.validateCountInput(); + this.validateBootstrapInputs(); } else if (this.input.mode === 'stop') { // A stop needs the shared label plus at least one instance id — either // the compat scalar or the JSON array from a batched start. @@ -105813,6 +105865,14 @@ class Config { } } + // The two bootstrap-extension inputs are mutually exclusive: pre-runner- + // script augments the built-in bootstrap, user-data-template replaces it. + validateBootstrapInputs() { + if (this.input.userDataTemplate && this.input.preRunnerScript) { + throw new Error(`'user-data-template' and 'pre-runner-script' are mutually exclusive`); + } + } + // Validate the multi-runner batch size. validateCountInput() { if (!(/^[0-9]+$/.test(this.input.count) && Number(this.input.count) >= 1)) { @@ -106159,6 +106219,71 @@ module.exports = { }; +/***/ }), + +/***/ 9275: +/***/ ((module) => { + +// User-data template rendering for the `user-data-template` input (full +// bootstrap override). The action renders a fixed set of placeholders and +// submits the result verbatim; the script itself is the user's business. +// Pure functions — no I/O — so rendering and validation are unit-testable. + +// EC2 caps user-data at 16 KB (raw, before base64). Guard against it up +// front with a clear error rather than a cryptic RunInstances rejection. +const MAX_USER_DATA_BYTES = 16 * 1024; + +// The documented placeholders a template may reference. +const PLACEHOLDERS = [ + 'RUNNER_VERSION', + 'RUNNER_CHECKSUM_X64', + 'RUNNER_CHECKSUM_ARM64', + 'REGISTRATION_TOKEN', + 'REPO_URL', + 'LABEL', + 'TTL_MINUTES', +]; + +// Substitute every documented {{PLACEHOLDER}} with its value, then fail if +// any unknown {{TOKEN}} remains (catches typos before boot). Unused known +// placeholders are fine. +function renderUserDataTemplate(template, vars) { + let out = template; + for (const key of PLACEHOLDERS) { + const value = vars[key] != null ? String(vars[key]) : ''; + out = out.split(`{{${key}}}`).join(value); + } + const unknown = [...out.matchAll(/\{\{\s*([A-Za-z0-9_]+)\s*\}\}/g)].map((m) => m[1]); + if (unknown.length > 0) { + const uniq = [...new Set(unknown)]; + throw new Error( + `Unknown placeholder(s) in user-data-template: ${uniq.join(', ')}. ` + + `Supported placeholders: ${PLACEHOLDERS.join(', ')}`, + ); + } + return out; +} + +// Throw if the rendered user-data exceeds the EC2 16 KB limit. +function assertUserDataSize(userData) { + const bytes = Buffer.byteLength(userData, 'utf8'); + if (bytes > MAX_USER_DATA_BYTES) { + throw new Error( + `Rendered user-data is ${bytes} bytes, over the EC2 limit of ${MAX_USER_DATA_BYTES} bytes. ` + + 'Trim the pre-runner-script / user-data-template (fetch large payloads at runtime instead).', + ); + } + return userData; +} + +module.exports = { + renderUserDataTemplate, + assertUserDataSize, + MAX_USER_DATA_BYTES, + PLACEHOLDERS, +}; + + /***/ }), /***/ 5804: diff --git a/examples/user-data/README.md b/examples/user-data/README.md new file mode 100644 index 00000000..28de6b1c --- /dev/null +++ b/examples/user-data/README.md @@ -0,0 +1,41 @@ +# Custom user-data templates + +> **Support boundary.** The built-in **yum-based** bootstrap (Amazon Linux 2023) is the only *supported* path. Templates in this directory are **community-maintained starting points**. When you supply `user-data-template`, the action renders your placeholders and gives you the same diagnostics tooling (bootstrap phone-home tags, console capture, cleanup) — but the script is yours to maintain. Bugs in a custom bootstrap are not action bugs. + +## Using a template + +```yml +- uses: namecheap/ec2-github-runner@v3 + with: + mode: start + github-token: ${{ secrets.GH_PERSONAL_ACCESS_TOKEN }} + ec2-image-id: ami-ubuntu-... # an Ubuntu AMI + ec2-instance-type: c7i.4xlarge + subnet-id: subnet-123 + security-group-id: sg-123 + user-data-template: ./examples/user-data/ubuntu.sh.tpl +``` + +`user-data-template` accepts a **repo-relative file path** or an **inline string**. + +## Placeholders + +The action substitutes these before launch (unknown `{{...}}` tokens fail the run so typos are caught early): + +| Placeholder | Value | +| --- | --- | +| `{{RUNNER_VERSION}}` | Pinned `actions/runner` version | +| `{{RUNNER_CHECKSUM_X64}}` | SHA-256 of the linux-x64 tarball | +| `{{RUNNER_CHECKSUM_ARM64}}` | SHA-256 of the linux-arm64 tarball | +| `{{REGISTRATION_TOKEN}}` | Ephemeral runner registration token (secret — never logged) | +| `{{REPO_URL}}` | `https://github.com//` | +| `{{LABEL}}` | The unique runner label | +| `{{TTL_MINUTES}}` | `max-lifetime-minutes` (`0` = disabled) | + +**Contract:** the template must register the runner (`config.sh … --ephemeral --unattended` then `run.sh`). Verify the tarball checksum. Everything else is your distro's business. Rendered user-data must stay under the EC2 16 KB limit — fetch large payloads at runtime. + +## Templates here + +- [`ubuntu.sh.tpl`](ubuntu.sh.tpl) — Ubuntu 24.04 (community-maintained). + +For a one-package tweak (e.g. install docker) you usually don't need a full template — use `pre-runner-script` instead, which keeps the supported bootstrap. diff --git a/examples/user-data/ubuntu.sh.tpl b/examples/user-data/ubuntu.sh.tpl new file mode 100644 index 00000000..077050d1 --- /dev/null +++ b/examples/user-data/ubuntu.sh.tpl @@ -0,0 +1,45 @@ +#!/bin/bash +# Community-maintained user-data-template for Ubuntu (24.04 tested). +# +# UNSUPPORTED: the built-in yum bootstrap is the only supported path. This +# template is a starting point so Ubuntu users copy one file instead of +# forking. The action substitutes the {{PLACEHOLDERS}} before launch; keep +# them intact. Pass it via: user-data-template: ./examples/user-data/ubuntu.sh.tpl +set -euo pipefail + +# TTL self-destruct (max-lifetime-minutes). Requires the action's +# InstanceInitiatedShutdownBehavior=terminate, which it sets when TTL > 0. +if [ "{{TTL_MINUTES}}" != "0" ]; then + shutdown -h +{{TTL_MINUTES}} || true +fi + +export DEBIAN_FRONTEND=noninteractive +apt-get update -y +apt-get install -y curl tar libicu-dev sudo + +# Non-root runner user (idempotent). +if ! id runner >/dev/null 2>&1; then + useradd -m -s /bin/bash runner +fi + +sudo -u runner -H bash <<'RUNNER_BOOTSTRAP' +set -euo pipefail +cd "$HOME" +mkdir -p actions-runner && cd actions-runner + +case "$(uname -m)" in + aarch64) RUNNER_ARCH="arm64"; EXPECTED_SHA="{{RUNNER_CHECKSUM_ARM64}}" ;; + amd64|x86_64) RUNNER_ARCH="x64"; EXPECTED_SHA="{{RUNNER_CHECKSUM_X64}}" ;; + *) echo "unsupported arch: $(uname -m)" >&2; exit 1 ;; +esac + +RUNNER_VERSION="{{RUNNER_VERSION}}" +TARBALL="actions-runner-linux-${RUNNER_ARCH}-${RUNNER_VERSION}.tar.gz" +curl -fsSLo "$TARBALL" "https://github.com/actions/runner/releases/download/v${RUNNER_VERSION}/${TARBALL}" +echo "$EXPECTED_SHA $TARBALL" | sha256sum -c - +tar xzf "$TARBALL" + +export DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 +./config.sh --url "{{REPO_URL}}" --token "{{REGISTRATION_TOKEN}}" --labels "{{LABEL}}" --ephemeral --unattended --disableupdate +./run.sh +RUNNER_BOOTSTRAP diff --git a/src/aws.js b/src/aws.js index 9e378986..84d7e3b3 100644 --- a/src/aws.js +++ b/src/aws.js @@ -9,11 +9,13 @@ const { AssociateAddressCommand, waitUntilInstanceRunning, } = require('@aws-sdk/client-ec2'); +const fs = require('fs'); const core = require('@actions/core'); const config = require('./config'); const log = require('./log'); const { withRetry } = require('./retry'); const { sortByCreationDate, parseCsv } = require('./utils'); +const { renderUserDataTemplate, assertUserDataSize } = require('./template'); const checksums = require('./runner-checksums'); // RunInstances error classification for the capacity-fallback chain. @@ -344,7 +346,21 @@ function buildRootDeviceMapping(image, opts = {}) { // registration timeout. Tagging is best-effort: it needs // `ec2:CreateTags` on the instance profile and degrades to // timeout-only detection when absent (every write is `|| true`). -function buildUserData({ runnerVersion, owner, repo, label, githubRegistrationToken, shaX64, shaArm64, maxLifetimeMinutes }) { +function buildUserData({ runnerVersion, owner, repo, label, githubRegistrationToken, shaX64, shaArm64, maxLifetimeMinutes, preRunnerScript }) { + // User-supplied pre-runner-script (#46): injected verbatim into the outer + // (root) shell before runner configuration, under the same set -euo + // pipefail + ERR trap, tagged as its own phase so a failure surfaces as + // failed:pre-runner-script. + const preRunnerLines = preRunnerScript && preRunnerScript.trim() + ? [ + '# User-supplied pre-runner-script (runs as root before runner config).', + 'GH_RUNNER_STEP=pre-runner-script', + 'gh_runner_phone_home pre-runner-script', + ...preRunnerScript.replace(/\r\n/g, '\n').split('\n'), + '', + ] + : []; + // TTL self-destruct (#42): arm a shutdown timer as an absolute upper // bound on instance lifetime. Combined with // InstanceInitiatedShutdownBehavior: terminate on RunInstances (set in @@ -403,6 +419,7 @@ function buildUserData({ runnerVersion, owner, repo, label, githubRegistrationTo ' useradd -m -s /bin/bash runner', 'fi', '', + ...preRunnerLines, '# The runner-user shell owns the download/configure/register phases and', '# reports them itself; drop the outer ERR trap so it does not overwrite', '# the inner shell\'s more specific failed: tag.', @@ -477,6 +494,15 @@ function buildTagSpecifications(label, startedAtIso) { ]; } +// Resolve the user-data-template input: a repo-relative path is read from +// disk; anything else is treated as an inline template string. +function resolveUserDataTemplate(templateInput) { + if (fs.existsSync(templateInput)) { + return fs.readFileSync(templateInput, 'utf8'); + } + return templateInput; +} + async function startEc2Instance(label, githubRegistrationToken) { const client = ec2Client(); @@ -494,7 +520,30 @@ async function startEc2Instance(label, githubRegistrationToken) { } const maxLifetimeMinutes = config.input.maxLifetimeMinutes; - const userData = buildUserData({ runnerVersion, owner, repo, label, githubRegistrationToken, shaX64, shaArm64, maxLifetimeMinutes }); + + // Bootstrap source (#46): a full user-data-template override renders the + // documented placeholders; otherwise the built-in yum bootstrap is used, + // optionally with an injected pre-runner-script. Either way the rendered + // payload is size-checked against the EC2 16 KB limit. + let userData; + if (config.input.userDataTemplate) { + const template = resolveUserDataTemplate(config.input.userDataTemplate); + userData = renderUserDataTemplate(template, { + RUNNER_VERSION: runnerVersion, + RUNNER_CHECKSUM_X64: shaX64, + RUNNER_CHECKSUM_ARM64: shaArm64, + REGISTRATION_TOKEN: githubRegistrationToken, + REPO_URL: `https://github.com/${owner}/${repo}`, + LABEL: label, + TTL_MINUTES: maxLifetimeMinutes, + }); + } else { + userData = buildUserData({ + runnerVersion, owner, repo, label, githubRegistrationToken, shaX64, shaArm64, maxLifetimeMinutes, + preRunnerScript: config.input.preRunnerScript, + }); + } + assertUserDataSize(userData); const resolved = await resolveImage(client); config.input.ec2ImageId = resolved.id; diff --git a/src/config.js b/src/config.js index af9ca41e..b22531ce 100644 --- a/src/config.js +++ b/src/config.js @@ -22,6 +22,8 @@ class Config { ec2InstanceIds: core.getInput('ec2-instance-ids') ? JSON.parse(core.getInput('ec2-instance-ids')) : null, count: core.getInput('count') || '1', allowPartial: core.getInput('allow-partial') || 'false', + preRunnerScript: core.getInput('pre-runner-script'), + userDataTemplate: core.getInput('user-data-template'), iamRoleName: core.getInput('iam-role-name'), runnerVersion: core.getInput('runner-version') || '2.335.1', architecture: core.getInput('architecture') || 'x64', @@ -75,6 +77,7 @@ class Config { this.validateMarketInputs(); this.validateArchitectureInputs(); this.validateCountInput(); + this.validateBootstrapInputs(); } else if (this.input.mode === 'stop') { // A stop needs the shared label plus at least one instance id — either // the compat scalar or the JSON array from a batched start. @@ -153,6 +156,14 @@ class Config { } } + // The two bootstrap-extension inputs are mutually exclusive: pre-runner- + // script augments the built-in bootstrap, user-data-template replaces it. + validateBootstrapInputs() { + if (this.input.userDataTemplate && this.input.preRunnerScript) { + throw new Error(`'user-data-template' and 'pre-runner-script' are mutually exclusive`); + } + } + // Validate the multi-runner batch size. validateCountInput() { if (!(/^[0-9]+$/.test(this.input.count) && Number(this.input.count) >= 1)) { diff --git a/src/template.js b/src/template.js new file mode 100644 index 00000000..41ae6da2 --- /dev/null +++ b/src/template.js @@ -0,0 +1,58 @@ +// User-data template rendering for the `user-data-template` input (full +// bootstrap override). The action renders a fixed set of placeholders and +// submits the result verbatim; the script itself is the user's business. +// Pure functions — no I/O — so rendering and validation are unit-testable. + +// EC2 caps user-data at 16 KB (raw, before base64). Guard against it up +// front with a clear error rather than a cryptic RunInstances rejection. +const MAX_USER_DATA_BYTES = 16 * 1024; + +// The documented placeholders a template may reference. +const PLACEHOLDERS = [ + 'RUNNER_VERSION', + 'RUNNER_CHECKSUM_X64', + 'RUNNER_CHECKSUM_ARM64', + 'REGISTRATION_TOKEN', + 'REPO_URL', + 'LABEL', + 'TTL_MINUTES', +]; + +// Substitute every documented {{PLACEHOLDER}} with its value, then fail if +// any unknown {{TOKEN}} remains (catches typos before boot). Unused known +// placeholders are fine. +function renderUserDataTemplate(template, vars) { + let out = template; + for (const key of PLACEHOLDERS) { + const value = vars[key] != null ? String(vars[key]) : ''; + out = out.split(`{{${key}}}`).join(value); + } + const unknown = [...out.matchAll(/\{\{\s*([A-Za-z0-9_]+)\s*\}\}/g)].map((m) => m[1]); + if (unknown.length > 0) { + const uniq = [...new Set(unknown)]; + throw new Error( + `Unknown placeholder(s) in user-data-template: ${uniq.join(', ')}. ` + + `Supported placeholders: ${PLACEHOLDERS.join(', ')}`, + ); + } + return out; +} + +// Throw if the rendered user-data exceeds the EC2 16 KB limit. +function assertUserDataSize(userData) { + const bytes = Buffer.byteLength(userData, 'utf8'); + if (bytes > MAX_USER_DATA_BYTES) { + throw new Error( + `Rendered user-data is ${bytes} bytes, over the EC2 limit of ${MAX_USER_DATA_BYTES} bytes. ` + + 'Trim the pre-runner-script / user-data-template (fetch large payloads at runtime instead).', + ); + } + return userData; +} + +module.exports = { + renderUserDataTemplate, + assertUserDataSize, + MAX_USER_DATA_BYTES, + PLACEHOLDERS, +}; diff --git a/tests/config.test.js b/tests/config.test.js index f6425032..11d50bcb 100644 --- a/tests/config.test.js +++ b/tests/config.test.js @@ -238,6 +238,25 @@ describe('Config — stop mode with instance batch', () => { }); }); +describe('Config — bootstrap extension inputs', () => { + test('accepts a pre-runner-script alone', () => { + const config = loadConfig({ ...startModeInputs, 'pre-runner-script': 'yum install -y docker' }); + expect(config.input.preRunnerScript).toBe('yum install -y docker'); + }); + + test('accepts a user-data-template alone', () => { + const config = loadConfig({ ...startModeInputs, 'user-data-template': './ci/boot.sh.tpl' }); + expect(config.input.userDataTemplate).toBe('./ci/boot.sh.tpl'); + }); + + test('rejects setting both (mutually exclusive)', () => { + expectValidationFailure( + { ...startModeInputs, 'pre-runner-script': 'echo hi', 'user-data-template': './boot.tpl' }, + /mutually exclusive/, + ); + }); +}); + describe('Config — architecture input', () => { test('defaults to x64', () => { expect(loadConfig(startModeInputs).input.architecture).toBe('x64'); diff --git a/tests/template.test.js b/tests/template.test.js new file mode 100644 index 00000000..ef22d5b4 --- /dev/null +++ b/tests/template.test.js @@ -0,0 +1,55 @@ +// Tests for user-data template rendering + size guard (src/template.js). +const { renderUserDataTemplate, assertUserDataSize, MAX_USER_DATA_BYTES } = require('../src/template'); + +const vars = { + RUNNER_VERSION: '2.335.1', + RUNNER_CHECKSUM_X64: 'x64sha', + RUNNER_CHECKSUM_ARM64: 'arm64sha', + REGISTRATION_TOKEN: 'SECRET-TOKEN', + REPO_URL: 'https://github.com/my-org/my-repo', + LABEL: 'runner-abc12', + TTL_MINUTES: '360', +}; + +describe('renderUserDataTemplate', () => { + test('substitutes every documented placeholder', () => { + const tpl = 'v={{RUNNER_VERSION}} x={{RUNNER_CHECKSUM_X64}} a={{RUNNER_CHECKSUM_ARM64}} t={{REGISTRATION_TOKEN}} u={{REPO_URL}} l={{LABEL}} ttl={{TTL_MINUTES}}'; + expect(renderUserDataTemplate(tpl, vars)).toBe( + 'v=2.335.1 x=x64sha a=arm64sha t=SECRET-TOKEN u=https://github.com/my-org/my-repo l=runner-abc12 ttl=360', + ); + }); + + test('substitutes repeated placeholders', () => { + expect(renderUserDataTemplate('{{LABEL}}-{{LABEL}}', vars)).toBe('runner-abc12-runner-abc12'); + }); + + test('leaves unused known placeholders absent without error', () => { + expect(renderUserDataTemplate('only {{LABEL}}', vars)).toBe('only runner-abc12'); + }); + + test('throws listing unknown placeholders (typo protection)', () => { + expect(() => renderUserDataTemplate('{{RUNNER_VERION}} {{FOO}}', vars)).toThrow(/RUNNER_VERION, FOO/); + }); + + test('renders the token so the runner can register (secret handled by caller redaction)', () => { + expect(renderUserDataTemplate('--token {{REGISTRATION_TOKEN}}', vars)).toContain('SECRET-TOKEN'); + }); +}); + +describe('assertUserDataSize', () => { + test('passes payloads within the 16 KB limit', () => { + const ud = 'a'.repeat(1000); + expect(assertUserDataSize(ud)).toBe(ud); + }); + + test('throws for payloads over the limit', () => { + const ud = 'a'.repeat(MAX_USER_DATA_BYTES + 1); + expect(() => assertUserDataSize(ud)).toThrow(/over the EC2 limit/); + }); + + test('counts bytes, not characters (multibyte)', () => { + // Each '€' is 3 bytes in UTF-8. + const ud = '€'.repeat(Math.ceil(MAX_USER_DATA_BYTES / 3) + 1); + expect(() => assertUserDataSize(ud)).toThrow(/over the EC2 limit/); + }); +}); diff --git a/tests/userdata.test.js b/tests/userdata.test.js index f56cd9e5..0366793f 100644 --- a/tests/userdata.test.js +++ b/tests/userdata.test.js @@ -103,4 +103,21 @@ describe('buildUserData', () => { const ud = buildUserData(args); expect(ud).not.toContain('shutdown -h'); }); + + test('injects a pre-runner-script with its own phase tag, before the runner-user handoff', () => { + const ud = buildUserData({ ...args, preRunnerScript: 'yum install -y docker\nsystemctl start docker' }); + expect(ud).toContain('GH_RUNNER_STEP=pre-runner-script'); + expect(ud).toContain('gh_runner_phone_home pre-runner-script'); + expect(ud).toContain('yum install -y docker'); + expect(ud).toContain('systemctl start docker'); + // Runs as root before dropping to the runner user. + expect(ud.indexOf('GH_RUNNER_STEP=pre-runner-script')).toBeLessThan(ud.indexOf("sudo -u runner -H bash <<'RUNNER_BOOTSTRAP'")); + }); + + test('omits the pre-runner-script phase entirely when not provided (default byte-identical)', () => { + const ud = buildUserData(args); + expect(ud).not.toContain('pre-runner-script'); + // whitespace-only script is treated as absent + expect(buildUserData({ ...args, preRunnerScript: ' \n ' })).not.toContain('pre-runner-script'); + }); });