Skip to main content

polyc_runtime/
ghcr.rs

1//! Shared OCI-registry helpers: the published-component roster plus immutable
2//! manifest-digest resolution, registry-agnostic.
3//!
4//! The cluster-upgrade path lives in two binaries — the operator CLI's
5//! executor (`polychrome upgrade --cluster`) rolls the images, and the control
6//! plane's mailbox originator captures the digests when it opens the approval
7//! ask. Both must agree on *which* images are published, *where* they live, and
8//! *how* a version's tag resolves to an immutable `sha256:…` content address, so
9//! that logic lives here once rather than duplicated per binary.
10//!
11//! Where the images live is configuration, not a constant. The open-source
12//! default is the public GHCR repositories; a private deployment overrides each
13//! component's full image reference (registry host + repository) through the
14//! `POLYCHROME_UPGRADE_IMAGE_*` environment variables — for example a Google
15//! Artifact Registry path like
16//! `us-east4-docker.pkg.dev/<project>/docker/polychrome-control-plane`. The
17//! reference carries no tag; the tag is derived from the target version.
18//!
19//! Resolution follows the OCI distribution flow: obtain a bearer token for the
20//! registry, `GET` the manifest, and read the registry-computed
21//! `Docker-Content-Digest` header — the canonical content address pinned onto
22//! the cluster. We never float a tag onto a running cluster. Which credential
23//! the token comes from depends on the registry host: a public GHCR repository
24//! uses the anonymous pull-token endpoint, while Google Artifact Registry / GCR
25//! uses a GCP OAuth access token.
26
27use std::time::Duration;
28
29/// Registry host assumed when a configured image reference names no host (the
30/// standard Docker heuristic: a leading path segment without a `.` or `:` is a
31/// repository namespace, not a registry).
32const DEFAULT_REGISTRY: &str = "ghcr.io";
33
34/// Bounds the registry TCP connect so a dead host fails fast.
35const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
36
37/// Overall per-request budget for a registry round-trip.
38const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
39
40/// A digest-resolution failure.
41///
42/// A library error (`thiserror`) so callers on `anyhow` can add context and
43/// callers on `thiserror` can wrap it.
44#[derive(Debug, thiserror::Error)]
45pub enum GhcrError {
46    /// The target version did not parse as semver (so it can't map to a
47    /// published image tag).
48    #[error("target version `{0}` is not valid semver")]
49    Version(String),
50    /// The HTTP client could not be built or a request failed in transport.
51    #[error("registry request failed: {0}")]
52    Http(#[from] reqwest::Error),
53    /// The registry accepted the request but returned no usable body/header
54    /// (no token, or no digest header on the manifest response).
55    #[error("registry response for {repo} had no {what}")]
56    Malformed {
57        /// The image repository the request was for.
58        repo: String,
59        /// What was missing (`pull token` / `digest header`).
60        what: &'static str,
61    },
62    /// A GCP access token could not be obtained for a `*.pkg.dev` / `*.gcr.io`
63    /// registry, so the digest cannot be resolved. Fail closed rather than fall
64    /// back to an anonymous request that would 401.
65    #[error("could not obtain a GCP access token for registry `{registry}`: {reason}")]
66    GcpToken {
67        /// The registry host the token was needed for.
68        registry: String,
69        /// Why the token could not be obtained.
70        reason: String,
71    },
72}
73
74/// One published component the cluster upgrade rolls.
75///
76/// Carries a short stable `name` (also the key an operator decision's signed
77/// digest set is keyed on and the `--digest <name>=…` CLI arg uses), the
78/// `Deployment` resource name, the `container` name inside the pod spec, the
79/// environment variable that overrides its image reference, and the GHCR
80/// default reference used when that variable is unset.
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub struct Component {
83    /// Short stable identifier (`control-plane` / `slack` / `telegram`). The key
84    /// in a signed decision's digest set and the `--digest` CLI arg.
85    pub name: &'static str,
86    /// `metadata.name` of the `Deployment`.
87    pub deployment: &'static str,
88    /// `spec.template.spec.containers[].name` the image lives on.
89    pub container: &'static str,
90    /// Environment variable that, when set, overrides this component's full
91    /// image reference (registry host + repository, no tag).
92    pub image_env: &'static str,
93    /// Default image reference (public GHCR) used when [`Component::image_env`]
94    /// is unset. May name a host or be a bare `owner/repo` (defaults to
95    /// `ghcr.io`).
96    pub default_image: &'static str,
97}
98
99impl Component {
100    /// The bare GHCR image name this component actually publishes under —
101    /// [`Component::default_image`]'s final path segment (e.g. `polychrome`,
102    /// `polychrome-slack`, `polychrome-telegram`).
103    ///
104    /// This, not [`Component::name`], is the key
105    /// `.github/workflows/publish.yml`'s `channel` job and
106    /// `scripts/make_channel.sh` write `release-images.yaml` entries under
107    /// (`crate::release_manifest::ManifestImage::name`) — the manifest is
108    /// keyed by what was actually published, not by the CLI-facing
109    /// identifier. [`Component::name`] remains the short stable id used for
110    /// the `--digest <name>=…` CLI arg and a signed decision's digest set.
111    #[must_use]
112    pub fn ghcr_basename(&self) -> &str {
113        self.default_image
114            .rsplit('/')
115            .next()
116            .unwrap_or(self.default_image)
117    }
118}
119
120/// The Deployments an upgrade rolls.
121///
122/// Names/containers verified against `manifests/base/*-deployment.yaml`,
123/// `manifests/components/edges/trigger/deployment.yaml`, and
124/// `manifests/components/scaffold/deployment.yaml`; default images against
125/// `publish.yml`.
126///
127/// `scaffold`'s `default_image` names the GHCR basename it *would* publish
128/// under if it ever did, purely so `Component::ghcr_basename` stays
129/// well-formed — `.github/workflows/publish.yml` deliberately does NOT
130/// publish it (`cloudbuild.yaml`: "scaffold is a GAR-only provisioning
131/// connector"), so a hand-run upgrade with no `POLYCHROME_UPGRADE_IMAGE_SCAFFOLD`
132/// override correctly fails loud (`ReleaseManifestError::MissingComponent`)
133/// rather than silently resolving a public image that doesn't exist — the
134/// same no-legacy, no-silent-fallback rule every other digest source already
135/// follows. Every real deployment (including prod) sets the override.
136///
137/// `trigger` (#1325 investigation) — unlike scaffold, it IS published to GHCR
138/// (`publish.yml`'s edge matrix) — was missing from this list entirely, so a
139/// hand-run upgrade could never roll it past its `REPLACE_AT_BUILD_TIME`
140/// placeholder image; a fresh `polychrome install` left that Deployment
141/// permanently stuck at `InvalidImageName` with no sanctioned way to fix it.
142/// Reproduced live bringing up a second GKE cluster with the gke overlay's
143/// full edge set composed.
144pub const COMPONENTS: [Component; 7] = [
145    Component {
146        name: "control-plane",
147        deployment: "polychrome-control-plane",
148        container: "polychrome",
149        image_env: "POLYCHROME_UPGRADE_IMAGE_CONTROL_PLANE",
150        default_image: "ghcr.io/officialunofficial/polychrome",
151    },
152    Component {
153        name: "state",
154        deployment: "polychrome-state",
155        container: "polychrome-state",
156        image_env: "POLYCHROME_UPGRADE_IMAGE_STATE",
157        default_image: "ghcr.io/officialunofficial/polychrome-state",
158    },
159    Component {
160        name: "harness",
161        deployment: "polychrome-harness",
162        container: "polychrome-harness",
163        image_env: "POLYCHROME_UPGRADE_IMAGE_HARNESS",
164        default_image: "ghcr.io/officialunofficial/polychrome-harness",
165    },
166    Component {
167        name: "slack",
168        deployment: "polychrome-slack",
169        container: "polychrome-slack",
170        image_env: "POLYCHROME_UPGRADE_IMAGE_SLACK",
171        default_image: "ghcr.io/officialunofficial/polychrome-slack",
172    },
173    Component {
174        name: "telegram",
175        deployment: "polychrome-telegram",
176        container: "polychrome-telegram",
177        image_env: "POLYCHROME_UPGRADE_IMAGE_TELEGRAM",
178        default_image: "ghcr.io/officialunofficial/polychrome-telegram",
179    },
180    Component {
181        name: "trigger",
182        deployment: "polychrome-trigger",
183        container: "polychrome-trigger",
184        image_env: "POLYCHROME_UPGRADE_IMAGE_TRIGGER",
185        default_image: "ghcr.io/officialunofficial/polychrome-trigger",
186    },
187    Component {
188        name: "scaffold",
189        deployment: "polychrome-scaffold",
190        container: "scaffold",
191        image_env: "POLYCHROME_UPGRADE_IMAGE_SCAFFOLD",
192        default_image: "ghcr.io/officialunofficial/polychrome-scaffold",
193    },
194];
195
196/// A resolved image reference — a registry host plus a repository, carrying no
197/// tag. The tag is derived per upgrade from the target version.
198#[derive(Debug, Clone, PartialEq, Eq)]
199pub struct ImageRef {
200    /// Registry host (e.g. `ghcr.io` or `us-east4-docker.pkg.dev`).
201    pub registry: String,
202    /// Repository path within the registry (e.g.
203    /// `officialunofficial/polychrome` or
204    /// `official-unofficial/docker/polychrome-control-plane`).
205    pub repository: String,
206}
207
208/// Split a full image reference string into its registry host and repository.
209///
210/// Applies the standard Docker heuristic: the segment before the first `/` is
211/// the registry host **iff** it contains a `.` or a `:` (a hostname or
212/// `host:port`); otherwise there is no host and the whole string is a
213/// repository under the default registry (`ghcr.io`).
214///
215/// # Examples
216/// - `us-east4-docker.pkg.dev/official-unofficial/docker/polychrome-control-plane`
217///   → registry `us-east4-docker.pkg.dev`, repo
218///   `official-unofficial/docker/polychrome-control-plane`.
219/// - `officialunofficial/polychrome` → registry `ghcr.io`, repo
220///   `officialunofficial/polychrome`.
221#[must_use]
222pub fn parse_image_ref(full: &str) -> ImageRef {
223    match full.split_once('/') {
224        Some((host, rest)) if host.contains('.') || host.contains(':') => ImageRef {
225            registry: host.to_owned(),
226            repository: rest.to_owned(),
227        },
228        _ => ImageRef {
229            registry: DEFAULT_REGISTRY.to_owned(),
230            repository: full.to_owned(),
231        },
232    }
233}
234
235/// Resolve a component's image reference from the environment, falling back to
236/// its public GHCR default.
237///
238/// The environment lookup is injected (rather than read from the process) so
239/// this stays a pure function — unit-testable without touching global state.
240/// Production callers pass `|k| std::env::var(k).ok()`.
241#[must_use]
242pub fn image_ref<F>(component: &Component, env_lookup: F) -> ImageRef
243where
244    F: Fn(&str) -> Option<String>,
245{
246    let raw = env_lookup(component.image_env).unwrap_or_else(|| component.default_image.to_owned());
247    parse_image_ref(&raw)
248}
249
250/// Normalize a `--version` / release tag into the image tag published.
251///
252/// `publish.yml` tags release manifests `{{version}}` (no leading `v`), so a
253/// release `v0.4.0` (or `--version v0.4.0`) maps to the image tag `0.4.0`. The
254/// tag is validated as semver so a typo can't pin the cluster to a
255/// non-existent / floating tag.
256///
257/// # Errors
258/// Returns [`GhcrError::Version`] if the stripped tag is not valid semver.
259pub fn image_tag_for_version(version: &str) -> Result<String, GhcrError> {
260    let tag = version.trim().trim_start_matches('v');
261    semver::Version::parse(tag).map_err(|_| GhcrError::Version(version.to_owned()))?;
262    Ok(tag.to_owned())
263}
264
265/// Build the digest-pinned image reference from a resolved [`ImageRef`] and a
266/// content digest (`{registry}/{repository}@{digest}`).
267#[must_use]
268pub fn pinned_reference(image: &ImageRef, digest: &str) -> String {
269    format!("{}/{}@{}", image.registry, image.repository, digest)
270}
271
272/// How to authenticate a digest-resolution request against a registry host.
273///
274/// Selected purely from the host by [`auth_for`]; the effectful token fetch is
275/// isolated behind each variant in [`resolve_digest`].
276#[derive(Debug, Clone, Copy, PartialEq, Eq)]
277pub enum RegistryAuth {
278    /// The OCI anonymous pull-token flow (public GHCR): `GET /token?scope=…`.
279    AnonymousToken,
280    /// A GCP OAuth access token, for Google Artifact Registry (`*.pkg.dev`) and
281    /// Container Registry (`*.gcr.io`).
282    GcpMetadata,
283}
284
285/// Pick the registry-auth strategy for a host.
286///
287/// Google Artifact Registry (`*.pkg.dev`) and Container Registry (`gcr.io` /
288/// `*.gcr.io`) require a GCP OAuth token; every other host (notably public
289/// GHCR) uses the anonymous pull-token flow.
290#[must_use]
291pub fn auth_for(registry: &str) -> RegistryAuth {
292    if registry.ends_with(".pkg.dev") || registry == "gcr.io" || registry.ends_with(".gcr.io") {
293        RegistryAuth::GcpMetadata
294    } else {
295        RegistryAuth::AnonymousToken
296    }
297}
298
299/// Build the shared HTTP client used for registry round-trips (bounded connect
300/// and request timeouts, a `polychrome/<version>` user agent).
301///
302/// # Errors
303/// Returns [`GhcrError::Http`] if the client can't be constructed.
304pub fn http_client() -> Result<reqwest::Client, GhcrError> {
305    Ok(reqwest::Client::builder()
306        .timeout(REQUEST_TIMEOUT)
307        .connect_timeout(CONNECT_TIMEOUT)
308        .user_agent(concat!("polychrome/", env!("CARGO_PKG_VERSION")))
309        .build()?)
310}
311
312/// Resolve the immutable manifest digest for `repository:tag` against `registry`.
313///
314/// The auth strategy is chosen from the host by [`auth_for`]: a public GHCR
315/// repository authenticates with an anonymous bearer token from the registry
316/// `token` endpoint, while a Google Artifact Registry / GCR host authenticates
317/// with a GCP OAuth access token.
318///
319/// The `GET` reads the `Docker-Content-Digest` response header — the registry
320/// computes it over the manifest bytes, so it is the canonical content address.
321///
322/// # Errors
323/// Returns [`GhcrError`] if the bearer token can't be obtained, the manifest
324/// request fails, or the response carries no digest header.
325pub async fn resolve_digest(
326    client: &reqwest::Client,
327    registry: &str,
328    repository: &str,
329    tag: &str,
330) -> Result<String, GhcrError> {
331    let token = match auth_for(registry) {
332        RegistryAuth::AnonymousToken => anonymous_pull_token(client, registry, repository).await?,
333        RegistryAuth::GcpMetadata => gcp_access_token(client, registry).await?,
334    };
335    let url = format!("https://{registry}/v2/{repository}/manifests/{tag}");
336    let resp = client
337        .get(&url)
338        .bearer_auth(&token)
339        // Accept both the OCI image index and the Docker manifest-list media
340        // types so multi-arch published images resolve to their index digest.
341        .header(
342            reqwest::header::ACCEPT,
343            "application/vnd.oci.image.index.v1+json, \
344             application/vnd.docker.distribution.manifest.list.v2+json, \
345             application/vnd.oci.image.manifest.v1+json, \
346             application/vnd.docker.distribution.manifest.v2+json",
347        )
348        .send()
349        .await?
350        .error_for_status()?;
351    let digest = resp
352        .headers()
353        .get("docker-content-digest")
354        .and_then(|v| v.to_str().ok())
355        .ok_or_else(|| GhcrError::Malformed {
356            repo: repository.to_owned(),
357            what: "digest header",
358        })?
359        .to_owned();
360    Ok(digest)
361}
362
363/// Fetch an anonymous pull token for a public repository (the OCI distribution
364/// flow — GHCR).
365async fn anonymous_pull_token(
366    client: &reqwest::Client,
367    registry: &str,
368    repository: &str,
369) -> Result<String, GhcrError> {
370    let url =
371        format!("https://{registry}/token?service={registry}&scope=repository:{repository}:pull");
372    let body: serde_json::Value = client
373        .get(&url)
374        .send()
375        .await?
376        .error_for_status()?
377        .json()
378        .await?;
379    // GHCR returns the bearer under `token`; some registries use `access_token`.
380    body["token"]
381        .as_str()
382        .or_else(|| body["access_token"].as_str())
383        .map(str::to_owned)
384        .ok_or_else(|| GhcrError::Malformed {
385            repo: repository.to_owned(),
386            what: "pull token",
387        })
388}
389
390/// Fetch a GCP OAuth access token for an Artifact Registry / GCR host.
391///
392/// In-cluster (with Workload Identity) this reads the token off the GCE
393/// metadata server. For local use, point at Application Default Credentials
394/// instead — export a token from `gcloud auth print-access-token` into the
395/// environment ahead of the process, or run against a metadata proxy; the
396/// resolver only needs a valid bearer for the registry host.
397async fn gcp_access_token(client: &reqwest::Client, registry: &str) -> Result<String, GhcrError> {
398    const METADATA_TOKEN_URL: &str = "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token";
399    let body: serde_json::Value = client
400        .get(METADATA_TOKEN_URL)
401        .header("Metadata-Flavor", "Google")
402        .send()
403        .await
404        .map_err(|e| GhcrError::GcpToken {
405            registry: registry.to_owned(),
406            reason: format!("metadata request failed: {e}"),
407        })?
408        .error_for_status()
409        .map_err(|e| GhcrError::GcpToken {
410            registry: registry.to_owned(),
411            reason: format!("metadata server returned an error: {e}"),
412        })?
413        .json()
414        .await
415        .map_err(|e| GhcrError::GcpToken {
416            registry: registry.to_owned(),
417            reason: format!("metadata response was not JSON: {e}"),
418        })?;
419    body["access_token"]
420        .as_str()
421        .map(str::to_owned)
422        .ok_or_else(|| GhcrError::GcpToken {
423            registry: registry.to_owned(),
424            reason: "metadata response carried no `access_token`".to_owned(),
425        })
426}
427
428#[cfg(test)]
429mod tests {
430    #![allow(clippy::pedantic, clippy::nursery)]
431
432    use super::*;
433
434    fn component(name: &str) -> &'static Component {
435        COMPONENTS.iter().find(|c| c.name == name).unwrap()
436    }
437
438    #[test]
439    fn image_tag_strips_v_and_validates() {
440        assert_eq!(image_tag_for_version("v0.4.0").unwrap(), "0.4.0");
441        assert_eq!(image_tag_for_version("0.4.0").unwrap(), "0.4.0");
442        assert_eq!(image_tag_for_version("  v1.2.3 ").unwrap(), "1.2.3");
443    }
444
445    #[test]
446    fn image_tag_rejects_non_semver() {
447        assert!(image_tag_for_version("latest").is_err());
448        assert!(image_tag_for_version("1.2").is_err());
449        assert!(image_tag_for_version("v").is_err());
450    }
451
452    #[test]
453    fn parse_splits_gar_host_from_repository() {
454        let r = parse_image_ref(
455            "us-east4-docker.pkg.dev/official-unofficial/docker/polychrome-control-plane",
456        );
457        assert_eq!(r.registry, "us-east4-docker.pkg.dev");
458        assert_eq!(
459            r.repository,
460            "official-unofficial/docker/polychrome-control-plane"
461        );
462    }
463
464    #[test]
465    fn parse_defaults_hostless_ref_to_ghcr() {
466        let r = parse_image_ref("officialunofficial/polychrome");
467        assert_eq!(r.registry, "ghcr.io");
468        assert_eq!(r.repository, "officialunofficial/polychrome");
469    }
470
471    #[test]
472    fn parse_treats_host_port_as_registry() {
473        let r = parse_image_ref("localhost:5000/team/app");
474        assert_eq!(r.registry, "localhost:5000");
475        assert_eq!(r.repository, "team/app");
476    }
477
478    #[test]
479    fn image_ref_defaults_to_ghcr_with_empty_env() {
480        let cp = image_ref(component("control-plane"), |_| None);
481        assert_eq!(cp.registry, "ghcr.io");
482        assert_eq!(cp.repository, "officialunofficial/polychrome");
483
484        let slack = image_ref(component("slack"), |_| None);
485        assert_eq!(
486            pinned_reference(&slack, "sha256:x"),
487            "ghcr.io/officialunofficial/polychrome-slack@sha256:x"
488        );
489    }
490
491    #[test]
492    fn image_ref_honors_gar_override() {
493        let env = |k: &str| match k {
494            "POLYCHROME_UPGRADE_IMAGE_CONTROL_PLANE" => Some(
495                "us-east4-docker.pkg.dev/official-unofficial/docker/polychrome-control-plane"
496                    .to_owned(),
497            ),
498            _ => None,
499        };
500        let cp = image_ref(component("control-plane"), env);
501        assert_eq!(cp.registry, "us-east4-docker.pkg.dev");
502        assert_eq!(
503            cp.repository,
504            "official-unofficial/docker/polychrome-control-plane"
505        );
506        // An unset component still falls back to its GHCR default.
507        let slack = image_ref(component("slack"), env);
508        assert_eq!(slack.registry, "ghcr.io");
509    }
510
511    #[test]
512    fn pinned_reference_format_for_ghcr_and_gar() {
513        let ghcr = ImageRef {
514            registry: "ghcr.io".to_owned(),
515            repository: "officialunofficial/polychrome".to_owned(),
516        };
517        assert_eq!(
518            pinned_reference(&ghcr, "sha256:abc"),
519            "ghcr.io/officialunofficial/polychrome@sha256:abc"
520        );
521        let gar = ImageRef {
522            registry: "us-east4-docker.pkg.dev".to_owned(),
523            repository: "official-unofficial/docker/polychrome-control-plane".to_owned(),
524        };
525        assert_eq!(
526            pinned_reference(&gar, "sha256:def"),
527            "us-east4-docker.pkg.dev/official-unofficial/docker/polychrome-control-plane@sha256:def"
528        );
529    }
530
531    #[test]
532    fn auth_for_selects_by_host() {
533        assert_eq!(auth_for("ghcr.io"), RegistryAuth::AnonymousToken);
534        assert_eq!(
535            auth_for("us-east4-docker.pkg.dev"),
536            RegistryAuth::GcpMetadata
537        );
538        assert_eq!(auth_for("us.gcr.io"), RegistryAuth::GcpMetadata);
539        assert_eq!(auth_for("gcr.io"), RegistryAuth::GcpMetadata);
540        assert_eq!(auth_for("docker.io"), RegistryAuth::AnonymousToken);
541    }
542
543    #[test]
544    fn components_carry_stable_names() {
545        let names: Vec<&str> = COMPONENTS.iter().map(|c| c.name).collect();
546        assert_eq!(
547            names,
548            vec![
549                "control-plane",
550                "state",
551                "harness",
552                "slack",
553                "telegram",
554                "trigger",
555                "scaffold"
556            ]
557        );
558    }
559
560    /// The GHCR basename is deliberately NOT the same string as
561    /// `Component::name` — it's the published image name
562    /// (`.github/workflows/publish.yml`'s matrix), which is what
563    /// `release_manifest::resolve_release_digests` must key its manifest
564    /// lookup on. A regression test for the naming mismatch that let the
565    /// release manifest reader and the publish workflow disagree on the key.
566    #[test]
567    fn ghcr_basename_matches_the_published_image_name_not_component_name() {
568        let basenames: Vec<&str> = COMPONENTS.iter().map(Component::ghcr_basename).collect();
569        assert_eq!(
570            basenames,
571            vec![
572                "polychrome",
573                "polychrome-state",
574                "polychrome-harness",
575                "polychrome-slack",
576                "polychrome-telegram",
577                "polychrome-trigger",
578                "polychrome-scaffold",
579            ]
580        );
581    }
582}