Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions roles/github_runner_arc/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,18 +142,23 @@ The limits are per profile and the node's disk is shared by all of them, so a no

A read-only mount for sensitive profiles was considered and is not offered: a profile's cache is its own and only its own jobs fill it, so a read-only copy of it would never be filled. A profile that must not share a cache with other jobs of its kind should not have the capability.

Two limits shape how a package manager uses the cache. First, Linux refuses a hard link between two mount points even of one filesystem, so a store in the node cache cannot be hard-linked into a workspace that sits on the pod's own volume (this was checked against two bind mounts of one directory, and against a subdirectory mounted separately, which is what a `subPath` is). The runner's workspace path is fixed by the runner, so it cannot be moved onto the shared mount, and a workspace shared by the pods would collide on the runner's own `_temp` and `_actions` directories. A store therefore has to be one that links with symbolic links, not hard links; pnpm's global virtual store is, and its `links` directory sits beside the store. The capability only provides and names the directory; the store is used through symbolic links only when the global virtual store is on, which the `env` of the example does for every job of the profile (`pnpm_config_enable_global_virtual_store`), or which a workflow can turn on for just the jobs that can use it, leaving it out of the capability: a job whose tools need the dependencies inside the checkout, such as a Next.js standalone build, cannot. This was checked with pnpm: with the global virtual store on, a project's `node_modules` holds symbolic links into `<store>/v11/links` and a few metadata files, and a second project installing the same dependencies writes no package files. Second, the cache is shared writable state: a job that changes a file under it changes it for every later job on the node. pnpm checks the files it imports into its store (`verify-store-integrity`, on by default), but not the linked directories a global virtual store serves.
Two limits shape how a package manager uses the cache. First, Linux refuses a hard link between two mount points even of one filesystem, so a store in the node cache cannot be hard-linked into a workspace that sits on the pod's own volume (this was checked against two bind mounts of one directory, and against a subdirectory mounted separately, which is what a `subPath` is). The runner's workspace path is fixed by the runner, so it cannot be moved onto the shared mount, and a workspace shared by the pods would collide on the runner's own `_temp` and `_actions` directories. A store therefore has to be one that links with symbolic links, not hard links; pnpm's global virtual store is, and its `links` directory sits beside the store. The capability only provides and names the directory; the store is used through symbolic links only when the global virtual store is on, which a workflow turns on for just the jobs that can use it: a job whose tools need the dependencies inside the checkout, such as a Next.js standalone build, cannot. The example names the directory with a variable pnpm does not read, and a workflow passes it to its install step as `pnpm_config_store_dir` together with `pnpm_config_enable_global_virtual_store: "true"`. Naming it with `pnpm_config_store_dir` on the pod instead points every pnpm the job runs at the shared store, including the version switch of `pnpm/action-setup` (`pnpm self-update` to the `packageManager` pin): that switch installs `@pnpm/exe` into the store's global virtual store and hard-links the platform binary over the package's placeholder file inside the shared slot, and the next pod on the node re-imports that slot by copying the placeholder into the same path, which writes through the hard link into the store's copy of the binary. From then on every switch on the node installs a placeholder and fails with `This: not found` (seen with pnpm 11.19.0). This was checked with pnpm: with the global virtual store on, a project's `node_modules` holds symbolic links into `<store>/v11/links` and a few metadata files, and a second project installing the same dependencies writes no package files. Second, the cache is shared writable state: a job that changes a file under it changes it for every later job on the node. pnpm checks the files it imports into its store (`verify-store-integrity`, on by default), but not the linked directories a global virtual store serves.

```yaml
github_runner_arc_capabilities:
pnpm-store:
node_cache:
init_image: docker.io/library/busybox:1.37.0@sha256:bdf57e528e45e4433820e045b29b4597825a1c9e38353532d90a01445013f82e
dirs:
pnpm_config_store_dir: pnpm
# Makes every job of the profile install through the global virtual store, which is what links a project's node_modules with symbolic links into the store. Leave it out to let a workflow turn it on for the jobs that can use it.
env:
pnpm_config_enable_global_virtual_store: "true"
# Not pnpm_config_store_dir, which every pnpm in the job would read: a job opts in by passing this to its install step (see above).
NODE_CACHE_PNPM_STORE_DIR: pnpm
```

```yaml
# The workflow's install step, in a job on that profile. The variable is read in the shell: it is set on the pod, and the workflow's env context does not hold variables the runner process inherits.
- run: pnpm_config_store_dir="$NODE_CACHE_PNPM_STORE_DIR" pnpm install --frozen-lockfile
env:
pnpm_config_enable_global_virtual_store: "true"
```

The `hostPath` volume needs a cluster that admits one: a namespace that enforces Pod Security's baseline or restricted level refuses it.
Expand Down
11 changes: 6 additions & 5 deletions tests/unit/test_node_cache_pruner_role.py
Original file line number Diff line number Diff line change
Expand Up @@ -543,12 +543,13 @@ def test_the_readmes_pnpm_example_validates_and_renders_into_a_pod(self) -> None
result = arc.arc_capability_pod(values, ["pnpm-store"], catalogue, "/opt/hostedtoolcache", "/opt/sysroot", "/node-cache", "/var/lib/github-runner/node-cache/arc/main", 1001, 1001, "/etc/github-runner/hooks", "job-started")
self.assertEqual(result["errors"], [])

def test_the_example_turns_the_global_virtual_store_on_because_the_text_says_symbolic_links_need_it(self) -> None:
readme = (ROLE / "README.md").read_text()
self.assertIn("global virtual store", readme)
def test_the_example_names_the_store_with_a_variable_pnpm_does_not_read(self) -> None:
# A pod-wide pnpm_config_store_dir reaches pnpm/action-setup's version switch, which corrupts a shared global virtual store (README, Capabilities), so the example names the directory for a workflow to pass to its install step.
env = {item["name"]: item["value"] for item in arc.arc_capability_pod({"template": {"spec": {"containers": [{"name": "runner"}]}}}, ["pnpm-store"], self.example()["github_runner_arc_capabilities"], "/a", "/b", "/node-cache", "/var/lib/x/node-cache/a/b", 1001, 1001, "/h", "c")["values"]["template"]["spec"]["containers"][0]["env"]}
self.assertEqual(env["pnpm_config_enable_global_virtual_store"], "true")
self.assertEqual(env["pnpm_config_store_dir"], "/node-cache/pnpm")
self.assertEqual(env["NODE_CACHE_PNPM_STORE_DIR"], "/node-cache/pnpm")
self.assertEqual([name for name in env if name.lower().startswith(("pnpm_config_", "npm_config_"))], [])
readme = (ROLE / "README.md").read_text()
self.assertIn('pnpm_config_store_dir="$NODE_CACHE_PNPM_STORE_DIR" pnpm install', readme)


class PathConsistencyTest(unittest.TestCase):
Expand Down
Loading