Skip to content
7 changes: 4 additions & 3 deletions plugins/filter/arc.py
Original file line number Diff line number Diff line change
Expand Up @@ -1016,16 +1016,17 @@ def _node_cache_env(node_cache_path: str, capabilities: Sequence[tuple[str, Mapp


def _node_cache_init_container(name: str, capability: Mapping[str, Any], node_cache_path: str, runner_user: int, runner_group: int) -> dict[str, Any]:
"""Return the init container that creates a node-cache capability's subdirectories, owned by the runner's user. It runs as root, since the node's directory belongs to root until a pod claims it, and keeps only the one capability that needs: chown."""
"""Return the init container that creates a node-cache capability's subdirectories, owned by the runner's user. It runs as root, since the node's directory belongs to root until a pod claims it. Its capabilities are CHOWN, to hand a directory to the runner user, and DAC_OVERRIDE, because root without it is an ordinary user of a directory it has handed over, or finds already owned by the runner user from an earlier pod or the pruner's emptying, and cannot create the next subdirectory in it."""
root = node_cache_path.rstrip("/")
# The mount root is the profile's own directory, created by the kubelet as root. Owning it lets a job recreate a subdirectory the pruner has set aside, instead of failing on a directory it cannot write to.
# One init container with DAC_OVERRIDE rather than two: a root container that only creates the directories, then a non-root one running as the runner user for the rest, would need no capability beyond CHOWN, but costs a container per capability and requires the image to run as a non-root user, which the digest-pinned busybox default does not guarantee. DAC_OVERRIDE is confined to a container that runs a fixed command on one mounted directory.
# The mount root is the profile's own directory, created by the kubelet as root. The runner user owns it so that a job can recreate a subdirectory the pruner has set aside; this container recreates the capability's own subdirectories at the start of every pod.
directories = [root] + [f"{root}/{subdirectory}" for subdirectory in capability["node_cache"]["dirs"].values()]
return {
"name": f"{_CAPABILITY_CONTAINER_PREFIX}{name}",
"image": capability["node_cache"]["init_image"],
# The image contract: a POSIX sh, mkdir and chown. chown is not recursive: what a pod creates under a directory is already the runner user's.
"command": ["/bin/sh", "-c", 'owner=$1; shift; for directory in "$@"; do mkdir -p "$directory" && chown "$owner" "$directory"; done', "prepare-node-cache", f"{int(runner_user)}:{int(runner_group)}", *directories],
"securityContext": {"runAsUser": 0, "runAsNonRoot": False, "allowPrivilegeEscalation": False, "readOnlyRootFilesystem": True, "capabilities": {"drop": ["ALL"], "add": ["CHOWN"]}},
"securityContext": {"runAsUser": 0, "runAsNonRoot": False, "allowPrivilegeEscalation": False, "readOnlyRootFilesystem": True, "capabilities": {"drop": ["ALL"], "add": ["CHOWN", "DAC_OVERRIDE"]}},
"volumeMounts": [{"name": _NODE_CACHE_VOLUME, "mountPath": node_cache_path}],
}

Expand Down
2 changes: 1 addition & 1 deletion roles/github_runner_arc/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ A system-library capability, `{sysroot_image, env?}`, brings shared libraries, t

A hook capability, `{job_started_hook}`, is a bash script the runner runs before each job's first step, for anything small: configuring git, writing a proxy setting to `GITHUB_ENV`, checking a mount.

A node-cache capability, `{node_cache: {init_image, dirs}, env?}`, gives the profile's runner pods on a node one directory of that node to share, for what is expensive to rebuild in every pod and safe to share: a package manager's content-addressed store. **Every job of the profile shares that directory and can write to it, so give it only to a profile whose workflows are all of one trust level, and never to one that runs pull requests from forks.** The runner pod gets a `hostPath` volume, `<github_runner_arc_node_cache_host_path>/<namespace>/<release>` on the node (so profiles never share a cache), mounted once on the runner container at `github_runner_arc_node_cache_path` (`/node-cache` by default). An init container named `capability-<name>`, run as root from the `init_image` (pinned by digest, since it runs as root beside a hostPath volume; it needs a POSIX `sh`, `mkdir` and `chown`, and keeps only the `CHOWN` capability), gives the runner's user the cache's directory and each subdirectory `dirs` names. `dirs` maps an environment variable to a subdirectory, and the variable is set on the runner container to its path, so `pnpm_config_store_dir: pnpm` makes pnpm keep its store in `/node-cache/pnpm`; `env` sets further variables literally. All the node-cache capabilities of a profile share the one volume. The host path root, `github_runner_arc_node_cache_host_path`, is checked whether or not any profile has a node cache, because the pruner mounts it read-write and empties it as root. It must be an absolute path with no colon, no repeated slash and no trailing slash (the pods' volume paths and the pruner's comparison are built from that one string), at least three levels deep, with a last component ending in `node-cache` (so `/var/lib/github-runner/node-cache` is valid and `/var/lib/etcd` is not), and not inside a directory the node itself uses (`/etc`, `/var/lib/kubelet`, `/tmp` and similar). A symbolic link at or above the path is not detected and defeats this validation.
A node-cache capability, `{node_cache: {init_image, dirs}, env?}`, gives the profile's runner pods on a node one directory of that node to share, for what is expensive to rebuild in every pod and safe to share: a package manager's content-addressed store. **Every job of the profile shares that directory and can write to it, so give it only to a profile whose workflows are all of one trust level, and never to one that runs pull requests from forks.** The runner pod gets a `hostPath` volume, `<github_runner_arc_node_cache_host_path>/<namespace>/<release>` on the node (so profiles never share a cache), mounted once on the runner container at `github_runner_arc_node_cache_path` (`/node-cache` by default). An init container named `capability-<name>`, run as root from the `init_image` (pinned by digest, since it runs as root beside a hostPath volume; it needs a POSIX `sh`, `mkdir` and `chown`, and keeps only the `CHOWN` and `DAC_OVERRIDE` capabilities, the second so that root can create a subdirectory in a directory it has handed to the runner user, or finds already theirs from an earlier pod or the pruner), gives the runner's user the cache's directory and each subdirectory `dirs` names. `dirs` maps an environment variable to a subdirectory, and the variable is set on the runner container to its path, so `pnpm_config_store_dir: pnpm` makes pnpm keep its store in `/node-cache/pnpm`; `env` sets further variables literally. All the node-cache capabilities of a profile share the one volume. The host path root, `github_runner_arc_node_cache_host_path`, is checked whether or not any profile has a node cache, because the pruner mounts it read-write and empties it as root. It must be an absolute path with no colon, no repeated slash and no trailing slash (the pods' volume paths and the pruner's comparison are built from that one string), at least three levels deep, with a last component ending in `node-cache` (so `/var/lib/github-runner/node-cache` is valid and `/var/lib/etcd` is not), and not inside a directory the node itself uses (`/etc`, `/var/lib/kubelet`, `/tmp` and similar). A symbolic link at or above the path is not detected and defeats this validation.

The cache outlives the pod, so the next job on the node starts with it filled, and a job on a node whose cache is empty fills it as it goes. It is the workflow's own cache step that remains the fallback: a workflow checks that the variable's path exists before relying on it, so it still runs on a runner without the capability, such as a GitHub-hosted one.

Expand Down
Loading
Loading