Skip to main content

yah_qed/
registries.rs

1//! Per-camp registry config (R381-T6).
2//!
3//! When a `build-image` step has `push = true`, qed needs to know whether the
4//! tag's registry is writable from this camp. The config lives at
5//! `<qed_dir>/registries.toml` (typically `.yah/qed/registries.toml`) and
6//! lists named entries with a per-host writable flag — opt-in by default.
7//!
8//! Parse-time validation rejects a `push = true` step whose tag points at a
9//! registry that isn't in the writable allowlist, so misconfigurations surface
10//! when the pipeline loads rather than after the build has already burned
11//! minutes pulling the BuildKit image.
12//!
13//! Shape:
14//!
15//! ```toml
16//! # .yah/qed/registries.toml
17//! [[registries]]
18//! name     = "ghcr"
19//! host     = "ghcr.io"
20//! writable = true
21//!
22//! [[registries]]
23//! name     = "local"
24//! host     = "localhost:5000"
25//! writable = true
26//! ```
27//!
28//! Credentials are not stored here — BuildKit (local: `docker buildx`; remote:
29//! buildctl in a BuildKit workload) resolves them via the engine's standard
30//! credentials store (`~/.docker/config.json` or equivalent). Sigstore signing
31//! for the bundled catalog images stays in the release pipeline (R381-T9).
32
33use serde::Deserialize;
34use std::fs;
35use std::path::Path;
36use thiserror::Error;
37
38/// Per-camp registry allowlist controlling which hosts a `push = true`
39/// build-image step is allowed to write to.
40#[derive(Debug, Clone, Default, Deserialize)]
41pub struct RegistryConfig {
42    #[serde(default)]
43    pub registries: Vec<RegistryEntry>,
44}
45
46/// One named registry entry from `registries.toml`.
47///
48/// `writable` is the opt-in: by default a host is read-only (catalog images
49/// are *pulled* without any per-camp config). Setting `writable = true`
50/// declares "this camp may push images to this host."
51#[derive(Debug, Clone, Deserialize)]
52pub struct RegistryEntry {
53    pub name: String,
54    pub host: String,
55    #[serde(default)]
56    pub writable: bool,
57}
58
59/// Errors surfaced while loading `registries.toml`. Missing file is **not**
60/// an error — it yields a [`RegistryConfig::default()`].
61#[derive(Debug, Error)]
62pub enum RegistryConfigError {
63    #[error("IO error reading {path}: {source}")]
64    Io {
65        path: String,
66        source: std::io::Error,
67    },
68    #[error("TOML parse error in {path}: {source}")]
69    Parse {
70        path: String,
71        source: toml::de::Error,
72    },
73}
74
75impl RegistryConfig {
76    /// Load `<qed_dir>/registries.toml` if present; return an empty config
77    /// otherwise. The empty config rejects every `push = true` build-image
78    /// step at parse time, which is the right v1 default — operators opt in
79    /// by writing the file.
80    pub fn load(qed_dir: &Path) -> Result<Self, RegistryConfigError> {
81        let path = qed_dir.join("registries.toml");
82        if !path.exists() {
83            return Ok(Self::default());
84        }
85        let src = fs::read_to_string(&path).map_err(|e| RegistryConfigError::Io {
86            path: path.display().to_string(),
87            source: e,
88        })?;
89        toml::from_str(&src).map_err(|e| RegistryConfigError::Parse {
90            path: path.display().to_string(),
91            source: e,
92        })
93    }
94
95    /// Returns true when `host` is declared writable in this config.
96    pub fn is_writable(&self, host: &str) -> bool {
97        self.registries.iter().any(|r| r.writable && r.host == host)
98    }
99}
100
101/// Extract the registry hostname from a docker tag.
102///
103/// Docker's tag-parsing rule (paraphrased): the first segment is a registry
104/// host iff it contains `.` or `:`, or is exactly `localhost`. Otherwise it's
105/// a Docker Hub repository segment and the implicit host is `docker.io`.
106///
107/// Examples:
108/// - `ghcr.io/yah-ai/yah-rust:dev`     → `ghcr.io`
109/// - `localhost:5000/yah-rust:dev`   → `localhost:5000`
110/// - `localhost/yah-rust:dev`        → `localhost`
111/// - `nginx`                         → `docker.io`
112/// - `library/nginx:latest`          → `docker.io`
113/// - `yah-rust:dev`                  → `docker.io`
114pub fn extract_registry_host(tag: &str) -> &str {
115    let first_segment = tag.split('/').next().unwrap_or(tag);
116    // Strip any trailing :port-or-tag from the first segment when checking
117    // for a host marker (a tag like `nginx:latest` has `:` in its first
118    // segment but the segment is the repo, not a host).
119    let has_dot = first_segment.contains('.');
120    let is_localhost = first_segment == "localhost" || first_segment.starts_with("localhost:");
121    let has_port_separator = first_segment.contains(':') && tag.contains('/');
122
123    if has_dot || is_localhost || has_port_separator {
124        first_segment
125    } else {
126        "docker.io"
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use tempfile::TempDir;
134
135    #[test]
136    fn extract_registry_host_recognises_ghcr() {
137        assert_eq!(
138            extract_registry_host("ghcr.io/yah-ai/yah-rust:dev"),
139            "ghcr.io"
140        );
141    }
142
143    #[test]
144    fn extract_registry_host_recognises_localhost_with_port() {
145        assert_eq!(
146            extract_registry_host("localhost:5000/yah-rust:dev"),
147            "localhost:5000",
148        );
149    }
150
151    #[test]
152    fn extract_registry_host_recognises_bare_localhost() {
153        assert_eq!(extract_registry_host("localhost/yah-rust:dev"), "localhost");
154    }
155
156    #[test]
157    fn extract_registry_host_falls_back_to_docker_io_for_bare_name() {
158        assert_eq!(extract_registry_host("nginx"), "docker.io");
159        assert_eq!(extract_registry_host("nginx:latest"), "docker.io");
160        assert_eq!(extract_registry_host("library/nginx:latest"), "docker.io");
161        assert_eq!(extract_registry_host("yah-rust:dev"), "docker.io");
162    }
163
164    #[test]
165    fn registry_config_load_missing_file_is_empty() {
166        let dir = TempDir::new().unwrap();
167        let cfg = RegistryConfig::load(dir.path()).unwrap();
168        assert!(cfg.registries.is_empty());
169        assert!(!cfg.is_writable("ghcr.io"));
170    }
171
172    #[test]
173    fn registry_config_load_parses_writable_entries() {
174        let dir = TempDir::new().unwrap();
175        std::fs::write(
176            dir.path().join("registries.toml"),
177            r#"
178[[registries]]
179name     = "ghcr"
180host     = "ghcr.io"
181writable = true
182
183[[registries]]
184name = "docker-hub"
185host = "docker.io"
186# writable omitted → defaults to false
187"#,
188        )
189        .unwrap();
190        let cfg = RegistryConfig::load(dir.path()).unwrap();
191        assert_eq!(cfg.registries.len(), 2);
192        assert!(cfg.is_writable("ghcr.io"));
193        assert!(
194            !cfg.is_writable("docker.io"),
195            "writable defaults to false; docker.io entry must not be considered writable"
196        );
197        assert!(!cfg.is_writable("nowhere.example"));
198    }
199
200    #[test]
201    fn registry_config_load_parses_bad_toml_as_error() {
202        let dir = TempDir::new().unwrap();
203        std::fs::write(dir.path().join("registries.toml"), "not = valid toml [[").unwrap();
204        let err = RegistryConfig::load(dir.path()).unwrap_err();
205        match err {
206            RegistryConfigError::Parse { .. } => {}
207            other => panic!("expected Parse, got {other:?}"),
208        }
209    }
210}