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}