@le-space/rootfs 0.11.0 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -13,6 +13,8 @@ Shared rootfs contract parsing, reference profile assets, and build helpers.
13
13
  ports and consumer env expectations
14
14
  - added an ephemeral `playwright-runner` profile with Node.js 24, exact
15
15
  Playwright/Chromium `1.61.1`, authenticated WSS, bounded logs, and a guest TTL
16
+ - added an ephemeral `github-runner` profile: a one-job GitHub Actions runner
17
+ that forgets its own INSTANCE after the job
16
18
 
17
19
  ## Reference assets
18
20
 
@@ -26,3 +28,5 @@ Current shared reference profiles live under:
26
28
  - `reference/ucan-store/rootfs/*`
27
29
  - `reference/playwright-runner/contract.json`
28
30
  - `reference/playwright-runner/rootfs/*`
31
+ - `reference/github-runner/contract.json`
32
+ - `reference/github-runner/rootfs/*`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@le-space/rootfs",
3
- "version": "0.11.0",
3
+ "version": "0.12.1",
4
4
  "description": "Shared rootfs contract parsing, reference profile assets, and build helpers.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,23 @@
1
+ # GitHub runner RootFS
2
+
3
+ Ubuntu 24.04 (as GitHub's own runners) with the GitHub `actions-runner` (pinned, sha256-checked, updates
4
+ off), Node.js 24, Docker, git, jq and rootfs build tools. It contains no
5
+ credential.
6
+
7
+ After boot, `scripts/aleph-github-runner.mjs` (`pnpm relay-button gh-runner up`)
8
+ writes over SSH, in this order:
9
+
10
+ - `/etc/github-runner/ttl`: maximum lifetime in minutes (default 360);
11
+ - `/etc/github-runner/forget.env` (optional): `ALEPH_FORGET_KEY`, `ALEPH_OWNER`,
12
+ `ALEPH_INSTANCE`, a delegate key authorized for INSTANCE and FORGET (Aleph
13
+ lets a delegate forget only what it could have created);
14
+ - `/etc/github-runner/jitconfig`: a one-job just-in-time runner config.
15
+
16
+ `github-runner.service` waits for the jitconfig, deletes it once read, and runs
17
+ one job as the `runner` user (passwordless sudo, docker group). When the runner
18
+ exits, `github-runner-finish` forgets the VM's own INSTANCE (if a forget key was
19
+ given) and powers off. The TTL timer runs the same path as a last resort.
20
+
21
+ Self-hosted runners execute workflow code with root rights on this VM: use them
22
+ for private repositories, or public ones whose workflows never run for forks.
23
+ See the docs page `architecture/aleph-github-runner`.
@@ -0,0 +1,34 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "id": "github-runner",
4
+ "displayName": "Ephemeral GitHub Actions Runner",
5
+ "source": {
6
+ "repository": "NiKrause/relay-button",
7
+ "subdirectory": "packages/rootfs/reference/github-runner"
8
+ },
9
+ "rootfs": {
10
+ "profile": "github-runner",
11
+ "installMode": "prebaked",
12
+ "installDir": "/opt/actions-runner",
13
+ "binaryPath": "/opt/actions-runner/run.sh",
14
+ "dataDir": "/var/lib/github-runner",
15
+ "envFile": "/etc/github-runner/runner.env"
16
+ },
17
+ "services": {
18
+ "bootstrap": "github-runner.service",
19
+ "main": "github-runner.service",
20
+ "autotlsRefresh": "github-runner-ttl.timer"
21
+ },
22
+ "ports": [
23
+ {
24
+ "port": 22,
25
+ "tcp": true,
26
+ "udp": false,
27
+ "purpose": "SSH bootstrap and diagnostics"
28
+ }
29
+ ],
30
+ "manifest": {
31
+ "copyTarget": "packages/rootfs/reference/github-runner/latest.json",
32
+ "notes": "GitHub actions-runner 2.338.0, Node.js 24, Docker and rootfs build tools are prebaked. A one-job just-in-time runner config must be injected after boot; the image contains no credential. After its job the runner forgets its own INSTANCE when a forget key was injected, and powers off; a bounded TTL timer is the last resort."
33
+ }
34
+ }
@@ -0,0 +1,16 @@
1
+ FROM debian:12
2
+
3
+ ENV DEBIAN_FRONTEND=noninteractive
4
+ ENV LIBGUESTFS_BACKEND=direct
5
+
6
+ RUN apt-get update \
7
+ && apt-get install -y --no-install-recommends \
8
+ ca-certificates \
9
+ curl \
10
+ libguestfs-tools \
11
+ linux-image-amd64 \
12
+ qemu-system-x86 \
13
+ qemu-utils \
14
+ && rm -rf /var/lib/apt/lists/*
15
+
16
+ WORKDIR /workspace/shared-rootfs
@@ -0,0 +1,104 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
5
+ OUT_DIR="${OUT_DIR:-${SCRIPT_DIR}/../dist-rootfs}"
6
+ # Ubuntu 24.04, the system of GitHub's own ubuntu-24.04 runners: jobs bring
7
+ # tools built for it, such as the aleph CLI release binary, which needs glibc
8
+ # 2.38 (Debian 12 has 2.36). Debian 13's cloud image did not boot on Aleph
9
+ # nodes (reboot loop after GRUB); Aleph offers ubuntu24 as a preset.
10
+ BASE_URL="${BASE_URL:-https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img}"
11
+ BASE_IMAGE="${OUT_DIR}/noble-server-cloudimg-amd64.img"
12
+ IMAGE="${OUT_DIR}/aleph-github-runner.qcow2"
13
+ ROOTFS_IMAGE_SIZE="${ROOTFS_IMAGE_SIZE:-10G}"
14
+ # Pinned on purpose: the runner is started with --disableupdate, so the image
15
+ # decides the version. Bump both lines together.
16
+ RUNNER_VERSION="${RUNNER_VERSION:-2.338.0}"
17
+ RUNNER_SHA256="${RUNNER_SHA256:-af4b794c1bc41d73d40535e3fe092a39f9679cd8d965954c2aca25a05ca41d32}"
18
+ ETHERS_VERSION="6.16.0"
19
+ HOST_UID="${HOST_UID:-}"
20
+ HOST_GID="${HOST_GID:-}"
21
+ ROOTFS_SPARSIFY_TMPDIR=""
22
+
23
+ require() {
24
+ command -v "$1" >/dev/null 2>&1 || { echo "Missing required command: $1" >&2; exit 1; }
25
+ }
26
+
27
+ for command in curl qemu-img virt-customize sha256sum; do require "${command}"; done
28
+
29
+ cleanup() {
30
+ if [ -n "${ROOTFS_SPARSIFY_TMPDIR}" ] && [ -d "${ROOTFS_SPARSIFY_TMPDIR}" ]; then
31
+ rm -rf "${ROOTFS_SPARSIFY_TMPDIR}"
32
+ fi
33
+ }
34
+ trap cleanup EXIT
35
+
36
+ mkdir -p "${OUT_DIR}"
37
+ if [ ! -f "${BASE_IMAGE}" ]; then
38
+ curl --fail --show-error --location --retry 5 --retry-all-errors \
39
+ --connect-timeout 20 --max-time 300 "${BASE_URL}" -o "${BASE_IMAGE}"
40
+ fi
41
+
42
+ RUNNER_TARBALL="${OUT_DIR}/actions-runner-linux-x64-${RUNNER_VERSION}.tar.gz"
43
+ if [ ! -f "${RUNNER_TARBALL}" ]; then
44
+ curl --fail --show-error --location --retry 5 --retry-all-errors \
45
+ --connect-timeout 20 --max-time 300 \
46
+ "https://github.com/actions/runner/releases/download/v${RUNNER_VERSION}/actions-runner-linux-x64-${RUNNER_VERSION}.tar.gz" \
47
+ -o "${RUNNER_TARBALL}"
48
+ fi
49
+ echo "${RUNNER_SHA256} ${RUNNER_TARBALL}" | sha256sum --check --strict
50
+
51
+ cp "${BASE_IMAGE}" "${IMAGE}"
52
+ qemu-img resize "${IMAGE}" "${ROOTFS_IMAGE_SIZE}"
53
+ # Growing the file alone leaves the cloud image's ~2.5 GB root partition full
54
+ # before Docker is unpacked. The root partition is the last one on the disk,
55
+ # so growpart extends it in place; virt-resize would rewrite the partition
56
+ # table, and Ubuntu's cloud image finds its root by partition UUID (a resized
57
+ # image never brought up SSH on Aleph).
58
+ virt-customize -a "${IMAGE}" --run-command "growpart /dev/sda 1 && resize2fs /dev/sda1"
59
+
60
+ virt-customize \
61
+ -a "${IMAGE}" \
62
+ --network \
63
+ --mkdir /opt/actions-runner \
64
+ --mkdir /opt/github-runner \
65
+ --mkdir /etc/github-runner \
66
+ --mkdir /etc/systemd/journald.conf.d \
67
+ --mkdir /var/lib/github-runner \
68
+ --copy-in "${RUNNER_TARBALL}:/opt/github-runner" \
69
+ --copy-in "${SCRIPT_DIR}/forget-self.mjs:/opt/github-runner" \
70
+ --copy-in "${SCRIPT_DIR}/github-runner-start:/usr/local/sbin" \
71
+ --copy-in "${SCRIPT_DIR}/github-runner-finish:/usr/local/sbin" \
72
+ --copy-in "${SCRIPT_DIR}/github-runner.service:/etc/systemd/system" \
73
+ --copy-in "${SCRIPT_DIR}/github-runner-ttl.service:/etc/systemd/system" \
74
+ --copy-in "${SCRIPT_DIR}/github-runner-ttl.timer:/etc/systemd/system" \
75
+ --run-command "apt-get update && apt-get install -y --no-install-recommends ca-certificates curl gnupg git jq unzip zip xz-utils zstd sudo python3 rsync e2fsprogs openssh-client qemu-utils cryptsetup debootstrap docker.io" \
76
+ --run-command "apt-get purge -y --auto-remove snapd lxd-installer || true" \
77
+ --run-command "curl -fsSL https://deb.nodesource.com/setup_24.x | bash -" \
78
+ --run-command "apt-get install -y --no-install-recommends nodejs" \
79
+ --run-command "useradd --create-home --home-dir /home/runner --shell /bin/bash --groups docker runner" \
80
+ --run-command "printf 'runner ALL=(ALL) NOPASSWD:ALL\\n' >/etc/sudoers.d/runner && chmod 0440 /etc/sudoers.d/runner" \
81
+ --run-command "tar -xzf /opt/github-runner/actions-runner-linux-x64-${RUNNER_VERSION}.tar.gz -C /opt/actions-runner && rm /opt/github-runner/actions-runner-linux-x64-${RUNNER_VERSION}.tar.gz" \
82
+ --run-command "/opt/actions-runner/bin/installdependencies.sh" \
83
+ --run-command "chown -R runner:runner /opt/actions-runner /var/lib/github-runner" \
84
+ --run-command "cd /opt/github-runner && npm init -y >/dev/null && npm install --omit=dev --no-audit --no-fund ethers@${ETHERS_VERSION}" \
85
+ --run-command "chmod 0700 /etc/github-runner && chmod 0755 /usr/local/sbin/github-runner-start /usr/local/sbin/github-runner-finish" \
86
+ --run-command "printf '[Journal]\\nSystemMaxUse=256M\\nRuntimeMaxUse=64M\\nMaxRetentionSec=2day\\n' >/etc/systemd/journald.conf.d/github-runner.conf" \
87
+ --run-command "npm cache clean --force" \
88
+ --run-command "apt-get clean && rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/* /root/.npm /tmp/* /var/tmp/*" \
89
+ --run-command "systemctl enable docker.service github-runner.service github-runner-ttl.timer"
90
+
91
+ if command -v virt-sparsify >/dev/null 2>&1; then
92
+ SPARSE_IMAGE="${IMAGE%.qcow2}.sparse.qcow2"
93
+ ROOTFS_SPARSIFY_TMPDIR="$(mktemp -d "${OUT_DIR}/virt-sparsify-tmp.XXXXXX")"
94
+ TMPDIR="${ROOTFS_SPARSIFY_TMPDIR}" virt-sparsify --check-tmpdir=continue --compress \
95
+ "${IMAGE}" "${SPARSE_IMAGE}"
96
+ mv "${SPARSE_IMAGE}" "${IMAGE}"
97
+ fi
98
+
99
+ if [ -n "${HOST_UID}" ] && [ -n "${HOST_GID}" ]; then
100
+ chown -R "${HOST_UID}:${HOST_GID}" "${OUT_DIR}"
101
+ fi
102
+
103
+ echo "Compressed RootFS size: $(stat -c '%s bytes' "${IMAGE}") ($(du -h "${IMAGE}" | cut -f1) on disk)"
104
+ echo "GitHub runner ${RUNNER_VERSION} RootFS ready: ${IMAGE}"
@@ -0,0 +1,69 @@
1
+ #!/usr/bin/env node
2
+ // Sends FORGET for this VM's own INSTANCE on behalf of the wallet that pays
3
+ // for it. The key is a delegate the owner authorized for INSTANCE and FORGET:
4
+ // Aleph lets a delegate forget only messages it could have created itself. Usage: forget-self.mjs <env-file> [reason]
5
+ //
6
+ // env file: ALEPH_FORGET_KEY, ALEPH_OWNER, ALEPH_INSTANCE, optional ALEPH_API_HOST
7
+ import { createHash } from 'node:crypto'
8
+ import { readFileSync } from 'node:fs'
9
+
10
+ export function parseEnv(text) {
11
+ const env = {}
12
+ for (const line of text.split('\n')) {
13
+ const match = /^\s*([A-Z_][A-Z0-9_]*)=(.*)$/u.exec(line)
14
+ if (match) env[match[1]] = match[2].trim().replace(/^(['"])(.*)\1$/u, '$2')
15
+ }
16
+ return env
17
+ }
18
+
19
+ export function buildForget({ sender, owner, instance, reason, now = Date.now() / 1000 }) {
20
+ if (!/^[a-f0-9]{64}$/u.test(instance)) throw new Error('ALEPH_INSTANCE must be a 64-hex item hash')
21
+ if (!/^0x[0-9a-fA-F]{40}$/u.test(owner)) throw new Error('ALEPH_OWNER must be an 0x address')
22
+ const itemContent = JSON.stringify({
23
+ address: owner,
24
+ time: now,
25
+ hashes: [instance],
26
+ aggregates: [],
27
+ reason
28
+ })
29
+ return {
30
+ sender,
31
+ chain: 'ETH',
32
+ type: 'FORGET',
33
+ item_hash: createHash('sha256').update(itemContent).digest('hex'),
34
+ item_type: 'inline',
35
+ item_content: itemContent,
36
+ time: now,
37
+ channel: 'ALEPH-CLOUDSOLUTIONS'
38
+ }
39
+ }
40
+
41
+ export function signaturePayload(message) {
42
+ return [message.chain, message.sender, message.type, message.item_hash].join('\n')
43
+ }
44
+
45
+ async function main() {
46
+ const [envFile, reason = 'github-runner finished'] = process.argv.slice(2)
47
+ const env = parseEnv(readFileSync(envFile, 'utf8'))
48
+ const { Wallet } = await import('ethers')
49
+ const wallet = new Wallet(env.ALEPH_FORGET_KEY)
50
+ const message = buildForget({
51
+ sender: wallet.address,
52
+ owner: env.ALEPH_OWNER,
53
+ instance: env.ALEPH_INSTANCE,
54
+ reason: `github-runner: ${reason}`
55
+ })
56
+ message.signature = await wallet.signMessage(signaturePayload(message))
57
+ const api = env.ALEPH_API_HOST || 'https://api2.aleph.im'
58
+ const response = await fetch(`${api}/api/v0/messages`, {
59
+ method: 'POST',
60
+ headers: { 'content-type': 'application/json' },
61
+ body: JSON.stringify({ sync: false, message }),
62
+ signal: AbortSignal.timeout(20_000)
63
+ })
64
+ const body = await response.json().catch(() => ({}))
65
+ console.log(`FORGET ${message.item_hash}: HTTP ${response.status} ${body.message_status ?? ''}`)
66
+ if (![200, 202].includes(response.status) || body.message_status === 'rejected') process.exit(1)
67
+ }
68
+
69
+ if (import.meta.url === `file://${process.argv[1]}`) await main()
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env bash
2
+ # Runs once the runner has exited (its one job is done, or it failed to
3
+ # start) and from the TTL timer. Powering off alone keeps the INSTANCE
4
+ # allocated and billed, so when the launcher injected a forget key the VM
5
+ # first sends FORGET for its own INSTANCE on behalf of the owner.
6
+ set -uo pipefail
7
+
8
+ reason="${1:-runner-exited}"
9
+ FORGET_ENV=/etc/github-runner/forget.env
10
+ MARKER=/run/github-runner-finished
11
+
12
+ # The TTL path powers off, which stops the runner, which runs this again.
13
+ [ -e "${MARKER}" ] && exit 0
14
+ touch "${MARKER}"
15
+
16
+ LOG=/var/lib/github-runner/finish.log
17
+
18
+ # Every attempt is bounded, and the whole loop stays well inside the unit's
19
+ # TimeoutStopSec: if systemd kills this script, the poweroff below never runs.
20
+ echo "$(date -Is) finish: ${reason}" | tee -a "${LOG}"
21
+ if [ -s "${FORGET_ENV}" ]; then
22
+ for attempt in 1 2 3; do
23
+ # pipefail: the pipeline fails when node does.
24
+ if timeout 30 node /opt/github-runner/forget-self.mjs "${FORGET_ENV}" "${reason}" 2>&1 | tee -a "${LOG}"; then
25
+ break
26
+ fi
27
+ sleep $((attempt * 5))
28
+ done
29
+ fi
30
+ echo "$(date -Is) poweroff" | tee -a "${LOG}"
31
+ systemctl --no-block poweroff
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env bash
2
+ # Waits for the launcher to inject a one-job just-in-time config, then runs
3
+ # exactly one job as the unprivileged runner user. The config is removed as
4
+ # soon as the runner has read it, so a job cannot find it on disk.
5
+ set -euo pipefail
6
+
7
+ CONF_DIR=/etc/github-runner
8
+ JIT="${CONF_DIR}/jitconfig"
9
+
10
+ until [ -s "${JIT}" ]; do sleep 2; done
11
+
12
+ if [ -s "${CONF_DIR}/ttl" ]; then
13
+ ttl="$(tr -dc '0-9' <"${CONF_DIR}/ttl")"
14
+ if [ -n "${ttl}" ] && [ "${ttl}" -gt 0 ]; then
15
+ mkdir -p /run/systemd/system/github-runner-ttl.timer.d
16
+ printf '[Timer]\nOnActiveSec=\nOnActiveSec=%smin\n' "${ttl}" \
17
+ >/run/systemd/system/github-runner-ttl.timer.d/ttl.conf
18
+ systemctl daemon-reload
19
+ fi
20
+ fi
21
+ # The timer already runs from boot (a VM that never gets a config still powers
22
+ # off); restarting it counts the TTL from now.
23
+ systemctl restart github-runner-ttl.timer
24
+
25
+ config="$(cat "${JIT}")"
26
+ rm -f "${JIT}"
27
+
28
+ cd /opt/actions-runner
29
+ exec runuser -u runner -- ./run.sh --jitconfig "${config}"
@@ -0,0 +1,6 @@
1
+ [Unit]
2
+ Description=Stop an expired ephemeral GitHub runner
3
+
4
+ [Service]
5
+ Type=oneshot
6
+ ExecStart=/usr/local/sbin/github-runner-finish ttl
@@ -0,0 +1,10 @@
1
+ [Unit]
2
+ Description=Maximum lifetime for an ephemeral GitHub runner
3
+
4
+ [Timer]
5
+ OnActiveSec=360min
6
+ AccuracySec=1min
7
+ Unit=github-runner-ttl.service
8
+
9
+ [Install]
10
+ WantedBy=timers.target
@@ -0,0 +1,17 @@
1
+ [Unit]
2
+ Description=Ephemeral GitHub Actions runner (one job, then forget and power off)
3
+ After=network-online.target docker.service
4
+ Wants=network-online.target docker.service
5
+
6
+ [Service]
7
+ Type=simple
8
+ ExecStart=/usr/local/sbin/github-runner-start
9
+ ExecStopPost=/usr/local/sbin/github-runner-finish
10
+ Restart=no
11
+ # Room for github-runner-finish: three bounded FORGET attempts, then poweroff.
12
+ TimeoutStopSec=180
13
+ StandardOutput=journal
14
+ StandardError=journal
15
+
16
+ [Install]
17
+ WantedBy=multi-user.target