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