Skip to content

Add a gs:// binary cache store with a credential helper - #84

Open
joshheinrichs-shopify wants to merge 7 commits into
mainfrom
gcs-store
Open

joshheinrichs-shopify wants to merge 7 commits into
mainfrom
gcs-store

Conversation

@joshheinrichs-shopify

@joshheinrichs-shopify joshheinrichs-shopify commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

TL;DR

Adds a gs:// binary cache store, and a way to authenticate to it on developer machines without a long-lived secret: gcs-credential-helper, a Git credential helper that the daemon runs as the connecting user. Authentication problems fail the build with the reason instead of looking like cache misses.

Approach

  • Credentials come from one of two sources, with no fall-through: the helper if configured, otherwise upstream's Application Default Credentials (a credentials file, or the GCE metadata server).
  • Helper: Git's credential protocol (<command> get, request on stdin, credential + password_expiry_utc out), the way Go's GOAUTH=git calls one. Asked once per store, reused until a minute before expiry; no expiry means it runs per request, with a warning. Runs as the daemon's client (uid/gid, HOME, minimal PATH), so per-user token brokers work and the daemon holds no credential.
  • Failures are errors: broken credentials never degrade to anonymous requests; a 403 from GCS is an error, not "not found" (GCS answers 404 for a missing object); a store with broken credentials still opens and fails on first use instead of being dropped with a warning; the helper's stderr is in the error. --fallback remains the opt-in to build locally anyway.
  • Left out on purpose: multipart uploads (the 5 GiB limit is S3's, and no measured gain), a credentials-file setting (GOOGLE_APPLICATION_CREDENTIALS already does that).

The store builds on #82. Design notes and open follow-ups: plans/gcs-binary-cache/intent.md. Details are in the commit messages.

Config

substituters = gs://my-bucket
trusted-public-keys = my-bucket-1:...
gcs-credential-helper = /usr/local/bin/my-credential-helper

Verified

  • Unit tests against an in-memory fake GCS; NixOS VM test against fake-gcs-server (upload and signed read-back, missing bucket is an error, daemon substitutes for an unprivileged user with the helper running as that user).
  • Against a real private bucket, reads only: the store and fetchurl gs:// through a helper; 980 narinfo lookups in 4 s with one helper run; a daemon with broken, absent and working credentials gives the helper's message, GCS's message, and a substitution respectively.
  • Not verified: writes to real GCS, and the root daemon on macOS (the VM test is Linux).

The constructor template was only defined in filetransfer.cc, so only
the instantiations used there existed, and nothing else could construct
a FileTransferError. Other implementations of the FileTransfer interface
(such as the in-memory fake GCS server used by the GCS store's unit
tests) need to report NotFound and friends, so move the definition into
the header.
Allow a `gs://` URL to carry an `endpoint` query parameter, either an
absolute `http(s)://` URL or a `host[:port]` authority, which replaces
`https://storage.googleapis.com` when building the API URLs. This is
what lets the GCS store be pointed at an emulator such as
fake-gcs-server. Anything else (another scheme, a URL without a host)
is rejected rather than read as `host:port`.
Add a `gs://bucket[/prefix]` store type, so that a Google Cloud Storage
bucket can be used directly as a substituter or as the target of
`nix copy`, authenticated with OAuth2 bearer tokens from Application
Default Credentials (service account key, gcloud user credentials or
the GCE metadata server). Reads fall back to anonymous access when no
credentials are found. Writes to Google's endpoint always require
credentials; writes to another `endpoint` (an emulator) use them only
when there are any.

Objects are read through the XML API, path-style, at
`https://storage.googleapis.com/<bucket>/<key>`, and written with the
JSON API's simple upload. The object's name is the key its download URL
has, so a cache under a prefix uploads under that prefix too, and a
compressed narinfo's encoding goes in the `contentEncoding` query
parameter: as a `Content-Encoding` header it would describe the request
body, and the object would be served back still compressed. (Both
learnt from the emulator test in #82.) GCS imposes no size limit on a
single-request upload below the 5 TiB object maximum, so there is no
multipart machinery here; the 5 GiB limit of the S3 store is S3's.

The store builds these requests itself (via a new virtual
`HttpBinaryCacheStore::makeRequest`) rather than relying on the `gs://`
rewriting in the file transfer layer, so that it can pick the right
token scope per operation and so that the whole store can be tested
against a fake `FileTransfer`. The unit tests drive it against an
in-memory emulation of the two APIs: authentication, prefixes,
compressed uploads, anonymous reads and emulator writes, and a store
path round trip.
Previously any failure to obtain a GCS access token made reads fall back
to anonymous requests. On a private bucket that turns an expired or
revoked `gcloud` login into 403s, which the binary cache code treats as
"not found": the substituter silently looks empty and everything builds
from source. Now only the absence of any configured credentials leads to
anonymous reads (`GcsNoCredentials`); credentials that exist but cannot
be refreshed are an error, with a hint to log in again. In the same
spirit, `GOOGLE_APPLICATION_CREDENTIALS` naming a file that does not
exist is now an error rather than a warning followed by the next
fallback.

The 403-as-404 treatment itself is an S3 workaround (S3 answers 403 for
a missing object when the bucket isn't listable), and it is wrong for
GCS, which answers 404 for a missing object whenever the caller may ask
and 403 only for denied access. `HttpBinaryCacheStore` now asks a
virtual `isMissing()` which the GCS store narrows to 404, so wrong
credentials fail the build with GCS's own explanation instead of being
remembered as a miss in the negative narinfo cache for an hour. Building
locally regardless remains what `--fallback` is for.

There is a second place where the same thing happened: a substituter
that fails to *open* is dropped with a warning, so that an unreachable
cache doesn't stop builds, and the GCS store's `init()` reads
`nix-cache-info`. On a cold narinfo disk cache, wrong credentials
therefore meant a warning and no substituter, i.e. building from source
again, while on a warm cache they meant an error. The GCS store now
opens regardless of a credential error (helper failure, 401, 403) and
lets its first query fail instead, where that stops the build; without
the narinfo disk cache, since nothing is known about the cache to
record.

Doing that exposed a pre-existing crash: `enqueueFileTransfer()` is
`noexcept`, but the `s3://` and `gs://` request setup it performs
(URL parsing, credential lookup) can throw, so e.g.
`builtins.fetchurl "s3://bucket"` terminated the process. Setup failures
are now delivered to the callback like any other transfer error.
Add the `gcs-credential-helper` setting: a Git credential helper that
provides the OAuth2 access token for `gs://` stores. When set, it
replaces the Application Default Credentials lookup.

Nix calls the helper the way Go's `GOAUTH=git` and Cargo do: it runs
`<command> get` with `protocol`, `host`, `path` and the `authtype`
capability on its standard input, and reads attributes back. GCS
authenticates with a bearer token, so the answer must be
`authtype=Bearer` with `credential=<token>` (or `password=<token>` from
a helper without the capability), and `password_expiry_utc` says when it
expires. Git's protocol rather than one of our own, or Bazel's
credential-helper spec, because it carries the same information, has
far more consumers outside its origin, and is what the credential
stores and token brokers on a developer's machine already speak.

A store asks once, for its root URL, and uses the answer for every
request under it; `builtins.fetchurl` of a `gs://` URL asks for that
object. The helper's expiry is the only lifetime policy: an answer is
reused until a minute before it, so a helper handing out hour-long
tokens is run about once an hour per daemon worker rather than once per
request. A helper that reports no expiry is run for every request, with
a one-time warning: a fixed reuse window would be a second policy that
is correct only while it happens to agree with the helper's own, and
nothing would tell anyone when it stopped agreeing. A failed run is
retried once; after that, a failure, a missing credential, an
`authtype` other than `Bearer` or an already expired credential is an
error rather than a fall back to anonymous access.

Per-user credential stores and token brokers only answer processes of
the user they serve, and the Nix daemon runs as root. The daemon forks
one worker per client connection and already knows the client's uid and
gid (that's how `trusted-users` is decided), so the worker now records
them (`daemon::setClient`) and the helper runs *as that user*, with that
user's home directory and, where `/run/user/<uid>` exists, its runtime
directory. The helper then sees an ordinary same-user caller, and the
daemon never holds a long-lived credential. Peer credentials are now
read even when trust is forced, so forced-trust daemons know their
client too; on macOS the peer's gid and pid are now filled in as well
(`LOCAL_PEERCRED` carries the effective gid, `LOCAL_PEERPID` the pid).

`runProgram()` cannot feed a child's standard input (that was removed
on purpose along with its I/O thread), so the helper is started with
`startProcess()` directly: the request is a few dozen bytes, written in
full before the answer is read.
A single-machine NixOS VM test with fake-gcs-server, after #82's, for
the two things the unit tests cannot cover: the real HTTP stack against
an independent implementation of the API (an upload with a compressed
narinfo, read back with the signature verified), and the daemon running
`gcs-credential-helper` as the user it serves. For the latter the
helper is a script that records which user ran it; the daemon is root,
so the assertion that every run was by the unprivileged user is the
test of the run-as-client path.
Why there are two credential modes and no third, why the helper speaks
Git's credential protocol and runs as the daemon's client, why the
helper's expiry is the only lifetime policy, and the three places where
an authentication failure used to look like a cache miss and how each
is closed. Plus what was deliberately left out, what was verified, and
what remains open.
@joshheinrichs-shopify joshheinrichs-shopify changed the title Add gcs auth and credential helper support Add a gs:// binary cache store with a credential helper Oct 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant