Skip to main content

greentic_deployer/cli/
bundle_fetch.rs

1//! Fetch a `.gtbundle` from a remote `bundle_source_uri` to a local file, so
2//! `op env apply` can stage a revision from a registry reference without a local
3//! `bundle_path` on the apply host.
4//!
5//! v1 resolves `oci://` references only. Other schemes (`repo://`, `store://`,
6//! `http(s)://`, local paths) return [`OpError::NotYetImplemented`] — author
7//! those with a local `bundle_path` until the richer scheme handling in
8//! `greentic-start`'s `bundle_ref::fetch_bundle_to_file` (which this
9//! intentionally mirrors, restricted to the apply case) is consolidated into a
10//! crate both repos can share. That consolidation is a tracked follow-up; this
11//! module deliberately duplicates the minimal `oci://` path rather than taking
12//! a dependency on `greentic-start` (which depends on this crate).
13//!
14//! The fetch is HTTPS-only and performs NO integrity check of its own: an
15//! `oci://…@sha256:…` resolved digest is the *manifest* digest, not the
16//! `.gtbundle` byte digest. The caller (`env_apply`) hashes the returned file
17//! and gates it against the manifest's `bundle_digest`, and the K8s worker
18//! re-verifies at boot (`materialize_revision_from_bundle`). Pulling over HTTPS
19//! to the real registry is the apply-host analog of the worker's digest-gated
20//! boot pull.
21
22use std::path::PathBuf;
23
24use greentic_distributor_client::oci_packs::DefaultRegistryClient;
25use greentic_distributor_client::{OciPackFetcher, PackFetchOptions};
26
27use super::OpError;
28
29/// Scheme prefix this module resolves. Everything else routes to `bundle_path`.
30const OCI_SCHEME: &str = "oci://";
31
32/// Fetch the `.gtbundle` archive at `reference` to a local cache file and return
33/// its path.
34///
35/// The returned path lives in the distributor's content-addressed cache; the
36/// caller copies it into the env's revision directory during staging, so there
37/// is nothing to clean up here. The returned file is the raw archive — the
38/// caller owns the digest gate against the manifest's `bundle_digest`.
39///
40/// Only `oci://host/repo:tag` / `oci://host/repo@sha256:…` references are
41/// supported; any other scheme is [`OpError::NotYetImplemented`].
42pub fn fetch_bundle_uri_to_local(reference: &str) -> Result<PathBuf, OpError> {
43    let trimmed = reference.trim();
44    if trimmed.is_empty() {
45        return Err(OpError::InvalidArgument(
46            "bundle_source_uri cannot be empty".to_string(),
47        ));
48    }
49    let oci_ref = trimmed.strip_prefix(OCI_SCHEME).ok_or_else(|| {
50        OpError::NotYetImplemented(format!(
51            "apply can only fetch `oci://` bundle_source_uri references; `{trimmed}` is \
52             unsupported — declare a local `bundle_path` instead"
53        ))
54    })?;
55    if oci_ref.is_empty() {
56        return Err(OpError::InvalidArgument(format!(
57            "bundle_source_uri `{trimmed}` has no registry reference after `{OCI_SCHEME}`"
58        )));
59    }
60    fetch_oci_to_cache(oci_ref)
61}
62
63/// Run the async OCI pull on a dedicated thread so it never nests inside the
64/// command dispatcher's current-thread Tokio runtime: `main.rs` drives every
65/// `op` verb under `block_on`, and building a second runtime to `block_on` from
66/// within the first panics ("Cannot start a runtime from within a runtime"). A
67/// scoped thread builds its own runtime off the ambient one. The fetch is a
68/// one-shot network pull, so the extra thread is negligible.
69fn fetch_oci_to_cache(oci_ref: &str) -> Result<PathBuf, OpError> {
70    std::thread::scope(|scope| {
71        let handle = scope.spawn(|| -> Result<PathBuf, OpError> {
72            let rt = tokio::runtime::Builder::new_current_thread()
73                .enable_all()
74                .build()
75                .map_err(|source| OpError::Fetch(format!("build oci fetch runtime: {source}")))?;
76            // `allow_tags` so `:tag` refs resolve (the deploy demo uses `:v1`);
77            // `offline: false` so a cold cache pulls from the registry.
78            let opts = PackFetchOptions {
79                allow_tags: true,
80                offline: false,
81                ..PackFetchOptions::default()
82            };
83            let fetcher: OciPackFetcher<DefaultRegistryClient> = OciPackFetcher::new(opts);
84            rt.block_on(fetcher.fetch_pack_to_cache(oci_ref))
85                .map(|resolved| resolved.path)
86                .map_err(|source| OpError::Fetch(format!("oci pull `{oci_ref}`: {source}")))
87        });
88        match handle.join() {
89            Ok(result) => result,
90            Err(_) => Err(OpError::Fetch("oci fetch thread panicked".to_string())),
91        }
92    })
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98
99    #[test]
100    fn empty_reference_is_invalid_argument() {
101        let err = fetch_bundle_uri_to_local("   ").unwrap_err();
102        assert_eq!(err.kind(), "invalid-argument");
103    }
104
105    #[test]
106    fn oci_scheme_with_no_target_is_invalid_argument() {
107        let err = fetch_bundle_uri_to_local("oci://").unwrap_err();
108        assert_eq!(err.kind(), "invalid-argument");
109    }
110
111    #[test]
112    fn local_path_reference_is_not_yet_implemented() {
113        let err = fetch_bundle_uri_to_local("/srv/bundles/webchat-bot.gtbundle").unwrap_err();
114        assert_eq!(err.kind(), "not-yet-implemented");
115    }
116
117    #[test]
118    fn repo_scheme_is_not_yet_implemented() {
119        let err = fetch_bundle_uri_to_local("repo://acme/webchat-bot:v1").unwrap_err();
120        assert_eq!(err.kind(), "not-yet-implemented");
121    }
122
123    #[test]
124    fn store_scheme_is_not_yet_implemented() {
125        let err = fetch_bundle_uri_to_local("store://acme/webchat-bot:v1").unwrap_err();
126        assert_eq!(err.kind(), "not-yet-implemented");
127    }
128
129    #[test]
130    fn https_scheme_is_not_yet_implemented() {
131        let err =
132            fetch_bundle_uri_to_local("https://example.com/webchat-bot.gtbundle").unwrap_err();
133        assert_eq!(err.kind(), "not-yet-implemented");
134    }
135
136    /// Live OCI pull against the public demo bundle. Ignored by default (needs
137    /// network + ghcr reachability); run with `--ignored` locally or in a
138    /// network-enabled CI job. Verifies the `oci://` happy path end-to-end:
139    /// thread-hop runtime, tag resolution, and a readable archive on disk.
140    #[test]
141    #[ignore = "network: pulls the public demo bundle from ghcr"]
142    fn live_oci_pull_returns_a_readable_archive() {
143        let path = fetch_bundle_uri_to_local(
144            "oci://ghcr.io/greenticai/greentic-demo-bundles/webchat-bot:v1",
145        )
146        .expect("pull public demo bundle");
147        assert!(
148            path.is_file(),
149            "fetched bundle should be a file: {}",
150            path.display()
151        );
152        let len = std::fs::metadata(&path).expect("stat fetched bundle").len();
153        assert!(len > 0, "fetched bundle should be non-empty");
154    }
155}