diff --git a/.dockerignore b/.dockerignore index 297a1aa..2bd1c74 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,7 +1,13 @@ .git .gitmodules -node_modules +node_modules/* +!node_modules/forgejo-runner-startos +node_modules/forgejo-runner-startos/* +!node_modules/forgejo-runner-startos/runtime javascript ncc-cache docker-images *.s9pk +**/.git +**/__pycache__ +**/*.pyc diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..2ca2021 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "upstream"] + path = upstream + url = https://gitea.com/gitea/runner.git diff --git a/AGENTS.md b/AGENTS.md index 5fbd10d..b5cc234 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,41 +1,6 @@ -# AGENTS.md +# Gitea Runner package development -This is a StartOS service-package repository — it builds a `.s9pk` for StartOS. - -Develop it inside a StartOS packaging workspace created by `start-cli s9pk init-workspace`, -which provides the packaging guide and agent context one level up. If you're reading this in a -bare clone with no workspace, the full guide is at . - -**Start every task at the recipe index** — `../start-technologies/projects/start-sdk/docs/src/recipes.md` -(or ). It maps an intent ("prompt the user to create -admin credentials", "expose a web UI") to the constructs, the reference pages, and a named production -package to copy. Find the recipe before you read this package's neighbours: a package you reach by -grepping may be non-conformant, and the recipe outranks it. - -Freshly scaffolded? Work the -[New Package Checklist](../start-technologies/projects/start-sdk/docs/src/new-package-checklist.md) -(or ) from top to bottom. It is a -guide page, not a file in this repo — read it, don't copy it in. - -Keep `README.md` (technical reference for an AI support or administering agent) and -`instructions.md` (end-user docs) in sync with your changes. This file restates neither: -whoever changes the package has both, so it carries only what they don't — repo mechanics, -a change that looks right and is not, where the next thing gets added, a naming trap, a -build or test invocation particular to this repo. - -**Fix a defect you spot rather than reporting it** — you have the package open and the -context to be sure. File **a GitHub issue on this repo** only when the call isn't yours to -make: you can't pin the cause down, two defensible fixes exist, or it's too large to ride on -the work in hand. An open issue is a report, not a queue — implement one when you're asked -to or when it's labelled `Approved`, then close it with `Closes #`. - -Don't record work in the repo instead: no `TODO.md`, no `NOTES.md`, no `PLAN.md`. What you -verified, tried, and decided belongs in the commit message and the PR body. - -## This repo - -- **Don't port `forgejo-runner-startos`'s credential handling here.** The two share a shape, but Gitea registers with a single-use token plus a runner name and Forgejo with a persistent UUID and token pair. -- **Keep `primary`'s check keyed off `runner/.runner` and observing the process.** The stored token is absent on a runner registered out of band, and a daemon's `ready.fn` overwrites the crash status the SDK sets, so a check on registration alone paints a crash loop green. -- **Apply capacity with `config set`, not a sed.** `gitea-runner config init` writes no `capacity` key, so a sed over the generated config has nothing to match. -- **`own-data` chowns only `runner/`, and `clean-runtime` requires it.** `store.json` shares the mount and keeps its own ownership; run concurrently, the `chown -R` walks the tree `clean-runtime` deletes and fails with ENOENT. -- **Keep `git` in the image.** The runner fetches `uses:` actions with the git CLI, and without it every `uses:` step fails with an exec error that doesn't name the cause. +- Start at `../start-technologies/projects/start-sdk/docs/src/recipes.md`; read upstream's `AGENTS.md` and `DEVELOPMENT.md` before changing carried Go patches. +- Keep `node_modules/forgejo-runner-startos/runtime/**/*.ts` in `tsconfig.json` and the runtime sources in Make's JavaScript dependencies. A green standalone `tsc` does not establish NCC emitted dependency TypeScript. +- Pin the shared dependency by immutable commit and update its lockfile. Generic runtime fixes belong in that owner; registration and native CLI changes belong here. +- Apply source patches only in a build/test copy. Stage the clean upstream gitlink, not throwaway commits or patched submodule content. diff --git a/Dockerfile b/Dockerfile index bbbfd0e..3cdd297 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,35 +1,29 @@ -# gitea-runner binary, pulled from the official multi-arch image (pinned). -# Bump the tag here and the version in startos/versions/current.ts together. -FROM gitea/runner:5.1.0 AS runner +ARG BINFMT_IMAGE +FROM --platform=$BUILDPLATFORM golang:1.27.3-trixie AS runner +WORKDIR /src +COPY upstream/ ./ +COPY patches/ /patches/ +RUN for file in /patches/*.patch; do git apply "$file"; done +RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build \ + CGO_ENABLED=0 go test -short -run '^Test(RemoteMetadataAndFallbackBoundary|RemoteDockerBuildBoundary|ActionBundleReadWriteBoundary|StepActionLocalTest|StepActionRemote|PatchBundle|PatchActionsAtTheContainerCopy|ExecAsDockerHoldsCloneLockForRemoteUncached|TrustedRootCA|SerialJobs|DefaultParallelJobs|DockerImagePlatform)' ./act/runner \ + && CGO_ENABLED=0 go test -short ./act/container ./internal/app/run ./internal/app/cmd ./internal/pkg/config ./internal/pkg/labels ./internal/app/poll +ARG TARGETOS +ARG TARGETARCH +RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build \ + CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -trimpath -tags netgo,osusergo \ + -ldflags '-s -w -X gitea.com/gitea/runner/internal/pkg/ver.version=v5.1.0' \ + -o /bin/gitea-runner . +FROM $BINFMT_IMAGE AS binfmt FROM debian:trixie-slim - -# Rootless Podman + its prerequisites (fuse-overlayfs storage, pasta/slirp4netns -# networking, nftables for netavark). See start-docs recipe-nested-oci-runtime. -# Also git: the runner fetches `uses:` actions with the git CLI — without it every -# `uses:` step fails at the action fetch (exec: "git": executable file not found in $PATH). -RUN apt-get update \ - && apt-get install -y --no-install-recommends \ - podman fuse-overlayfs uidmap iproute2 iptables nftables aardvark-dns \ - passt slirp4netns ca-certificates git openssh-client \ - && apt-get clean \ - && rm -rf /var/lib/apt/lists/* - -COPY --from=runner /usr/local/bin/gitea-runner /usr/local/bin/gitea-runner - -RUN mkdir -p /etc/containers \ - && printf 'unqualified-search-registries = ["docker.io"]\n' \ - > /etc/containers/registries.conf - -# Non-root user + subordinate UID/GID ranges for nested user namespaces. The -# range must live inside the subcontainer's userns (0..65535) and must not -# overlap the user's own UID (1000) — so start at 1001. -RUN useradd --create-home --uid 1000 --shell /bin/bash app \ - && echo 'app:1001:64535' > /etc/subuid \ - && echo 'app:1001:64535' > /etc/subgid - -COPY assets/entrypoint.sh /usr/local/bin/entrypoint.sh -RUN chmod +x /usr/local/bin/entrypoint.sh - +ARG BINFMT_IMAGE +COPY node_modules/forgejo-runner-startos/runtime/assets/install-runtime.sh /usr/local/share/ci-runner/install-runtime.sh +RUN sh /usr/local/share/ci-runner/install-runtime.sh +RUN apt-get update && apt-get install -y --no-install-recommends openssh-client && rm -rf /var/lib/apt/lists/* +COPY node_modules/forgejo-runner-startos/runtime/assets/ /usr/local/share/ci-runner/ +COPY --from=runner /bin/gitea-runner /usr/local/bin/gitea-runner +COPY --from=binfmt /usr/bin/ /usr/bin/ +RUN printf '%s\n' "$BINFMT_IMAGE" > /usr/local/share/binfmt-image +COPY --chmod=755 assets/entrypoint.sh /usr/local/bin/entrypoint.sh USER app WORKDIR /home/app diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 599ad42..0000000 --- a/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2024 Start9 Labs - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/LICENSE b/LICENSE new file mode 120000 index 0000000..4d5ed76 --- /dev/null +++ b/LICENSE @@ -0,0 +1 @@ +upstream/LICENSE \ No newline at end of file diff --git a/Makefile b/Makefile index f93fef2..e5dfc23 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ # overrides to s9pk.mk must precede the include statement -# The gitea-runner binary ships only x86_64 + aarch64 (no riscv64), so skip -# riscv. (Foreign-arch *jobs* are still built via the host's qemu binfmt; this -# is only about which arches the runner package itself runs on.) +# The shared guest images support these package architectures. ARCHES := x86 arm include node_modules/@start9labs/start-sdk/s9pk.mk + +javascript/index.js: $(shell find node_modules/forgejo-runner-startos/runtime -name '*.ts' 2>/dev/null) diff --git a/README.md b/README.md index b76c054..d441b24 100644 --- a/README.md +++ b/README.md @@ -9,10 +9,7 @@ > upstream documentation is accurate and fully applicable — see the > Documentation section of `instructions.md` for links. -[Gitea Runner](https://gitea.com/gitea/runner) executes Gitea Actions workflows. This package runs it against the Gitea on this same device, with a rootless Podman engine inside the service so each job gets its own container. - -- **Upstream repo:** -- **Wrapper repo:** +[Gitea Runner](https://gitea.com/gitea/runner) executes Actions workflows for this device's Gitea. The package adds assignment-only VM isolation, a bounded shared guest-memory pool, and an explicit container backend. --- @@ -35,162 +32,174 @@ ## Image and Container Runtime -The image is built here: upstream's runner binary is copied onto a Debian base carrying a rootless container engine, because the runner needs somewhere to run each job. - -| Property | Value | -| ------------- | ---------------------------------------------------------------------- | -| Image | Built from `Dockerfile` — upstream's `gitea-runner` binary plus Podman | -| Architectures | x86_64, aarch64 | -| Command | The repo's `entrypoint.sh`, run as the unprivileged `app` user | -| Subcontainer | `gitea-runner-sub` — the `primary` daemon, and the one to `attach` to | +A custom Debian image builds the pinned upstream source with package patches. The VM lifecycle, Docker engine, resource policy, public-CA installation and process probes come from the pinned shared runtime in `forgejo-runner-startos`; Gitea registration and CLI handling remain package-specific. -The manifest declares two device grants that this arrangement requires: **userspace filesystems** for the storage driver, and **virtual networking** for job networking. Without either, the nested engine cannot start a job container. The image also carries `git` and the OpenSSH client for downloading actions and reusable workflows, including SSH URLs. These tools live in the runner container, not in arbitrary job images; the job-image requirements for `builtin:checkout` are described in `instructions.md`. +| Subcontainer | Purpose | +| --- | --- | +| `registration-owner` | Publishes the authoritative persistent registration before controllers start | +| `vm-image-preparation` | Downloads and prepares the guest base on virtualized startup | +| `vm-memory-pool` | Brokers aggregate guest RAM independently of worker lifetime | +| `vm-worker-N` | One host-side poller and disposable guest lifecycle per configured slot | +| `container-engine` | Rootful Docker backend when virtualization is explicitly disabled | +| `runner-controller` | One native daemon with configured assignment capacity in container mode | -Three oneshots run as root before the daemon. `own-data` creates the runner's working directory on the volume and hands it to `app`, leaving the rest of the volume alone. `device-perms` re-opens `/dev/net/tun` and `/dev/fuse` to mode 0666: StartOS 0.4.0.1 and earlier can create those granted nodes root-only, and the engine opens both as the unprivileged user. It is idempotent and becomes a no-op once the OS-side fix ships. `clean-runtime` removes the engine's runtime directory. `XDG_RUNTIME_DIR` sits on the persistent volume, so the boot ID podman caches there survives a reboot and podman refuses to start against it. It runs after `own-data` rather than beside it, so the two never walk the same tree at once, and it removes no durable state: the entrypoint recreates the directory on every start, and the image layer store is a sibling that is left alone. +The controller, preparer and nested engine commands run as root inside the service. The image supports x86_64 and aarch64, includes Git/OpenSSH, and uses filesystem and KVM grants, not a TUN device. KVM is probed by opening its device. Once VM execution is selected, boot or provisioning errors fail the assignment rather than retrying it as a container or using TCG. -## Volume and Data Layout +Virtualized workers wait for an assignment before requesting a guest. Each assignment gets a new overlay, cloud-init identity and Docker server certificate. No guest runs while its slot is idle. The base disk is read-only to workers. The guest receives its server-authentication key and public CA certificate, never the controller's registration, Docker client private key, CA signing key, filesystem or engine socket. The CA signing key is discarded after issuing the per-guest identities. -One volume, shared between the package's state and the runner's working area. +The memory broker owns half the service-visible RAM, rounded down to 128 MiB blocks. Admission reserves a 512 MiB boot floor per guest. A lone assignment can borrow the pool; another assignment waits while memory is reclaimed. Only acknowledged QEMU device sizes count as released memory. Busy or pinned pages can delay admission. Guest RAM excludes QEMU, Docker, service and StartOS overhead: this is not a total-RSS limit. Workers do not depend on the broker's SDK daemon lifetime, and handle broker recovery without an SDK-induced job restart. -| Volume | Mount Point | Purpose | -| ------ | ----------- | -------------------------------------------------------------------------------------------------------- | -| `main` | `/data` | `store.json`, and the runner's working directory under `runner/` — including its registration state file | +Per-guest disks use half the available scratch space divided by capacity, capped at 32 GiB and rejected below 8 GiB. A VM disk is not elastic when a build fills it. Independent jobs and matrix entries inside one assignment execute sequentially, recursively through reusable workflows; separate assignments retain configured concurrency. -## File Models +## Volume and Data Layout -Two models. One is what you supply; the other is the runner's own registration state, which the package reads and, at start, corrects or discards. +Durable settings and identity live in `main`; rebuildable execution data lives in `scratch`. Controller mounts and guest disks are separate storage boundaries. -| File | Format | Modelled | Written by | -| ---------------- | ------ | ----------------------- | -------------------------------------------------------------- | -| `store.json` | JSON | Yes — `FileHelper.json` | Every init, and the Configure action | -| `runner/.runner` | JSON | Read-only | The runner itself, when it registers; `entrypoint.sh` at start | +| Volume path | Mount and role | +| --- | --- | +| `main/store.json` | Settings; `/data/store.json` in the registration owner | +| `main/runner/.runner` | Authoritative identity; `/data/runner/.runner` for the owner, read-only `/registration/.runner` for controllers | +| `main/runner/registration-revision` | Receipt for a successfully published registration request | +| `main/controller/container` | Private `/data` for the container controller | +| `main/controller/workers/N` | Private `/data` and transport credentials for worker N | +| `scratch/container` | `/scratch` for the engine and controller: Docker layers, runtime socket and job scratch | +| `scratch/vm-images` | Writable `/cache` for the preparer, read-only `/base` for workers | +| `scratch/vm-preparation/{runtime,control}` | Preparer `/vm` and `/data` | +| `scratch/vm-pool` | `/pool` for broker and workers | +| `scratch/vm-workers/N` | Disposable `/vm` disk, seed, guest transport and QEMU state | -| Key | Notes | -| ------------------- | -------------------------------------------------------------------------------- | -| `registrationToken` | A **single-use** token from Gitea's Create-new-Runner screen | -| `runnerName` | How the runner identifies itself in Gitea; defaults to `startos-runner` | -| `labels` | Comma-separated, in the runner's own `name:docker://image` or `name:host` syntax | -| `capacity` | How many jobs run at once | +Guest Docker bind paths resolve inside the guest. Container mode instead permits the service's job-scratch directory and resolver file; it does not authorize arbitrary controller paths. Job containers in container mode are privileged and share the StartOS kernel, making that mode suitable only for trusted workflows. -The package reads the declared `store.json` keys and merges updates into the file; unknown keys already on disk are retained. The entrypoint regenerates `runner/config.yaml` on every start, setting `runner.capacity` and `runner.labels` from the store and leaving all other options at upstream defaults. Hand edits to that config do not survive a restart. Everything else reaches the runner as environment on each start, including the connection to Gitea — whose address is resolved rather than stored. +## File Models -`USER` is set to the unprivileged account the daemon runs as, not left at the container's inherited `root`. The container engine resolves its subordinate UID and GID ranges by `$USER`, and finding none for `root` it falls back to a single-ID mapping — under which any job image carrying a file not owned by root fails to unpack. +The package owns settings and registration intent, while the native runner owns the registration format. Hand-edited controller configurations are regenerated rather than treated as authoritative. -**`runner/.runner` is the ground truth for whether this runner is registered**, and it is why the health check does not simply look at whether a token is stored. A runner registered out of band, or restored from a backup whose stored token was cleared, is fully working while carrying no token here; the state file reflects that and the token does not. +- `store.json` is a loose JSON model. Init merges missing defaults, Configure merges settings, and unknown keys survive. Runtime-only edits do not invalidate registration. Registration tokens are single-use requests, not proof of a usable identity; `registrationRevision` distinguishes a fresh request from an already consumed token. +- `runner/.runner` is read without rewriting it. A usable identity needs nonempty UUID/token strings and must not be ephemeral. Its persistent token is different from the token saved in Configure; an empty stored registration token does not invalidate a restored or out-of-band identity. +- `runner/registration-revision` is a plain-text publication receipt. A matching receipt prevents a spent registration token being retried if the identity later disappears. +- `controller/.../runner/rejected-uuid` records server rejection of that controller's identity. It does not delete the authoritative registration; it clears when the controller sees a different UUID. +- Each controller regenerates `runner/config.yaml` in its private `/data`. Labels, capacity, Docker endpoint, source-cache location, narrow bind permissions, local host aliases and provisioning hook are package-managed. The container backend places native Docker proxy sockets in the shared runtime directory, while action-source caches stay private to the controller. Artifact-cache service and configured Gitea client certificate/key fields are disabled. Docker client mTLS credentials are not Gitea client credentials. -The daemon dials the `address` saved in `.runner`, not the environment, so `entrypoint.sh` rewrites that field to Gitea's current bridge address on every start. When Gitea answers `unregistered runner` (its database no longer holds the registration, as after Gitea is reinstalled), or registering with the stored token fails, the entrypoint deletes `.runner` and idles instead of exiting, so the health check reports missing registration rather than the service restarting in a loop. Recover by running Configure with a fresh token, then restarting. +The owner holds an exclusive lock on `runner/.runner.lock`. It registers into a request-specific staging file, keeps the old identity on failure, atomically publishes the new identity and then its receipt. A valid stage recovers an interrupted publication without resending the token. Controllers hold a shared original lock and give the native daemon its own private snapshot and exclusive lock. Only the snapshot's forge address changes when the dependency's bridge address changes. ## Dependencies -One, and it is required in the strong sense. - -| Dependency | Kind | Health check | Mounts | Why | -| ---------- | --------- | ------------ | ------ | ---------------------------------------------- | -| Gitea | `running` | `primary` | none | The forge this runner registers with and polls | - -The health check is required as well as "running", because the runner talks to Gitea's HTTP API — a Gitea that is up but not yet serving is no use to it. +Gitea is required, running and healthy on its `primary` check. No dependency volume is mounted; the runner resolves Gitea's HTTP bridge address and supplies the server's public CA and local hostname aliases to execution environments. -Gitea Actions must also be enabled. The package raises a critical task on Gitea's **Configure** action whenever Enable Actions is off, which holds Gitea stopped until it is turned back on. - -**This runner only ever serves the Gitea on this device.** The address is resolved from Gitea's own binding over the service bridge; there is no field for a remote forge. If Gitea is not reachable, `main` refuses to start with a message saying so rather than starting a runner that cannot register. +A task on Gitea enforces Enable Actions. This package does not accept a remote-forge address. ## Network Access and Interfaces -None. The runner dials out to its forge and pulls job images; it accepts no inbound connections and exports nothing. - -## Installation and First-Run Flow +No user-facing interface is exposed. Controllers connect out to Gitea and action/image sources; guest Docker endpoints are loopback mTLS endpoints for their controller, not advertised interfaces. -Install seeds the store with defaults. On every init, a watcher raises a critical **Configure** task when neither a registration token nor `runner/.runner` is present. The task blocks startup until you save a token. A registered runner needs no setup task, even if its stored token is empty. +The public StartOS root CA is installed in controllers, preparers and engines. Job initialization validates CA-only PEM, installs system trust as root before actions, and supplies Node trust through the shared job environment. Local Gitea aliases are installed in controller and engine rootfs as well as job containers, retaining the real HTTPS hostname and verification for action-source clones and job requests. An unrelated certificate authority is not trusted. Arbitrary Docker action images still own their system trust and DNS configuration; the package does not rewrite their images or disable TLS verification. -Two gates apply before the daemon can run, both enforced rather than advisory: +## Installation and First-Run Flow -1. **Hardware.** `main` refuses to start on a device below 2 CPU cores or roughly a 4 GB machine's worth of memory, because every job is a full build. The message says so explicitly rather than failing obscurely later. -2. **Gitea.** It must be installed and serving. +Init seeds settings with a hardware-aware virtualization default. New settings use container mode without accessible KVM. If virtualization is already enabled and KVM becomes unavailable, a critical Configure task blocks startup and main refuses to create either backend. Restore KVM access and save Configure with Virtualization on, or explicitly save it with Virtualization off to use containers. The saved choice and registration are not automatically changed; the task clears when the selected backend is usable. The service requires at least two visible CPU cores and 1.5 GiB of service-visible memory; VM admission additionally validates capacity against the memory floor and scratch budget. -Complete the **Configure** task with a registration token from Gitea's **Settings → Actions → Runners → Create new Runner**, then start the service. Registration happens at startup, at which point the runner writes its state file and the health check turns green. A saved token permits startup; it is not proof of registration. +Registration completes before controllers poll. Guest-image preparation runs before virtualized workers start and is cached by image, architecture, provisioning inputs and public CA fingerprint. Preparation can take several minutes. A restarted idle slot launches only its poller, not an idle guest. ## Actions -One action. +Configure changes settings and restarts execution; saving interrupts active assignments. It is safe to repeat for runtime-only changes without a new token. The write is short, but a change that invalidates the guest base requires preparation again. -### Configure - -Registers the runner with Gitea and sets how it advertises itself. - -- **What it changes:** every field in `store.json` — the registration token, name, labels, and concurrency. -- **Cost:** the write is instant, but **registration happens on the next restart**, not on save. -- **Repeat safety:** re-running is safe, but **a registration token is single-use.** Saving re-registers on the next restart, so a second run needs a _fresh_ token from Gitea; reusing the old one fails to register. -- **Input notes:** labels use the runner's own syntax. A foreign-architecture label here also serves emulated jobs, which run far slower than native ones — a second runner on the other architecture is the better arrangement for regular builds. +A fresh token requests registration replacement on startup, not at save time. Replacement can change the runner name and preserves the previous identity until successful publication. A rejected or consumed token is not repaired by repeated restarts; recovery needs an explicitly fresh token. Neither registration nor configuration returns credentials. ## Tasks -One setup task of its own and one on its dependency: +A critical Configure task on this service is raised when there is neither a usable persistent identity nor an unconsumed registration request, or when execution settings fail validation. It clears when the watcher can accept the identity/request and settings; it can return if the identity is removed or settings become invalid. -| Task | On | Severity | Raised when | Cleared when | -| --------- | ------------ | ---------- | --------------------------------------------- | ------------------------------------- | -| Configure | Gitea Runner | `critical` | No stored registration token and no `.runner` | A token is saved or `.runner` appears | -| Configure | Gitea | `critical` | Gitea's Enable Actions is off | Enable Actions is turned on | +A critical Configure task on Gitea is raised while Enable Actions is off and clears when Actions is enabled. The prompt belongs to the dependency, not this runner's own configuration. ## Health Checks -One check, on the `primary` daemon. +Checks distinguish daemon readiness from visible assignment progress. VM workers have a five-minute startup grace period; preparation has its own progress check and can outlast that interval before workers start. -| Check | Method | Grace Period | -| ------------------ | ------------------------------------------------------------------------------------------- | ------------ | -| `primary` "Runner" | Registration state file present, then the runner process alive and the Podman API answering | 60 seconds | - -Before registration it reports that the runner is not registered. The setup task handles a missing token; an invalid or spent token still needs replacing through Configure. After registration, the check runs a script in the service container every 30 seconds: a `gitea-runner` process must appear in `/proc`, and `podman --remote info` must succeed against the API socket the runner drives. Both have to hold — the runner exits when the engine is unreachable, and an engine with no runner behind it serves nothing. Whether Gitea is currently handing it jobs is visible in Gitea, not here. Task-start messages in the service logs include task, job and run identifiers for matching a job to its workflow run. +- VM image preparation checks its marker and preparing process. Loading means download/install is still active; failure means the preparer failed, died or left invalid state. Inspect `vm-image-preparation` logs and free scratch/network access before retrying. +- The memory-pool check observes the broker process and protocol status. Failure points to `vm-memory-pool`, not necessarily a dead assignment. Inspect broker and worker logs before stopping a live job. +- Each virtualized slot observes its controller, native runner, phase and, while active, QEMU plus authenticated Docker readiness. Idle reports success with no VM allocated. Admission, boot and recycling report loading; running work also reports loading. Hidden daemon readiness maps an active slot to success, preventing task progress from making the service unready. +- The container runner check requires a usable, non-rejected identity, a native runner process and a responding Docker backend. The execution-mode check explains the shared-kernel mode. +- Server rejection is reported against the current authoritative UUID. Inspect the private controller log when registration exists but health fails. Do not delete the original registration to diagnose an engine or boot failure. ## Backups and Restore -The `main` volume is copied wholesale — `sdk.Backups.ofVolumes('main')`. No dump step and nothing excluded. +Backups copy the `main` volume wholesale using `ofVolumes('main')`, including settings, authoritative registration, receipts and host-controller files. The separate `scratch` volume is deliberately excluded: guest bases, disks, engine layers and job scratch rebuild. -- **Included:** the store, and the runner's working directory with its registration state file. -- **Restore:** the runner comes back registered, because the state file travels with the backup. Whether Gitea still recognises that registration depends on Gitea's own state, which is that package's backup rather than this one's — if the forge was rebuilt, register again with a fresh token. +Restored registrations work only while Gitea still recognizes them; restore of this package cannot reconstruct Gitea's runner database. Guest transport identities are regenerated for new assignments. Restoring an older backup runs the execution-data migration without discarding identity or unknown settings. ## Limitations and Differences -1. **Only the Gitea on this device.** There is no field for a remote forge; the address is resolved from the local dependency. -2. **Registration tokens are single-use.** Re-running Configure needs a new one from Gitea. -3. **Configuration applies on restart**, not immediately. -4. **The service refuses to start on small hardware** — under 2 cores or roughly a 4 GB machine. -5. **Emulated jobs are much slower than native**, and are opted into by adding a foreign-architecture label by hand. -6. **Jobs run in a rootless engine inside the service**, which requires the two device grants named above, and on StartOS 0.4.0.1 and earlier a startup step to make those device nodes readable by the unprivileged user. -7. **No riscv64 build.** x86_64 and aarch64 only. +1. Only this device's Gitea is supported; there is no remote forge input. +2. Host-execution labels and ephemeral registration are rejected. Docker labels must be unique. +3. VM execution requires accessible KVM and suitable guest firmware/kernel support. Package builds and emulation do not establish native ARM64 KVM coverage; that remains unverified. +4. Guest-memory admission can wait on reclaim; the pool does not cap total process RSS. Capacity also reduces the per-guest disk budget. +5. VM image layers do not survive assignment cleanup. A cached base avoids repeated OS provisioning, not repeated job-image pulls. +6. Recursive serialization is per assignment, not a global limit; independent assignments can contend for CPU, RAM and storage. +7. Container mode is privileged, shares the StartOS kernel and is not equivalent to VM isolation. Both modes require trusted workflow administration. +8. Arbitrary Docker actions may need image-specific trust/DNS configuration. No TLS-verification bypass is supplied. +9. Only x86_64 and aarch64 package architectures are supported. --- ## Quick Reference for AI Consumers +This index names the package's storage and execution surfaces; the sections above describe their lifecycle and guarantees. + ```yaml package_id: gitea-runner -image: ./Dockerfile # upstream's runner binary plus rootless Podman on Debian -architectures: - - x86_64 - - aarch64 +image: ./Dockerfile +architectures: [x86_64, aarch64] subcontainers: - - gitea-runner-sub + - registration-owner + - vm-image-preparation + - vm-memory-pool + - vm-worker-N + - container-engine + - runner-controller volumes: main: /data + scratch: [/scratch, /cache, /base, /vm, /pool] file_models: - store.json - - runner/.runner # read-only; the runner's own registration state + - runner/.runner + - runner/registration-revision + - controller/.../runner/rejected-uuid startos_managed_env_vars: - INSTANCE_URL - RUNNER_TOKEN - RUNNER_NAME - RUNNER_LABELS - RUNNER_CAPACITY - - XDG_RUNTIME_DIR - - USER -dependencies: - - gitea # required; gated on its primary health check -interfaces: {} # none; the runner accepts no inbound connections -actions: - - configure + - RUNNER_REGISTRATION_DIR + - RUNNER_REGISTRATION_REVISION + - RUNNER_REGISTRATION_PENDING + - RUNNER_REGISTRATION_RECEIPT + - RUNNER_ROOT_CA + - RUNNER_LOCAL_HOSTS + - RUNNER_BEFORE_JOB + - RUNNER_SCRATCH_DIR + - DOCKER_HOST + - VM_SLOT + - VM_CAPACITY + - VM_POOL_MEMORY_MB + - VM_BOOT_MEMORY_MB + - VM_MEMORY_BLOCK_MB + - VM_DISK_GB + - VM_CPU_CORES + - VM_DOCKER_PORT + - VM_GUEST_HOSTNAME +dependencies: [gitea] +interfaces: {} +actions: [configure] tasks: - - { package: gitea-runner, action: configure, severity: critical } # while token and registration are both absent - - { package: gitea, action: configure, severity: critical } # while Gitea Actions is disabled + - {package: gitea-runner, action: configure, severity: critical} + - {package: gitea, action: configure, severity: critical} health_checks: - - primary # displayed "Runner"; registration, then runner + engine liveness + - vm-image + - memory-pool + - worker-N + - jobs-N + - primary + - execution-mode ``` diff --git a/UPDATING.md b/UPDATING.md index 6484123..b46f25b 100644 --- a/UPDATING.md +++ b/UPDATING.md @@ -1,8 +1,35 @@ # Updating -## gitea-runner +## Gitea Runner -- **Determining the upstream version:** latest release at , or the `gitea/runner` tags on Docker Hub. The current pin lives in `Dockerfile` (`FROM gitea/runner:`). -- **Applying the bump:** - 1. `Dockerfile` — set the `FROM gitea/runner:` tag. - 2. `startos/versions/current.ts` — set `version: ':0'`, or bump only the `:N` suffix if just the packaging changed. Update the release notes. +- **Find releases:** . The `upstream/` gitlink pins source; the image builds it rather than copying an official runner binary. +- **Apply a bump:** fetch the intended upstream tag into the submodule, verify its commit, and stage that gitlink in the package. Match the version embedded by `Dockerfile` and the upstream portion in `startos/versions/current.ts`. +- **Review patches:** apply all `patches/*.patch` sequentially to clean pinned source. Remove one only when upstream has equivalent semantics and regression coverage, not merely a similar helper. +- **Verify source:** read upstream's `AGENTS.md` and `DEVELOPMENT.md`, run `make fmt`, `make checks`, `make lint-go`, and relevant tests on the patched source. Broad runner tests need a live, isolated Docker fixture; a daemon-free build cannot establish that suite passed. Use a supported patched Go compiler and run its vulnerability checks. Do not weaken tests or linters. +- **Release metadata:** follow the guide's version-graph rules. A migration stays with its introducing version; spin it out only when a later bump replaces a nonempty `up`. Preserve all five locale notes and the root application's MIT license. + +### Patch retirement gates + +1. `0001-prepare-assigned-job`: the task reporter must be live during the host preparation hook, before Docker access, with cancellation/failure propagation. Native `daemon --once` must wait for one assignment without requiring an idle guest. +2. `0002-confine-action-source-files`: metadata, fallback files, JavaScript compatibility patches, Docker contexts and ignore files remain rooted; synthetic local-action files go through the container API. Test symlink reads and writes, not just traversal strings. +3. `0003-isolate-job-action-caches`: each assignment gets a private source-cache directory and cleans it on return. Clone locks alone do not remove stale-lock or cross-assignment state. +4. `0004-install-trusted-root-ca`: reject malformed, empty, private-key and non-CA inputs; install system/Node trust before actions as root. Preserve the real HTTPS hostname and reject an unrelated CA. +5. `0005-serialize-assigned-task-jobs`: bound both job and matrix fan-outs, recursively through reusable workflows. A global semaphore around reusable callers can deadlock; assert per-assignment serialization and independent assignment overlap. +6. `0006-select-label-image-platform`: strip platform queries from Docker image names, preserve label roundtrips and select platform per evaluated job, including matrix, services, action containers and builds. +7. `0007-ignore-shared-network-host-options`: Docker actions sharing a job network must not inherit its `--add-host` options; Docker rejects that combination. Preserve aliases on ordinary job containers and keep custom action trust/DNS image-specific. + +## Shared execution runtime + +The npm dependency pins an immutable `forgejo-runner-startos` commit. Its `runtime/` owns the memory/disk/label policy, VM/engine assets, host binfmt pin, public CA installation and health probes. Update the dependency and lockfile together; do not copy those implementations or immutable digests here. Preserve its license asset in the runtime image. + +Keep the runtime directory in Docker's allowlist, NCC's `tsconfig.json` include, and the JavaScript target's Make dependencies. Pass the shared `BINFMT_IMAGE` export through manifest build arguments. The shared runtime's `README.md` documents the adapter contract; its `UPDATING.md` documents cloud image, hotplug-kernel and binfmt refresh verification. + +Gitea's single-use registration request remains package-specific. Verify a restored identity with an empty stored token, runtime-only edits, replacement failure, interruption between staged publication and receipt, and refusal to retry a consumed token. Controllers use read-only authoritative registration plus native-lockable private snapshots; do not remove the native lifetime lock to enable parallel pollers. Prove compatibility against a real Gitea server with two active assignments and an idle peer restart. + +## Package verification + +Build both x86_64 and aarch64 packages and inspect the returned artifacts, including runner architecture/compiler, runtime asset bytes, symlinks, executable modes, helper pin and license. Test an ordinary action, a Docker action, foreign-architecture work, artifacts and recursive reusable workflows in both execution modes on an owned isolated fixture. + +Exercise zero idle guests, distinct guest identities, acknowledged memory reclaim under pressure, cancellation while waiting/booting/running, broker restart without assignment restart, VM preparation failures and idle-success/active-loading health. Confirm source/credential boundaries and local HTTPS trust without certificate bypasses. Native ARM64 KVM requires native hardware; successful builds or emulation are not that evidence. + +Upgrade a registered legacy package and restore a legacy backup. Preserve `.runner`, store ownership, unknown settings and unrelated content while discarding only old Podman layers/runtime state. Confirm the scratch volume is excluded from backups. Update `README.md` and `instructions.md` when behavior changes. diff --git a/assets/entrypoint.sh b/assets/entrypoint.sh index b9239d0..3ed0fbc 100644 --- a/assets/entrypoint.sh +++ b/assets/entrypoint.sh @@ -1,93 +1,153 @@ #!/bin/bash -# Orchestrates a rootless Podman engine + the Gitea Actions runner (gitea-runner) -# inside the service container. main.ts passes the forge connection via the env. set -euo pipefail +umask 077 -: "${INSTANCE_URL:=}" -: "${RUNNER_TOKEN:=}" +: "${INSTANCE_URL:?}" +: "${RUNNER_LABELS:?}" +: "${RUNNER_REGISTRATION_DIR:?}" +: "${RUNNER_REGISTRATION_REVISION:=0}" +: "${RUNNER_REGISTRATION_PENDING:=false}" : "${RUNNER_NAME:=startos-runner}" -: "${RUNNER_LABELS:=}" : "${RUNNER_CAPACITY:=1}" -# App-owned working area (chowned by the own-data oneshot); StartOS's own -# store.json stays at /data and is left untouched. -DATA=/data/runner -CONFIG="$DATA/config.yaml" -# `register` writes its state file (.runner) into the working directory. -cd "$DATA" +IFS=',' read -ra label_list <<< "$RUNNER_LABELS" +ORIGINAL="$RUNNER_REGISTRATION_DIR/.runner" +RECEIPT=${RUNNER_REGISTRATION_RECEIPT:-} -# Podman needs a writable XDG_RUNTIME_DIR for its socket + transient state. -export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/data/runner/run}" -mkdir -p "$XDG_RUNTIME_DIR/podman" "$XDG_RUNTIME_DIR/containers" "$DATA/containers/storage" -chmod 700 "$XDG_RUNTIME_DIR" -SOCK="$XDG_RUNTIME_DIR/podman/podman.sock" +valid_registration() { + [ -f "$1" ] && [ ! -L "$1" ] && jq -e ' + (.uuid | type == "string" and length > 0) and + (.token | type == "string" and length > 0) and + ((.ephemeral // false) == false) + ' "$1" >/dev/null 2>&1 +} -# Bring up the Podman API socket (docker-compatible) the runner drives. Layer -# store lives on the data volume so job images survive restarts; -# --cgroup-manager=cgroupfs because there's no user systemd session here. -podman --root "$DATA/containers/storage" --runroot "$XDG_RUNTIME_DIR/containers" \ - --cgroup-manager=cgroupfs system service -t 0 "unix://$SOCK" & -export DOCKER_HOST="unix://$SOCK" +identity_config() { + local config=$1 registration=$2 + gitea-runner --config "$config" config init --force + gitea-runner --config "$config" config set runner.file "$registration" + gitea-runner --config "$config" config set runner.labels "${label_list[@]}" + gitea-runner --config "$config" config set runner.env_file '' +} -# Wait until the Podman API actually answers — not just the socket file -# appearing — so the runner daemon doesn't race a half-up socket. -for _ in $(seq 1 60); do - podman --remote --url "unix://$SOCK" info >/dev/null 2>&1 && break - sleep 1 -done +publish_registration() { + local staged=$1 + cp --remove-destination "$staged" "$ORIGINAL.tmp" + mv -f "$ORIGINAL.tmp" "$ORIGINAL" + printf '%s\n' "$RUNNER_REGISTRATION_REVISION" > "$RECEIPT.tmp" + mv -f "$RECEIPT.tmp" "$RECEIPT" + rm -f "$staged" "$staged.lock" +} -# gitea-runner reads runner labels from config.yaml. Rewrite the config each -# start so the Configure action's labels take effect; every key left unset keeps -# gitea-runner's own default. -gitea-runner --config "$CONFIG" config init --force -# `config init` writes a minimal file with no capacity key, so this must be a -# `config set` — a sed over the generated config has nothing to match. -gitea-runner --config "$CONFIG" config set runner.capacity "$RUNNER_CAPACITY" -IFS=',' read -ra RUNNER_LABEL_LIST <<<"$RUNNER_LABELS" -for label in "${RUNNER_LABEL_LIST[@]}"; do - label="$(printf '%s' "$label" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')" - [ -n "$label" ] && gitea-runner --config "$CONFIG" config add runner.labels "$label" -done - -# Register exactly once; the .runner state file makes this idempotent. -if [ ! -f "$DATA/.runner" ]; then - if [ -n "$INSTANCE_URL" ] && [ -n "$RUNNER_TOKEN" ]; then - echo "Registering runner '$RUNNER_NAME' with $INSTANCE_URL ..." - if ! gitea-runner register --no-interactive \ - --instance "$INSTANCE_URL" --token "$RUNNER_TOKEN" \ - --name "$RUNNER_NAME" --config "$CONFIG"; then - echo "gitea-runner: registration with $INSTANCE_URL failed. Run the 'Configure'" \ - "action with a fresh registration token, then restart this service." >&2 - exec sleep infinity +register_identity() { + : "${RUNNER_REGISTRATION_RECEIPT:?}" + [[ "$RUNNER_REGISTRATION_REVISION" =~ ^(0|[1-9][0-9]*)$ ]] + case "$RUNNER_REGISTRATION_PENDING" in true|false) ;; *) exit 1;; esac + mkdir -p "$RUNNER_REGISTRATION_DIR" + chmod 700 "$RUNNER_REGISTRATION_DIR" + exec 9> "$ORIGINAL.lock" + flock -x 9 + local staged="$RUNNER_REGISTRATION_DIR/.runner.pending-$RUNNER_REGISTRATION_REVISION" + local config="$RUNNER_REGISTRATION_DIR/registration-config.yaml" + local receipt + receipt=$(cat "$RECEIPT" 2>/dev/null || true) + if [ "$RUNNER_REGISTRATION_PENDING" = true ] && [ "$receipt" != "$RUNNER_REGISTRATION_REVISION" ]; then + if ! valid_registration "$staged"; then + : "${RUNNER_TOKEN:?A fresh Gitea registration token is required}" + rm -f "$staged" + identity_config "$config" "$staged" + gitea-runner --config "$config" register --no-interactive \ + --instance "$INSTANCE_URL" --token "$RUNNER_TOKEN" --name "$RUNNER_NAME" + valid_registration "$staged" fi - else - echo "gitea-runner: not configured. Run the 'Configure' action to set a" \ - "registration token, then restart this service." >&2 - # Stay alive so the health check can report 'needs config' instead of crash-looping. - exec sleep infinity + publish_registration "$staged" + elif ! valid_registration "$ORIGINAL" && [ "$receipt" = "$RUNNER_REGISTRATION_REVISION" ] && valid_registration "$staged"; then + publish_registration "$staged" + fi + if ! valid_registration "$ORIGINAL"; then + echo 'gitea-runner: a persistent registration is required; use Configure with a fresh token.' >&2 + return 1 fi -fi + rm -f "$config" "$staged" "$staged.lock" +} -# The daemon dials the address saved at registration, which goes stale when Gitea's bridge port changes. -sed -i "s|\"address\": \"[^\"]*\"|\"address\": \"$INSTANCE_URL\"|" "$DATA/.runner" +wait_for_registration() { + echo 'gitea-runner: Gitea no longer recognizes this identity; use Configure with a fresh registration token.' >&2 + exec sleep infinity +} -LOG="$DATA/run/daemon.log" -exec 3> >(tee "$LOG") -tee_pid=$! -gitea-runner daemon --config "$CONFIG" >&3 2>&1 & -pid=$! -exec 3>&- -trap 'kill -TERM "$pid" 2>/dev/null' TERM INT -status=0 -wait "$pid" || status=$? -while kill -0 "$pid" 2>/dev/null; do status=0; wait "$pid" || status=$?; done -wait "$tee_pid" || true +run_controller() { + : "${DOCKER_HOST:?}" + : "${RUNNER_SCRATCH_DIR:?}" + : "${RUNNER_DEFAULT_IMAGE:?}" + local data=/data/runner config=/data/runner/config.yaml snapshot=/data/runner/.runner + test "$ORIGINAL" != "$snapshot" + mkdir -p "$data/run" + exec 9< "$ORIGINAL.lock" + flock -s 9 + valid_registration "$ORIGINAL" + jq --arg address "$INSTANCE_URL" '.address = $address' "$ORIGINAL" > "$snapshot.tmp" + mv -f "$snapshot.tmp" "$snapshot" + local uuid rejected="$data/rejected-uuid" + uuid=$(jq -er .uuid "$snapshot") + if [ "$(cat "$rejected" 2>/dev/null || true)" = "$uuid" ]; then + wait_for_registration + fi + rm -f "$rejected" + identity_config "$config" "$snapshot" + gitea-runner --config "$config" config set runner.capacity "$RUNNER_CAPACITY" + gitea-runner --config "$config" config set runner.default_image "$RUNNER_DEFAULT_IMAGE" + gitea-runner --config "$config" config set runner.client_cert_file '' + gitea-runner --config "$config" config set runner.client_key_file '' + gitea-runner --config "$config" config set host.workdir_parent /tmp/runner-actions + gitea-runner --config "$config" config set cache.enabled false + gitea-runner --config "$config" config set container.require_docker true + gitea-runner --config "$config" config set container.privileged true + gitea-runner --config "$config" config set container.docker_host "$DOCKER_HOST" + if [ -n "${RUNNER_BEFORE_JOB:-}" ]; then + gitea-runner --config "$config" config set runner.before_job "$RUNNER_BEFORE_JOB" + fi + local hosts options + hosts=$(jq -er 'if type != "array" or any(.[]; type != "string" or (test("^[A-Za-z0-9][A-Za-z0-9.-]*:[0-9.]+$") | not)) then error("Invalid local host mapping") else map(" --add-host " + .) | join("") end' <<< "${RUNNER_LOCAL_HOSTS:-[]}") + options="--volume $RUNNER_SCRATCH_DIR:/root/setup-docker-action --volume /etc/resolv.conf:/etc/resolv.conf:ro$hosts" + local -a volumes=("$RUNNER_SCRATCH_DIR" /etc/resolv.conf) + if [[ "$DOCKER_HOST" = tcp://* ]]; then + options+=' --volume /var/run/docker.sock:/var/run/docker.sock' + volumes+=(/var/run/docker.sock) + fi + gitea-runner --config "$config" config set container.options "$options" + gitea-runner --config "$config" config set container.valid_volumes "${volumes[@]}" + source /usr/local/libexec/runner-docker-client.sh + cd "$data" + local log="$data/run/daemon.log" pid tee_pid status=0 + exec 3> >(tee "$log") + tee_pid=$! + gitea-runner --config "$config" "$@" >&3 2>&1 & + pid=$! + exec 3>&- + trap 'kill -TERM "$pid" 2>/dev/null || true' TERM INT + wait "$pid" || status=$? + while kill -0 "$pid" 2>/dev/null; do status=0; wait "$pid" || status=$?; done + wait "$tee_pid" || true + if grep -qE '^Error: ([a-z_]+: )?(unregistered runner|runner is no longer registered with the server; please register it again)$' "$log"; then + printf '%s\n' "$uuid" > "$rejected" + wait_for_registration + fi + return "$status" +} -# Gitea answers "unregistered runner" once its database no longer holds this registration. -if grep -qE '^Error: ([a-z_]+: )?(unregistered runner|runner is no longer registered with the server; please register it again)$' "$LOG"; then - rm -f "$DATA/.runner" - echo "gitea-runner: Gitea no longer recognizes this runner. Run the 'Configure'" \ - "action with a fresh registration token, then restart this service." >&2 - exec sleep infinity -fi -exit "$status" +case "${1:-daemon}" in + register) + [ "$#" -eq 1 ] + register_identity + ;; + one-job) + [ "$#" -eq 2 ] && [ "$2" = --wait ] + run_controller daemon --once + ;; + daemon) + [ "$#" -le 1 ] + run_controller daemon + ;; + *) echo 'Unsupported runner adapter mode' >&2; exit 1;; +esac diff --git a/instructions.md b/instructions.md index 8c5eb8a..81444aa 100644 --- a/instructions.md +++ b/instructions.md @@ -2,54 +2,45 @@ ## Documentation -- [Gitea Actions documentation](https://docs.gitea.com/usage/actions) — upstream reference for workflow syntax, runners, and the Actions admin settings. - -## What you get on StartOS - -A CI/CD runner that executes Gitea Actions workflows for the **Gitea on this device**. Each job runs in its own isolated, rootless container, and jobs that target another CPU architecture (`arm64`, `riscv64`) run under emulation automatically. The runner has no interface of its own — you see it and its job logs inside Gitea, in its Runners list and the Actions tab. +- [Gitea Actions documentation](https://docs.gitea.com/usage/actions) — workflows, runners and Actions administration. ## Getting set up -This runner serves the Gitea on the same device, so install and start **Gitea** first, with Gitea Actions enabled (the default). A critical **Configure** task keeps an unconfigured runner stopped until you save a registration token. +Your runner serves **Gitea on this device**. Have Gitea running with Actions enabled before configuring the runner. + +1. In Gitea, create a runner registration token under **Site / Organization / Repository Settings → Actions → Runners → Create new Runner**. +2. Open **Configure** here. Paste the token and choose a runner name, labels and concurrent-job limit. The default label is `ubuntu-latest`. +3. Leave **Virtualization** enabled when available for a separate guest kernel per assignment. Without accessible KVM, new configurations default to containers sharing the StartOS kernel. Use container mode only for trusted workflows. +4. Start the service. First-time VM preparation can take several minutes; watch **VM Image Preparation**. When ready, your runner appears online in Gitea. -1. In Gitea, create a runner registration token under **Site / Organization / Repository Settings → Actions → Runners → Create new Runner**, and copy it. -2. Open the **Configure** task here and paste the token. Adjust the runner name, labels, and concurrent-jobs count if you like — the default `ubuntu-latest` label runs jobs in a standard Ubuntu image. -3. Start (or restart) the service. It registers with Gitea and begins picking up jobs; it should appear as **Online** in Gitea's Runners. +For later execution-setting changes, **leave Registration Token blank** to keep your identity. Saving Configure interrupts active jobs and restarts the runner. Supply a fresh token only to create or replace registration, including a name change. A failed replacement keeps the previous identity. -> Running **Configure** re-registers the runner on the next start, so supply a fresh token each time (registration tokens are single-use). +If Gitea no longer recognizes your runner, use Configure with a fresh token. Restarting with a spent token cannot repair registration. A restored identity does not require the old registration token to remain saved. -If Gitea is reinstalled, it no longer knows this runner: the **Runner** health check reports that it is not registered. Run **Configure** with a fresh token, then restart. The same applies if registration fails with an invalid or spent token. +## Running workflows -## Using the Gitea Runner +Follow runs, logs and artifacts in Gitea's **Actions** tab. Here, an idle virtualized slot reports success without running a VM. Loading means an assignment is waiting for memory, preparing its VM, running or cleaning up. A slot can remain loading while another job releases memory; raising concurrency does not create more RAM. -Once it is online, Gitea dispatches workflow jobs to it automatically — there is nothing to drive here day to day. Follow progress and read job logs in Gitea's **Actions** tab; here, the **Runner** health check shows whether it is up, and the service logs show registration and startup. When a task starts, the service logs include task, job and run identifiers to help match it to a workflow run. +Reusable-workflow jobs and matrices run sequentially within each assignment. Separate assignments can still overlap up to your configured limit. Guest disks are bounded; if a build fills its disk, reduce concurrent jobs to allow a larger per-guest budget, or reduce the build's storage use. -### Labels and architecture +VM job images are pulled into a fresh guest for each assignment, so image downloads can take time even after the guest base is prepared. Container mode can retain engine image layers between assignments. -Each job must have a nonempty `runs-on:` matching the runner's labels, unless it calls a reusable workflow. Add a foreign-architecture label in **Configure** to also serve emulated jobs for that architecture — they work, but are much slower than native, so prefer a separate runner on native hardware for each architecture you build for regularly. +### Labels and emulation -### First run +Use unique labels in `name:docker://image` format; host-execution labels are not supported. A job's `runs-on` must match a runner label unless it calls a reusable workflow. -The first job on each label pulls its container image before anything else runs — the default `ubuntu-latest` image is about 1 GB, so **Set up job** can sit for a minute or two showing only a spinner. The image is cached afterward; later runs start in seconds. +Enable **Enable Emulation** in Configure to add the other CPU architecture's label: `linux-arm64` on an x86_64 runner, or `linux-amd64` on an aarch64 runner. Point the corresponding workflow jobs at that label. Emulation is slower than native execution; prefer a native runner per architecture for regular builds. -### Checking out your repository +### Checkout and HTTPS -To check out the repository a workflow belongs to, use the runner's built-in action. It needs neither an action download nor Node, and uses the job's token by default. Your job image must include Git; it must also be able to reach Gitea and trust its certificate if you use HTTPS. SSH checkouts additionally need an SSH client, and LFS checkouts need Git LFS: +The built-in checkout action can use your job's token without downloading another action: ```yaml - uses: builtin:checkout ``` -Checkout removes untracked and ignored files by default. Set `with: { clean: false }` if you need to keep them. It supports SSH keys, submodules, LFS, sparse checkout and partial clones, and persists credentials for later fetches and pushes unless you set `persist-credentials: false`. A tag-triggered checkout fails if the tag moved after the workflow started. +Your job image needs Git; SSH checkout also needs an SSH client. Ordinary jobs receive public StartOS certificate trust and routing for Gitea's local hostname. If you use a custom Docker action image, provide its required certificate trust and DNS configuration in that image or workflow. Do not disable certificate verification to work around a failing connection. -The standard checkout action also works and authenticates with the job's token: +### Choosing container mode -```yaml -- uses: actions/checkout@v4 -``` - -To clone in a plain `run:` step instead, authenticate with the job's token; the in-job clone URL is anonymous by default and fails on private repositories: - -```yaml -- run: git clone "http://ci:${{ github.token }}@${GITHUB_SERVER_URL#*://}/${GITHUB_REPOSITORY}.git" . -``` +Disabling Virtualization reduces startup cost but removes the separate guest-kernel boundary: privileged job containers share the StartOS kernel. A VM boot or provisioning failure does not switch an assignment to container mode. If KVM becomes unavailable while Virtualization is saved as enabled, the runner stops serving jobs and raises a critical Configure task. Restore KVM access and save Configure with Virtualization on, or save it with Virtualization off to explicitly permit container mode. Registration is preserved. Inspect the failed slot's logs before retrying. diff --git a/package-lock.json b/package-lock.json index 7313627..dfa4356 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,8 @@ "": { "name": "gitea-runner-startos", "dependencies": { - "@start9labs/start-sdk": "3.0.6", + "@start9labs/start-sdk": "3.1.0", + "forgejo-runner-startos": "github:Start9Labs/forgejo-runner-startos#567cf950b6786ad3109e72acae758fb28d6a3bb3", "gitea-startos": "github:Start9Labs/gitea-startos#next" } }, @@ -251,9 +252,9 @@ "license": "MIT" }, "node_modules/@start9labs/start-sdk": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@start9labs/start-sdk/-/start-sdk-3.0.6.tgz", - "integrity": "sha512-gVLdb5d488yNDZv7e0/FReJfc3klHbwkyleOwnegSt61fCM/AFI9VZmFPagLcbysjGOMLWlfwWUaDI51VKuFLA==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@start9labs/start-sdk/-/start-sdk-3.1.0.tgz", + "integrity": "sha512-JyTlm5S+eYg+hyh78N9buq9I091Wcp0Thg7Tm0CCfKJhCCz8KGVTa9OyS57YY/bsBNeEME4zTCQ5bybEpwyV6A==", "bundleDependencies": [ "@start9labs/start-core" ], @@ -276,8 +277,7 @@ "typescript": "^6.0.3", "typescript-eslint": "^8.61.0", "yaml": "^2.8.3", - "zod": "4.4.3", - "zod-deep-partial": "^1.2.0" + "zod": "4.6.7" } }, "node_modules/@start9labs/start-sdk/node_modules/@start9labs/start-core": { @@ -291,8 +291,7 @@ "isomorphic-fetch": "^3.0.0", "mime": "^4.1.0", "yaml": "^2.8.3", - "zod": "4.4.3", - "zod-deep-partial": "^1.4.4" + "zod": "4.6.7" } }, "node_modules/@start9labs/start-sdk/node_modules/@start9labs/start-core/node_modules/@iarna/toml": { @@ -422,21 +421,13 @@ } }, "node_modules/@start9labs/start-sdk/node_modules/@start9labs/start-core/node_modules/zod": { - "version": "4.4.3", + "version": "4.6.7", "inBundle": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" } }, - "node_modules/@start9labs/start-sdk/node_modules/@start9labs/start-core/node_modules/zod-deep-partial": { - "version": "1.4.4", - "inBundle": true, - "license": "MIT", - "peerDependencies": { - "zod": "^4.1.13" - } - }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -1199,6 +1190,20 @@ "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", "license": "ISC" }, + "node_modules/forgejo-runner-startos": { + "resolved": "git+ssh://git@github.com/Start9Labs/forgejo-runner-startos.git#567cf950b6786ad3109e72acae758fb28d6a3bb3", + "integrity": "sha512-nxO/k9uoR+yytpLOJcwwxKbNqXheZ37pKxZFoQ6NpsGrKMie0YY/pudmV4KxGXJaOUO5xcgRW/XE/8XcSzMf2g==", + "dependencies": { + "@start9labs/start-sdk": "3.1.0", + "forgejo-startos": "github:Start9Labs/forgejo-startos#next" + } + }, + "node_modules/forgejo-startos": { + "resolved": "git+ssh://git@github.com/Start9Labs/forgejo-startos.git#0eca7176123e75f2d1b0fe94ad2510becc010d34", + "dependencies": { + "@start9labs/start-sdk": "3.1.0" + } + }, "node_modules/gitea-startos": { "resolved": "git+ssh://git@github.com/Start9Labs/gitea-startos.git#ce48fd23cd5effb10e3d3cfa3498750826b81ed2", "dependencies": { @@ -1873,22 +1878,13 @@ } }, "node_modules/zod": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", - "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "version": "4.6.7", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", + "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" } - }, - "node_modules/zod-deep-partial": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/zod-deep-partial/-/zod-deep-partial-1.4.4.tgz", - "integrity": "sha512-aWkPl7hVStgE01WzbbSxCgX4O+sSpgt8JOjvFUtMTF75VgL6MhWQbiZi+AWGN85SfSTtI9gsOtL1vInoqfDVaA==", - "license": "MIT", - "peerDependencies": { - "zod": "^4.1.13" - } } } } diff --git a/package.json b/package.json index 4fa7750..412e0a0 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,11 @@ { "name": "gitea-runner-startos", "dependencies": { - "@start9labs/start-sdk": "3.0.6", - "gitea-startos": "github:Start9Labs/gitea-startos#next" + "@start9labs/start-sdk": "3.1.0", + "gitea-startos": "github:Start9Labs/gitea-startos#next", + "forgejo-runner-startos": "github:Start9Labs/forgejo-runner-startos#567cf950b6786ad3109e72acae758fb28d6a3bb3" }, "overrides": { - "@start9labs/start-sdk": "3.0.6" + "@start9labs/start-sdk": "$@start9labs/start-sdk" } } diff --git a/patches/0001-prepare-assigned-job.patch b/patches/0001-prepare-assigned-job.patch new file mode 100644 index 0000000..b94f196 --- /dev/null +++ b/patches/0001-prepare-assigned-job.patch @@ -0,0 +1,186 @@ +Subject: [PATCH] Provision the host execution environment after assignment + +Retire when upstream offers a reporter-live host provisioning hook without idle Docker access. + +diff --git a/docs/host-provisioning.md b/docs/host-provisioning.md +new file mode 100644 +index 00000000..dd766294 +--- /dev/null ++++ b/docs/host-provisioning.md +@@ -0,0 +1,7 @@ ++# Host provisioning before an assigned task ++ ++`runner.before_job` is an operator-controlled argv list. The runner executes it on the controller host after assignment, with the task reporter active, before platform, cache or execution-environment discovery. A nonzero exit fails the assigned task; cancellation interrupts the command. Its output goes through the task reporter and secret masker. ++ ++With this hook configured, daemon startup defers Docker readiness checks and idle maintenance. Endpoint resolution, `DOCKER_HOST` configuration and remote-socket mount suppression remain active. The hook must make the configured Docker endpoint available before it returns. Default behavior is unchanged without the hook. ++ ++Never derive this command from workflow contents. Registration and Docker client private keys belong to the controller, not the guest seed or a workflow bind mount. An external VM supervisor must stop the guest and release its resources on success, failure and cancellation; the successful-task-only post-task script is not sufficient for that cleanup. +diff --git a/internal/app/cmd/daemon.go b/internal/app/cmd/daemon.go +index 937435ba..b6ff0afb 100644 +--- a/internal/app/cmd/daemon.go ++++ b/internal/app/cmd/daemon.go +@@ -112,8 +112,9 @@ func runDaemon(ctx context.Context, daemArgs *daemonArgs, configFile *string) fu + } + + if ls.RequireDocker() || cfg.Container.RequireDocker { ++ validateDocker := len(cfg.Runner.BeforeJob) == 0 + // Wait for dockerd be ready +- if timeout := cfg.Container.DockerTimeout; timeout > 0 { ++ if timeout := cfg.Container.DockerTimeout; timeout > 0 && validateDocker { + tctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + keepRunning := true +@@ -143,12 +144,16 @@ func runDaemon(ctx context.Context, daemArgs *daemonArgs, configFile *string) fu + if err != nil { + return err + } +- if err := envcheck.CheckIfDockerRunning(ctx, dockerSocketPath); err != nil { +- return err ++ if validateDocker { ++ if err := envcheck.CheckIfDockerRunning(ctx, dockerSocketPath); err != nil { ++ return err ++ } + } +- // if dockerSocketPath passes the check, override DOCKER_HOST with dockerSocketPath ++ // Remote endpoints remain in DOCKER_HOST after mount normalization. + os.Setenv("DOCKER_HOST", dockerSocketPath) +- run.WarnIfDaemonHasNoProxy(ctx) ++ if validateDocker { ++ run.WarnIfDaemonHasNoProxy(ctx) ++ } + // empty cfg.Container.DockerHost means runner need to find an available docker host automatically + // and assign the path to cfg.Container.DockerHost + if cfg.Container.DockerHost == "" { +diff --git a/internal/app/run/before_job.go b/internal/app/run/before_job.go +new file mode 100644 +index 00000000..3d4a8123 +--- /dev/null ++++ b/internal/app/run/before_job.go +@@ -0,0 +1,22 @@ ++// Copyright 2026 The Gitea Authors. All rights reserved. ++// SPDX-License-Identifier: MIT ++ ++package run ++ ++import ( ++ "context" ++ "io" ++ "os/exec" ++ "time" ++) ++ ++func runBeforeJob(ctx context.Context, command []string, output io.Writer) error { ++ if len(command) == 0 { ++ return nil ++ } ++ cmd := exec.CommandContext(ctx, command[0], command[1:]...) ++ cmd.Stdout = output ++ cmd.Stderr = output ++ cmd.WaitDelay = 5 * time.Second ++ return cmd.Run() ++} +diff --git a/internal/app/run/before_job_test.go b/internal/app/run/before_job_test.go +new file mode 100644 +index 00000000..0d530cce +--- /dev/null ++++ b/internal/app/run/before_job_test.go +@@ -0,0 +1,40 @@ ++// Copyright 2026 The Gitea Authors. All rights reserved. ++// SPDX-License-Identifier: MIT ++ ++package run ++ ++import ( ++ "bytes" ++ "context" ++ "os" ++ "path/filepath" ++ "testing" ++ "time" ++ ++ "github.com/stretchr/testify/require" ++) ++ ++func TestHostProvisioning(t *testing.T) { ++ t.Run("absent", func(t *testing.T) { ++ require.NoError(t, runBeforeJob(t.Context(), nil, &bytes.Buffer{})) ++ }) ++ t.Run("completed before execution", func(t *testing.T) { ++ marker := filepath.Join(t.TempDir(), "ready") ++ var output bytes.Buffer ++ require.NoError(t, runBeforeJob(t.Context(), []string{"sh", "-ec", "printf 'line one\\nline two'; printf ready > \"$1\"", "hook", marker}, &output)) ++ data, err := os.ReadFile(marker) ++ require.NoError(t, err) ++ require.Equal(t, "ready", string(data)) ++ require.Equal(t, "line one\nline two", output.String()) ++ }) ++ t.Run("failure", func(t *testing.T) { ++ require.Error(t, runBeforeJob(t.Context(), []string{"sh", "-c", "exit 7"}, &bytes.Buffer{})) ++ }) ++ t.Run("cancelled", func(t *testing.T) { ++ ctx, cancel := context.WithCancel(t.Context()) ++ cancel() ++ start := time.Now() ++ require.Error(t, runBeforeJob(ctx, []string{"sleep", "60"}, &bytes.Buffer{})) ++ require.Less(t, time.Since(start), time.Second) ++ }) ++} +diff --git a/internal/app/run/runner.go b/internal/app/run/runner.go +index 85a0a697..ce7fd1f1 100644 +--- a/internal/app/run/runner.go ++++ b/internal/app/run/runner.go +@@ -162,6 +162,9 @@ var removeOrphanKubernetesResources = container.RemoveOrphanKubernetesResources + // throttles invocations to runner.idle_cleanup_interval so the impact on + // poll cadence is bounded even when the workdir root is large. + func (r *Runner) OnIdle(ctx context.Context) { ++ if len(r.cfg.Runner.BeforeJob) > 0 { ++ return ++ } + if !r.shouldRunIdleCleanup() { + return + } +@@ -474,6 +477,19 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report. + reporter.ResetSteps(job.Steps) + telemetry.SetJobName(ctx, cmp.Or(job.Name, jobID)) + ++ if len(r.cfg.Runner.BeforeJob) > 0 { ++ reporter.Logf("Preparing job execution environment") ++ output := common.NewLineWriter(func(line string) bool { ++ reporter.Logf("%s", strings.TrimRight(line, "\r\n")) ++ return true ++ }) ++ err := runBeforeJob(ctx, r.cfg.Runner.BeforeJob, output) ++ common.FlushWriter(output) ++ if err != nil { ++ return fmt.Errorf("prepare job execution environment: %w", err) ++ } ++ } ++ + taskContext := task.Context.Fields + envs := r.cloneEnvs() + +diff --git a/internal/pkg/config/config.example.yaml b/internal/pkg/config/config.example.yaml +index 5a90db19..099b1d77 100644 +--- a/internal/pkg/config/config.example.yaml ++++ b/internal/pkg/config/config.example.yaml +@@ -18,6 +18,8 @@ log: + # max_size: 1GB + + runner: ++ # Trusted host argv before execution-environment access; see docs/host-provisioning.md. ++ #before_job: [] + # Where to store the registration result. + #file: .runner + # Execute how many tasks concurrently at the same time. +diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go +index c6a8f06d..ae57fc8d 100644 +--- a/internal/pkg/config/config.go ++++ b/internal/pkg/config/config.go +@@ -58,6 +58,7 @@ type LogJob struct { + + // Runner represents the configuration for the runner. + type Runner struct { ++ BeforeJob []string `yaml:"before_job"` // Trusted host command after assignment, before execution-environment access. + File string `yaml:"file"` // File specifies the file path for the runner. + Capacity int `yaml:"capacity"` // Capacity specifies the capacity of the runner. + Envs map[string]string `yaml:"envs"` // Envs stores environment variables for the runner. diff --git a/patches/0002-confine-action-source-files.patch b/patches/0002-confine-action-source-files.patch new file mode 100644 index 0000000..2e88fd8 --- /dev/null +++ b/patches/0002-confine-action-source-files.patch @@ -0,0 +1,589 @@ +Subject: [PATCH] Confine host action reads and writes to their repository + +Retire when upstream confines metadata, synthetic files, compatibility bundles and Docker build inputs. + +diff --git a/act/container/docker_build.go b/act/container/docker_build.go +index 2e072252..c55c15ad 100644 +--- a/act/container/docker_build.go ++++ b/act/container/docker_build.go +@@ -87,14 +87,19 @@ func createBuildContext(ctx context.Context, contextDir, relDockerfile string) ( + // And canonicalize dockerfile name to a platform-independent one + relDockerfile = filepath.ToSlash(relDockerfile) + +- f, err := os.Open(filepath.Join(contextDir, ".dockerignore")) ++ source, err := os.OpenRoot(contextDir) ++ if err != nil { ++ return nil, err ++ } ++ defer source.Close() ++ f, err := source.Open(".dockerignore") + if err != nil && !os.IsNotExist(err) { + return nil, err + } +- defer f.Close() + + var excludes []string + if err == nil { ++ defer f.Close() + excludes, err = ignorefile.ReadAll(f) + if err != nil { + return nil, err +diff --git a/act/container/docker_build_boundary_test.go b/act/container/docker_build_boundary_test.go +new file mode 100644 +index 00000000..180f7d80 +--- /dev/null ++++ b/act/container/docker_build_boundary_test.go +@@ -0,0 +1,50 @@ ++// Copyright 2026 The Gitea Authors. All rights reserved. ++// SPDX-License-Identifier: MIT ++ ++//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd)) ++ ++package container ++ ++import ( ++ "archive/tar" ++ "io" ++ "os" ++ "path/filepath" ++ "testing" ++ ++ "github.com/stretchr/testify/require" ++) ++ ++func TestBuildContextIgnoreBoundary(t *testing.T) { ++ for _, linked := range []bool{false, true} { ++ t.Run(map[bool]string{false: "regular", true: "outside-link"}[linked], func(t *testing.T) { ++ contextDir := t.TempDir() ++ require.NoError(t, os.WriteFile(filepath.Join(contextDir, "Dockerfile"), []byte("FROM scratch\n"), 0o600)) ++ if linked { ++ outside := filepath.Join(t.TempDir(), "private-ignore") ++ require.NoError(t, os.WriteFile(outside, []byte("hidden\n"), 0o600)) ++ require.NoError(t, os.Symlink(outside, filepath.Join(contextDir, ".dockerignore"))) ++ } else { ++ require.NoError(t, os.WriteFile(filepath.Join(contextDir, ".dockerignore"), []byte("hidden\n"), 0o600)) ++ } ++ archive, err := createBuildContext(t.Context(), contextDir, "Dockerfile") ++ if linked { ++ require.Error(t, err) ++ return ++ } ++ require.NoError(t, err) ++ defer archive.Close() ++ reader := tar.NewReader(archive) ++ found := false ++ for { ++ header, err := reader.Next() ++ if err == io.EOF { ++ break ++ } ++ require.NoError(t, err) ++ found = found || header.Name == "Dockerfile" ++ } ++ require.True(t, found) ++ }) ++ } ++} +diff --git a/act/runner/action.go b/act/runner/action.go +index 8be1cc32..bf4159f8 100644 +--- a/act/runner/action.go ++++ b/act/runner/action.go +@@ -153,7 +153,7 @@ func maybeCopyToActionDir(ctx context.Context, step actionStep, actionDir, actio + + if !rc.Config.NoActionPatch { + // A concurrent job's prepare resets this directory, so patch under the copy's lock. +- defer patchActions(ctx, actionScriptPaths(filepath.Join(actionDir, actionPath), step.getActionModel()))() ++ defer patchActions(ctx, actionDir, actionScriptPaths(filepath.Join(actionDir, actionPath), step.getActionModel()))() + } + + return rc.JobContainer.CopyDir(containerActionDirCopy, actionDir+"/", rc.Config.UseGitIgnore, true)(ctx) +@@ -306,6 +306,17 @@ func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, b + } else { + image = rc.dockerActionImage(ctx, step, actionName, localAction) + contextDir, fileName := filepath.Split(filepath.Join(basedir, action.Runs.Image)) ++ if !localAction { ++ actionPath, err := filepath.Rel(actionDir, basedir) ++ if err != nil { ++ return err ++ } ++ directory, filename, err := remoteActionBuildContext(actionDir, actionPath, action.Runs.Image) ++ if err != nil { ++ return err ++ } ++ contextDir, fileName = directory, filename ++ } + + anyArchExists, err := ContainerImageExistsLocally(ctx, image, "any") + if err != nil { +diff --git a/act/runner/action_source.go b/act/runner/action_source.go +new file mode 100644 +index 00000000..7b400668 +--- /dev/null ++++ b/act/runner/action_source.go +@@ -0,0 +1,48 @@ ++// Copyright 2026 The Gitea Authors. All rights reserved. ++// SPDX-License-Identifier: MIT ++ ++package runner ++ ++import ( ++ "errors" ++ "fmt" ++ "os" ++ "path/filepath" ++) ++ ++func remoteActionBuildContext(repository, actionPath, image string) (string, string, error) { ++ name := filepath.Join(actionPath, image) ++ if filepath.IsAbs(actionPath) || filepath.IsAbs(image) || !filepath.IsLocal(name) { ++ return "", "", errors.New("action Dockerfile must remain inside its repository") ++ } ++ source, err := os.OpenRoot(repository) ++ if err != nil { ++ return "", "", err ++ } ++ defer source.Close() ++ file, err := source.Open(name) ++ if err != nil { ++ return "", "", fmt.Errorf("open action Dockerfile: %w", err) ++ } ++ info, err := file.Stat() ++ file.Close() ++ if err != nil { ++ return "", "", err ++ } ++ if !info.Mode().IsRegular() { ++ return "", "", errors.New("action Dockerfile is not a regular file") ++ } ++ canonicalRepository, err := filepath.EvalSymlinks(repository) ++ if err != nil { ++ return "", "", err ++ } ++ contextDir, err := filepath.EvalSymlinks(filepath.Join(canonicalRepository, filepath.Dir(name))) ++ if err != nil { ++ return "", "", err ++ } ++ relative, err := filepath.Rel(canonicalRepository, contextDir) ++ if err != nil || !filepath.IsLocal(relative) { ++ return "", "", errors.New("action build context escapes its repository") ++ } ++ return contextDir, filepath.Base(name), nil ++} +diff --git a/act/runner/action_source_test.go b/act/runner/action_source_test.go +new file mode 100644 +index 00000000..115c5150 +--- /dev/null ++++ b/act/runner/action_source_test.go +@@ -0,0 +1,124 @@ ++// Copyright 2026 The Gitea Authors. All rights reserved. ++// SPDX-License-Identifier: MIT ++ ++package runner ++ ++import ( ++ "context" ++ "os" ++ "path/filepath" ++ "testing" ++ ++ "gitea.com/gitea/runner/act/common" ++ "gitea.com/gitea/runner/act/common/git" ++ ++ "gitea.dev/actionslib/pkg/model" ++ "github.com/stretchr/testify/require" ++) ++ ++func TestRemoteMetadataAndFallbackBoundary(t *testing.T) { ++ for _, target := range []string{"regular", "action.yml", "trampoline.js"} { ++ t.Run(target, func(t *testing.T) { ++ outside := filepath.Join(t.TempDir(), "controller-secret") ++ metadata := []byte("name: fixture\nruns:\n using: node20\n main: index.js\n") ++ require.NoError(t, os.WriteFile(outside, metadata, 0o600)) ++ setCloneExecutor(t, func(input git.NewGitCloneExecutorInput) common.Executor { ++ return func(context.Context) error { ++ directory := filepath.Join(input.Dir, "action") ++ if err := os.MkdirAll(directory, 0o700); err != nil { ++ return err ++ } ++ if target == "regular" { ++ return os.WriteFile(filepath.Join(directory, "action.yml"), metadata, 0o600) ++ } ++ return os.Symlink(outside, filepath.Join(directory, target)) ++ } ++ }) ++ sar := &stepActionRemote{ ++ Step: &model.Step{Uses: "org/repo/action@v1", With: map[string]string{"args": "echo fixture"}}, ++ RunContext: &RunContext{ ++ Config: &Config{Workdir: t.TempDir(), ActionCacheDir: t.TempDir(), GitHubInstance: "https://example.invalid"}, ++ Run: &model.Run{JobID: "build", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"build": {}}}}, ++ }, ++ readAction: readActionImpl, ++ } ++ err := sar.prepareActionExecutor()(t.Context()) ++ if target == "regular" { ++ require.NoError(t, err) ++ } else { ++ require.Error(t, err) ++ } ++ contents, err := os.ReadFile(outside) ++ require.NoError(t, err) ++ require.Equal(t, metadata, contents) ++ }) ++ } ++} ++ ++func TestRemoteDockerBuildBoundary(t *testing.T) { ++ root := t.TempDir() ++ repository := filepath.Join(root, "repository") ++ outside := filepath.Join(root, "repository-neighbor") ++ for _, directory := range []string{repository, outside, filepath.Join(repository, "actions"), filepath.Join(repository, "actions", "nested")} { ++ require.NoError(t, os.MkdirAll(directory, 0o700)) ++ require.NoError(t, os.WriteFile(filepath.Join(directory, "Dockerfile"), []byte("FROM scratch\n"), 0o600)) ++ } ++ require.NoError(t, os.Symlink(outside, filepath.Join(repository, "escape-dir"))) ++ require.NoError(t, os.Symlink(filepath.Join(outside, "Dockerfile"), filepath.Join(repository, "escape-file"))) ++ require.NoError(t, os.Symlink("actions", filepath.Join(repository, "internal-dir"))) ++ for _, test := range []struct { ++ name, action, image, expected string ++ invalid bool ++ }{ ++ {"root", "", "Dockerfile", repository, false}, ++ {"nested", "actions/nested", "Dockerfile", filepath.Join(repository, "actions", "nested"), false}, ++ {"internal-parent", "actions/nested", "../Dockerfile", filepath.Join(repository, "actions"), false}, ++ {"internal-link", "", "internal-dir/Dockerfile", filepath.Join(repository, "actions"), false}, ++ {"parent", "", "../repository-neighbor/Dockerfile", "", true}, ++ {"action-parent", "../repository-neighbor", "Dockerfile", "", true}, ++ {"absolute-image", "", filepath.Join(outside, "Dockerfile"), "", true}, ++ {"absolute-action", outside, "Dockerfile", "", true}, ++ {"linked-directory", "", "escape-dir/Dockerfile", "", true}, ++ {"linked-file", "", "escape-file", "", true}, ++ {"directory", "", "actions", "", true}, ++ } { ++ t.Run(test.name, func(t *testing.T) { ++ directory, filename, err := remoteActionBuildContext(repository, test.action, test.image) ++ if test.invalid { ++ require.Error(t, err) ++ return ++ } ++ require.NoError(t, err) ++ expected, err := filepath.EvalSymlinks(test.expected) ++ require.NoError(t, err) ++ require.Equal(t, expected, directory) ++ require.Equal(t, "Dockerfile", filename) ++ }) ++ } ++} ++ ++func TestActionBundleReadWriteBoundary(t *testing.T) { ++ repository := t.TempDir() ++ outside := filepath.Join(t.TempDir(), "private.js") ++ require.NoError(t, os.WriteFile(outside, []byte(gateTSC), 0o600)) ++ script := filepath.Join(repository, "index.js") ++ require.NoError(t, os.Symlink(outside, script)) ++ source, err := os.OpenRoot(repository) ++ require.NoError(t, err) ++ defer source.Close() ++ original, err := patchBundle(source, "index.js") ++ require.Error(t, err) ++ require.Nil(t, original) ++ require.NoError(t, os.Remove(script)) ++ require.NoError(t, os.WriteFile(script, []byte(gateTSC), 0o600)) ++ restore := patchActions(t.Context(), repository, []string{script}) ++ patched, err := os.ReadFile(script) ++ require.NoError(t, err) ++ require.True(t, gateOpened(string(patched))) ++ require.NoError(t, os.Remove(script)) ++ require.NoError(t, os.Symlink(outside, script)) ++ restore() ++ contents, err := os.ReadFile(outside) ++ require.NoError(t, err) ++ require.Equal(t, gateTSC, string(contents)) ++} +diff --git a/act/runner/action_test.go b/act/runner/action_test.go +index 94222d59..0ad58334 100644 +--- a/act/runner/action_test.go ++++ b/act/runner/action_test.go +@@ -403,6 +403,7 @@ func TestMaybeCopyToActionDirHoldsCloneLock(t *testing.T) { + + func TestExecAsDockerHoldsCloneLockForRemoteUncached(t *testing.T) { + actionDir := t.TempDir() ++ require.NoError(t, os.WriteFile(filepath.Join(actionDir, "Dockerfile"), []byte("FROM scratch\n"), 0o600)) + + unlock, err := git.AcquireCloneLock(t.Context(), actionDir) + require.NoError(t, err) +diff --git a/act/runner/patch_actions.go b/act/runner/patch_actions.go +index c82f75d7..8ec6d327 100644 +--- a/act/runner/patch_actions.go ++++ b/act/runner/patch_actions.go +@@ -106,20 +106,31 @@ func actionScriptPaths(dir string, action *model.Action) []string { + } + + // patchActions returns a restore: the tree is shared, so a job with patching off gets no edits. +-func patchActions(ctx context.Context, scripts []string) func() { ++func patchActions(ctx context.Context, repository string, scripts []string) func() { ++ source, err := os.OpenRoot(repository) ++ if err != nil { ++ common.Logger(ctx).Warnf("actions toolkit: cannot open source repository: %v", err) ++ return func() {} ++ } + restore := map[string][]byte{} + for _, script := range scripts { +- original, err := patchBundle(script) ++ relative, err := filepath.Rel(repository, script) ++ if err != nil { ++ common.Logger(ctx).Warnf("actions toolkit: cannot resolve script: %v", err) ++ continue ++ } ++ original, err := patchBundle(source, relative) + if original != nil { +- restore[script] = original // also when the write failed part way through ++ restore[relative] = original // also when the write failed part way through + } + if err != nil { + common.Logger(ctx).Warnf("actions toolkit: %s left unpatched: %v", script, err) + } + } + return func() { ++ defer source.Close() + for script, original := range restore { +- if err := os.WriteFile(script, original, 0o644); err != nil { //nolint:gosec // as the checkout wrote it ++ if err := source.WriteFile(script, original, 0o644); err != nil { + common.Logger(ctx).Warnf("actions toolkit: %s left patched in the shared copy: %v", script, err) + } + } +@@ -127,15 +138,15 @@ func patchActions(ctx context.Context, scripts []string) func() { + } + + // patchBundle returns the bytes it replaced, or nil when it left the bundle alone. +-func patchBundle(script string) ([]byte, error) { +- info, err := os.Stat(script) ++func patchBundle(source *os.Root, script string) ([]byte, error) { ++ info, err := source.Stat(script) + if err != nil { + return nil, err + } + if info.Size() > maxBundleSize { + return nil, nil + } +- data, err := os.ReadFile(script) ++ data, err := source.ReadFile(script) + if err != nil { + return nil, err + } +@@ -143,7 +154,7 @@ func patchBundle(script string) ([]byte, error) { + if !ok { + return nil, nil + } +- return data, os.WriteFile(script, patched, info.Mode().Perm()) ++ return data, source.WriteFile(script, patched, info.Mode().Perm()) + } + + // patchedBundle opens the GHES gate, and where the cache toolkit is present, points the cache +diff --git a/act/runner/patch_actions_e2e_test.go b/act/runner/patch_actions_e2e_test.go +index 5a8d14cc..d9affedd 100644 +--- a/act/runner/patch_actions_e2e_test.go ++++ b/act/runner/patch_actions_e2e_test.go +@@ -116,7 +116,7 @@ func patchedAction(t *testing.T, repo, ref, entrypoint string) string { + require.NoError(t, err) + script := filepath.Join(tempDirPath(t), filepath.Base(entrypoint)) + require.NoError(t, os.WriteFile(script, body, 0o600)) +- patchActions(t.Context(), []string{script}) ++ t.Cleanup(patchActions(t.Context(), filepath.Dir(script), []string{script})) + return script + } + +diff --git a/act/runner/patch_actions_test.go b/act/runner/patch_actions_test.go +index bc7e8e4f..3e8d16e8 100644 +--- a/act/runner/patch_actions_test.go ++++ b/act/runner/patch_actions_test.go +@@ -175,15 +175,18 @@ func TestPatchedBundleSurvivesInsideAStringLiteral(t *testing.T) { + // An action with a pre step is copied, and so patched, twice. + func TestPatchBundleIsIdempotent(t *testing.T) { + script := bundleFile(t, gateTSC) ++ source, err := os.OpenRoot(filepath.Dir(script)) ++ require.NoError(t, err) ++ defer source.Close() + +- original, err := patchBundle(script) ++ original, err := patchBundle(source, filepath.Base(script)) + require.NoError(t, err) + require.NotNil(t, original) + patched, err := os.ReadFile(script) + require.NoError(t, err) + require.True(t, gateOpened(string(patched))) + +- original, err = patchBundle(script) ++ original, err = patchBundle(source, filepath.Base(script)) + require.NoError(t, err) + assert.Nil(t, original, "a patched bundle is not patched again") + again, err := os.ReadFile(script) +@@ -193,8 +196,11 @@ func TestPatchBundleIsIdempotent(t *testing.T) { + + func TestPatchBundleLeavesOtherActionsAlone(t *testing.T) { + script := bundleFile(t, `console.log("checkout")`) ++ source, err := os.OpenRoot(filepath.Dir(script)) ++ require.NoError(t, err) ++ defer source.Close() + +- original, err := patchBundle(script) ++ original, err := patchBundle(source, filepath.Base(script)) + require.NoError(t, err) + assert.Nil(t, original) + body, err := os.ReadFile(script) +diff --git a/act/runner/step_action_local.go b/act/runner/step_action_local.go +index ec715f8f..e7214e05 100644 +--- a/act/runner/step_action_local.go ++++ b/act/runner/step_action_local.go +@@ -16,6 +16,7 @@ import ( + "path/filepath" + + "gitea.com/gitea/runner/act/common" ++ "gitea.com/gitea/runner/act/container" + + "gitea.dev/actionslib/pkg/model" + ) +@@ -80,7 +81,15 @@ func (sal *stepActionLocal) main() common.Executor { + return nil, nil, fmt.Errorf("max depth %d of symlinks exceeded while reading %s", maxSymlinkDepth, spath) + } + +- actionModel, err := sal.readAction(ctx, sal.Step, actionDir, "", localReader, os.WriteFile) ++ localWriter := func(filename string, data []byte, mode fs.FileMode) error { ++ _, destination := getContainerActionPaths(sal.Step, filepath.Dir(filename), sal.RunContext) ++ return sal.RunContext.JobContainer.Copy(destination, &container.FileEntry{ ++ Name: filepath.Base(filename), ++ Mode: int64(mode), ++ Body: string(data), ++ })(ctx) ++ } ++ actionModel, err := sal.readAction(ctx, sal.Step, actionDir, "", localReader, localWriter) + if err != nil { + return err + } +diff --git a/act/runner/step_action_local_test.go b/act/runner/step_action_local_test.go +index b6c09497..0afe5828 100644 +--- a/act/runner/step_action_local_test.go ++++ b/act/runner/step_action_local_test.go +@@ -8,15 +8,18 @@ import ( + "bytes" + "context" + "io" ++ "os" + "path/filepath" + "strings" + "testing" + + "gitea.com/gitea/runner/act/common" ++ "gitea.com/gitea/runner/act/container" + + "gitea.dev/actionslib/pkg/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" ++ "github.com/stretchr/testify/require" + "go.yaml.in/yaml/v4" + ) + +@@ -36,6 +39,9 @@ func (salm *stepActionLocalMocks) readAction(_ context.Context, step *model.Step + + func TestStepActionLocalTest(t *testing.T) { + ctx := t.Context() ++ workdir := t.TempDir() ++ actionDir := filepath.Join(workdir, "path", "to", "action") ++ require.NoError(t, os.MkdirAll(actionDir, 0o700)) + + cm := &containerMock{} + salm := &stepActionLocalMocks{} +@@ -47,7 +53,7 @@ func TestStepActionLocalTest(t *testing.T) { + StepResults: map[string]*model.StepResult{}, + ExprEval: &expressionEvaluator{}, + Config: &Config{ +- Workdir: "/tmp", ++ Workdir: workdir, + }, + Run: &model.Run{ + JobID: "1", +@@ -71,8 +77,14 @@ func TestStepActionLocalTest(t *testing.T) { + }, + } + +- salm.On("readAction", sal.Step, filepath.Clean("/tmp/path/to/action"), "", mock.Anything, mock.Anything). +- Return(&model.Action{}, nil) ++ salm.On("readAction", sal.Step, actionDir, "", mock.Anything, mock.Anything). ++ Run(func(args mock.Arguments) { ++ writer := args.Get(4).(fileWriter) ++ require.NoError(t, writer(filepath.Join(actionDir, "trampoline.js"), []byte("fixture"), 0o400)) ++ }).Return(&model.Action{}, nil) ++ cm.On("Copy", actionDir, mock.MatchedBy(func(files []*container.FileEntry) bool { ++ return len(files) == 1 && files[0].Name == "trampoline.js" && files[0].Body == "fixture" && files[0].Mode == 0o400 ++ })).Return(noopExecutor).Once() + + cm.On("Copy", "/var/run/act", mock.AnythingOfType("[]*container.FileEntry")).Return(noopExecutor) + +@@ -84,7 +96,7 @@ func TestStepActionLocalTest(t *testing.T) { + + cm.On("GetContainerArchive", ctx, "/var/run/act/workflow/pathcmd.txt").Return(io.NopCloser(&bytes.Buffer{}), nil) + +- salm.On("runAction", sal, filepath.Clean("/tmp/path/to/action"), (*remoteAction)(nil)).Return(noopExecutor) ++ salm.On("runAction", sal, actionDir, (*remoteAction)(nil)).Return(noopExecutor) + + err := sal.pre()(ctx) + assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act +@@ -92,6 +104,7 @@ func TestStepActionLocalTest(t *testing.T) { + err = sal.main()(ctx) + assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act + ++ require.NoFileExists(t, filepath.Join(actionDir, "trampoline.js")) + cm.AssertExpectations(t) + salm.AssertExpectations(t) + } +diff --git a/act/runner/step_action_remote.go b/act/runner/step_action_remote.go +index d13d1686..d2bfe89f 100644 +--- a/act/runner/step_action_remote.go ++++ b/act/runner/step_action_remote.go +@@ -101,12 +101,24 @@ func (sar *stepActionRemote) prepareActionExecutor() common.Executor { + sar.resolvedSha = sha + } + ++ source, err := os.OpenRoot(actionDir) ++ if err != nil { ++ return err ++ } ++ defer source.Close() + remoteReader := func(filename string) (io.Reader, io.Closer, error) { +- f, err := os.Open(filepath.Join(actionDir, sar.remoteAction.Path, filename)) ++ f, err := source.Open(filepath.Join(sar.remoteAction.Path, filename)) + return f, f, err + } ++ remoteWriter := func(filename string, data []byte, mode os.FileMode) error { ++ relative, err := filepath.Rel(actionDir, filename) ++ if err != nil { ++ return err ++ } ++ return source.WriteFile(relative, data, mode) ++ } + +- actionModel, err := sar.readAction(ctx, sar.Step, actionDir, sar.remoteAction.Path, remoteReader, os.WriteFile) ++ actionModel, err := sar.readAction(ctx, sar.Step, actionDir, sar.remoteAction.Path, remoteReader, remoteWriter) + sar.action = actionModel + return err + } +diff --git a/act/runner/step_action_remote_test.go b/act/runner/step_action_remote_test.go +index 95b0fcc2..340bd432 100644 +--- a/act/runner/step_action_remote_test.go ++++ b/act/runner/step_action_remote_test.go +@@ -37,7 +37,15 @@ func actionDirSuffix(suffix string) any { + + func setCloneExecutor(t *testing.T, executor func(git.NewGitCloneExecutorInput) common.Executor) { + original := stepActionRemoteNewCloneExecutor +- stepActionRemoteNewCloneExecutor = executor ++ stepActionRemoteNewCloneExecutor = func(input git.NewGitCloneExecutorInput) common.Executor { ++ clone := executor(input) ++ return func(ctx context.Context) error { ++ if err := clone(ctx); err != nil { ++ return err ++ } ++ return os.MkdirAll(input.Dir, 0o755) ++ } ++ } + t.Cleanup(func() { stepActionRemoteNewCloneExecutor = original }) + } + diff --git a/patches/0003-isolate-job-action-caches.patch b/patches/0003-isolate-job-action-caches.patch new file mode 100644 index 0000000..d8fce18 --- /dev/null +++ b/patches/0003-isolate-job-action-caches.patch @@ -0,0 +1,104 @@ +Subject: [PATCH] Give each assigned task a private action-source cache + +Retire when upstream isolates task source trees without reusing abandoned Git index locks. + +diff --git a/internal/app/run/job_action_cache.go b/internal/app/run/job_action_cache.go +new file mode 100644 +index 00000000..7ab72b9e +--- /dev/null ++++ b/internal/app/run/job_action_cache.go +@@ -0,0 +1,15 @@ ++// Copyright 2026 The Gitea Authors. All rights reserved. ++// SPDX-License-Identifier: MIT ++ ++package run ++ ++import "os" ++ ++func newJobActionCache(parent string) (string, error) { ++ if parent != "" { ++ if err := os.MkdirAll(parent, 0o700); err != nil { ++ return "", err ++ } ++ } ++ return os.MkdirTemp(parent, "gitea-runner-actions-") ++} +diff --git a/internal/app/run/job_action_cache_test.go b/internal/app/run/job_action_cache_test.go +new file mode 100644 +index 00000000..fff9e092 +--- /dev/null ++++ b/internal/app/run/job_action_cache_test.go +@@ -0,0 +1,44 @@ ++// Copyright 2026 The Gitea Authors. All rights reserved. ++// SPDX-License-Identifier: MIT ++ ++package run ++ ++import ( ++ "os" ++ "path/filepath" ++ "runtime" ++ "testing" ++ ++ "github.com/stretchr/testify/require" ++) ++ ++func TestJobActionCacheIsolation(t *testing.T) { ++ defaultCache, err := newJobActionCache("") ++ require.NoError(t, err) ++ defer os.RemoveAll(defaultCache) ++ require.DirExists(t, defaultCache) ++ parent := t.TempDir() ++ stale := filepath.Join(parent, "old-action", ".git", "index.lock") ++ require.NoError(t, os.MkdirAll(filepath.Dir(stale), 0o755)) ++ require.NoError(t, os.WriteFile(stale, []byte("another task"), 0o600)) ++ first, err := newJobActionCache(parent) ++ require.NoError(t, err) ++ second, err := newJobActionCache(parent) ++ require.NoError(t, err) ++ require.NotEqual(t, first, second) ++ for _, directory := range []string{first, second} { ++ entries, err := os.ReadDir(directory) ++ require.NoError(t, err) ++ require.Empty(t, entries) ++ info, err := os.Stat(directory) ++ require.NoError(t, err) ++ if runtime.GOOS != "windows" { ++ require.Equal(t, os.FileMode(0o700), info.Mode().Perm()) ++ } ++ } ++ require.NoError(t, os.RemoveAll(first)) ++ require.DirExists(t, second) ++ contents, err := os.ReadFile(stale) ++ require.NoError(t, err) ++ require.Equal(t, "another task", string(contents)) ++} +diff --git a/internal/app/run/runner.go b/internal/app/run/runner.go +index ce7fd1f1..28287f49 100644 +--- a/internal/app/run/runner.go ++++ b/internal/app/run/runner.go +@@ -594,12 +594,23 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report. + + r.setResultsService(envs, resultsURL) + ++ actionCacheDir, err := newJobActionCache(filepath.FromSlash(r.cfg.Host.WorkdirParent)) ++ if err != nil { ++ return fmt.Errorf("create private action cache: %w", err) ++ } ++ defer func() { ++ if err := os.RemoveAll(actionCacheDir); err != nil { ++ common.Logger(ctx).WithError(err).Warnf("remove private action cache") ++ } ++ }() ++ ++ + runnerConfig := &runner.Config{ + // On Linux, Workdir will be like "///" + // On Windows, Workdir will be like "\\\" + Workdir: workdir, + BindWorkdir: r.cfg.Container.BindWorkdir, +- ActionCacheDir: filepath.FromSlash(r.cfg.Host.WorkdirParent), ++ ActionCacheDir: actionCacheDir, + AllocatePTY: r.cfg.Runner.AllocatePTY, + ActionOfflineMode: r.cfg.Cache.OfflineMode, + ActionCloneDepth: actionCloneDepth, diff --git a/patches/0004-install-trusted-root-ca.patch b/patches/0004-install-trusted-root-ca.patch new file mode 100644 index 0000000..b41dff8 --- /dev/null +++ b/patches/0004-install-trusted-root-ca.patch @@ -0,0 +1,274 @@ +Subject: [PATCH] Install CA-only system and Node trust before actions + +Retire when upstream supports public root installation before actions with no private-key propagation. + +diff --git a/act/runner/run_context.go b/act/runner/run_context.go +index 37dd629d..0e448e02 100644 +--- a/act/runner/run_context.go ++++ b/act/runner/run_context.go +@@ -499,6 +499,7 @@ func (rc *RunContext) startKubernetesPod() common.Executor { + rc.JobContainer.Create(nil, nil), + rc.startServiceContainers(), + rc.JobContainer.Start(false), ++ rc.installTrustedRootCA(), + func(ctx context.Context) error { rc.setRunnerContextEnv(ctx); return nil }, + rc.captureJobContainerInfo(), + rc.copyWorkflowFiles(), +@@ -620,6 +621,7 @@ func (rc *RunContext) startJobContainer() common.Executor { + func(ctx context.Context) error { return rc.createJobVolumes(ctx, containerInput.Mounts) }, + rc.JobContainer.Create(rc.Config.ContainerCapAdd, rc.Config.ContainerCapDrop), + rc.JobContainer.Start(false), ++ rc.installTrustedRootCA(), + rc.captureJobContainerInfo(), + rc.copyWorkflowFiles(), + rc.waitForServiceContainers(), +diff --git a/act/runner/runner.go b/act/runner/runner.go +index ec2cb9bc..13a16564 100644 +--- a/act/runner/runner.go ++++ b/act/runner/runner.go +@@ -29,6 +29,7 @@ import ( + type Config struct { + Actor string // the user that triggered the event + Workdir string // path to working directory ++ TrustedRootCA string // CA-only PEM installed before job actions + ActionCacheDir string // path used for caching action contents + ActionOfflineMode bool // when offline, use cached action contents + ActionCloneDepth int // limit history when cloning an action repo, 0 clones every branch in full +diff --git a/act/runner/trusted_root_ca.go b/act/runner/trusted_root_ca.go +new file mode 100644 +index 00000000..63b310d5 +--- /dev/null ++++ b/act/runner/trusted_root_ca.go +@@ -0,0 +1,83 @@ ++// Copyright 2026 The Gitea Authors. All rights reserved. ++// SPDX-License-Identifier: MIT ++ ++package runner ++ ++import ( ++ "bytes" ++ "context" ++ "crypto/x509" ++ "encoding/pem" ++ "errors" ++ "fmt" ++ "os" ++ ++ "gitea.com/gitea/runner/act/common" ++ "gitea.com/gitea/runner/act/container" ++) ++ ++const ( ++ rootCAFilename = "startos-root-ca.crt" ++ rootCASystemDir = "/usr/local/share/ca-certificates" ++ rootCASharedDir = "/var/run/act/trust" ++) ++ ++// TrustedRootCAPath is shared by job containers and Docker actions. ++const TrustedRootCAPath = rootCASharedDir + "/" + rootCAFilename ++ ++// LoadTrustedRootCA accepts a nonempty CA-only PEM bundle, or an unset path. ++func LoadTrustedRootCA(path string) (string, error) { ++ if path == "" { ++ return "", nil ++ } ++ contents, err := os.ReadFile(path) ++ if err != nil { ++ return "", err ++ } ++ remaining := bytes.TrimSpace(contents) ++ if len(remaining) == 0 { ++ return "", errors.New("empty trusted CA bundle") ++ } ++ for len(remaining) > 0 { ++ const begin = "-----BEGIN CERTIFICATE-----" ++ const end = "-----END CERTIFICATE-----" ++ boundary := bytes.Index(remaining, []byte(end)) ++ if !bytes.HasPrefix(remaining, []byte(begin)) || boundary < 0 { ++ return "", errors.New("trusted CA bundle contains non-certificate data") ++ } ++ boundary += len(end) ++ block, extra := pem.Decode(remaining[:boundary]) ++ if block == nil || block.Type != "CERTIFICATE" || len(block.Headers) != 0 || len(bytes.TrimSpace(extra)) != 0 || bytes.Count(remaining[:boundary], []byte("-----BEGIN")) != 1 { ++ return "", errors.New("malformed trusted CA certificate") ++ } ++ certificate, err := x509.ParseCertificate(block.Bytes) ++ if err != nil || !certificate.IsCA || !certificate.BasicConstraintsValid { ++ return "", errors.New("trusted certificate is not a CA") ++ } ++ remaining = bytes.TrimSpace(remaining[boundary:]) ++ } ++ return string(contents), nil ++} ++ ++func (rc *RunContext) installTrustedRootCA() common.Executor { ++ if rc.Config.TrustedRootCA == "" { ++ return func(context.Context) error { return nil } ++ } ++ certificate := func() *container.FileEntry { ++ return &container.FileEntry{Name: rootCAFilename, Mode: 0o644, Body: rc.Config.TrustedRootCA} ++ } ++ return common.NewPipelineExecutor( ++ rc.JobContainer.Exec([]string{"sh", "-ec", fmt.Sprintf("mkdir -p %q %q", rootCASystemDir, rootCASharedDir)}, nil, "0", "/"), ++ rc.JobContainer.Copy(rootCASystemDir+"/", certificate()), ++ rc.JobContainer.Copy(rootCASharedDir+"/", certificate()), ++ rc.JobContainer.Exec([]string{"sh", "-ec", fmt.Sprintf(`if command -v update-ca-certificates >/dev/null 2>&1; then ++ update-ca-certificates ++elif command -v update-ca-trust >/dev/null 2>&1; then ++ mkdir -p /etc/pki/ca-trust/source/anchors ++ cp %q /etc/pki/ca-trust/source/anchors/%s ++ update-ca-trust extract ++else ++ echo 'System CA updater unavailable; Node uses the extra public CA' >&2 ++fi`, rootCASystemDir+"/"+rootCAFilename, rootCAFilename)}, nil, "0", "/"), ++ ) ++} +diff --git a/act/runner/trusted_root_ca_test.go b/act/runner/trusted_root_ca_test.go +new file mode 100644 +index 00000000..ae72dc04 +--- /dev/null ++++ b/act/runner/trusted_root_ca_test.go +@@ -0,0 +1,117 @@ ++// Copyright 2026 The Gitea Authors. All rights reserved. ++// SPDX-License-Identifier: MIT ++ ++package runner ++ ++import ( ++ "context" ++ "crypto/ecdsa" ++ "crypto/elliptic" ++ "crypto/rand" ++ "crypto/x509" ++ "encoding/pem" ++ "errors" ++ "math/big" ++ "os" ++ "path/filepath" ++ "testing" ++ "time" ++ ++ "gitea.com/gitea/runner/act/common" ++ "gitea.com/gitea/runner/act/container" ++ ++ "github.com/stretchr/testify/require" ++) ++ ++func TestTrustedRootCAValidation(t *testing.T) { ++ key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) ++ require.NoError(t, err) ++ certificate := func(ca bool) []byte { ++ template := &x509.Certificate{SerialNumber: big.NewInt(1), NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour), IsCA: ca, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature} ++ der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) ++ require.NoError(t, err) ++ return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) ++ } ++ ca := string(certificate(true)) ++ privateDER, err := x509.MarshalECPrivateKey(key) ++ require.NoError(t, err) ++ private := string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: privateDER})) ++ for _, test := range []struct { ++ name, data string ++ accepted bool ++ }{ ++ {"CA", ca, true}, ++ {"bundle", ca + ca, true}, ++ {"leaf", string(certificate(false)), false}, ++ {"private", private, false}, ++ {"mixed", ca + private, false}, ++ {"empty", "", false}, ++ {"garbage", ca + "garbage", false}, ++ {"malformed-block", "-----BEGIN CERTIFICATE-----\ninvalid\n-----END CERTIFICATE-----\n" + ca, false}, ++ {"malformed-prefix", "-----BEGIN CERTIFICATE-----\ninvalid\n" + ca, false}, ++ } { ++ t.Run(test.name, func(t *testing.T) { ++ path := filepath.Join(t.TempDir(), "ca.pem") ++ require.NoError(t, os.WriteFile(path, []byte(test.data), 0o600)) ++ actual, err := LoadTrustedRootCA(path) ++ if test.accepted { ++ require.NoError(t, err) ++ require.Equal(t, test.data, actual) ++ } else { ++ require.Error(t, err) ++ require.Empty(t, actual) ++ } ++ }) ++ } ++ value, err := LoadTrustedRootCA("") ++ require.NoError(t, err) ++ require.Empty(t, value) ++ _, err = LoadTrustedRootCA(filepath.Join(t.TempDir(), "missing")) ++ require.Error(t, err) ++} ++ ++type caInstallContainer struct { ++ containerMock ++ operations []string ++ users []string ++ files []*container.FileEntry ++ failure error ++} ++ ++func (c *caInstallContainer) Exec(command []string, _ map[string]string, user, _ string) common.Executor { ++ return func(context.Context) error { ++ c.operations = append(c.operations, command[2]) ++ c.users = append(c.users, user) ++ return c.failure ++ } ++} ++ ++func (c *caInstallContainer) Copy(destination string, files ...*container.FileEntry) common.Executor { ++ return func(context.Context) error { ++ c.operations = append(c.operations, destination) ++ c.files = append(c.files, files...) ++ return nil ++ } ++} ++ ++func TestTrustedRootCAInstallation(t *testing.T) { ++ rc := &RunContext{Config: &Config{}} ++ require.NoError(t, rc.installTrustedRootCA()(t.Context())) ++ job := &caInstallContainer{} ++ rc.JobContainer = job ++ rc.Config.TrustedRootCA = "public CA fixture" ++ require.NoError(t, common.NewPipelineExecutor(rc.installTrustedRootCA(), func(context.Context) error { job.operations = append(job.operations, "first action"); return nil })(t.Context())) ++ require.Len(t, job.operations, 5) ++ require.Contains(t, job.operations[0], "mkdir") ++ require.Equal(t, "/usr/local/share/ca-certificates/", job.operations[1]) ++ require.Equal(t, "/var/run/act/trust/", job.operations[2]) ++ require.Contains(t, job.operations[3], "update-ca-certificates") ++ require.Equal(t, "first action", job.operations[4]) ++ require.Equal(t, []string{"0", "0"}, job.users) ++ for _, file := range job.files { ++ require.Equal(t, "public CA fixture", file.Body) ++ require.EqualValues(t, 0o644, file.Mode) ++ } ++ job.failure = errors.New("cannot install") ++ require.ErrorIs(t, rc.installTrustedRootCA()(t.Context()), job.failure) ++} +diff --git a/internal/app/run/runner.go b/internal/app/run/runner.go +index 28287f49..b08b1a36 100644 +--- a/internal/app/run/runner.go ++++ b/internal/app/run/runner.go +@@ -492,6 +492,13 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report. + + taskContext := task.Context.Fields + envs := r.cloneEnvs() ++ trustedRootCA, err := runner.LoadTrustedRootCA(os.Getenv("RUNNER_ROOT_CA")) ++ if err != nil { ++ return fmt.Errorf("load public job CA: %w", err) ++ } ++ if trustedRootCA != "" { ++ envs["NODE_EXTRA_CA_CERTS"] = runner.TrustedRootCAPath ++ } + + // act asks for the platform once per step, so resolve the fallback at most once per task. + fallbackPlatform := sync.OnceValue(func() string { return r.fallbackPlatform(ctx) }) +@@ -606,6 +613,7 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report. + + + runnerConfig := &runner.Config{ ++ TrustedRootCA: trustedRootCA, + // On Linux, Workdir will be like "///" + // On Windows, Workdir will be like "\\\" + Workdir: workdir, diff --git a/patches/0005-serialize-assigned-task-jobs.patch b/patches/0005-serialize-assigned-task-jobs.patch new file mode 100644 index 0000000..6cc7bdb --- /dev/null +++ b/patches/0005-serialize-assigned-task-jobs.patch @@ -0,0 +1,212 @@ +Subject: [PATCH] Bound locally expanded jobs and matrices within each assignment + +Retire when upstream bounds both local scheduler fan-outs recursively per assigned task. + +diff --git a/act/runner/runner.go b/act/runner/runner.go +index 13a16564..7117d5a1 100644 +--- a/act/runner/runner.go ++++ b/act/runner/runner.go +@@ -36,6 +36,7 @@ type Config struct { + BindWorkdir bool // bind the workdir to the job container + EventName string // name of event to run + EventPath string // path to JSON file to use for event.json in containers ++ SerialJobs bool // serialize local jobs and matrix entries within an assignment + ForcePull bool // force pulling of the image, even if already present + ForceRebuild bool // force rebuilding local docker image action + JSONLogger bool // use json or text logger +@@ -293,7 +294,7 @@ func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor { + // Run all matrix combinations of this job, then drop its aggregation mutex: the + // combos are the only users of it, so once they finish the jobMutexes entry can be + // released, keeping the map from growing unbounded over a long-lived runner. +- stageParallel := common.NewParallelExecutor(maxParallel, stageExecutor...) ++ stageParallel := runner.parallelJobExecutor(maxParallel, stageExecutor...) + pipeline = append(pipeline, func(ctx context.Context) error { + defer jobMutexes.Delete(job) + return stageParallel(ctx) +@@ -331,7 +332,7 @@ func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor { + } + + logging.Infof("Executing %d pipeline elements with parallelism %d", len(pipeline), parallelism) +- return common.NewParallelExecutor(parallelism, pipeline...)(ctx) ++ return runner.parallelJobExecutor(parallelism, pipeline...)(ctx) + }) + } + +@@ -339,6 +340,13 @@ func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor { + return func(ctx context.Context) error { return executor(ghcontext.WithPinnedCheckout(ctx)) } + } + ++func (runner *runnerImpl) parallelJobExecutor(parallel int, executors ...common.Executor) common.Executor { ++ if runner.config.SerialJobs { ++ parallel = 1 ++ } ++ return common.NewParallelExecutor(parallel, executors...) ++} ++ + func handleFailure(plan *model.Plan) common.Executor { + return func(ctx context.Context) error { + for _, stage := range plan.Stages { +diff --git a/act/runner/serial_jobs_test.go b/act/runner/serial_jobs_test.go +new file mode 100644 +index 00000000..5e3ac290 +--- /dev/null ++++ b/act/runner/serial_jobs_test.go +@@ -0,0 +1,144 @@ ++// Copyright 2026 The Gitea Authors. All rights reserved. ++// SPDX-License-Identifier: MIT ++ ++package runner ++ ++import ( ++ "context" ++ "errors" ++ "os" ++ "path/filepath" ++ "strings" ++ "sync/atomic" ++ "testing" ++ "time" ++ ++ "gitea.com/gitea/runner/act/common" ++ ++ "gitea.dev/actionslib/pkg/model" ++ "github.com/stretchr/testify/require" ++) ++ ++func TestDefaultParallelJobs(t *testing.T) { ++ r := &runnerImpl{config: &Config{}} ++ entered := make(chan struct{}, 4) ++ release := make(chan struct{}) ++ finished := make(chan error, 1) ++ jobs := make([]common.Executor, 4) ++ for i := range jobs { ++ jobs[i] = func(ctx context.Context) error { ++ entered <- struct{}{} ++ select { ++ case <-release: ++ return nil ++ case <-ctx.Done(): ++ return ctx.Err() ++ } ++ } ++ } ++ ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) ++ defer cancel() ++ go func() { finished <- r.parallelJobExecutor(4, jobs...)(ctx) }() ++ for range jobs { ++ select { ++ case <-entered: ++ case <-ctx.Done(): ++ t.Fatal("unconfigured executors did not overlap") ++ } ++ } ++ close(release) ++ require.NoError(t, <-finished) ++} ++ ++func TestSerialJobsFailureAndCancellation(t *testing.T) { ++ r := &runnerImpl{config: &Config{SerialJobs: true}} ++ failure := errors.New("job failed") ++ cleaned := false ++ err := r.parallelJobExecutor(4, func(context.Context) error { return failure }, func(context.Context) error { cleaned = true; return nil })(t.Context()) ++ require.ErrorIs(t, err, failure) ++ require.True(t, cleaned) ++ ctx, cancel := context.WithCancel(t.Context()) ++ started := make(chan struct{}) ++ finished := make(chan error, 1) ++ var laterWork atomic.Int32 ++ go func() { ++ finished <- r.parallelJobExecutor(4, ++ func(ctx context.Context) error { close(started); <-ctx.Done(); return ctx.Err() }, ++ func(ctx context.Context) error { ++ if ctx.Err() == nil { ++ laterWork.Add(1) ++ } ++ return ctx.Err() ++ }, ++ )(ctx) ++ }() ++ <-started ++ cancel() ++ select { ++ case err := <-finished: ++ require.ErrorIs(t, err, context.Canceled) ++ case <-time.After(5 * time.Second): ++ t.Fatal("serial cancellation did not finish") ++ } ++ require.Zero(t, laterWork.Load()) ++} ++ ++func TestSerialJobsPlanAndNestedMatrices(t *testing.T) { ++ root := t.TempDir() ++ t.Chdir(root) ++ t.Setenv("RUNNER_TOOL_CACHE", filepath.Join(root, "tools")) ++ workflowDir := filepath.Join(root, ".github", "workflows") ++ require.NoError(t, os.MkdirAll(workflowDir, 0o755)) ++ step := ` steps: ++ - run: | ++ if ! mkdir "$LOCK_DIR"; then echo overlap >> "$TRACE_FILE"; exit 1; fi ++ trap 'rmdir "$LOCK_DIR"' EXIT ++ echo "$LABEL" >> "$TRACE_FILE" ++ head -c 33554432 /dev/zero | sha256sum >/dev/null ++` ++ main := `name: serial plan ++on: push ++jobs: ++ matrix: ++ runs-on: self-hosted ++ strategy: ++ max-parallel: 8 ++ matrix: ++ value: [matrix-one, matrix-two] ++ env: ++ LABEL: ${{ matrix.value }} ++` + step + ` independent: ++ runs-on: self-hosted ++ env: ++ LABEL: independent ++` + step + ` nested: ++ uses: ./.github/workflows/nested.yml ++` ++ nested := `name: nested matrix ++on: workflow_call ++jobs: ++ nested: ++ runs-on: self-hosted ++ strategy: ++ max-parallel: 8 ++ matrix: ++ value: [nested-one, nested-two] ++ env: ++ LABEL: ${{ matrix.value }} ++` + step ++ require.NoError(t, os.WriteFile(filepath.Join(workflowDir, "main.yml"), []byte(main), 0o644)) ++ require.NoError(t, os.WriteFile(filepath.Join(workflowDir, "nested.yml"), []byte(nested), 0o644)) ++ planner, err := model.NewWorkflowPlanner(workflowDir, true) ++ require.NoError(t, err) ++ plan, err := planner.PlanEvent("push") ++ require.NoError(t, err) ++ trace := filepath.Join(root, "trace") ++ r, err := New(&Config{SerialJobs: true, Workdir: root, ActionCacheDir: filepath.Join(root, "cache"), EventName: "push", Env: map[string]string{"LOCK_DIR": filepath.Join(root, "lock"), "TRACE_FILE": trace}, PlatformPicker: func([]string) string { return "-self-hosted" }}) ++ require.NoError(t, err) ++ ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second) ++ defer cancel() ++ require.NoError(t, r.NewPlanExecutor(plan)(ctx)) ++ data, err := os.ReadFile(trace) ++ require.NoError(t, err) ++ require.ElementsMatch(t, []string{"matrix-one", "matrix-two", "independent", "nested-one", "nested-two"}, strings.Fields(string(data))) ++} +diff --git a/internal/app/run/runner.go b/internal/app/run/runner.go +index b08b1a36..92137173 100644 +--- a/internal/app/run/runner.go ++++ b/internal/app/run/runner.go +@@ -611,8 +611,8 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report. + } + }() + +- + runnerConfig := &runner.Config{ ++ SerialJobs: true, + TrustedRootCA: trustedRootCA, + // On Linux, Workdir will be like "///" + // On Windows, Workdir will be like "\\\" diff --git a/patches/0006-select-label-image-platform.patch b/patches/0006-select-label-image-platform.patch new file mode 100644 index 0000000..c100fff --- /dev/null +++ b/patches/0006-select-label-image-platform.patch @@ -0,0 +1,334 @@ +Subject: [PATCH] Select job and action image architecture from Docker labels + +Retire when upstream selects Docker image platforms per expanded job and carries them into reusable workflows. + +diff --git a/act/runner/action.go b/act/runner/action.go +index bf4159f8..7d3c4a45 100644 +--- a/act/runner/action.go ++++ b/act/runner/action.go +@@ -323,7 +323,7 @@ func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, b + return err + } + +- correctArchExists, err := ContainerImageExistsLocally(ctx, image, rc.Config.ContainerArchitecture) ++ correctArchExists, err := ContainerImageExistsLocally(ctx, image, rc.dockerImagePlatform(ctx)) + if err != nil { + return err + } +@@ -339,7 +339,7 @@ func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, b + } + + if !correctArchExists || rc.Config.ForceRebuild { +- logger.Debugf("image '%s' for architecture '%s' will be built from context '%s", image, rc.Config.ContainerArchitecture, contextDir) ++ logger.Debugf("image '%s' for architecture '%s' will be built from context '%s", image, rc.dockerImagePlatform(ctx), contextDir) + var buildContext io.ReadCloser + if localAction { + buildContext, err = rc.JobContainer.GetContainerArchive(ctx, contextDir+"/.") +@@ -353,7 +353,7 @@ func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, b + Dockerfile: fileName, + ImageTag: image, + BuildContext: buildContext, +- Platform: rc.Config.ContainerArchitecture, ++ Platform: rc.dockerImagePlatform(ctx), + BuildArgs: rc.proxyBuildArgs(), + }) + if buildContext == nil { +@@ -369,7 +369,7 @@ func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, b + } + } + } else { +- logger.Debugf("image '%s' for architecture '%s' already exists", image, rc.Config.ContainerArchitecture) ++ logger.Debugf("image '%s' for architecture '%s' already exists", image, rc.dockerImagePlatform(ctx)) + } + } + cmd, err := shellquote.Split(step.getStepModel().With["args"]) +@@ -487,7 +487,7 @@ func newStepContainer(ctx context.Context, step step, image string, cmd, entrypo + Stderr: logWriter, + Privileged: rc.Config.Privileged, + UsernsMode: rc.Config.UsernsMode, +- Platform: rc.Config.ContainerArchitecture, ++ Platform: rc.dockerImagePlatform(ctx), + RunnerOptions: runnerOptions, + AutoRemove: true, + ValidVolumes: rc.validVolumes(), +diff --git a/act/runner/image_platform_test.go b/act/runner/image_platform_test.go +new file mode 100644 +index 00000000..b3184f19 +--- /dev/null ++++ b/act/runner/image_platform_test.go +@@ -0,0 +1,37 @@ ++// Copyright 2026 The Gitea Authors. All rights reserved. ++// SPDX-License-Identifier: MIT ++ ++package runner ++ ++import ( ++ "testing" ++ ++ "gitea.com/gitea/runner/internal/pkg/labels" ++ ++ "gitea.dev/actionslib/pkg/model" ++ "github.com/stretchr/testify/require" ++ "go.yaml.in/yaml/v4" ++) ++ ++func TestDockerImagePlatformSelection(t *testing.T) { ++ label, err := labels.Parse("linux-arm64:docker://example/image:tag?platform=linux/arm64") ++ require.NoError(t, err) ++ configured := labels.Labels{label} ++ var runsOn, services yaml.Node ++ require.NoError(t, runsOn.Encode([]string{"linux-${{ matrix.arch }}"})) ++ require.NoError(t, services.Encode(map[string]any{"database": map[string]any{"image": "example/database:tag"}})) ++ rc := &RunContext{ ++ Config: &Config{Workdir: t.TempDir(), ContainerArchitecture: "linux/amd64", DockerImagePlatformPicker: configured.PickDockerImagePlatform}, ++ Run: &model.Run{JobID: "build", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"build": {RawRunsOn: runsOn, RawServices: services}}}}, ++ Matrix: map[string]any{"arch": "arm64"}, ++ } ++ rc.ExprEval = rc.NewExpressionEvaluator(t.Context()) ++ require.Equal(t, "linux/arm64", rc.dockerImagePlatform(t.Context())) ++ inputs, err := rc.jobServices(t.Context(), "network") ++ require.NoError(t, err) ++ require.Equal(t, "linux/arm64", inputs["database"].Platform) ++ rc.Matrix["arch"] = "unknown" ++ require.Equal(t, "linux/amd64", rc.dockerImagePlatform(t.Context())) ++ rc.Config.DockerImagePlatformPicker = nil ++ require.Equal(t, "linux/amd64", rc.dockerImagePlatform(t.Context())) ++} +diff --git a/act/runner/run_context.go b/act/runner/run_context.go +index 0e448e02..f204fb9c 100644 +--- a/act/runner/run_context.go ++++ b/act/runner/run_context.go +@@ -587,7 +587,7 @@ func (rc *RunContext) startJobContainer() common.Executor { + Stderr: logWriter, + Privileged: rc.Config.Privileged, + UsernsMode: rc.Config.UsernsMode, +- Platform: rc.Config.ContainerArchitecture, ++ Platform: rc.dockerImagePlatform(ctx), + RunnerOptions: rc.Config.ContainerOptions, + WorkflowOptions: rc.containerSpec.Options, + AutoRemove: true, +@@ -689,7 +689,7 @@ func (rc *RunContext) jobServices(ctx context.Context, networkName string) (map[ + Stderr: serviceLog, + Privileged: rc.Config.Privileged, + UsernsMode: rc.Config.UsernsMode, +- Platform: rc.Config.ContainerArchitecture, ++ Platform: rc.dockerImagePlatform(ctx), + AutoRemove: false, // so a dead service's log survives, cleanupJobResources removes it + WorkflowOptions: spec.Options, + NetworkMode: networkName, +@@ -1252,6 +1252,15 @@ func (rc *RunContext) runsOnImage(ctx context.Context) (string, error) { + return "", nil + } + ++func (rc *RunContext) dockerImagePlatform(ctx context.Context) string { ++ if pick := rc.Config.DockerImagePlatformPicker; pick != nil { ++ if platform := pick(rc.runsOnPlatformNames(ctx)); platform != "" { ++ return platform ++ } ++ } ++ return rc.Config.ContainerArchitecture ++} ++ + func (rc *RunContext) runsOnPlatformNames(ctx context.Context) []string { + job := rc.Run.Job() + +diff --git a/act/runner/runner.go b/act/runner/runner.go +index 7117d5a1..fb2aaaff 100644 +--- a/act/runner/runner.go ++++ b/act/runner/runner.go +@@ -80,6 +80,7 @@ type Config struct { + // AppURL. It is never set for github.com or a GithubMirror, so the token stays on-instance. + DefaultActionInstanceIsSelfHosted bool + PlatformPicker func(labels []string) string ++ DockerImagePlatformPicker func(labels []string) string // per-label OS/architecture overrides ContainerArchitecture + KubernetesPicker func(labels []string) container.KubernetesOptions + InstanceTransport http.RoundTripper + JobLoggerLevel *logging.Level // the level of job logger +diff --git a/docs/assigned-task-isolation.md b/docs/assigned-task-isolation.md +new file mode 100644 +index 00000000..49d3aa30 +--- /dev/null ++++ b/docs/assigned-task-isolation.md +@@ -0,0 +1,11 @@ ++# Assigned-task execution isolation ++ ++The packaged daemon sets `act.Config.SerialJobs` for each assignment. Independent jobs and matrix entries expanded locally, including nested reusable workflows, share that assignment's serial execution policy. Separate assignments retain poller capacity. Library/exec callers leave this opt-in flag unset unless they request the same bound; no global semaphore spans reusable callers. ++ ++Action downloads use a fresh private task directory, removed when execution returns. Shared Git clones and abandoned Git index locks are not reused or deleted. Remote metadata, generated fallback files and compatibility bundle edits use an opened repository root. Dockerfiles and build contexts must remain within that repository; `.dockerignore` must remain within its context. Local generated action files are copied through the container API instead of written to the controller. ++ ++Docker labels may append `?platform=linux/arm64` or another Docker platform. The image reference excludes the query. Platform selection evaluates each expanded job's `runs-on`, applies to job/service/action containers and image builds, and survives reusable workflow configuration copies. Without a matching label platform, the existing runner-wide architecture remains the fallback. Installing binfmt alone does not select a platform. ++ ++`RUNNER_ROOT_CA` optionally names an operator-owned public CA PEM bundle on the controller. Private keys, non-CA certificates, malformed PEM and extra data are rejected. The public certificate is copied into the job's system trust and shared action volume, and the supported system CA updater runs as user 0 before workflow actions. Node receives `NODE_EXTRA_CA_CERTS` for the shared public certificate. Custom Docker-action and service images still own their system trust stores. ++ ++Runner-level Gitea client-certificate fields are not the Docker mTLS identity. Builtin checkout can copy those configured Gitea client keys into a job; a VM package must leave those fields unset. Docker client private keys and persistent registration remain controller-only. +diff --git a/internal/app/run/runner.go b/internal/app/run/runner.go +index 92137173..90994867 100644 +--- a/internal/app/run/runner.go ++++ b/internal/app/run/runner.go +@@ -657,6 +657,7 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report. + DefaultActionInstance: r.getDefaultActionsURL(task), + DefaultActionInstanceIsSelfHosted: r.isSelfHostedActionsURL(task), + PlatformPicker: platformPicker, ++ DockerImagePlatformPicker: r.labels.PickDockerImagePlatform, + JobStartedHook: r.cfg.Runner.Hooks.JobStarted, + JobCompletedHook: r.cfg.Runner.Hooks.JobCompleted, + Vars: task.Vars, +diff --git a/internal/pkg/labels/labels.go b/internal/pkg/labels/labels.go +index a4910d9e..ccde9acb 100644 +--- a/internal/pkg/labels/labels.go ++++ b/internal/pkg/labels/labels.go +@@ -5,6 +5,8 @@ package labels + + import ( + "errors" ++ "fmt" ++ "net/url" + "strings" + ) + +@@ -12,15 +14,17 @@ const ( + SchemeHost = "host" + SchemeDocker = "docker" + SchemeKubernetes = "kubernetes" ++ OptionPlatform = "platform" + + // SelfHostedPlatform is the platform marker act treats as "run on the host". + SelfHostedPlatform = "-self-hosted" + ) + + type Label struct { +- Name string +- Schema string +- Arg string ++ Name string ++ Schema string ++ Arg string ++ ImagePlatform string // OS/architecture from the Docker label query. + // Opaque marks a label whose name contains a colon but no supported schema, + // like "pool:e57e18d4-...". It is kept verbatim and behaves like a host label. + Opaque bool +@@ -52,6 +56,17 @@ func Parse(str string) (*Label, error) { + Opaque: true, + }, nil + } ++ if label.Schema == SchemeDocker { ++ image, query, found := strings.Cut(label.Arg, "?") ++ if found { ++ values, err := url.ParseQuery(query) ++ if err != nil || len(values) != 1 || len(values[OptionPlatform]) != 1 || values.Get(OptionPlatform) == "" { ++ return nil, fmt.Errorf("invalid Docker label options %q", query) ++ } ++ label.Arg = image ++ label.ImagePlatform = values.Get(OptionPlatform) ++ } ++ } + return label, nil + } + +@@ -68,28 +83,43 @@ func (l Labels) RequireDocker() bool { + + // PickPlatform returns the platform of the first runs-on entry this runner has a label for, or "". + func (l Labels) PickPlatform(runsOn []string) string { +- platforms := make(map[string]string, len(l)) +- for _, label := range l { ++ if label := l.pick(runsOn); label != nil { + switch label.Schema { + case SchemeDocker: +- // "//" will be ignored +- platforms[label.Name] = strings.TrimPrefix(label.Arg, "//") ++ return strings.TrimPrefix(label.Arg, "//") + case SchemeKubernetes: +- platforms[label.Name] = SchemeKubernetes + ":" + label.Arg ++ return SchemeKubernetes + ":" + label.Arg + case SchemeHost: +- platforms[label.Name] = SelfHostedPlatform +- default: +- continue ++ return SelfHostedPlatform + } + } +- for _, v := range runsOn { +- if v, ok := platforms[v]; ok { +- return v +- } ++ return "" ++} ++ ++// PickDockerImagePlatform returns the matching Docker label's OS/architecture, or empty. ++func (l Labels) PickDockerImagePlatform(runsOn []string) string { ++ if label := l.pick(runsOn); label != nil && label.Schema == SchemeDocker { ++ return label.ImagePlatform + } + return "" + } + ++func (l Labels) pick(runsOn []string) *Label { ++ byName := make(map[string]*Label, len(l)) ++ for _, label := range l { ++ switch label.Schema { ++ case SchemeDocker, SchemeKubernetes, SchemeHost: ++ byName[label.Name] = label ++ } ++ } ++ for _, name := range runsOn { ++ if label, ok := byName[name]; ok { ++ return label ++ } ++ } ++ return nil ++} ++ + func (l Labels) Names() []string { + names := make([]string, 0, len(l)) + for _, label := range l { +@@ -108,6 +138,9 @@ func (l Labels) ToStrings() []string { + lbl += ":" + label.Arg + } + } ++ if label.ImagePlatform != "" { ++ lbl += "?" + url.Values{OptionPlatform: {label.ImagePlatform}}.Encode() ++ } + ls = append(ls, lbl) + } + return ls +diff --git a/internal/pkg/labels/platform_test.go b/internal/pkg/labels/platform_test.go +new file mode 100644 +index 00000000..0aa1bd1f +--- /dev/null ++++ b/internal/pkg/labels/platform_test.go +@@ -0,0 +1,35 @@ ++// Copyright 2026 The Gitea Authors. All rights reserved. ++// SPDX-License-Identifier: MIT ++ ++package labels ++ ++import ( ++ "testing" ++ ++ "github.com/stretchr/testify/require" ++) ++ ++func TestDockerPlatformLabel(t *testing.T) { ++ arm, err := Parse("linux-arm64:docker://example/image:tag?platform=linux/arm64") ++ require.NoError(t, err) ++ native, err := Parse("native:docker://example/image:tag") ++ require.NoError(t, err) ++ configured := Labels{arm, native} ++ require.Equal(t, "example/image:tag", configured.PickPlatform([]string{"self-hosted", "linux-arm64"})) ++ require.Equal(t, "linux/arm64", configured.PickDockerImagePlatform([]string{"self-hosted", "linux-arm64"})) ++ require.Empty(t, configured.PickDockerImagePlatform([]string{"native", "linux-arm64"})) ++ require.Empty(t, configured.PickPlatform([]string{"unmatched"})) ++ encoded := configured.ToStrings() ++ roundtrip, err := Parse(encoded[0]) ++ require.NoError(t, err) ++ require.Equal(t, arm, roundtrip) ++ for _, query := range []string{"platform=", "platform=linux/arm64&platform=linux/amd64", "other=value", "platform=%"} { ++ _, err := Parse("bad:docker://image?" + query) ++ require.Error(t, err) ++ } ++ for _, value := range []string{"pool:id?question", "host:host", "cluster:kubernetes://image"} { ++ label, err := Parse(value) ++ require.NoError(t, err) ++ require.Equal(t, []string{value}, (Labels{label}).ToStrings()) ++ } ++} diff --git a/patches/0007-ignore-shared-network-host-options.patch b/patches/0007-ignore-shared-network-host-options.patch new file mode 100644 index 0000000..907aa24 --- /dev/null +++ b/patches/0007-ignore-shared-network-host-options.patch @@ -0,0 +1,50 @@ +Subject: [PATCH] Omit inherited extra hosts for shared-network action containers + +Retire when upstream accepts job host aliases without passing them to Docker actions +that share the job's network namespace. Docker rejects that combination at create. + +diff --git a/act/container/docker_run.go b/act/container/docker_run.go +--- a/act/container/docker_run.go ++++ b/act/container/docker_run.go +@@ -591,6 +591,10 @@ func (cr *containerReference) mergeContainerConfigs(ctx context.Context, config *co + } + } + ++ if container.NetworkMode(cr.input.NetworkMode).IsContainer() { ++ copts.extraHosts = opts.NewListOpts(opts.ValidateExtraHost) ++ } ++ + // If the `privileged` config has been disabled, `copts.privileged` need to be forced to false, + // even if the user specifies `--privileged` in the options string. + if !hostConfig.Privileged && copts.privileged { +diff --git a/act/container/docker_run_test.go b/act/container/docker_run_test.go +--- a/act/container/docker_run_test.go ++++ b/act/container/docker_run_test.go +@@ -730,6 +730,27 @@ func mergeOptions(t *testing.T, runnerOptions, workflowOptions string, privileged + return hostConfig, hook + } + ++func TestMergeContainerConfigsHostsWithSharedNetwork(t *testing.T) { ++ for _, mode := range []string{"bridge", "container:job"} { ++ t.Run(mode, func(t *testing.T) { ++ cr := &containerReference{input: &NewContainerInput{ ++ NetworkMode: mode, ++ RunnerOptions: "--add-host forge.local:10.0.3.1", ++ WorkflowOptions: "--add-host custom.local:10.0.3.2", ++ }} ++ _, hostConfig, err := cr.mergeContainerConfigs(t.Context(), &container.Config{}, &container.HostConfig{ ++ NetworkMode: container.NetworkMode(mode), ++ }) ++ require.NoError(t, err) ++ if container.NetworkMode(mode).IsContainer() { ++ assert.Empty(t, hostConfig.ExtraHosts) ++ } else { ++ assert.ElementsMatch(t, []string{"forge.local:10.0.3.1", "custom.local:10.0.3.2"}, hostConfig.ExtraHosts) ++ } ++ }) ++ } ++} ++ + func TestMergeContainerConfigsStripsDangerousOptionsWhenUnprivileged(t *testing.T) { + // OS-independent options only, --device and --gpus need a linux/windows server OS + const dangerousOptions = "--pid=host --ipc=host --uts=host --cgroupns=host " + diff --git a/startos/actions/configure.ts b/startos/actions/configure.ts index fb9accc..b83da87 100644 --- a/startos/actions/configure.ts +++ b/startos/actions/configure.ts @@ -1,98 +1,152 @@ import { i18n } from '../i18n' import { sdk } from '../sdk' -import { mount } from '../utils' import { storeJson } from '../fileModels/store.json' - -const { InputSpec, Value } = sdk - -const inputSpec = InputSpec.of({ - registrationToken: Value.text({ - name: i18n('Registration Token'), - description: i18n( - 'A runner registration token from the Gitea instance, under Site/Org/Repo Settings → Actions → Runners → Create new Runner.', - ), - required: true, - default: null, - }), - runnerName: Value.text({ - name: i18n('Runner Name'), - description: i18n('A name to identify this runner in the Gitea UI.'), - required: false, - default: 'startos-runner', - }), - labels: Value.text({ - name: i18n('Labels'), - description: i18n( - 'Comma-separated runner labels — syntax "name:docker://image" or "name:host". Adding a foreign-arch label also serves emulated jobs, which are much slower; prefer a native runner per architecture.', - ), - required: false, - default: 'ubuntu-latest:docker://ghcr.io/catthehacker/ubuntu:act-22.04', - }), - capacity: Value.number({ - name: i18n('Concurrent Jobs'), - description: i18n( - "Every job running at the same time adds its own build's CPU and memory use on this device. Raise it only if the device can carry several builds at once.", - ), - required: true, - default: 1, - min: 1, - integer: true, - }), -}) +import { runnerState } from '../fileModels/runnerState' +import { registrationReceipt } from '../fileModels/registrationReceipt' +import { + DEFAULT_RUNNER_LABELS, + MAX_JOB_CAPACITY, + registrationPending, + registrationName, + registrationUsable, + runnerLabels, + runnerBackend, + virtualizationAvailable, + vmResources, + vmDiskSize, +} from '../utils' export const configure = sdk.Action.withInput( 'configure', - - async ({ effects }) => ({ + async () => ({ name: i18n('Configure'), description: i18n( - 'Register this runner with the Gitea on this device. Saving re-registers on the next restart, so provide a fresh registration token each time.', + 'Configure execution without replacing the runner identity. Supply a fresh registration token only to create or replace the registration.', ), - warning: null, + warning: i18n('Saving stops active jobs and restarts the runner.'), allowedStatuses: 'any', group: null, visibility: 'enabled', }), - - inputSpec, - async ({ effects }) => { - const s = await storeJson.read().once() - if (!s) return null + const available = await virtualizationAvailable(effects) + const registered = await runnerState.read().once() + const store = await storeJson.read().once() + const receipt = await registrationReceipt.read().once() + const pending = store && registrationPending(store, !!registered, receipt) + return sdk.InputSpec.of({ + registrationToken: sdk.Value.text({ + name: i18n('Registration Token'), + description: i18n( + 'Leave blank to keep the existing registration. A fresh token creates or replaces the registration; runtime settings do not require one.', + ), + required: !registrationUsable(registered) && !pending, + default: null, + masked: true, + }), + runnerName: sdk.Value.text({ + name: i18n('Runner Name'), + description: i18n('A name to identify this runner in the Gitea UI.'), + required: false, + default: 'startos-runner', + }), + labels: sdk.Value.text({ + name: i18n('Labels'), + description: i18n( + 'Runner labels must use name:docker://image. Host labels are not allowed.', + ), + required: true, + default: DEFAULT_RUNNER_LABELS, + }), + capacity: sdk.Value.number({ + name: i18n('Concurrent Jobs'), + description: i18n( + 'Maximum concurrent assignments. Reusable-workflow jobs run sequentially within each assignment. VMs share one guest-memory pool; a job may wait for memory.', + ), + required: true, + default: 1, + min: 1, + max: MAX_JOB_CAPACITY, + integer: true, + }), + emulation: sdk.Value.toggle({ + name: i18n('Enable Emulation'), + description: i18n( + 'Also serve jobs for the other CPU architecture, run under emulation (much slower than native). Appends a foreign-architecture label pinned to that platform; prefer a native runner per architecture for regular builds.', + ), + default: false, + }), + virtualization: sdk.Value.dynamicToggle(async () => ({ + name: i18n('Virtualization'), + description: i18n( + 'Run each job in a disposable VM with its own guest kernel. Container mode starts faster and uses less memory, but shares the StartOS kernel. VM failures do not switch to container mode.', + ), + default: available, + disabled: available + ? false + : i18n( + 'KVM is unavailable. Save with Virtualization off to explicitly use containers that share the StartOS kernel.', + ), + })), + }) + }, + async ({ effects }) => { + const store = await storeJson.read().once() + if (!store) return null + const registered = await runnerState.read().once() + const receipt = await registrationReceipt.read().once() return { - registrationToken: s.registrationToken || undefined, - runnerName: s.runnerName || null, - labels: s.labels, - capacity: s.capacity, + registrationToken: undefined, + runnerName: registrationName(store, registered, receipt), + labels: store.labels, + capacity: store.capacity, + emulation: store.emulation, + virtualization: + (await virtualizationAvailable(effects)) && store.virtualization, } }, - async ({ effects, input }) => { + const store = await storeJson.read().once() + if (!store) throw new Error(i18n('Store not found')) + const registered = await runnerState.read().once() + const receipt = await registrationReceipt.read().once() + const pending = registrationPending(store, !!registered, receipt) + const token = input.registrationToken?.trim() || '' + const previousName = registrationName(store, registered, receipt) + const name = input.runnerName?.trim() || previousName + if (!token && registered && name !== previousName) { + throw new Error( + i18n('Changing the runner name requires a fresh registration token.'), + ) + } + if (!registrationUsable(registered) && !pending && !token) { + throw new Error( + i18n('A fresh registration token is required to register this runner.'), + ) + } + runnerLabels(input) + if ( + runnerBackend( + input.virtualization, + await virtualizationAvailable(effects), + ) === 'vm' + ) { + vmResources(input.capacity) + await vmDiskSize(effects, input.capacity) + } await storeJson.merge(effects, { - registrationToken: input.registrationToken, - runnerName: input.runnerName ?? '', - labels: input.labels ?? '', + registrationToken: token || (pending ? store.registrationToken : ''), + registrationRevision: store.registrationRevision + (token ? 1 : 0), + runnerName: name, + labels: input.labels, capacity: input.capacity, + emulation: input.emulation, + virtualization: input.virtualization, }) - - // Drop the registration state so the runner re-registers with the new - // settings (and the freshly supplied token) on the next start. - await sdk.SubContainer.withTemp( - effects, - { imageId: 'main' }, - mount, - 'reset-registration', - async (sub) => { - await sub.exec(['rm', '-f', '/data/runner/.runner']) - }, - ) - return { version: '1', title: i18n('Saved'), - message: i18n( - 'Runner configuration saved. Restart the service to (re)register with these settings.', - ), + message: i18n('Runner configuration saved.'), result: null, } }, diff --git a/startos/fileModels/registrationReceipt.ts b/startos/fileModels/registrationReceipt.ts new file mode 100644 index 0000000..87436bb --- /dev/null +++ b/startos/fileModels/registrationReceipt.ts @@ -0,0 +1,8 @@ +import { FileHelper, z } from '@start9labs/start-sdk' +import { sdk } from '../sdk' +import { REGISTRATION_RECEIPT_SUBPATH } from '../utils' + +export const registrationReceipt = FileHelper.string( + { base: sdk.volumes.main, subpath: REGISTRATION_RECEIPT_SUBPATH }, + z.string(), +) diff --git a/startos/fileModels/rejectedUuid.ts b/startos/fileModels/rejectedUuid.ts new file mode 100644 index 0000000..384e26d --- /dev/null +++ b/startos/fileModels/rejectedUuid.ts @@ -0,0 +1,11 @@ +import { FileHelper, z } from '@start9labs/start-sdk' +import { sdk } from '../sdk' + +export const rejectedUuid = (controllerPath: string) => + FileHelper.string( + { + base: sdk.volumes.main, + subpath: `${controllerPath}/runner/rejected-uuid`, + }, + z.string(), + ) diff --git a/startos/fileModels/runnerState.ts b/startos/fileModels/runnerState.ts index cc2eb4a..b9069bd 100644 --- a/startos/fileModels/runnerState.ts +++ b/startos/fileModels/runnerState.ts @@ -1,13 +1,7 @@ import { FileHelper, z } from '@start9labs/start-sdk' import { sdk } from '../sdk' -// The runner's registration state file, written into the working directory by -// `register`. We only ever READ it: its presence is the ground truth that this -// runner has registered with a forge — which is what the health check keys off, -// rather than the store token. A runner registered out-of-band (or restored -// from a backup whose store token was cleared) carries no token in our store -// yet is fully working; `.runner` reflects that, the token doesn't. Schema is -// permissive — only the file's existence matters here. +// `registrationUsable` validates identity fields without rewriting the registration. export const runnerState = FileHelper.json( { base: sdk.volumes.main, subpath: './runner/.runner' }, z.looseObject({}), diff --git a/startos/fileModels/store.json.ts b/startos/fileModels/store.json.ts index a44a71a..96008a3 100644 --- a/startos/fileModels/store.json.ts +++ b/startos/fileModels/store.json.ts @@ -1,15 +1,15 @@ import { FileHelper, z } from '@start9labs/start-sdk' import { sdk } from '../sdk' +import { DEFAULT_RUNNER_LABELS, MAX_JOB_CAPACITY } from '../utils' const shape = z.looseObject({ registrationToken: z.string().catch(''), + registrationRevision: z.number().int().nonnegative().catch(0), runnerName: z.string().catch(''), - // gitea-runner label syntax: ":docker://" or ":host". - // Add foreign-arch labels here to also serve emulated jobs (slow — see README). - labels: z - .string() - .catch('ubuntu-latest:docker://ghcr.io/catthehacker/ubuntu:act-22.04'), - capacity: z.number().int().catch(1), + labels: z.string().catch(DEFAULT_RUNNER_LABELS), + capacity: z.number().int().min(1).max(MAX_JOB_CAPACITY).catch(1), + emulation: z.boolean().catch(false), + virtualization: z.boolean().catch(true), }) export const storeJson = FileHelper.json( diff --git a/startos/i18n/dictionaries/default.ts b/startos/i18n/dictionaries/default.ts index 1a4184e..309ffd0 100644 --- a/startos/i18n/dictionaries/default.ts +++ b/startos/i18n/dictionaries/default.ts @@ -1,27 +1,55 @@ export const DEFAULT_LANG = 'en_US' const dict = { - 'This device does not meet the minimum requirements to run CI jobs (4 GB of RAM and 2 CPU cores).': 0, + 'This device does not meet the minimum requirements to run CI jobs (1.5 GiB of RAM and 2 CPU cores).': 0, 'Store not found': 1, Runner: 2, 'Runner is running': 3, 'Run the Configure action to connect this runner to a Gitea instance': 4, 'Registration Token': 5, - 'A runner registration token from the Gitea instance, under Site/Org/Repo Settings → Actions → Runners → Create new Runner.': 6, + 'Leave blank to keep the existing registration. A fresh token creates or replaces the registration; runtime settings do not require one.': 6, 'Runner Name': 7, 'A name to identify this runner in the Gitea UI.': 8, Labels: 9, - 'Comma-separated runner labels — syntax "name:docker://image" or "name:host". Adding a foreign-arch label also serves emulated jobs, which are much slower; prefer a native runner per architecture.': 10, + 'Runner labels must use name:docker://image. Host labels are not allowed.': 10, 'Concurrent Jobs': 11, - "Every job running at the same time adds its own build's CPU and memory use on this device. Raise it only if the device can carry several builds at once.": 12, + 'Maximum concurrent assignments. Reusable-workflow jobs run sequentially within each assignment. VMs share one guest-memory pool; a job may wait for memory.': 12, Configure: 13, - 'Register this runner with the Gitea on this device. Saving re-registers on the next restart, so provide a fresh registration token each time.': 14, + 'Configure execution without replacing the runner identity. Supply a fresh registration token only to create or replace the registration.': 14, Saved: 15, - 'Runner configuration saved. Restart the service to (re)register with these settings.': 16, + 'Runner configuration saved.': 16, 'Gitea is not yet reachable on the internal network. The runner will connect once its Gitea dependency is running.': 17, 'The runner is not running': 18, 'Gitea Actions must be enabled for Gitea Runner to run jobs.': 19, 'The runner is not registered': 20, + 'Saving stops active jobs and restarts the runner.': 21, + 'Enable Emulation': 22, + 'Also serve jobs for the other CPU architecture, run under emulation (much slower than native). Appends a foreign-architecture label pinned to that platform; prefer a native runner per architecture for regular builds.': 23, + Virtualization: 24, + 'Run each job in a disposable VM with its own guest kernel. Container mode starts faster and uses less memory, but shares the StartOS kernel. VM failures do not switch to container mode.': 25, + 'KVM is unavailable. Save with Virtualization off to explicitly use containers that share the StartOS kernel.': 26, + 'Changing the runner name requires a fresh registration token.': 27, + 'A fresh registration token is required to register this runner.': 28, + 'Too many concurrent jobs: each VM needs at least 512 MiB of guest RAM.': 29, + 'Insufficient scratch space: each VM needs at least 8 GiB of disk space within the guest-disk budget.': 30, + 'Runner labels must be unique.': 31, + 'Gitea no longer recognizes this runner. Use Configure with a fresh registration token.': 32, + 'Container Mode': 33, + 'Virtualization is disabled. Jobs use privileged containers within the service and share the StartOS kernel. Only run trusted workflows.': 34, + 'KVM is unavailable. Jobs use containers and share the StartOS kernel.': 35, + 'VM Image Preparation': 36, + 'Guest Memory Pool': 37, + 'Virtualized Jobs': 38, + 'Guest VM image preparation failed': 39, + 'Guest VM image is prepared': 40, + 'Downloading and installing the guest VM image': 41, + 'Shared guest-memory controller is running': 42, + 'Guest-memory controller is not running': 43, + 'Virtualized job slot is not running': 44, + 'Ready for a job; no VM allocated': 45, + 'Preparing a disposable VM': 46, + 'Running a job': 47, + 'Virtualization is enabled but KVM is unavailable. Restore KVM access and save Configure with Virtualization on, or run Configure to explicitly select container mode.': 48, } export type I18nKey = keyof typeof dict diff --git a/startos/i18n/dictionaries/translations.ts b/startos/i18n/dictionaries/translations.ts index f5c14a5..be62613 100644 --- a/startos/i18n/dictionaries/translations.ts +++ b/startos/i18n/dictionaries/translations.ts @@ -2,95 +2,207 @@ import { LangDict } from './default' export default { es_ES: { - 0: 'Este dispositivo no cumple los requisitos mínimos para ejecutar trabajos de CI (4 GB de RAM y 2 núcleos de CPU).', - 1: 'Almacén no encontrado', - 2: 'Ejecutor', - 3: 'El ejecutor está en ejecución', - 4: 'Ejecute la acción Configurar para conectar este ejecutor a una instancia de Gitea', - 5: 'Token de registro', - 6: 'Un token de registro de ejecutor de la instancia de Gitea, en Configuración de Sitio/Organización/Repositorio → Acciones → Ejecutores → Crear nuevo ejecutor.', - 7: 'Nombre del ejecutor', - 8: 'Un nombre para identificar este ejecutor en la interfaz de Gitea.', - 9: 'Etiquetas', - 10: 'Etiquetas del ejecutor separadas por comas — sintaxis "name:docker://image" o "name:host". Añadir una etiqueta de otra arquitectura también atiende trabajos emulados, mucho más lentos; prefiera un ejecutor nativo por arquitectura.', - 11: 'Trabajos simultáneos', - 12: 'Cada trabajo que se ejecuta al mismo tiempo suma el uso de CPU y memoria de su propia compilación en este dispositivo. Auméntelo solo si el dispositivo puede soportar varias compilaciones a la vez.', - 13: 'Configurar', - 14: 'Registre este ejecutor con el Gitea de este dispositivo. Al guardar se vuelve a registrar en el próximo reinicio, así que proporcione un token de registro nuevo cada vez.', - 15: 'Guardado', - 16: 'Configuración del ejecutor guardada. Reinicie el servicio para volver a registrar con estos ajustes.', - 17: 'Gitea aún no es accesible en la red interna. El ejecutor se conectará una vez que su dependencia de Gitea esté en ejecución.', - 18: 'El ejecutor no está en ejecución', - 19: 'Gitea Actions debe estar activado para que Gitea Runner pueda ejecutar trabajos.', - 20: 'El ejecutor no está registrado', + '0': 'Este dispositivo no cumple los requisitos mínimos para ejecutar trabajos de CI (1,5 GiB de RAM y 2 núcleos de CPU).', + '1': 'Almacén no encontrado', + '2': 'Ejecutor', + '3': 'El ejecutor está en ejecución', + '4': 'Ejecute la acción Configurar para conectar este ejecutor a una instancia de Gitea', + '5': 'Token de registro', + '6': 'Déjelo vacío para conservar el registro existente. Un token nuevo crea o sustituye el registro; los ajustes de ejecución no lo requieren.', + '7': 'Nombre del ejecutor', + '8': 'Un nombre para identificar este ejecutor en la interfaz de Gitea.', + '9': 'Etiquetas', + '10': 'Las etiquetas deben usar name:docker://image. No se permiten etiquetas host.', + '11': 'Trabajos simultáneos', + '12': 'Máximo de asignaciones simultáneas. Los trabajos reutilizables se ejecutan secuencialmente dentro de cada asignación. Las máquinas comparten memoria; un trabajo puede esperar a que haya memoria.', + '13': 'Configurar', + '14': 'Configure la ejecución sin sustituir la identidad del ejecutor. Proporcione un token de registro nuevo solo para crear o sustituir el registro.', + '15': 'Guardado', + '16': 'Configuración del ejecutor guardada.', + '17': 'Gitea aún no es accesible en la red interna. El ejecutor se conectará una vez que su dependencia de Gitea esté en ejecución.', + '18': 'El ejecutor no está en ejecución', + '19': 'Gitea Actions debe estar activado para que Gitea Runner pueda ejecutar trabajos.', + '20': 'El ejecutor no está registrado', + '21': 'Guardar detiene los trabajos activos y reinicia el ejecutor.', + '22': 'Habilitar emulación', + '23': 'Atender también trabajos de la otra arquitectura de CPU bajo emulación, mucho más lenta. Añade una etiqueta fijada a esa plataforma; para compilaciones habituales, prefiera un ejecutor nativo por arquitectura.', + '24': 'Virtualización', + '25': 'Ejecute cada trabajo en una máquina virtual desechable con su propio núcleo. El modo contenedor inicia más rápido y usa menos memoria, pero comparte el núcleo de StartOS. Los fallos de las máquinas virtuales no cambian al modo contenedor.', + '26': 'KVM no está disponible. Guarde con Virtualización desactivada para usar explícitamente contenedores que comparten el núcleo de StartOS.', + '27': 'Cambiar el nombre del ejecutor requiere un token de registro nuevo.', + '28': 'Se requiere un token de registro nuevo para registrar este ejecutor.', + '29': 'Demasiados trabajos simultáneos: cada máquina virtual necesita al menos 512 MiB de RAM.', + '30': 'Espacio de trabajo insuficiente: cada máquina virtual necesita al menos 8 GiB dentro del presupuesto de discos de invitados.', + '31': 'Las etiquetas del ejecutor deben ser únicas.', + '32': 'Gitea ya no reconoce este ejecutor. Use Configurar con un token de registro nuevo.', + '33': 'Modo contenedor', + '34': 'La virtualización está desactivada. Los trabajos usan contenedores privilegiados dentro del servicio y comparten el núcleo de StartOS. Ejecute solo flujos de confianza.', + '35': 'KVM no está disponible. Los trabajos usan contenedores y comparten el núcleo de StartOS.', + '36': 'Preparación de la imagen de la máquina virtual', + '37': 'Presupuesto común de memoria de invitados', + '38': 'Trabajos virtualizados', + '39': 'Falló la preparación de la imagen de la máquina virtual', + '40': 'La imagen de la máquina virtual está preparada', + '41': 'Descargando e instalando la imagen de la máquina virtual', + '42': 'El controlador del presupuesto de memoria está en ejecución', + '43': 'El controlador de memoria de invitados no está en ejecución', + '44': 'La instancia de trabajo virtualizado no está en ejecución', + '45': 'Listo para un trabajo; sin máquina virtual asignada', + '46': 'Preparando una máquina virtual desechable', + '47': 'Ejecutando un trabajo', + '48': 'La virtualización está activada, pero KVM no está disponible. Restablezca el acceso a KVM y guarde Configurar con Virtualización activada, o use Configurar para seleccionar explícitamente el modo contenedor.', }, de_DE: { - 0: 'Dieses Gerät erfüllt nicht die Mindestanforderungen zum Ausführen von CI-Aufträgen (4 GB RAM und 2 CPU-Kerne).', - 1: 'Speicher nicht gefunden', - 2: 'Runner', - 3: 'Runner läuft', - 4: 'Führen Sie die Aktion "Konfigurieren" aus, um diesen Runner mit einer Gitea-Instanz zu verbinden', - 5: 'Registrierungs-Token', - 6: 'Ein Runner-Registrierungs-Token der Gitea-Instanz, unter Website-/Organisations-/Repository-Einstellungen → Aktionen → Runner → Neuen Runner erstellen.', - 7: 'Runner-Name', - 8: 'Ein Name zur Identifizierung dieses Runners in der Gitea-Oberfläche.', - 9: 'Labels', - 10: 'Durch Kommas getrennte Runner-Labels — Syntax "name:docker://image" oder "name:host". Das Hinzufügen eines Labels für eine fremde Architektur bedient auch emulierte Aufträge, die viel langsamer sind; bevorzugen Sie einen nativen Runner pro Architektur.', - 11: 'Gleichzeitige Aufträge', - 12: 'Jeder gleichzeitig laufende Auftrag fügt die CPU- und Arbeitsspeichernutzung seines eigenen Builds auf diesem Gerät hinzu. Erhöhen Sie den Wert nur, wenn das Gerät mehrere Builds gleichzeitig tragen kann.', - 13: 'Konfigurieren', - 14: 'Registrieren Sie diesen Runner beim Gitea auf diesem Gerät. Beim Speichern erfolgt beim nächsten Neustart eine erneute Registrierung — geben Sie daher jedes Mal ein neues Registrierungs-Token an.', - 15: 'Gespeichert', - 16: 'Runner-Konfiguration gespeichert. Starten Sie den Dienst neu, um sich mit diesen Einstellungen erneut zu registrieren.', - 17: 'Gitea ist im internen Netzwerk noch nicht erreichbar. Der Runner verbindet sich, sobald seine Gitea-Abhängigkeit läuft.', - 18: 'Der Runner läuft nicht', - 19: 'Gitea Actions muss aktiviert sein, damit Gitea Runner Jobs ausführen kann.', - 20: 'Der Runner ist nicht registriert', + '0': 'Dieses Gerät erfüllt nicht die Mindestanforderungen für CI-Jobs (1,5 GiB RAM und 2 CPU-Kerne).', + '1': 'Speicher nicht gefunden', + '2': 'Runner', + '3': 'Runner läuft', + '4': 'Führen Sie die Aktion "Konfigurieren" aus, um diesen Runner mit einer Gitea-Instanz zu verbinden', + '5': 'Registrierungs-Token', + '6': 'Leer lassen, um die bestehende Registrierung beizubehalten. Ein neuer Token erstellt oder ersetzt die Registrierung; Ausführungseinstellungen benötigen keinen.', + '7': 'Runner-Name', + '8': 'Ein Name zur Identifizierung dieses Runners in der Gitea-Oberfläche.', + '9': 'Labels', + '10': 'Runner-Labels müssen name:docker://image verwenden. Host-Labels sind nicht erlaubt.', + '11': 'Gleichzeitige Jobs', + '12': 'Maximale gleichzeitige Zuweisungen. Jobs wiederverwendbarer Workflows laufen innerhalb einer Zuweisung nacheinander. VMs teilen ein RAM-Budget; Jobs können auf Speicher warten.', + '13': 'Konfigurieren', + '14': 'Ausführung konfigurieren, ohne die Runner-Identität zu ersetzen. Ein neues Registrierungs-Token nur zum Erstellen oder Ersetzen der Registrierung angeben.', + '15': 'Gespeichert', + '16': 'Runner-Konfiguration gespeichert.', + '17': 'Gitea ist im internen Netzwerk noch nicht erreichbar. Der Runner verbindet sich, sobald seine Gitea-Abhängigkeit läuft.', + '18': 'Der Runner läuft nicht', + '19': 'Gitea Actions muss aktiviert sein, damit Gitea Runner Jobs ausführen kann.', + '20': 'Der Runner ist nicht registriert', + '21': 'Speichern beendet aktive Jobs und startet den Runner neu.', + '22': 'Emulation aktivieren', + '23': 'Auch Jobs der anderen CPU-Architektur unter deutlich langsamerer Emulation bedienen. Fügt ein plattformgebundenes Label hinzu; für regelmäßige Builds einen nativen Runner pro Architektur bevorzugen.', + '24': 'Virtualisierung', + '25': 'Jeder Job läuft in einer wegwerfbaren VM mit eigenem Kernel. Der Containermodus startet schneller und benötigt weniger RAM, teilt aber den StartOS-Kernel. VM-Fehler führen nicht zum Wechsel in den Containermodus.', + '26': 'KVM ist nicht verfügbar. Speichern Sie mit deaktivierter Virtualisierung, um ausdrücklich Container mit gemeinsamem StartOS-Kernel zu verwenden.', + '27': 'Eine Änderung des Runner-Namens erfordert ein neues Registrierungs-Token.', + '28': 'Für die Registrierung dieses Runners ist ein neues Registrierungs-Token erforderlich.', + '29': 'Zu viele gleichzeitige Jobs: Jede VM benötigt mindestens 512 MiB Gast-RAM.', + '30': 'Zu wenig Arbeitsdatenspeicher: Jede VM benötigt mindestens 8 GiB im Festplattenbudget für Gäste.', + '31': 'Runner-Labels müssen eindeutig sein.', + '32': 'Gitea erkennt diesen Runner nicht mehr. Konfigurieren Sie ihn mit einem neuen Registrierungs-Token.', + '33': 'Containermodus', + '34': 'Virtualisierung ist deaktiviert. Jobs nutzen privilegierte Container innerhalb des Dienstes und teilen den StartOS-Kernel. Nur vertrauenswürdige Workflows ausführen.', + '35': 'KVM ist nicht verfügbar. Jobs nutzen Container und teilen den StartOS-Kernel.', + '36': 'Vorbereitung des VM-Abbilds', + '37': 'Gemeinsames Gast-RAM-Budget', + '38': 'Virtualisierte Jobs', + '39': 'Vorbereitung des VM-Abbilds fehlgeschlagen', + '40': 'VM-Abbild ist vorbereitet', + '41': 'VM-Abbild wird heruntergeladen und installiert', + '42': 'Die Verwaltung des Gast-RAM-Budgets läuft', + '43': 'Die Gast-RAM-Verwaltung läuft nicht', + '44': 'Der virtualisierte Job-Slot läuft nicht', + '45': 'Bereit für einen Job; keine VM zugewiesen', + '46': 'Eine wegwerfbare VM wird vorbereitet', + '47': 'Ein Job wird ausgeführt', + '48': 'Virtualisierung ist aktiviert, aber KVM ist nicht verfügbar. Stellen Sie den KVM-Zugriff wieder her und speichern Sie Konfigurieren mit aktivierter Virtualisierung, oder wählen Sie unter Konfigurieren ausdrücklich den Containermodus.', }, pl_PL: { - 0: 'To urządzenie nie spełnia minimalnych wymagań do uruchamiania zadań CI (4 GB RAM i 2 rdzenie CPU).', - 1: 'Nie znaleziono magazynu', - 2: 'Runner', - 3: 'Runner działa', - 4: 'Uruchom akcję Konfiguruj, aby połączyć ten runner z instancją Gitea', - 5: 'Token rejestracji', - 6: 'Token rejestracji runnera z instancji Gitea, w Ustawieniach witryny/organizacji/repozytorium → Akcje → Runnery → Utwórz nowego runnera.', - 7: 'Nazwa runnera', - 8: 'Nazwa identyfikująca ten runner w interfejsie Gitea.', - 9: 'Etykiety', - 10: 'Etykiety runnera oddzielone przecinkami — składnia "name:docker://image" lub "name:host". Dodanie etykiety obcej architektury obsługuje również zadania emulowane, znacznie wolniejsze; preferuj natywny runner na każdą architekturę.', - 11: 'Zadania równoległe', - 12: 'Każde zadanie uruchomione w tym samym czasie dokłada zużycie procesora i pamięci własnej kompilacji na tym urządzeniu. Zwiększaj tę wartość tylko wtedy, gdy urządzenie udźwignie kilka kompilacji jednocześnie.', - 13: 'Konfiguruj', - 14: 'Zarejestruj ten runner w Gitea na tym urządzeniu. Zapisanie powoduje ponowną rejestrację przy następnym uruchomieniu, więc podawaj nowy token rejestracji za każdym razem.', - 15: 'Zapisano', - 16: 'Konfiguracja runnera zapisana. Uruchom ponownie usługę, aby ponownie zarejestrować z tymi ustawieniami.', - 17: 'Gitea nie jest jeszcze osiągalne w sieci wewnętrznej. Runner połączy się, gdy jego zależność Gitea będzie uruchomiona.', - 18: 'Runner nie działa', - 19: 'Gitea Actions musi być włączone, aby Gitea Runner mógł wykonywać zadania.', - 20: 'Runner nie jest zarejestrowany', + '0': 'Urządzenie nie spełnia minimalnych wymagań CI (1,5 GiB RAM i 2 rdzenie CPU).', + '1': 'Nie znaleziono magazynu', + '2': 'Runner', + '3': 'Runner działa', + '4': 'Uruchom akcję Konfiguruj, aby połączyć ten runner z instancją Gitea', + '5': 'Token rejestracji', + '6': 'Pozostaw puste, aby zachować istniejącą rejestrację. Nowy token tworzy lub zastępuje rejestrację; ustawienia wykonywania go nie wymagają.', + '7': 'Nazwa runnera', + '8': 'Nazwa identyfikująca ten runner w interfejsie Gitea.', + '9': 'Etykiety', + '10': 'Etykiety muszą używać name:docker://image. Etykiety host są niedozwolone.', + '11': 'Zadania równoległe', + '12': 'Maksymalna liczba równoległych przydziałów. Zadania przepływu wielokrotnego użytku wykonują się kolejno w każdym przydziale. Maszyny dzielą pulę RAM; zadanie może czekać na pamięć.', + '13': 'Konfiguruj', + '14': 'Skonfiguruj wykonywanie bez zastępowania tożsamości runnera. Podaj nowy token rejestracji tylko w celu utworzenia lub zastąpienia rejestracji.', + '15': 'Zapisano', + '16': 'Zapisano konfigurację runnera.', + '17': 'Gitea nie jest jeszcze osiągalne w sieci wewnętrznej. Runner połączy się, gdy jego zależność Gitea będzie uruchomiona.', + '18': 'Runner nie działa', + '19': 'Gitea Actions musi być włączone, aby Gitea Runner mógł wykonywać zadania.', + '20': 'Runner nie jest zarejestrowany', + '21': 'Zapis zatrzymuje aktywne zadania i uruchamia runner ponownie.', + '22': 'Włącz emulację', + '23': 'Obsługuj także drugą architekturę CPU przez znacznie wolniejszą emulację. Dodaje etykietę przypisaną do platformy; do regularnych kompilacji używaj natywnego runnera dla każdej architektury.', + '24': 'Wirtualizacja', + '25': 'Każde zadanie działa w jednorazowej maszynie wirtualnej z własnym jądrem. Tryb kontenerowy uruchamia się szybciej i zużywa mniej pamięci, ale współdzieli jądro StartOS. Awaria maszyny nie przełącza na kontenery.', + '26': 'KVM jest niedostępne. Zapisz z wyłączoną wirtualizacją, aby jawnie użyć kontenerów współdzielących jądro StartOS.', + '27': 'Zmiana nazwy runnera wymaga nowego tokena rejestracji.', + '28': 'Do zarejestrowania tego runnera jest wymagany nowy token rejestracji.', + '29': 'Zbyt wiele zadań równoległych: każda maszyna potrzebuje co najmniej 512 MiB RAM.', + '30': 'Za mało miejsca roboczego: każda maszyna potrzebuje co najmniej 8 GiB w budżecie dysków gości.', + '31': 'Etykiety runnera muszą być unikalne.', + '32': 'Gitea nie rozpoznaje już tego runnera. Użyj akcji Konfiguruj z nowym tokenem rejestracji.', + '33': 'Tryb kontenerowy', + '34': 'Wirtualizacja jest wyłączona. Zadania korzystają z uprzywilejowanych kontenerów wewnątrz usługi i współdzielą jądro StartOS. Uruchamiaj tylko zaufane przepływy pracy.', + '35': 'KVM jest niedostępne. Zadania używają kontenerów i współdzielą jądro StartOS.', + '36': 'Przygotowanie obrazu maszyny wirtualnej', + '37': 'Wspólna pula pamięci gości', + '38': 'Zadania wirtualizowane', + '39': 'Przygotowanie obrazu maszyny wirtualnej nie powiodło się', + '40': 'Obraz maszyny wirtualnej jest przygotowany', + '41': 'Pobieranie i instalowanie obrazu maszyny wirtualnej', + '42': 'Kontroler puli pamięci gości działa', + '43': 'Kontroler pamięci gości nie działa', + '44': 'Wirtualizowane stanowisko zadania nie działa', + '45': 'Gotowość do zadania; brak przydzielonej maszyny', + '46': 'Przygotowywanie jednorazowej maszyny wirtualnej', + '47': 'Wykonywanie zadania', + '48': 'Wirtualizacja jest włączona, ale KVM jest niedostępne. Przywróć dostęp do KVM i zapisz akcję Konfiguruj z włączoną wirtualizacją lub jawnie wybierz tryb kontenerowy w akcji Konfiguruj.', }, fr_FR: { - 0: 'Cet appareil ne répond pas aux exigences minimales pour exécuter des tâches CI (4 Go de RAM et 2 cœurs de CPU).', - 1: 'Stockage non trouvé', - 2: 'Exécuteur', - 3: "L'exécuteur est en cours d'exécution", - 4: "Exécutez l'action Configurer pour connecter cet exécuteur à une instance Gitea", - 5: "Jeton d'enregistrement", - 6: "Un jeton d'enregistrement d'exécuteur de l'instance Gitea, sous Paramètres du site/de l'organisation/du dépôt → Actions → Exécuteurs → Créer un nouvel exécuteur.", - 7: "Nom de l'exécuteur", - 8: "Un nom pour identifier cet exécuteur dans l'interface de Gitea.", - 9: 'Étiquettes', - 10: "Étiquettes de l'exécuteur séparées par des virgules — syntaxe « name:docker://image » ou « name:host ». Ajouter une étiquette d'une autre architecture sert aussi les tâches émulées, bien plus lentes ; préférez un exécuteur natif par architecture.", - 11: 'Tâches simultanées', - 12: "Chaque tâche exécutée en même temps ajoute l'utilisation du processeur et de la mémoire de sa propre compilation sur cet appareil. Augmentez cette valeur uniquement si l'appareil peut supporter plusieurs compilations à la fois.", - 13: 'Configurer', - 14: "Enregistrez cet exécuteur auprès du Gitea de cet appareil. L'enregistrement se refait au prochain redémarrage, fournissez donc un nouveau jeton d'enregistrement à chaque fois.", - 15: 'Enregistré', - 16: "Configuration de l'exécuteur enregistrée. Redémarrez le service pour vous réenregistrer avec ces paramètres.", - 17: "Gitea n'est pas encore accessible sur le réseau interne. L'exécuteur se connectera une fois que sa dépendance Gitea sera en cours d'exécution.", - 18: "L'exécuteur n'est pas en cours d'exécution", - 19: 'Gitea Actions doit être activé pour que Gitea Runner puisse exécuter des tâches.', - 20: "L'exécuteur n'est pas enregistré", + '0': 'Cet appareil ne répond pas aux exigences minimales CI (1,5 Gio de RAM et 2 cœurs CPU).', + '1': 'Stockage introuvable', + '2': 'Exécuteur', + '3': 'L’exécuteur fonctionne', + '4': "Exécutez l'action Configurer pour connecter cet exécuteur à une instance Gitea", + '5': "Jeton d'enregistrement", + '6': 'Laissez vide pour conserver l’inscription existante. Un nouveau jeton crée ou remplace l’inscription ; les paramètres d’exécution n’en nécessitent pas.', + '7': "Nom de l'exécuteur", + '8': "Un nom pour identifier cet exécuteur dans l'interface de Gitea.", + '9': 'Étiquettes', + '10': 'Les étiquettes doivent utiliser name:docker://image. Les étiquettes host sont interdites.', + '11': 'Tâches simultanées', + '12': 'Nombre maximal d’attributions simultanées. Les tâches d’un workflow réutilisable s’exécutent successivement dans chaque attribution. Les VM partagent la RAM ; une tâche peut attendre de la mémoire.', + '13': 'Configurer', + '14': 'Configurez l’exécution sans remplacer l’identité du runner. Fournissez un nouveau jeton d’inscription uniquement pour créer ou remplacer l’inscription.', + '15': 'Enregistré', + '16': 'Configuration de l’exécuteur enregistrée.', + '17': "Gitea n'est pas encore accessible sur le réseau interne. L'exécuteur se connectera une fois que sa dépendance Gitea sera en cours d'exécution.", + '18': 'L’exécuteur ne fonctionne pas', + '19': 'Gitea Actions doit être activé pour que Gitea Runner puisse exécuter des tâches.', + '20': "L'exécuteur n'est pas enregistré", + '21': 'Enregistrer arrête les tâches actives et redémarre l’exécuteur.', + '22': 'Activer l’émulation', + '23': 'Servir aussi l’autre architecture CPU par émulation, nettement plus lente. Ajoute une étiquette liée à cette plateforme ; privilégiez un exécuteur natif par architecture pour les compilations régulières.', + '24': 'Virtualisation', + '25': 'Chaque tâche utilise une VM jetable avec son propre noyau. Le mode conteneur démarre plus vite et consomme moins de mémoire, mais partage le noyau StartOS. Une panne de VM ne provoque pas de bascule vers les conteneurs.', + '26': 'KVM est indisponible. Enregistrez avec la virtualisation désactivée pour choisir explicitement des conteneurs partageant le noyau StartOS.', + '27': 'Modifier le nom du runner nécessite un nouveau jeton d’inscription.', + '28': 'Un nouveau jeton d’inscription est nécessaire pour inscrire ce runner.', + '29': 'Trop de tâches simultanées : chaque VM nécessite au moins 512 Mio de RAM invitée.', + '30': 'Espace de travail insuffisant : chaque VM nécessite au moins 8 Gio dans le budget disque des invités.', + '31': 'Les étiquettes de l’exécuteur doivent être uniques.', + '32': 'Gitea ne reconnaît plus ce runner. Utilisez Configurer avec un nouveau jeton d’inscription.', + '33': 'Mode conteneur', + '34': 'La virtualisation est désactivée. Les tâches utilisent des conteneurs privilégiés dans le service et partagent le noyau StartOS. Exécutez uniquement des workflows de confiance.', + '35': 'KVM est indisponible. Les tâches utilisent des conteneurs et partagent le noyau StartOS.', + '36': 'Préparation de l’image de la machine virtuelle', + '37': 'Budget mémoire commun des invités', + '38': 'Tâches virtualisées', + '39': 'La préparation de l’image de la machine virtuelle a échoué', + '40': 'L’image de la machine virtuelle est prête', + '41': 'Téléchargement et installation de l’image de la machine virtuelle', + '42': 'Le contrôleur du budget mémoire fonctionne', + '43': 'Le contrôleur de mémoire ne fonctionne pas', + '44': 'L’emplacement de tâche virtualisée ne fonctionne pas', + '45': 'Prêt pour une tâche ; aucune VM allouée', + '46': 'Préparation d’une VM jetable', + '47': 'Exécution d’une tâche', + '48': 'La virtualisation est activée, mais KVM est indisponible. Rétablissez l’accès à KVM et enregistrez Configurer avec la virtualisation activée, ou choisissez explicitement le mode conteneur avec Configurer.', }, } satisfies Record diff --git a/startos/init/seedStore.ts b/startos/init/seedStore.ts index e67f578..aba6115 100644 --- a/startos/init/seedStore.ts +++ b/startos/init/seedStore.ts @@ -1,9 +1,14 @@ import { storeJson } from '../fileModels/store.json' import { sdk } from '../sdk' +import { virtualizationAvailable } from '../utils' // Seed the store with schema defaults so main.ts and the Configure action // always read a well-formed object. merge({}) fills missing fields from their // .catch() defaults and is a no-op on an already-populated store. export const seedStore = sdk.setupOnInit(async (effects) => { - await storeJson.merge(effects, {}) + const existing = await storeJson.read().once() + await storeJson.merge( + effects, + existing ? {} : { virtualization: await virtualizationAvailable(effects) }, + ) }) diff --git a/startos/init/watchConfiguration.ts b/startos/init/watchConfiguration.ts index f3e21ec..99c7429 100644 --- a/startos/init/watchConfiguration.ts +++ b/startos/init/watchConfiguration.ts @@ -1,20 +1,52 @@ import { configure } from '../actions/configure' import { runnerState } from '../fileModels/runnerState' +import { registrationReceipt } from '../fileModels/registrationReceipt' import { storeJson } from '../fileModels/store.json' import { i18n } from '../i18n' import { sdk } from '../sdk' +import { + registrationPending, + registrationUsable, + runnerLabels, + runnerBackend, + virtualizationAvailable, + vmResources, + vmDiskSize, +} from '../utils' export const watchConfiguration = sdk.setupOnInit(async (effects) => { const registered = await runnerState.read().const(effects) + const receipt = await registrationReceipt.read().const(effects) const store = await storeJson.read().const(effects) - - if (!registered && !store?.registrationToken) { + if ( + !store || + (!registrationUsable(registered) && + !registrationPending(store, !!registered, receipt)) + ) { await sdk.action.createOwnTask(effects, configure, 'critical', { reason: i18n( 'Run the Configure action to connect this runner to a Gitea instance', ), }) - } else { - await sdk.action.clearTask(effects, `${sdk.manifest.id}:${configure.id}`) + return + } + try { + runnerLabels(store) + if ( + runnerBackend( + store.virtualization, + await virtualizationAvailable(effects), + ) === 'vm' + ) { + vmResources(store.capacity) + await vmDiskSize(effects, store.capacity) + } + } catch (error) { + if (!(error instanceof Error)) throw error + await sdk.action.createOwnTask(effects, configure, 'critical', { + reason: error.message, + }) + return } + await sdk.action.clearTask(effects, `${sdk.manifest.id}:${configure.id}`) }) diff --git a/startos/main.ts b/startos/main.ts index 0b4c17a..33812b2 100644 --- a/startos/main.ts +++ b/startos/main.ts @@ -1,32 +1,65 @@ import { cpus, totalmem } from 'os' import { mainHostId as giteaHostId, uiPort } from 'gitea-startos/startos/utils' +import { + VM_PREPARATION_STATUS, + VM_PREPARATION_PROBE, + VM_POOL_PROBE, + vmJobProbe, + vmJobState, + vmPreparationState, + vmPoolState, +} from 'forgejo-runner-startos/runtime' import { i18n } from './i18n' import { sdk } from './sdk' import { storeJson } from './fileModels/store.json' import { runnerState } from './fileModels/runnerState' -import { DATA_DIR, MIN_CPU_CORES, MIN_MEMORY_BYTES, mount } from './utils' +import { registrationReceipt } from './fileModels/registrationReceipt' +import { rejectedUuid } from './fileModels/rejectedUuid' +import { + DATA_DIR, + REGISTRATION_DIR, + REGISTRATION_RECEIPT, + DOCKER_HOST, + JOB_SCRATCH_DIR, + RUNTIME_DIR, + MIN_CPU_CORES, + MIN_MEMORY_BYTES, + EMULATION_IMAGE, + ROOT_CA_PATH, + registrationPending, + registrationName, + registrationUsable, + registrationHealth, + runnerLabels, + runnerBackend, + virtualizationAvailable, + vmResources, + vmDiskSize, + installRootCa, + installLocalHosts, +} from './utils' export const main = sdk.setupMain(async ({ effects }) => { - // A CI runner runs full builds (compilers, image pulls, nested containers) - // per job. Refuse to run on hardware that can't handle it. if (totalmem() < MIN_MEMORY_BYTES || cpus().length < MIN_CPU_CORES) { throw new Error( i18n( - 'This device does not meet the minimum requirements to run CI jobs (4 GB of RAM and 2 CPU cores).', + 'This device does not meet the minimum requirements to run CI jobs (1.5 GiB of RAM and 2 CPU cores).', ), ) } - const store = await storeJson.read().const(effects) if (!store) throw new Error(i18n('Store not found')) - - // The runner connects to its Gitea dependency over the internal LXC bridge. - // The bridge address (`10.0.3.1:`) only changes - // when Gitea's binding does, so this `.const()` restarts main exactly on - // Gitea install/uninstall/port-change and never on Gitea updates. The - // `kind:'running'` + primary-health-check dependency means Gitea is up when - // main starts, so the address resolves and the runner never sees the throw. - const forgeAddr = await sdk.host + const registered = await runnerState.read().once() + const receipt = await registrationReceipt.read().once() + const pending = registrationPending(store, !!registered, receipt) + if (!registrationUsable(registered) && !pending) { + throw new Error( + i18n( + 'Run the Configure action to connect this runner to a Gitea instance', + ), + ) + } + const bridge = await sdk.host .getBridgeAddress(effects, { packageId: 'gitea', hostId: giteaHostId, @@ -34,100 +67,445 @@ export const main = sdk.setupMain(async ({ effects }) => { ssl: false, }) .const() - if (!forgeAddr) + if (!bridge) { throw new Error( i18n( 'Gitea is not yet reachable on the internal network. The runner will connect once its Gitea dependency is running.', ), ) - - const subcontainer = sdk.SubContainer.of( + } + const available = await virtualizationAvailable(effects) + const backend = runnerBackend(store.virtualization, available) + const rootCa = await sdk.getRootCa(effects) + const osIp = await sdk.getOsIp(effects) + const localHosts = await sdk.host + .get(effects, { packageId: 'gitea', hostId: giteaHostId }, (host) => + [ + ...new Set( + Object.values(host?.bindings[uiPort]?.interfaces ?? {}) + .flatMap((iface) => + iface.addressInfo + .filter({ kind: 'mdns' }) + .format('hostname-info'), + ) + .map((address) => `${address.hostname}:${osIp}`), + ), + ].sort(), + ) + .const() + const env = { + INSTANCE_URL: `http://${bridge}`, + RUNNER_LABELS: runnerLabels(store).join(','), + RUNNER_DEFAULT_IMAGE: EMULATION_IMAGE, + RUNNER_REGISTRATION_DIR: '/registration', + RUNNER_ROOT_CA: ROOT_CA_PATH, + RUNNER_LOCAL_HOSTS: JSON.stringify(localHosts), + } + const owner = sdk.SubContainer.of( effects, { imageId: 'main' }, - mount, - 'gitea-runner-sub', + sdk.Mounts.of().mountVolume({ + volumeId: 'main', + subpath: null, + mountpoint: DATA_DIR, + readonly: false, + }), + 'registration-owner', ) + await installRootCa(owner, rootCa) + await installLocalHosts(owner, localHosts) + const base = sdk.Daemons.of(effects).addOneshot('register', { + subcontainer: owner, + exec: { + command: ['/usr/local/bin/entrypoint.sh', 'register'], + user: 'root', + env: { + INSTANCE_URL: env.INSTANCE_URL, + RUNNER_LABELS: env.RUNNER_LABELS, + RUNNER_NAME: registrationName(store, registered, receipt), + RUNNER_TOKEN: pending ? store.registrationToken : '', + RUNNER_REGISTRATION_DIR: REGISTRATION_DIR, + RUNNER_REGISTRATION_RECEIPT: REGISTRATION_RECEIPT, + RUNNER_REGISTRATION_REVISION: String(store.registrationRevision), + RUNNER_REGISTRATION_PENDING: String(pending), + }, + }, + requires: [], + }) - return sdk.Daemons.of(effects) - .addOneshot('own-data', { - // The runner runs rootless as 'app' (uid 1000); give it ownership of its - // working area on the persistent volume. StartOS's own store.json at - // /data is left untouched. - subcontainer, + if (backend === 'container') { + const controllerPath = './controller/container' + const engine = sdk.SubContainer.of( + effects, + { imageId: 'main' }, + sdk.Mounts.of().mountVolume({ + volumeId: 'scratch', + subpath: './container', + mountpoint: '/scratch', + readonly: false, + }), + 'container-engine', + ) + const controller = sdk.SubContainer.of( + effects, + { imageId: 'main' }, + sdk.Mounts.of() + .mountVolume({ + volumeId: 'main', + subpath: controllerPath, + mountpoint: '/data', + readonly: false, + }) + .mountVolume({ + volumeId: 'main', + subpath: './runner', + mountpoint: '/registration', + readonly: true, + }) + .mountVolume({ + volumeId: 'scratch', + subpath: './container', + mountpoint: '/scratch', + readonly: false, + }), + 'runner-controller', + ) + await installRootCa(engine, rootCa) + await installRootCa(controller, rootCa) + await installLocalHosts(engine, localHosts) + await installLocalHosts(controller, localHosts) + const rejection = rejectedUuid(controllerPath) + return base + .addOneshot('prepare-controller', { + subcontainer: controller, + exec: { + command: ['mkdir', '-p', '/data/runner/run', JOB_SCRATCH_DIR], + user: 'root', + }, + requires: [], + }) + .addOneshot('prepare-engine', { + subcontainer: engine, + exec: { + command: [ + 'sh', + '-ec', + `rm -rf ${RUNTIME_DIR}; mkdir -p ${RUNTIME_DIR}`, + ], + user: 'root', + }, + requires: [], + }) + .addDaemon('engine', { + subcontainer: engine, + exec: { + command: [ + '/usr/local/bin/engine.sh', + '--host', + DOCKER_HOST, + '--group', + 'app', + '--data-root', + '/scratch/docker', + '--exec-root', + `${RUNTIME_DIR}/docker`, + '--pidfile', + `${RUNTIME_DIR}/docker.pid`, + '--bridge', + 'runner', + '--default-address-pool', + 'base=172.20.0.0/16,size=24', + ], + user: 'root', + env: { + BINFMT_DIR: `${RUNTIME_DIR}/binfmt`, + RUNNER_BRIDGE: 'runner', + RUNNER_BRIDGE_ADDRESS: '172.18.0.1/24', + }, + }, + ready: { + display: null, + fn: () => + sdk.healthCheck.runHealthScript( + ['docker', '--host', DOCKER_HOST, 'info'], + engine, + { timeout: 10000 }, + ), + }, + requires: ['prepare-engine'], + }) + .addDaemon('primary', { + subcontainer: controller, + exec: { + command: ['/usr/local/bin/entrypoint.sh'], + user: 'root', + env: { + ...env, + DOCKER_HOST, + RUNNER_CAPACITY: String(store.capacity), + RUNNER_SCRATCH_DIR: JOB_SCRATCH_DIR, + TMPDIR: RUNTIME_DIR, + }, + }, + ready: { + display: i18n('Runner'), + fn: async () => + registrationHealth( + await runnerState.read().once(), + await rejection.read().once(), + ) ?? + sdk.healthCheck.runHealthScript( + [ + 'sh', + '-ec', + `grep -qx gitea-runner /proc/[0-9]*/comm && docker --host ${DOCKER_HOST} info >/dev/null`, + ], + controller, + { + timeout: 10000, + message: () => i18n('Runner is running'), + errorMessage: i18n('The runner is not running'), + }, + ), + }, + requires: ['register', 'prepare-controller', 'engine'], + }) + .addHealthCheck('execution-mode', { + ready: { + display: i18n('Container Mode'), + fn: async () => ({ + result: 'success', + message: available + ? i18n( + 'Virtualization is disabled. Jobs use privileged containers within the service and share the StartOS kernel. Only run trusted workflows.', + ) + : i18n( + 'KVM is unavailable. Jobs use containers and share the StartOS kernel.', + ), + }), + }, + requires: [], + }) + } + + const resources = vmResources(store.capacity) + const diskGiB = await vmDiskSize(effects, store.capacity) + const preparationResources = vmResources(1) + const preparer = sdk.SubContainer.of( + effects, + { imageId: 'main' }, + sdk.Mounts.of() + .mountVolume({ + volumeId: 'scratch', + subpath: './vm-images', + mountpoint: '/cache', + readonly: false, + }) + .mountVolume({ + volumeId: 'scratch', + subpath: './vm-preparation/runtime', + mountpoint: '/vm', + readonly: false, + }) + .mountVolume({ + volumeId: 'scratch', + subpath: './vm-preparation/control', + mountpoint: '/data', + readonly: false, + }), + 'vm-image-preparation', + ) + await installRootCa(preparer, rootCa) + await installLocalHosts(preparer, localHosts) + await preparer.execFail(['rm', '-f', VM_PREPARATION_STATUS], { user: 'root' }) + const memoryEnv = { + VM_POOL_MEMORY_MB: String(resources.memoryMiB), + VM_BOOT_MEMORY_MB: String(resources.bootMemoryMiB), + VM_MEMORY_BLOCK_MB: String(resources.blockMemoryMiB), + VM_MEMORY_DEVICE_ID: resources.memoryDeviceId, + VM_CAPACITY: String(store.capacity), + } + const memoryPool = sdk.SubContainer.of( + effects, + { imageId: 'main' }, + sdk.Mounts.of() + .mountVolume({ + volumeId: 'scratch', + subpath: './vm-pool', + mountpoint: '/pool', + readonly: false, + }) + .mountVolume({ + volumeId: 'scratch', + subpath: './vm-workers', + mountpoint: '/workers', + readonly: true, + }), + 'vm-memory-pool', + ) + let daemons = base + .addOneshot('prepare-vm', { + subcontainer: preparer, exec: { - command: [ - 'sh', - '-c', - `mkdir -p ${DATA_DIR}/runner && chown -R app:app ${DATA_DIR}/runner`, - ], + command: ['bash', '/usr/local/bin/prepare-vm.sh'], user: 'root', + env: { + VM_DOCKER_PORT: '23650', + VM_MEMORY_MB: String(preparationResources.memoryMiB), + VM_CPU_CORES: String(preparationResources.cpuCores), + VM_PREPARATION_STATUS, + RUNNER_ROOT_CA: ROOT_CA_PATH, + }, }, - requires: [], + requires: ['register'], }) - .addOneshot('device-perms', { - // StartOS 0.4.0.1 and earlier can hand the service root-only /dev/net/tun - // and /dev/fuse; podman opens both as 'app'. - subcontainer, - exec: { - command: ['chmod', '0666', '/dev/net/tun', '/dev/fuse'], - user: 'root', + .addHealthCheck('vm-image', { + ready: { + display: i18n('VM Image Preparation'), + trigger: sdk.trigger.statusTrigger(30000, { + loading: 1000, + starting: 1000, + failure: 5000, + }), + fn: async () => { + const status = await preparer.exec( + ['sh', '-ec', VM_PREPARATION_PROBE], + { user: 'root', timeout: 5000 }, + ) + const health = vmPreparationState( + status.exitCode, + String(status.stdout).trim(), + ) + return { ...health, message: i18n(health.message) } + }, }, requires: [], }) - .addOneshot('clean-runtime', { - // XDG_RUNTIME_DIR is on the persistent volume, so the boot ID podman - // caches there survives a reboot and podman refuses to start against it. - subcontainer, + .addDaemon('memory-pool', { + subcontainer: memoryPool, exec: { - command: ['rm', '-rf', `${DATA_DIR}/runner/run`], + command: ['python3', '-B', '/usr/local/bin/vm-pool.py', 'serve'], user: 'root', - }, - requires: ['own-data'], - }) - .addDaemon('primary', { - subcontainer, - exec: { - command: ['/usr/local/bin/entrypoint.sh'], - user: 'app', - env: { - INSTANCE_URL: `http://${forgeAddr}`, - RUNNER_TOKEN: store.registrationToken, - RUNNER_NAME: store.runnerName || 'startos-runner', - RUNNER_LABELS: store.labels, - RUNNER_CAPACITY: String(store.capacity), - XDG_RUNTIME_DIR: `${DATA_DIR}/runner/run`, - // podman looks up its subuid/subgid ranges by $USER, which the - // container inherits as 'root' regardless of the user it runs as. - USER: 'app', - }, + env: memoryEnv, }, ready: { - display: i18n('Runner'), - gracePeriod: 60000, - // Key the registered/not test off the state file, not the store token: - // a runner registered out-of-band has a live `.runner` but no token here. - trigger: sdk.trigger.cooldownTrigger(30000), - fn: async () => - (await runnerState.read().once()) - ? sdk.healthCheck.runHealthScript( - [ - 'bash', - '-c', - // `comm` is 15 characters wide, which `gitea-runner` fits. - `grep -qx gitea-runner /proc/[0-9]*/comm 2>/dev/null && podman --remote --url unix://${DATA_DIR}/runner/run/podman/podman.sock info >/dev/null`, - ], - subcontainer, - { - timeout: 10000, - message: () => i18n('Runner is running'), - errorMessage: i18n('The runner is not running'), - }, - ) - : { - result: 'failure', - message: i18n('The runner is not registered'), - }, + display: i18n('Guest Memory Pool'), + fn: async () => { + const status = await memoryPool.exec(['sh', '-ec', VM_POOL_PROBE], { + user: 'root', + timeout: 10000, + }) + const health = vmPoolState(status.exitCode) + return { ...health, message: i18n(health.message) } + }, }, - requires: ['own-data', 'device-perms', 'clean-runtime'], + requires: ['prepare-vm'], }) + for (let slot = 0; slot < store.capacity; slot++) { + const controllerPath = `./controller/workers/${slot}` + const port = 23750 + slot + const worker = sdk.SubContainer.of( + effects, + { imageId: 'main' }, + sdk.Mounts.of() + .mountVolume({ + volumeId: 'scratch', + subpath: './vm-pool', + mountpoint: '/pool', + readonly: false, + }) + .mountVolume({ + volumeId: 'scratch', + subpath: './vm-images', + mountpoint: '/base', + readonly: true, + }) + .mountVolume({ + volumeId: 'scratch', + subpath: `./vm-workers/${slot}`, + mountpoint: '/vm', + readonly: false, + }) + .mountVolume({ + volumeId: 'main', + subpath: controllerPath, + mountpoint: '/data', + readonly: false, + }) + .mountVolume({ + volumeId: 'main', + subpath: './runner', + mountpoint: '/registration', + readonly: true, + }), + `vm-worker-${slot}`, + ) + await installRootCa(worker, rootCa) + await installLocalHosts(worker, localHosts) + const rejection = rejectedUuid(controllerPath) + const jobHealth = async () => { + const failure = registrationHealth( + await runnerState.read().once(), + await rejection.read().once(), + ) + if (failure) return failure + const status = await worker.exec( + ['sh', '-ec', vmJobProbe(port, 'gitea-runner')], + { user: 'root', timeout: 5000 }, + ) + const health = vmJobState(status.exitCode, String(status.stdout).trim()) + return { ...health, message: i18n(health.message) } + } + daemons = daemons + .addDaemon(`worker-${slot}`, { + subcontainer: worker, + exec: { + command: ['bash', '/usr/local/bin/vm-worker.sh'], + user: 'root', + env: { + ...env, + ...memoryEnv, + VM_SLOT: String(slot), + VM_GUEST_HOSTNAME: 'gitea-job', + RUNNER_BEFORE_JOB: '/usr/local/bin/request-vm.sh', + RUNNER_CAPACITY: '1', + RUNNER_EMULATION: String(store.emulation), + RUNNER_SCRATCH_DIR: '/var/lib/runner-scratch', + VM_DOCKER_PORT: String(port), + VM_DISK_GB: String(diskGiB), + VM_CPU_CORES: String(resources.cpuCores), + }, + }, + ready: { + display: null, + gracePeriod: 300000, + fn: async () => { + const health = await jobHealth() + return health.result === 'loading' + ? { ...health, result: 'success' } + : health + }, + }, + requires: ['register', 'prepare-vm'], + }) + .addHealthCheck(`jobs-${slot}`, { + ready: { + display: `${i18n('Virtualized Jobs')} ${slot + 1}`, + gracePeriod: 300000, + trigger: sdk.trigger.statusTrigger(30000, { + success: 1000, + loading: 1000, + starting: 1000, + }), + fn: async () => { + const health = await jobHealth() + return health.result === 'starting' + ? { ...health, result: 'loading' } + : health + }, + }, + requires: ['vm-image'], + }) as typeof daemons + } + return daemons }) diff --git a/startos/manifest/i18n.ts b/startos/manifest/i18n.ts index d414c3e..317f591 100644 --- a/startos/manifest/i18n.ts +++ b/startos/manifest/i18n.ts @@ -8,15 +8,15 @@ export const short = { export const long = { en_US: - 'Gitea Runner executes Gitea Actions workflows for the Gitea instance on this device, running each job inside an isolated, rootless OCI container. Published under the MIT license.', + 'Gitea Runner executes Actions workflows for Gitea on this device. With accessible KVM, assignments use disposable VMs sharing a bounded guest-memory pool; no guests run while idle. Optional container mode shares the StartOS kernel and should run trusted workflows only. Supports configurable concurrency and emulated jobs for the other CPU architecture. Gitea Runner is published under the MIT license.', es_ES: - 'Gitea Runner ejecuta flujos de trabajo de Gitea Actions para la instancia de Gitea en este dispositivo, ejecutando cada tarea dentro de un contenedor OCI aislado y sin privilegios de root. Publicado bajo la licencia MIT.', + 'Gitea Runner ejecuta flujos de Actions para Gitea en este dispositivo. Con KVM accesible, las tareas usan máquinas virtuales desechables con un fondo de memoria limitado; no hay máquinas en reposo. El modo contenedor opcional comparte el núcleo de StartOS y debe ejecutar solo flujos de confianza. Admite concurrencia configurable y emulación de la otra arquitectura CPU. Gitea Runner se publica bajo la licencia MIT.', de_DE: - 'Gitea Runner führt Gitea-Actions-Workflows für die Gitea-Instanz auf diesem Gerät aus und lässt jeden Auftrag in einem isolierten, rootless OCI-Container laufen. Veröffentlicht unter der MIT-Lizenz.', + 'Gitea Runner führt Actions-Workflows für Gitea auf diesem Gerät aus. Bei zugänglichem KVM nutzen Aufträge kurzlebige VMs mit begrenztem gemeinsamen Gastspeicher; im Leerlauf laufen keine Gäste. Der optionale Containermodus teilt den StartOS-Kernel und eignet sich nur für vertrauenswürdige Workflows. Unterstützt einstellbare Parallelität und Emulation der anderen CPU-Architektur. Gitea Runner steht unter der MIT-Lizenz.', pl_PL: - 'Gitea Runner wykonuje przepływy pracy Gitea Actions dla instancji Gitea na tym urządzeniu, uruchamiając każde zadanie w izolowanym, bezrootowym kontenerze OCI. Publikowane na licencji MIT.', + 'Gitea Runner wykonuje przepływy Actions dla Gitei na tym urządzeniu. Przy dostępnym KVM zadania używają jednorazowych maszyn ze wspólną, ograniczoną pulą RAM; bez zadań nie działają żadne maszyny. Opcjonalny tryb kontenerowy współdzieli jądro StartOS i jest przeznaczony tylko dla zaufanych przepływów. Obsługuje konfigurowaną równoległość i emulację drugiej architektury CPU. Gitea Runner jest dostępny na licencji MIT.', fr_FR: - "Gitea Runner exécute les workflows Gitea Actions pour l'instance Gitea sur cet appareil, en lançant chaque tâche dans un conteneur OCI isolé et sans privilèges root. Publié sous la licence MIT.", + 'Gitea Runner exécute les workflows Actions pour Gitea sur cet appareil. Si KVM est accessible, les tâches utilisent des VM jetables partageant une réserve de mémoire bornée ; aucun invité ne tourne au repos. Le mode conteneur facultatif partage le noyau de StartOS et convient uniquement aux workflows de confiance. Prend en charge la concurrence configurable et l’émulation de l’autre architecture CPU. Gitea Runner est publié sous licence MIT.', } export const dependencyDescription = { diff --git a/startos/manifest/index.ts b/startos/manifest/index.ts index 415bfa6..36f1100 100644 --- a/startos/manifest/index.ts +++ b/startos/manifest/index.ts @@ -1,5 +1,6 @@ import { setupManifest } from '@start9labs/start-sdk' import { long, short } from './i18n' +import { BINFMT_IMAGE } from 'forgejo-runner-startos/runtime' export const manifest = setupManifest({ id: 'gitea-runner', @@ -10,19 +11,15 @@ export const manifest = setupManifest({ marketingUrl: 'https://gitea.com/', donationUrl: null, description: { short, long }, - volumes: ['main'], + volumes: ['main', 'scratch'], images: { main: { - source: { dockerBuild: { workdir: '.' } }, + source: { dockerBuild: { workdir: '.', buildArgs: { BINFMT_IMAGE } } }, arch: ['x86_64', 'aarch64'], emulateMissing: false, }, }, - // Run a rootless Podman engine inside the service to sandbox each CI job. - // It needs both device grants the former nestedRuntime flag bundled: - // userspaceFilesystems for /dev/fuse (fuse-overlayfs storage) and - // virtualNetworking for /dev/net/tun (slirp4netns job networking). - // See start-docs recipe-nested-oci-runtime. userspaceFilesystems: true, - virtualNetworking: true, + virtualNetworking: false, + hardwareVirtualization: true, }) diff --git a/startos/utils.ts b/startos/utils.ts index 9f495b8..833d984 100644 --- a/startos/utils.ts +++ b/startos/utils.ts @@ -1,4 +1,20 @@ import { sdk } from './sdk' +import { i18n } from './i18n' +import { + KVM_PROBE, + guestDiskGiB, + guestResources, + resolveRunnerLabels, + resolveRunnerBackend, +} from 'forgejo-runner-startos/runtime' + +export { + DEFAULT_RUNNER_LABELS, + EMULATION_IMAGE, + ROOT_CA_PATH, + installRootCa, + installLocalHosts, +} from 'forgejo-runner-startos/runtime' // ---- Resource gate ---- // A CI runner only makes sense on a box with real headroom: every job runs a @@ -9,13 +25,168 @@ import { sdk } from './sdk' // reports ~2.8 GiB and a 2 GB one ~0.8 GiB. 1.5 GiB sits between them. export const MIN_MEMORY_BYTES = 1.5 * 1024 ** 3 export const MIN_CPU_CORES = 2 +export const MAX_JOB_CAPACITY = 64 // ---- Paths (inside the service container) ---- export const DATA_DIR = '/data' +export const SCRATCH_DIR = '/scratch' +export const REGISTRATION_DIR = `${DATA_DIR}/runner` +export const REGISTRATION_RECEIPT_SUBPATH = './runner/registration-revision' +export const REGISTRATION_RECEIPT = `${DATA_DIR}/${REGISTRATION_RECEIPT_SUBPATH.slice(2)}` +export const JOB_SCRATCH_DIR = `${SCRATCH_DIR}/jobs` +export const RUNTIME_DIR = `${SCRATCH_DIR}/run` +export const DOCKER_HOST = `unix://${RUNTIME_DIR}/docker.sock` + +export type RunnerSettings = { + registrationToken: string + registrationRevision: number + runnerName: string + labels: string + capacity: number + emulation: boolean + virtualization: boolean +} + +export function registrationPending( + settings: Pick, + registered: boolean, + receipt: string | null | undefined, +) { + if (!settings.registrationToken) return false + return settings.registrationRevision > 0 + ? String(settings.registrationRevision) !== receipt?.trim() + : !registered && receipt?.trim() !== '0' +} + +export function registrationUsable( + registered: + | { uuid?: unknown; token?: unknown; ephemeral?: unknown } + | null + | undefined, +) { + return ( + !!registered && + typeof registered.uuid === 'string' && + registered.uuid.length > 0 && + typeof registered.token === 'string' && + registered.token.length > 0 && + (registered.ephemeral === undefined || + registered.ephemeral === null || + registered.ephemeral === false) + ) +} + +export function registrationHealth( + registered: + | { uuid?: unknown; token?: unknown; ephemeral?: unknown } + | null + | undefined, + rejected: string | null | undefined, +) { + if (!registrationUsable(registered)) { + return { + result: 'failure', + message: i18n('The runner is not registered'), + } as const + } + if ( + typeof registered?.uuid === 'string' && + rejected?.trim() === registered.uuid + ) { + return { + result: 'failure', + message: i18n( + 'Gitea no longer recognizes this runner. Use Configure with a fresh registration token.', + ), + } as const + } + return null +} + +export function registrationName( + settings: Pick< + RunnerSettings, + 'registrationToken' | 'registrationRevision' | 'runnerName' + >, + registered: { name?: unknown } | null | undefined, + receipt: string | null | undefined, +) { + if ( + !registrationPending(settings, !!registered, receipt) && + typeof registered?.name === 'string' + ) { + return registered.name + } + return settings.runnerName || 'startos-runner' +} + +export function runnerLabels( + settings: Pick, +) { + const resolved = resolveRunnerLabels(settings) + if (resolved.error) throw new Error(i18n(resolved.error)) + return resolved.labels +} + +export function runnerBackend(virtualization: boolean, available: boolean) { + const resolved = resolveRunnerBackend(virtualization, available) + if (resolved.error) throw new Error(i18n(resolved.error)) + return resolved.backend +} + +export async function virtualizationAvailable( + effects: Parameters[0], +) { + const result = await sdk.SubContainer.withTemp( + effects, + { imageId: 'main' }, + sdk.Mounts.of(), + 'virtualization-availability', + (sub) => + sub.exec(['sh', '-ec', KVM_PROBE], { user: 'root', timeout: 5000 }), + ) + return result.exitCode === 0 +} + +export function vmResources(capacity: number) { + const resources = guestResources(capacity) + if (!resources) { + throw new Error( + i18n( + 'Too many concurrent jobs: each VM needs at least 512 MiB of guest RAM.', + ), + ) + } + return resources +} -export const mount = sdk.Mounts.of().mountVolume({ - volumeId: 'main', - subpath: null, - mountpoint: DATA_DIR, - readonly: false, -}) +export async function vmDiskSize( + effects: Parameters[0], + capacity: number, +) { + const result = await sdk.SubContainer.withTemp( + effects, + { imageId: 'main' }, + sdk.Mounts.of().mountVolume({ + volumeId: 'scratch', + subpath: null, + mountpoint: SCRATCH_DIR, + readonly: false, + }), + 'vm-disk-budget', + (sub) => + sub.execFail(['df', '-Pm', SCRATCH_DIR], { user: 'root', timeout: 5000 }), + ) + const available = Number( + String(result.stdout).trim().split('\n')[1]?.trim().split(/\s+/)[3], + ) + const diskGiB = guestDiskGiB(available, capacity) + if (!diskGiB) { + throw new Error( + i18n( + 'Insufficient scratch space: each VM needs at least 8 GiB of disk space within the guest-disk budget.', + ), + ) + } + return diskGiB +} diff --git a/startos/versions/current.ts b/startos/versions/current.ts index 0ae9731..a3615c6 100644 --- a/startos/versions/current.ts +++ b/startos/versions/current.ts @@ -1,61 +1,117 @@ -import { VersionInfo, IMPOSSIBLE } from '@start9labs/start-sdk' +import { FileHelper, VersionInfo, IMPOSSIBLE, z } from '@start9labs/start-sdk' +import { sdk } from '../sdk' +import { storeJson } from '../fileModels/store.json' +import { DATA_DIR, virtualizationAvailable } from '../utils' export const current = VersionInfo.of({ - version: '5.1.0:0', + version: '5.1.0:1', releaseNotes: { - en_US: `Updated Gitea Runner to 5.1.0. - -- Retries startup while Gitea is unreachable instead of exiting. -- Shows action pre/post steps separately on Gitea versions that support it, and preserves their environment files after timeouts or cancellation. -- Fixes action copies on rootless Podman with images that symlink /var/run, and PATH handling in cross-architecture jobs. -- Ignores workflow container --entrypoint options with a warning instead of breaking the container. -- Includes security updates to Go and networking dependencies. Updated start-sdk to 3.0.6. -- An unconfigured runner now raises a critical Configure task. Save a registration token before starting; existing registrations need no setup task. - -[Full upstream release notes](https://gitea.com/gitea/runner/releases/tag/v5.1.0)`, - es_ES: `Gitea Runner actualizado a 5.1.0. - -- Reintenta el inicio cuando Gitea no está disponible en lugar de finalizar. -- Muestra los pasos pre/post de las acciones por separado en las versiones de Gitea compatibles y conserva sus archivos de entorno tras un tiempo de espera agotado o una cancelación. -- Corrige la copia de acciones en Podman sin privilegios con imágenes que usan un enlace simbólico para /var/run y la gestión de PATH en trabajos de otra arquitectura. -- Ignora las opciones --entrypoint de los contenedores del flujo con una advertencia en lugar de romper el contenedor. -- Incluye actualizaciones de seguridad de Go y las dependencias de red. start-sdk actualizado a 3.0.6. -- Un ejecutor sin configurar crea una tarea crítica Configurar. Guarde un token de registro antes de iniciar; los registros existentes no necesitan esta tarea. - -[Notas completas de la versión](https://gitea.com/gitea/runner/releases/tag/v5.1.0)`, - de_DE: `Gitea Runner auf 5.1.0 aktualisiert. - -- Wiederholt den Start bei nicht erreichbarem Gitea, statt sich zu beenden. -- Zeigt Pre-/Post-Schritte von Actions in unterstützten Gitea-Versionen separat an und behält deren Umgebungsdateien nach Zeitüberschreitungen oder Abbrüchen. -- Behebt das Kopieren von Actions unter rootless Podman bei Images mit einem Symlink für /var/run sowie die PATH-Verarbeitung bei Jobs für andere Architekturen. -- Ignoriert --entrypoint-Optionen in Workflow-Containern mit einer Warnung, statt den Container unbrauchbar zu machen. -- Enthält Sicherheitsupdates für Go und Netzwerkabhängigkeiten. start-sdk auf 3.0.6 aktualisiert. -- Ein unkonfigurierter Runner erstellt eine kritische Aufgabe „Konfigurieren“. Speichern Sie vor dem Start ein Registrierungs-Token; bestehende Registrierungen benötigen keine Einrichtungsaufgabe. - -[Vollständige Versionshinweise](https://gitea.com/gitea/runner/releases/tag/v5.1.0)`, - pl_PL: `Zaktualizowano Gitea Runner do 5.1.0. - -- Ponawia uruchamianie, gdy Gitea jest nieosiągalna, zamiast kończyć działanie. -- Pokazuje kroki pre/post akcji osobno w obsługujących to wersjach Gitei i zachowuje ich pliki środowiska po przekroczeniu limitu czasu lub anulowaniu. -- Naprawia kopiowanie akcji w Podmanie bez uprawnień z obrazami używającymi dowiązania symbolicznego dla /var/run oraz obsługę PATH w zadaniach dla innych architektur. -- Ignoruje opcje --entrypoint kontenerów przepływu z ostrzeżeniem zamiast psuć kontener. -- Zawiera aktualizacje bezpieczeństwa Go i zależności sieciowych. Zaktualizowano start-sdk do 3.0.6. -- Nieskonfigurowany runner tworzy krytyczne zadanie Konfiguruj. Zapisz token rejestracji przed uruchomieniem; istniejące rejestracje nie wymagają tego zadania. - -[Pełne informacje o wydaniu](https://gitea.com/gitea/runner/releases/tag/v5.1.0)`, - fr_FR: `Gitea Runner mis à jour vers la version 5.1.0. - -- Réessaie le démarrage lorsque Gitea est inaccessible au lieu de quitter. -- Affiche séparément les étapes pre/post des actions sur les versions compatibles de Gitea et conserve leurs fichiers d'environnement après une expiration ou une annulation. -- Corrige la copie des actions avec Podman sans privilèges et les images utilisant un lien symbolique pour /var/run, ainsi que la gestion de PATH dans les tâches pour une autre architecture. -- Ignore les options --entrypoint des conteneurs de workflow avec un avertissement au lieu de casser le conteneur. -- Inclut des mises à jour de sécurité de Go et des dépendances réseau. start-sdk mis à jour vers 3.0.6. -- Un runner non configuré crée une tâche critique Configurer. Enregistrez un jeton avant de démarrer ; les enregistrements existants ne nécessitent pas cette tâche. - -[Notes de version complètes](https://gitea.com/gitea/runner/releases/tag/v5.1.0)`, + en_US: `**Execution and isolation** + +- With accessible KVM, new installations default to disposable, assignment-only VMs; no guests run while idle. Container mode remains available, shares the StartOS kernel and should run trusted workflows only. VM failures do not fall back to containers. A saved VM choice blocks startup with a critical Configure task if KVM becomes unavailable; restore KVM access or explicitly save container mode. +- VMs share half the service-visible RAM, with acknowledged memory reclaim. Guest disks remain bounded to 8–32 GiB each. Reusable-workflow jobs and matrices run sequentially within each assignment; separate assignments can overlap. +- Optional emulation adds a label for the other CPU architecture. Only Docker labels are accepted; replace any host-execution labels through Configure. +- Public StartOS certificate trust and local Gitea hostname routing reach jobs. Action-source access is confined, caches are private per assignment and VM Docker access uses per-guest mTLS. + +**Registration and upgrade** + +- Runtime-only Configure edits preserve registration; leave the token blank. A fresh token explicitly creates or replaces registration, preserving the old identity until replacement succeeds. Restored registrations do not need a saved token. +- The upgrade discards old Podman image layers and transient runtime state, but keeps registration, settings and unrelated files. Execution scratch is not backed up. Jobs repull images in fresh VMs. +- Idle slots report success; assigned slots report loading without blocking daemon readiness. Preparation and registration failures remain visible. +- Builds use patched Go TLS verification and start-sdk 3.1.0.`, + es_ES: `**Ejecución y aislamiento** + +- Con KVM accesible, las instalaciones nuevas usan por defecto máquinas virtuales desechables, creadas solo para tareas asignadas; no hay máquinas en reposo. El modo contenedor sigue disponible, comparte el núcleo de StartOS y debe ejecutar solo flujos de confianza. Los fallos de una máquina virtual no cambian al modo contenedor. Si KVM deja de estar disponible con VM activada, una tarea crítica Configurar bloquea el inicio; restablezca KVM o guarde explícitamente el modo contenedor. +- Las máquinas comparten la mitad de la RAM visible para el servicio, con recuperación de memoria confirmada. Cada disco sigue limitado a 8–32 GiB. Los trabajos y matrices de flujos reutilizables se ejecutan secuencialmente dentro de cada asignación; distintas asignaciones pueden coincidir. +- La emulación opcional añade una etiqueta para la otra arquitectura. Solo se aceptan etiquetas Docker; sustituya las etiquetas de ejecución en el host mediante Configurar. +- Los trabajos reciben la confianza en el certificado público de StartOS y el acceso al nombre local de Gitea. Se limita el acceso a los archivos de acciones, cada asignación tiene su caché privada y el acceso Docker de cada máquina usa mTLS propio. + +**Registro y actualización** + +- Los cambios de ejecución en Configurar conservan el registro; deje el token vacío. Un token nuevo crea o sustituye el registro, conservando la identidad anterior hasta que la sustitución termine. Los registros restaurados no necesitan un token guardado. +- La actualización descarta las capas de imágenes de Podman y su estado temporal, pero conserva el registro, los ajustes y los archivos ajenos. El espacio de ejecución no se respalda. Las máquinas nuevas descargan de nuevo las imágenes. +- Los puestos inactivos indican éxito y los asignados indican carga sin bloquear la disponibilidad del proceso. Los fallos de preparación y registro siguen visibles. +- Las compilaciones usan la verificación TLS corregida de Go y start-sdk 3.1.0.`, + de_DE: `**Ausführung und Isolation** + +- Bei zugänglichem KVM verwenden Neuinstallationen standardmäßig kurzlebige VMs nur für zugewiesene Aufträge; im Leerlauf laufen keine Gäste. Der Containermodus bleibt verfügbar, teilt den StartOS-Kernel und ist nur für vertrauenswürdige Workflows geeignet. VM-Fehler wechseln nicht zum Containermodus. Bei gespeicherter VM-Auswahl blockiert fehlendes KVM den Start mit einer kritischen Aufgabe; KVM wiederherstellen oder den Containermodus ausdrücklich speichern. +- Die VMs teilen die Hälfte des für den Dienst sichtbaren RAMs mit bestätigter Speicherrückgabe. Gastplatten bleiben auf jeweils 8–32 GiB begrenzt. Jobs und Matrizen wiederverwendbarer Workflows laufen innerhalb einer Zuweisung nacheinander; getrennte Zuweisungen können parallel laufen. +- Optionale Emulation ergänzt ein Label für die andere CPU-Architektur. Nur Docker-Labels sind zulässig; Host-Ausführungslabels müssen über Konfigurieren ersetzt werden. +- Jobs erhalten das öffentliche StartOS-Vertrauenszertifikat und die lokale Gitea-Namensauflösung. Der Zugriff auf Action-Quelldateien ist begrenzt, Caches sind pro Zuweisung privat und Docker-Zugriffe der VMs verwenden eigenes mTLS. + +**Registrierung und Upgrade** + +- Änderungen der Ausführung unter Konfigurieren erhalten die Registrierung; das Tokenfeld bleibt leer. Ein neues Token erstellt oder ersetzt die Registrierung und erhält die alte Identität bis zum erfolgreichen Austausch. Wiederhergestellte Registrierungen benötigen kein gespeichertes Token. +- Das Upgrade entfernt alte Podman-Imageschichten und Laufzeitdaten, erhält aber Registrierung, Einstellungen und andere Dateien. Ausführungsspeicher ist nicht Teil der Sicherung. Neue VMs laden Job-Images erneut. +- Leere Slots melden Erfolg, zugewiesene Slots einen laufenden Vorgang, ohne die Prozessbereitschaft zu blockieren. Vorbereitungs- und Registrierungsfehler bleiben sichtbar. +- Builds verwenden die korrigierte Go-TLS-Prüfung und start-sdk 3.1.0.`, + pl_PL: `**Wykonywanie i izolacja** + +- Przy dostępnym KVM nowe instalacje domyślnie używają jednorazowych maszyn wirtualnych tworzonych tylko dla przydzielonych zadań; bez zadań nie działają żadne maszyny. Tryb kontenerowy pozostaje dostępny, współdzieli jądro StartOS i powinien obsługiwać tylko zaufane przepływy. Awaria maszyny nie przełącza zadania na kontener. Jeśli przy zapisanym trybie VM zabraknie KVM, krytyczne zadanie Konfiguruj blokuje start; przywróć KVM lub jawnie zapisz tryb kontenerowy. +- Maszyny współdzielą połowę pamięci RAM widocznej dla usługi, z potwierdzanym zwalnianiem pamięci. Dysk każdej maszyny pozostaje ograniczony do 8–32 GiB. Zadania i macierze przepływów wielokrotnego użytku działają kolejno w ramach jednego przydziału; osobne przydziały mogą działać równolegle. +- Opcjonalna emulacja dodaje etykietę drugiej architektury CPU. Dozwolone są tylko etykiety Docker; etykiety uruchamiania na hoście należy zastąpić przez Konfiguruj. +- Zadania otrzymują zaufanie do publicznego certyfikatu StartOS i dostęp do lokalnej nazwy Gitei. Dostęp do źródeł akcji jest ograniczony, pamięć podręczna jest prywatna dla każdego przydziału, a połączenie Docker każdej maszyny używa osobnego mTLS. + +**Rejestracja i aktualizacja** + +- Zmiany wykonania przez Konfiguruj zachowują rejestrację; pozostaw pole tokena puste. Nowy token tworzy lub zastępuje rejestrację, zachowując starą tożsamość do udanego zastąpienia. Przywrócone rejestracje nie wymagają zapisanego tokena. +- Aktualizacja usuwa stare warstwy obrazów Podmana i stan tymczasowy, zachowując rejestrację, ustawienia i pozostałe pliki. Przestrzeń wykonawcza nie jest objęta kopią zapasową. Nowe maszyny ponownie pobierają obrazy. +- Bezczynne stanowiska wskazują powodzenie, a przydzielone wskazują trwającą pracę bez blokowania gotowości procesu. Błędy przygotowania i rejestracji pozostają widoczne. +- Kompilacje korzystają z poprawionej weryfikacji TLS w Go i start-sdk 3.1.0.`, + fr_FR: `**Exécution et isolation** + +- Si KVM est accessible, les nouvelles installations utilisent par défaut des machines virtuelles jetables créées uniquement pour les tâches attribuées ; aucun invité ne tourne au repos. Le mode conteneur reste disponible, partage le noyau de StartOS et doit exécuter uniquement des workflows de confiance. Un échec de VM ne déclenche pas de repli vers les conteneurs. Si KVM devient indisponible alors que le mode VM est enregistré, une tâche critique Configurer bloque le démarrage ; rétablissez KVM ou enregistrez explicitement le mode conteneur. +- Les VM partagent la moitié de la RAM visible par le service, avec récupération de mémoire confirmée. Chaque disque reste limité à 8–32 Gio. Les jobs et matrices des workflows réutilisables s’exécutent séquentiellement dans chaque attribution ; des attributions distinctes peuvent se chevaucher. +- L’émulation facultative ajoute une étiquette pour l’autre architecture CPU. Seules les étiquettes Docker sont acceptées ; remplacez les étiquettes d’exécution sur l’hôte via Configurer. +- Les jobs reçoivent la confiance envers le certificat public de StartOS et le routage du nom local de Gitea. L’accès aux sources des actions est limité, les caches sont privés par attribution et l’accès Docker de chaque VM utilise son propre mTLS. + +**Inscription et mise à niveau** + +- Les changements d’exécution via Configurer conservent l’inscription ; laissez le jeton vide. Un nouveau jeton crée ou remplace l’inscription en préservant l’ancienne identité jusqu’à la réussite. Une inscription restaurée ne nécessite pas de jeton enregistré. +- La mise à niveau supprime les anciennes couches Podman et leur état temporaire, mais conserve l’inscription, les paramètres et les autres fichiers. L’espace d’exécution n’est pas sauvegardé. Les nouvelles VM téléchargent à nouveau les images. +- Les emplacements inactifs indiquent une réussite, les emplacements attribués une opération en cours sans bloquer la disponibilité du processus. Les échecs de préparation et d’inscription restent visibles. +- Les compilations utilisent la vérification TLS corrigée de Go et start-sdk 3.1.0.`, }, migrations: { - up: async () => {}, + up: async ({ effects }) => { + const previous = await FileHelper.json( + { base: sdk.volumes.main, subpath: './store.json' }, + z.looseObject({ + virtualization: z.boolean().optional().catch(undefined), + }), + ) + .read() + .once() + await storeJson.merge(effects, { + virtualization: + previous?.virtualization ?? (await virtualizationAvailable(effects)), + }) + await sdk.SubContainer.withTemp( + effects, + { imageId: 'main' }, + sdk.Mounts.of().mountVolume({ + volumeId: 'main', + subpath: null, + mountpoint: DATA_DIR, + readonly: false, + }), + 'migrate-podman-storage', + (subcontainer) => + subcontainer.execFail( + [ + 'sh', + '-ec', + `if [ -d ${DATA_DIR}/runner ] && [ ! -L ${DATA_DIR}/runner ]; then + rm -rf -- ${DATA_DIR}/runner/run + if [ -d ${DATA_DIR}/runner/containers ] && [ ! -L ${DATA_DIR}/runner/containers ]; then + rm -rf -- ${DATA_DIR}/runner/containers/storage + fi +fi`, + ], + { user: 'root', timeout: null }, + ), + ) + }, down: IMPOSSIBLE, }, }) diff --git a/tsconfig.json b/tsconfig.json index f032dc1..7a03717 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,4 +1,8 @@ { "extends": "@start9labs/start-sdk/tsconfig.base.json", - "include": ["startos/**/*.ts", "node_modules/**/startos"] + "include": [ + "startos/**/*.ts", + "node_modules/**/startos", + "node_modules/forgejo-runner-startos/runtime/**/*.ts" + ] } diff --git a/upstream b/upstream new file mode 160000 index 0000000..470969d --- /dev/null +++ b/upstream @@ -0,0 +1 @@ +Subproject commit 470969dc749f6800de24b79401e52c4320f6a7c2