Skip to main content

greentic_deployer/env_packs/
slot.rs

1//! Env-pack handler abstraction (`A9`).
2//!
3//! An [`EnvPackHandler`] is the native counterpart of a [`PackDescriptor`] bound
4//! to a [`CapabilitySlot`]: it declares which slot and which descriptor versions
5//! it serves. Phase A handlers are **metadata-only** — the slot-specific
6//! behavior (deploy, read a secret, emit a span) lands in Phase D. The trait is
7//! the seam Phase D plug-ins implement when they register through
8//! [`EnvPackRegistry::register`](super::registry::EnvPackRegistry::register).
9//!
10//! The built-in set ([`BUILTIN_HANDLERS`]) covers the five default `local`
11//! bindings. The registry locates a handler by its version-independent
12//! [`descriptor_path`](EnvPackHandler::descriptor_path), then validates the
13//! requested `@<semver>` against the handler's
14//! [`supported_versions`](EnvPackHandler::supported_versions) requirement, so a
15//! binding can't pin a version the native handler does not implement.
16
17use greentic_deploy_spec::CapabilitySlot;
18use semver::VersionReq;
19
20use crate::tool_check::ToolCheck;
21
22/// Native handler bound to one [`CapabilitySlot`].
23///
24/// Object-safe so the registry can hold `Box<dyn EnvPackHandler>` and Phase D
25/// plug-ins can register their own implementations.
26pub trait EnvPackHandler: std::fmt::Debug + Send + Sync {
27    /// The capability slot this handler serves.
28    fn slot(&self) -> CapabilitySlot;
29
30    /// Version-independent descriptor path (e.g. `greentic.deployer.local-process`).
31    /// This is the registry key — `kind@<semver>` resolves on the path alone.
32    fn descriptor_path(&self) -> &str;
33
34    /// Descriptor versions this native handler implements.
35    ///
36    /// A binding's `kind@<semver>` is rejected when its version does not match
37    /// this requirement, so an operator cannot pin a version the binary does
38    /// not support and discover the skew only at deploy time. Returning a
39    /// [`VersionReq`] (not a string) makes an unparseable requirement
40    /// unrepresentable.
41    fn supported_versions(&self) -> VersionReq;
42
43    /// Preflight checks the handler needs to pass before it can do real work.
44    /// Returns the list of [`ToolCheck`] results — handlers compose the
45    /// primitives + named-tool catalog in [`crate::tool_check`].
46    ///
47    /// The default returns an empty vec, which is the honest answer for the
48    /// in-process built-ins (`local-process`, `dev-store`, `stdout`,
49    /// `in-memory`): they need no external tools. Handlers that shell out
50    /// (K8s, cloud) override this to compose the named-tool checks.
51    fn preflight(&self) -> Vec<ToolCheck> {
52        Vec::new()
53    }
54
55    /// Deployer-only: credentials contract (C1).
56    ///
57    /// Returns `Some(_)` only on `Deployer`-slot handlers that ship a
58    /// [`DeployerCredentials`](crate::credentials::DeployerCredentials)
59    /// impl (C2 reference: local-process; Phase D adds AWS / K8s / etc.).
60    /// `None` for non-deployer handlers AND for deployer handlers that
61    /// haven't registered a credentials contract yet — the latter surface
62    /// as `HandlerNotRegistered` through the requirements/bootstrap CLI
63    /// flows, so deployer authors are nudged to ship one rather than
64    /// silently producing pass-through credential operations.
65    fn deployer_credentials(&self) -> Option<&dyn crate::credentials::DeployerCredentials> {
66        None
67    }
68
69    /// Env-pack wizard QASpec (`C6`).
70    ///
71    /// Returns the YAML source of the env-pack's `wizard.qaspec.yaml`,
72    /// the spec the operator's wizard driver runs to collect a binding's
73    /// `answers_ref` payload. Raw YAML (not a typed `qa_spec::FormSpec`)
74    /// so the env-pack TRAIT carries no qa-spec types (the crate itself
75    /// depends on qa-spec since the env-manifest authoring form,
76    /// `cli::env_manifest::manifest_form_spec`); the operator already
77    /// depends on `qa-spec` and parses at the call site. Default `None`: metadata-
78    /// only built-ins ship no wizard. Env-packs that ship a QASpec
79    /// override with `Some(include_str!("wizard.qaspec.yaml"))`.
80    ///
81    /// **C6 ↔ Phase D scope split**: C6 attaches the spec. It does NOT
82    /// plumb the captured answers into
83    /// [`DeployerCredentials::validate`](crate::credentials::DeployerCredentials::validate)
84    /// or any other handler op — there is no reader for
85    /// [`EnvPackBinding.answers_ref`](greentic_deploy_spec::EnvPackBinding)
86    /// today. Probes that look like they should scope to a wizard
87    /// answer (`region` on an AWS handler, `cluster` on a K8s handler)
88    /// run against the ambient environment. See the AWS-ECS YAML's
89    /// "Trust-boundary disclosure" header for the reference authoring
90    /// pattern when this gap matters for an env-pack.
91    fn wizard_qaspec_yaml(&self) -> Option<&'static str> {
92        None
93    }
94
95    /// Deployer-only: the [`Deployer`](super::deployer::Deployer) trait
96    /// impl this handler ships.
97    ///
98    /// Returns `Some(_)` only on `Deployer`-slot handlers that have a
99    /// real impl wired up (the local-process reference and Phase D
100    /// K8s / AWS / GCP / Azure). Returns `None` for every non-deployer
101    /// slot AND for deployer handlers that haven't been migrated yet —
102    /// consumers MUST treat the `None` arm as "no provider-side
103    /// behavior available, the CLI's storage-layer path remains in
104    /// charge" rather than as an error.
105    ///
106    /// Pairs with [`deployer_credentials`](Self::deployer_credentials):
107    /// every deployer env-pack ships a credentials contract (enforced
108    /// at registration); shipping a [`Deployer`](super::deployer::Deployer)
109    /// impl is the second half of the Phase D pluggability contract
110    /// and is verified end-to-end by
111    /// [`run_conformance`](super::deployer::run_conformance).
112    fn as_deployer(&self) -> Option<&dyn super::deployer::Deployer> {
113        None
114    }
115
116    /// Deployer-only: declarative manifest rendering seam (plan §6 step 10).
117    ///
118    /// Returns `Some(_)` only on `Deployer`-slot handlers whose desired
119    /// state is expressible as declarative manifest documents (K8s) —
120    /// this is what `gtc op env render` dispatches through. The default
121    /// `None` is NOT an error state: imperative deployers (local-process,
122    /// AWS-ECS) have no manifest form, and the render verb reports the
123    /// kind as non-renderable.
124    fn as_manifest_renderer(&self) -> Option<&dyn super::render::ManifestRenderer> {
125        None
126    }
127}
128
129/// A built-in, metadata-only handler. One value per default `local` binding.
130#[derive(Debug, Clone, Copy)]
131pub struct BuiltinHandler {
132    slot: CapabilitySlot,
133    descriptor_path: &'static str,
134    /// Validity is guarded by a unit test, so the parse in `supported_versions`
135    /// is infallible.
136    version_req: &'static str,
137}
138
139impl EnvPackHandler for BuiltinHandler {
140    fn slot(&self) -> CapabilitySlot {
141        self.slot
142    }
143    fn descriptor_path(&self) -> &str {
144        self.descriptor_path
145    }
146    fn supported_versions(&self) -> VersionReq {
147        self.version_req
148            .parse()
149            .expect("built-in version-req is valid (guarded by tests)")
150    }
151    // No `deployer_credentials` override — every entry in `BUILTIN_HANDLERS`
152    // serves a non-Deployer slot. The Deployer slot is served by the
153    // dedicated `LocalProcessDeployerHandler` registered separately in
154    // `EnvPackRegistry::with_builtins`. The registry's deployer-credentials
155    // gate ensures any future Deployer-slot handler ships a real contract.
156}
157
158/// Metadata-only built-in handlers backing the default `local` environment.
159///
160/// Four entries (Secrets / Telemetry / Sessions / State) — the Deployer
161/// slot's handler ships behavior (C2's
162/// [`LocalProcessDeployerHandler`](super::local_process::LocalProcessDeployerHandler))
163/// and is registered separately in
164/// [`EnvPackRegistry::with_builtins`](super::registry::EnvPackRegistry::with_builtins).
165///
166/// The combined `(slot, descriptor_path)` set across `BUILTIN_HANDLERS` plus
167/// the local-process deployer mirrors [`crate::defaults::LOCAL_DEFAULT_BINDINGS`]
168/// (a test asserts they stay in lock-step).
169pub const BUILTIN_HANDLERS: &[BuiltinHandler] = &[
170    BuiltinHandler {
171        slot: CapabilitySlot::Secrets,
172        descriptor_path: "greentic.secrets.dev-store",
173        version_req: "^0.1.0",
174    },
175    BuiltinHandler {
176        slot: CapabilitySlot::Telemetry,
177        descriptor_path: "greentic.telemetry.stdout",
178        version_req: "^0.1.0",
179    },
180    BuiltinHandler {
181        slot: CapabilitySlot::Sessions,
182        descriptor_path: "greentic.sessions.in-memory",
183        version_req: "^0.1.0",
184    },
185    BuiltinHandler {
186        slot: CapabilitySlot::State,
187        descriptor_path: "greentic.state.in-memory",
188        version_req: "^0.1.0",
189    },
190];
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195    use greentic_deploy_spec::PackDescriptor;
196
197    #[test]
198    fn builtin_table_matches_default_bindings() {
199        // The registry's built-in set across BUILTIN_HANDLERS and the
200        // C2 LocalProcessDeployerHandler must stay in lock-step with the
201        // bootstrap `local` bindings: same slots, same descriptor paths.
202        use crate::env_packs::local_process::LocalProcessDeployerHandler;
203        let mut handlers: Vec<(CapabilitySlot, String)> = BUILTIN_HANDLERS
204            .iter()
205            .map(|h| (h.slot, h.descriptor_path.to_string()))
206            .collect();
207        // Compose the C2 deployer handler — its slot+path participates in
208        // the same lock-step contract even though it lives outside
209        // BUILTIN_HANDLERS (it ships behavior, not just metadata).
210        let lpdh = LocalProcessDeployerHandler::default();
211        handlers.push((lpdh.slot(), lpdh.descriptor_path().to_string()));
212        handlers.sort_by(|a, b| a.1.cmp(&b.1));
213
214        let mut defaults: Vec<(CapabilitySlot, String)> = crate::defaults::LOCAL_DEFAULT_BINDINGS
215            .iter()
216            .map(|(slot, descriptor)| {
217                let path = PackDescriptor::try_new(*descriptor)
218                    .expect("default descriptor parses")
219                    .path()
220                    .to_string();
221                (*slot, path)
222            })
223            .collect();
224        defaults.sort_by(|a, b| a.1.cmp(&b.1));
225
226        assert_eq!(handlers, defaults);
227    }
228
229    /// C6: the trait's `wizard_qaspec_yaml` default returns `None` so the
230    /// four metadata-only built-ins (Secrets/Telemetry/Sessions/State) ship
231    /// no wizard. Asserting on the trait method itself (not the handlers')
232    /// catches an override slipping into `BuiltinHandler` by accident.
233    #[test]
234    fn builtin_metadata_handlers_ship_no_wizard_qaspec() {
235        for h in BUILTIN_HANDLERS {
236            assert!(
237                h.wizard_qaspec_yaml().is_none(),
238                "metadata-only built-in `{}` must not ship a wizard QASpec — \
239                 override `wizard_qaspec_yaml` only on handlers that surface \
240                 operator-tunable knobs",
241                h.descriptor_path(),
242            );
243        }
244    }
245
246    #[test]
247    fn builtin_version_reqs_accept_their_default_binding_version() {
248        // Every built-in's VersionReq must parse and must accept the version
249        // the bootstrap `local` binding pins — otherwise `doctor` would flag
250        // the defaults we ship as version-skewed. Composes BUILTIN_HANDLERS
251        // and the C2 LocalProcessDeployerHandler the same way
252        // `with_builtins()` registers them.
253        use crate::env_packs::local_process::LocalProcessDeployerHandler;
254        let lpdh = LocalProcessDeployerHandler::default();
255        for (slot, descriptor) in crate::defaults::LOCAL_DEFAULT_BINDINGS {
256            let pd = PackDescriptor::try_new(*descriptor).expect("default descriptor parses");
257            let req = BUILTIN_HANDLERS
258                .iter()
259                .find(|h| h.descriptor_path() == pd.path())
260                .map(|h| h.supported_versions())
261                .or_else(|| {
262                    if lpdh.descriptor_path() == pd.path() {
263                        Some(lpdh.supported_versions())
264                    } else {
265                        None
266                    }
267                })
268                .unwrap_or_else(|| panic!("no built-in handler for {slot}"));
269            assert!(
270                req.matches(&pd.version().0),
271                "{descriptor}: req {req} rejects its own default version"
272            );
273        }
274    }
275}