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
99/// The three Deployments an upgrade rolls. Names/containers verified against
100/// `manifests/base/*-deployment.yaml`; default images against `publish.yml`.
101pub const COMPONENTS: [Component; 3] = [
102 Component {
103 name: "control-plane",
104 deployment: "polychrome-control-plane",
105 container: "polychrome",
106 image_env: "POLYCHROME_UPGRADE_IMAGE_CONTROL_PLANE",
107 default_image: "ghcr.io/officialunofficial/polychrome",
108 },
109 Component {
110 name: "slack",
111 deployment: "polychrome-slack",
112 container: "polychrome-slack",
113 image_env: "POLYCHROME_UPGRADE_IMAGE_SLACK",
114 default_image: "ghcr.io/officialunofficial/polychrome-slack",
115 },
116 Component {
117 name: "telegram",
118 deployment: "polychrome-telegram",
119 container: "polychrome-telegram",
120 image_env: "POLYCHROME_UPGRADE_IMAGE_TELEGRAM",
121 default_image: "ghcr.io/officialunofficial/polychrome-telegram",
122 },
123];
124
125/// A resolved image reference — a registry host plus a repository, carrying no
126/// tag. The tag is derived per upgrade from the target version.
127#[derive(Debug, Clone, PartialEq, Eq)]
128pub struct ImageRef {
129 /// Registry host (e.g. `ghcr.io` or `us-east4-docker.pkg.dev`).
130 pub registry: String,
131 /// Repository path within the registry (e.g.
132 /// `officialunofficial/polychrome` or
133 /// `official-unofficial/docker/polychrome-control-plane`).
134 pub repository: String,
135}
136
137/// Split a full image reference string into its registry host and repository.
138///
139/// Applies the standard Docker heuristic: the segment before the first `/` is
140/// the registry host **iff** it contains a `.` or a `:` (a hostname or
141/// `host:port`); otherwise there is no host and the whole string is a
142/// repository under the default registry (`ghcr.io`).
143///
144/// # Examples
145/// - `us-east4-docker.pkg.dev/official-unofficial/docker/polychrome-control-plane`
146/// → registry `us-east4-docker.pkg.dev`, repo
147/// `official-unofficial/docker/polychrome-control-plane`.
148/// - `officialunofficial/polychrome` → registry `ghcr.io`, repo
149/// `officialunofficial/polychrome`.
150#[must_use]
151pub fn parse_image_ref(full: &str) -> ImageRef {
152 match full.split_once('/') {
153 Some((host, rest)) if host.contains('.') || host.contains(':') => ImageRef {
154 registry: host.to_owned(),
155 repository: rest.to_owned(),
156 },
157 _ => ImageRef {
158 registry: DEFAULT_REGISTRY.to_owned(),
159 repository: full.to_owned(),
160 },
161 }
162}
163
164/// Resolve a component's image reference from the environment, falling back to
165/// its public GHCR default.
166///
167/// The environment lookup is injected (rather than read from the process) so
168/// this stays a pure function — unit-testable without touching global state.
169/// Production callers pass `|k| std::env::var(k).ok()`.
170#[must_use]
171pub fn image_ref<F>(component: &Component, env_lookup: F) -> ImageRef
172where
173 F: Fn(&str) -> Option<String>,
174{
175 let raw = env_lookup(component.image_env).unwrap_or_else(|| component.default_image.to_owned());
176 parse_image_ref(&raw)
177}
178
179/// Normalize a `--version` / release tag into the image tag published.
180///
181/// `publish.yml` tags release manifests `{{version}}` (no leading `v`), so a
182/// release `v0.4.0` (or `--version v0.4.0`) maps to the image tag `0.4.0`. The
183/// tag is validated as semver so a typo can't pin the cluster to a
184/// non-existent / floating tag.
185///
186/// # Errors
187/// Returns [`GhcrError::Version`] if the stripped tag is not valid semver.
188pub fn image_tag_for_version(version: &str) -> Result<String, GhcrError> {
189 let tag = version.trim().trim_start_matches('v');
190 semver::Version::parse(tag).map_err(|_| GhcrError::Version(version.to_owned()))?;
191 Ok(tag.to_owned())
192}
193
194/// Build the digest-pinned image reference from a resolved [`ImageRef`] and a
195/// content digest (`{registry}/{repository}@{digest}`).
196#[must_use]
197pub fn pinned_reference(image: &ImageRef, digest: &str) -> String {
198 format!("{}/{}@{}", image.registry, image.repository, digest)
199}
200
201/// How to authenticate a digest-resolution request against a registry host.
202///
203/// Selected purely from the host by [`auth_for`]; the effectful token fetch is
204/// isolated behind each variant in [`resolve_digest`].
205#[derive(Debug, Clone, Copy, PartialEq, Eq)]
206pub enum RegistryAuth {
207 /// The OCI anonymous pull-token flow (public GHCR): `GET /token?scope=…`.
208 AnonymousToken,
209 /// A GCP OAuth access token, for Google Artifact Registry (`*.pkg.dev`) and
210 /// Container Registry (`*.gcr.io`).
211 GcpMetadata,
212}
213
214/// Pick the registry-auth strategy for a host.
215///
216/// Google Artifact Registry (`*.pkg.dev`) and Container Registry (`gcr.io` /
217/// `*.gcr.io`) require a GCP OAuth token; every other host (notably public
218/// GHCR) uses the anonymous pull-token flow.
219#[must_use]
220pub fn auth_for(registry: &str) -> RegistryAuth {
221 if registry.ends_with(".pkg.dev") || registry == "gcr.io" || registry.ends_with(".gcr.io") {
222 RegistryAuth::GcpMetadata
223 } else {
224 RegistryAuth::AnonymousToken
225 }
226}
227
228/// Build the shared HTTP client used for registry round-trips (bounded connect
229/// and request timeouts, a `polychrome/<version>` user agent).
230///
231/// # Errors
232/// Returns [`GhcrError::Http`] if the client can't be constructed.
233pub fn http_client() -> Result<reqwest::Client, GhcrError> {
234 Ok(reqwest::Client::builder()
235 .timeout(REQUEST_TIMEOUT)
236 .connect_timeout(CONNECT_TIMEOUT)
237 .user_agent(concat!("polychrome/", env!("CARGO_PKG_VERSION")))
238 .build()?)
239}
240
241/// Resolve the immutable manifest digest for `repository:tag` against `registry`.
242///
243/// The auth strategy is chosen from the host by [`auth_for`]: a public GHCR
244/// repository authenticates with an anonymous bearer token from the registry
245/// `token` endpoint, while a Google Artifact Registry / GCR host authenticates
246/// with a GCP OAuth access token.
247///
248/// The `GET` reads the `Docker-Content-Digest` response header — the registry
249/// computes it over the manifest bytes, so it is the canonical content address.
250///
251/// # Errors
252/// Returns [`GhcrError`] if the bearer token can't be obtained, the manifest
253/// request fails, or the response carries no digest header.
254pub async fn resolve_digest(
255 client: &reqwest::Client,
256 registry: &str,
257 repository: &str,
258 tag: &str,
259) -> Result<String, GhcrError> {
260 let token = match auth_for(registry) {
261 RegistryAuth::AnonymousToken => anonymous_pull_token(client, registry, repository).await?,
262 RegistryAuth::GcpMetadata => gcp_access_token(client, registry).await?,
263 };
264 let url = format!("https://{registry}/v2/{repository}/manifests/{tag}");
265 let resp = client
266 .get(&url)
267 .bearer_auth(&token)
268 // Accept both the OCI image index and the Docker manifest-list media
269 // types so multi-arch published images resolve to their index digest.
270 .header(
271 reqwest::header::ACCEPT,
272 "application/vnd.oci.image.index.v1+json, \
273 application/vnd.docker.distribution.manifest.list.v2+json, \
274 application/vnd.oci.image.manifest.v1+json, \
275 application/vnd.docker.distribution.manifest.v2+json",
276 )
277 .send()
278 .await?
279 .error_for_status()?;
280 let digest = resp
281 .headers()
282 .get("docker-content-digest")
283 .and_then(|v| v.to_str().ok())
284 .ok_or_else(|| GhcrError::Malformed {
285 repo: repository.to_owned(),
286 what: "digest header",
287 })?
288 .to_owned();
289 Ok(digest)
290}
291
292/// Fetch an anonymous pull token for a public repository (the OCI distribution
293/// flow — GHCR).
294async fn anonymous_pull_token(
295 client: &reqwest::Client,
296 registry: &str,
297 repository: &str,
298) -> Result<String, GhcrError> {
299 let url =
300 format!("https://{registry}/token?service={registry}&scope=repository:{repository}:pull");
301 let body: serde_json::Value = client
302 .get(&url)
303 .send()
304 .await?
305 .error_for_status()?
306 .json()
307 .await?;
308 // GHCR returns the bearer under `token`; some registries use `access_token`.
309 body["token"]
310 .as_str()
311 .or_else(|| body["access_token"].as_str())
312 .map(str::to_owned)
313 .ok_or_else(|| GhcrError::Malformed {
314 repo: repository.to_owned(),
315 what: "pull token",
316 })
317}
318
319/// Fetch a GCP OAuth access token for an Artifact Registry / GCR host.
320///
321/// In-cluster (with Workload Identity) this reads the token off the GCE
322/// metadata server. For local use, point at Application Default Credentials
323/// instead — export a token from `gcloud auth print-access-token` into the
324/// environment ahead of the process, or run against a metadata proxy; the
325/// resolver only needs a valid bearer for the registry host.
326async fn gcp_access_token(client: &reqwest::Client, registry: &str) -> Result<String, GhcrError> {
327 const METADATA_TOKEN_URL: &str = "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token";
328 let body: serde_json::Value = client
329 .get(METADATA_TOKEN_URL)
330 .header("Metadata-Flavor", "Google")
331 .send()
332 .await
333 .map_err(|e| GhcrError::GcpToken {
334 registry: registry.to_owned(),
335 reason: format!("metadata request failed: {e}"),
336 })?
337 .error_for_status()
338 .map_err(|e| GhcrError::GcpToken {
339 registry: registry.to_owned(),
340 reason: format!("metadata server returned an error: {e}"),
341 })?
342 .json()
343 .await
344 .map_err(|e| GhcrError::GcpToken {
345 registry: registry.to_owned(),
346 reason: format!("metadata response was not JSON: {e}"),
347 })?;
348 body["access_token"]
349 .as_str()
350 .map(str::to_owned)
351 .ok_or_else(|| GhcrError::GcpToken {
352 registry: registry.to_owned(),
353 reason: "metadata response carried no `access_token`".to_owned(),
354 })
355}
356
357#[cfg(test)]
358mod tests {
359 #![allow(clippy::pedantic, clippy::nursery)]
360
361 use super::*;
362
363 fn component(name: &str) -> &'static Component {
364 COMPONENTS.iter().find(|c| c.name == name).unwrap()
365 }
366
367 #[test]
368 fn image_tag_strips_v_and_validates() {
369 assert_eq!(image_tag_for_version("v0.4.0").unwrap(), "0.4.0");
370 assert_eq!(image_tag_for_version("0.4.0").unwrap(), "0.4.0");
371 assert_eq!(image_tag_for_version(" v1.2.3 ").unwrap(), "1.2.3");
372 }
373
374 #[test]
375 fn image_tag_rejects_non_semver() {
376 assert!(image_tag_for_version("latest").is_err());
377 assert!(image_tag_for_version("1.2").is_err());
378 assert!(image_tag_for_version("v").is_err());
379 }
380
381 #[test]
382 fn parse_splits_gar_host_from_repository() {
383 let r = parse_image_ref(
384 "us-east4-docker.pkg.dev/official-unofficial/docker/polychrome-control-plane",
385 );
386 assert_eq!(r.registry, "us-east4-docker.pkg.dev");
387 assert_eq!(
388 r.repository,
389 "official-unofficial/docker/polychrome-control-plane"
390 );
391 }
392
393 #[test]
394 fn parse_defaults_hostless_ref_to_ghcr() {
395 let r = parse_image_ref("officialunofficial/polychrome");
396 assert_eq!(r.registry, "ghcr.io");
397 assert_eq!(r.repository, "officialunofficial/polychrome");
398 }
399
400 #[test]
401 fn parse_treats_host_port_as_registry() {
402 let r = parse_image_ref("localhost:5000/team/app");
403 assert_eq!(r.registry, "localhost:5000");
404 assert_eq!(r.repository, "team/app");
405 }
406
407 #[test]
408 fn image_ref_defaults_to_ghcr_with_empty_env() {
409 let cp = image_ref(component("control-plane"), |_| None);
410 assert_eq!(cp.registry, "ghcr.io");
411 assert_eq!(cp.repository, "officialunofficial/polychrome");
412
413 let slack = image_ref(component("slack"), |_| None);
414 assert_eq!(
415 pinned_reference(&slack, "sha256:x"),
416 "ghcr.io/officialunofficial/polychrome-slack@sha256:x"
417 );
418 }
419
420 #[test]
421 fn image_ref_honors_gar_override() {
422 let env = |k: &str| match k {
423 "POLYCHROME_UPGRADE_IMAGE_CONTROL_PLANE" => Some(
424 "us-east4-docker.pkg.dev/official-unofficial/docker/polychrome-control-plane"
425 .to_owned(),
426 ),
427 _ => None,
428 };
429 let cp = image_ref(component("control-plane"), env);
430 assert_eq!(cp.registry, "us-east4-docker.pkg.dev");
431 assert_eq!(
432 cp.repository,
433 "official-unofficial/docker/polychrome-control-plane"
434 );
435 // An unset component still falls back to its GHCR default.
436 let slack = image_ref(component("slack"), env);
437 assert_eq!(slack.registry, "ghcr.io");
438 }
439
440 #[test]
441 fn pinned_reference_format_for_ghcr_and_gar() {
442 let ghcr = ImageRef {
443 registry: "ghcr.io".to_owned(),
444 repository: "officialunofficial/polychrome".to_owned(),
445 };
446 assert_eq!(
447 pinned_reference(&ghcr, "sha256:abc"),
448 "ghcr.io/officialunofficial/polychrome@sha256:abc"
449 );
450 let gar = ImageRef {
451 registry: "us-east4-docker.pkg.dev".to_owned(),
452 repository: "official-unofficial/docker/polychrome-control-plane".to_owned(),
453 };
454 assert_eq!(
455 pinned_reference(&gar, "sha256:def"),
456 "us-east4-docker.pkg.dev/official-unofficial/docker/polychrome-control-plane@sha256:def"
457 );
458 }
459
460 #[test]
461 fn auth_for_selects_by_host() {
462 assert_eq!(auth_for("ghcr.io"), RegistryAuth::AnonymousToken);
463 assert_eq!(
464 auth_for("us-east4-docker.pkg.dev"),
465 RegistryAuth::GcpMetadata
466 );
467 assert_eq!(auth_for("us.gcr.io"), RegistryAuth::GcpMetadata);
468 assert_eq!(auth_for("gcr.io"), RegistryAuth::GcpMetadata);
469 assert_eq!(auth_for("docker.io"), RegistryAuth::AnonymousToken);
470 }
471
472 #[test]
473 fn components_carry_stable_names() {
474 let names: Vec<&str> = COMPONENTS.iter().map(|c| c.name).collect();
475 assert_eq!(names, vec!["control-plane", "slack", "telegram"]);
476 }
477}