Skip to main content

hyperi_rustlib/deployment/
contract_identity.rs

1// Project:   hyperi-rustlib
2// File:      src/deployment/contract_identity.rs
3// Purpose:   Contract Identity Annotation Scheme v1
4// Language:  Rust
5//
6// License:   BUSL-1.1
7// Copyright: (c) 2026 HYPERI PTY LIMITED
8
9//! Contract Identity Annotation Scheme v1.
10//!
11//! Stamps every deployment artefact (OCI image, Helm chart, ArgoCD Application)
12//! with three uniform, greppable identity annotations:
13//!
14//! | Key                                | Meaning                              | Format                            |
15//! |------------------------------------|--------------------------------------|-----------------------------------|
16//! | `io.hyperi.contract.version`       | Contract schema version              | Literal string `v1`               |
17//! | `io.hyperi.contract.source-commit` | Git SHA of the consumer app's HEAD   | 40-char lowercase hex             |
18//! | `io.hyperi.contract.image-ref`     | Intended pull reference for the image | `<reg>/<repo>:<tag>` or `@<digest>` |
19//!
20//! Same key string on every surface. The grep payoff:
21//! `grep -r 'io.hyperi.contract' .` finds every contract-emitted artefact.
22//!
23//! # Pre-push vs post-push image_ref
24//!
25//! At Dockerfile-emit time the image isn't built, so its digest is unknown:
26//! the image label carries the TAG form (`<reg>/<repo>:<tag>`). The push step
27//! re-renders `Chart.yaml` and the ArgoCD `Application` with the DIGEST form
28//! returned by the registry, for digest-pinned reproducibility.
29//!
30//! # Rollout phase
31//!
32//! Phase 1 (this commit): generators accept `Option<&ContractIdentity>` --
33//! `Some` emits the annotations, `None` is silent (backwards-compat for
34//! consumers not yet migrated). Phase 2 makes it required once all six DFE
35//! consumers pass identity. Phase 3 drops the `Option` wrapper.
36
37use std::env;
38use std::process::Command;
39
40/// Annotation key prefix shared across all three keys.
41pub const KEY_PREFIX: &str = "io.hyperi.contract";
42
43/// Schema version literal. Bumps only when the contract format itself
44/// breaks, NOT when the consumer's app version moves.
45pub const VERSION: &str = "v1";
46
47/// Errors from constructing or detecting a `ContractIdentity`.
48#[derive(Debug, thiserror::Error)]
49pub enum IdentityError {
50    /// `source_commit` was not 40-char lowercase hex.
51    #[error("source_commit must be 40-char lowercase hex (got {got:?})")]
52    InvalidSourceCommit {
53        /// The rejected value (echoed so the caller can log it).
54        got: String,
55    },
56
57    /// `image_ref` was empty or lacked an explicit registry host.
58    ///
59    /// Bare names like `nginx:1.25` are rejected per spec -- we never
60    /// allow the implicit `docker.io/library/` prefix. The registry host
61    /// must be present explicitly and look like an FQDN
62    /// (contains `.`), a `host:port`, or the literal `localhost`.
63    #[error(
64        "image_ref must include an explicit registry host (got {got:?}); \
65         no implicit docker.io/library/ prefix allowed"
66    )]
67    InvalidImageRef {
68        /// The rejected value (echoed so the caller can log it).
69        got: String,
70    },
71
72    /// `source_commit` couldn't be auto-detected.
73    #[error(
74        "could not auto-detect source_commit: GITHUB_SHA env var is unset \
75         or invalid, and `git rev-parse HEAD` failed: {reason}"
76    )]
77    DetectFailed {
78        /// What the detection attempt reported.
79        reason: String,
80    },
81}
82
83/// Three-key identity stamped on every deployment artefact.
84///
85/// Construct via [`ContractIdentity::new`] when both inputs are known
86/// up front (typical for the push step that has the registry digest),
87/// or via [`ContractIdentity::detect`] to auto-resolve `source_commit`
88/// from CI env or local git when only `image_ref` is in hand.
89#[derive(Clone, Debug, PartialEq, Eq)]
90pub struct ContractIdentity {
91    source_commit: String,
92    image_ref: String,
93}
94
95impl ContractIdentity {
96    /// Construct from explicit inputs.
97    ///
98    /// `source_commit` must be 40-char lowercase hex (no `sha256:` prefix --
99    /// it's a git SHA, not a content hash). `image_ref` must be non-empty
100    /// and include an explicit registry host (no implicit `docker.io`).
101    ///
102    /// # Errors
103    ///
104    /// Returns [`IdentityError::InvalidSourceCommit`] or
105    /// [`IdentityError::InvalidImageRef`] when validation fails.
106    pub fn new(
107        source_commit: impl Into<String>,
108        image_ref: impl Into<String>,
109    ) -> Result<Self, IdentityError> {
110        let source_commit = source_commit.into();
111        let image_ref = image_ref.into();
112        validate_source_commit(&source_commit)?;
113        validate_image_ref(&image_ref)?;
114        Ok(Self {
115            source_commit,
116            image_ref,
117        })
118    }
119
120    /// Auto-detect `source_commit` from the CI env (`GITHUB_SHA`) or
121    /// `git rev-parse HEAD` and combine with the caller-supplied
122    /// `image_ref`.
123    ///
124    /// `image_ref` always comes from the caller because it derives from
125    /// the contract (`<image_registry>/<app_name>:<tag>`) and the
126    /// detection layer has no business reading the contract.
127    ///
128    /// # Errors
129    ///
130    /// Returns [`IdentityError::DetectFailed`] when neither source works,
131    /// or [`IdentityError::InvalidImageRef`] when the caller-supplied
132    /// ref fails validation.
133    pub fn detect(image_ref: impl Into<String>) -> Result<Self, IdentityError> {
134        let source_commit = detect_source_commit()?;
135        Self::new(source_commit, image_ref)
136    }
137
138    /// Schema version literal (`"v1"` in this build).
139    #[must_use]
140    pub fn version(&self) -> &'static str {
141        VERSION
142    }
143
144    /// 40-char lowercase hex git SHA of the consumer app's HEAD.
145    #[must_use]
146    pub fn source_commit(&self) -> &str {
147        &self.source_commit
148    }
149
150    /// Intended pull reference for the container image.
151    #[must_use]
152    pub fn image_ref(&self) -> &str {
153        &self.image_ref
154    }
155
156    /// Render as three Dockerfile `LABEL` lines, in canonical order
157    /// (version, source-commit, image-ref). No trailing newline; the
158    /// caller is responsible for any separator.
159    #[must_use]
160    pub fn as_dockerfile_labels(&self) -> String {
161        format!(
162            "LABEL {KEY_PREFIX}.version=\"{VERSION}\"\n\
163             LABEL {KEY_PREFIX}.source-commit=\"{c}\"\n\
164             LABEL {KEY_PREFIX}.image-ref=\"{r}\"",
165            c = self.source_commit,
166            r = self.image_ref,
167        )
168    }
169
170    /// Render as three YAML annotation lines, indented by `indent`
171    /// spaces. Suitable for inclusion under `metadata.annotations:`
172    /// (ArgoCD Application) or top-level `annotations:` (Helm
173    /// `Chart.yaml`). No trailing newline.
174    #[must_use]
175    pub fn as_yaml_annotations(&self, indent: usize) -> String {
176        let pad = " ".repeat(indent);
177        format!(
178            "{pad}{KEY_PREFIX}.version: \"{VERSION}\"\n\
179             {pad}{KEY_PREFIX}.source-commit: \"{c}\"\n\
180             {pad}{KEY_PREFIX}.image-ref: \"{r}\"",
181            c = self.source_commit,
182            r = self.image_ref,
183        )
184    }
185}
186
187fn validate_source_commit(s: &str) -> Result<(), IdentityError> {
188    if s.len() != 40 || !s.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) {
189        return Err(IdentityError::InvalidSourceCommit { got: s.to_string() });
190    }
191    Ok(())
192}
193
194fn validate_image_ref(s: &str) -> Result<(), IdentityError> {
195    if s.is_empty() {
196        return Err(IdentityError::InvalidImageRef { got: s.to_string() });
197    }
198    // Must contain '/' separating host from repo path.
199    let Some((host, _rest)) = s.split_once('/') else {
200        return Err(IdentityError::InvalidImageRef { got: s.to_string() });
201    };
202    // Host must look like an FQDN (contains '.'), a host:port (contains
203    // ':'), or the literal 'localhost'. Bare repo segments like 'library'
204    // would mean "default docker.io" -- forbidden per spec.
205    if host == "localhost" || host.contains('.') || host.contains(':') {
206        Ok(())
207    } else {
208        Err(IdentityError::InvalidImageRef { got: s.to_string() })
209    }
210}
211
212fn detect_source_commit() -> Result<String, IdentityError> {
213    // 1. Prefer GITHUB_SHA env var -- standard in GitHub Actions runners.
214    if let Ok(sha) = env::var("GITHUB_SHA") {
215        let sha = sha.trim().to_lowercase();
216        if validate_source_commit(&sha).is_ok() {
217            return Ok(sha);
218        }
219    }
220    // 2. Fall back to `git rev-parse HEAD` from cwd.
221    let output = Command::new("git")
222        .args(["rev-parse", "HEAD"])
223        .output()
224        .map_err(|e| IdentityError::DetectFailed {
225            reason: e.to_string(),
226        })?;
227    if !output.status.success() {
228        return Err(IdentityError::DetectFailed {
229            reason: String::from_utf8_lossy(&output.stderr).trim().to_string(),
230        });
231    }
232    let sha = String::from_utf8_lossy(&output.stdout)
233        .trim()
234        .to_lowercase();
235    validate_source_commit(&sha)?;
236    Ok(sha)
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    const VALID_SHA: &str = "0123456789abcdef0123456789abcdef01234567";
244
245    #[test]
246    fn new_accepts_valid_inputs() {
247        let id = ContractIdentity::new(VALID_SHA, "ghcr.io/hyperi-io/dfe-loader:v2.7.2").unwrap();
248        assert_eq!(id.version(), "v1");
249        assert_eq!(id.source_commit(), VALID_SHA);
250        assert_eq!(id.image_ref(), "ghcr.io/hyperi-io/dfe-loader:v2.7.2");
251    }
252
253    #[test]
254    fn new_rejects_short_source_commit() {
255        let err = ContractIdentity::new("abc123", "ghcr.io/x/y:v1").unwrap_err();
256        assert!(matches!(err, IdentityError::InvalidSourceCommit { .. }));
257    }
258
259    #[test]
260    fn new_rejects_uppercase_source_commit() {
261        let upper = "0123456789ABCDEF0123456789ABCDEF01234567";
262        let err = ContractIdentity::new(upper, "ghcr.io/x/y:v1").unwrap_err();
263        assert!(matches!(err, IdentityError::InvalidSourceCommit { .. }));
264    }
265
266    #[test]
267    fn new_rejects_source_commit_with_sha256_prefix() {
268        let prefixed = format!("sha256:{VALID_SHA}");
269        let err = ContractIdentity::new(prefixed, "ghcr.io/x/y:v1").unwrap_err();
270        assert!(matches!(err, IdentityError::InvalidSourceCommit { .. }));
271    }
272
273    #[test]
274    fn new_rejects_empty_image_ref() {
275        let err = ContractIdentity::new(VALID_SHA, "").unwrap_err();
276        assert!(matches!(err, IdentityError::InvalidImageRef { .. }));
277    }
278
279    #[test]
280    fn new_rejects_bare_docker_hub_shortcut() {
281        // No registry host -- this would mean implicit docker.io/library/.
282        let err = ContractIdentity::new(VALID_SHA, "nginx:1.25").unwrap_err();
283        assert!(matches!(err, IdentityError::InvalidImageRef { .. }));
284    }
285
286    #[test]
287    fn new_rejects_bare_repo_path_without_host() {
288        // "library/nginx" has a '/' but the prefix isn't a hostname.
289        let err = ContractIdentity::new(VALID_SHA, "library/nginx:1.25").unwrap_err();
290        assert!(matches!(err, IdentityError::InvalidImageRef { .. }));
291    }
292
293    #[test]
294    fn new_accepts_localhost_registry() {
295        // The local-registry pattern (kind+registry:2) commonly uses
296        // localhost:5000/<repo>.
297        let id = ContractIdentity::new(VALID_SHA, "localhost:5000/dfe-loader:test").unwrap();
298        assert_eq!(id.image_ref(), "localhost:5000/dfe-loader:test");
299    }
300
301    #[test]
302    fn new_accepts_digest_form() {
303        let digest_ref = format!("ghcr.io/hyperi-io/dfe-loader@sha256:{VALID_SHA}");
304        let id = ContractIdentity::new(VALID_SHA, digest_ref.clone()).unwrap();
305        assert_eq!(id.image_ref(), digest_ref);
306    }
307
308    #[test]
309    fn dockerfile_labels_canonical_order_and_quoting() {
310        let id = ContractIdentity::new(VALID_SHA, "ghcr.io/hyperi-io/dfe-loader:v2.7.2").unwrap();
311        let out = id.as_dockerfile_labels();
312        assert_eq!(
313            out,
314            "LABEL io.hyperi.contract.version=\"v1\"\n\
315             LABEL io.hyperi.contract.source-commit=\"0123456789abcdef0123456789abcdef01234567\"\n\
316             LABEL io.hyperi.contract.image-ref=\"ghcr.io/hyperi-io/dfe-loader:v2.7.2\""
317        );
318    }
319
320    #[test]
321    fn yaml_annotations_canonical_order_and_quoting() {
322        let id = ContractIdentity::new(VALID_SHA, "ghcr.io/hyperi-io/dfe-loader:v2.7.2").unwrap();
323        let out = id.as_yaml_annotations(4);
324        assert_eq!(
325            out,
326            "    io.hyperi.contract.version: \"v1\"\n    \
327             io.hyperi.contract.source-commit: \"0123456789abcdef0123456789abcdef01234567\"\n    \
328             io.hyperi.contract.image-ref: \"ghcr.io/hyperi-io/dfe-loader:v2.7.2\""
329        );
330    }
331
332    #[test]
333    fn yaml_annotations_zero_indent() {
334        let id = ContractIdentity::new(VALID_SHA, "ghcr.io/x/y:v1").unwrap();
335        let out = id.as_yaml_annotations(0);
336        assert!(out.starts_with("io.hyperi.contract.version: \"v1\""));
337    }
338
339    #[test]
340    fn key_prefix_is_grep_target() {
341        // Sanity check the documented grep payoff:
342        //   grep -r 'io.hyperi.contract' .
343        // -- which only works if every output line literally contains
344        // that string.
345        let id = ContractIdentity::new(VALID_SHA, "ghcr.io/x/y:v1").unwrap();
346        let dockerfile = id.as_dockerfile_labels();
347        let yaml = id.as_yaml_annotations(2);
348        assert_eq!(dockerfile.matches(KEY_PREFIX).count(), 3);
349        assert_eq!(yaml.matches(KEY_PREFIX).count(), 3);
350    }
351}