greentic_deployer/env_packs/deployer/trait_def.rs
1//! [`Deployer`] trait + error/outcome types.
2//!
3//! The trait is object-safe via `async_trait`: the env-pack registry
4//! returns `&dyn Deployer` so plug-in handlers compiled outside this
5//! crate (Phase D K8s/AWS) can be resolved by descriptor.
6
7use async_trait::async_trait;
8use greentic_deploy_spec::{DeploymentId, Environment, RevisionId, RuntimeConfig};
9use serde_json::Value;
10use thiserror::Error;
11
12use crate::environment::runtime_config::materialize_runtime_config;
13
14/// Side-effect outcome of [`Deployer::stage_revision`].
15///
16/// Empty for the v1 trait; K8s/AWS impls may extend with provider-
17/// discovered artifacts (uploaded digests, signed manifest refs, …)
18/// once the second concrete deployer lands.
19#[derive(Debug, Clone, Default, PartialEq, Eq)]
20pub struct StageOutcome {}
21
22/// Side-effect outcome of [`Deployer::warm_revision`].
23#[derive(Debug, Clone, Default, PartialEq, Eq)]
24pub struct WarmOutcome {}
25
26/// Side-effect outcome of [`Deployer::drain_revision`].
27#[derive(Debug, Clone, Default, PartialEq, Eq)]
28pub struct DrainOutcome {}
29
30/// Side-effect outcome of [`Deployer::archive_revision`].
31#[derive(Debug, Clone, Default, PartialEq, Eq)]
32pub struct ArchiveOutcome {}
33
34/// Side-effect outcome of [`Deployer::apply_traffic_split`].
35///
36/// The conformance bench asserts both fields so a deployer that
37/// quietly applies the wrong deployment or silently mutates a
38/// sibling deployment's split cannot pass.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct TrafficSplitOutcome {
41 /// The deployment whose split this call enforced. MUST equal the
42 /// `deployment_id` argument to [`Deployer::apply_traffic_split`].
43 pub applied_deployment_id: DeploymentId,
44 /// The exact entries the impl applied. MUST equal
45 /// `env.traffic_splits.iter().find(|s| s.deployment_id == deployment_id).unwrap().entries`.
46 pub applied_entries: Vec<greentic_deploy_spec::TrafficSplitEntry>,
47}
48
49/// Errors a [`Deployer`] impl may return.
50///
51/// Pure-spec rejections (sum != 10000bps, revision not in env, mismatched
52/// deployment) MUST be returned BEFORE any provider call so a failing
53/// precondition is cheap and deterministic. Provider-side failures
54/// (`kubectl apply` errored, ECS API rejected the task-set) surface as
55/// [`DeployerError::Provider`] carrying the underlying message.
56#[derive(Debug, Error)]
57pub enum DeployerError {
58 /// The targeted revision is not present in the env passed in.
59 #[error("revision `{revision_id}` not found in env `{env_id}`")]
60 RevisionNotFound {
61 env_id: greentic_deploy_spec::EnvId,
62 revision_id: RevisionId,
63 },
64
65 /// `apply_traffic_split` was called for a `deployment_id` that has no
66 /// `TrafficSplit` recorded in the env.
67 #[error("no TrafficSplit recorded for deployment `{deployment_id}` in env `{env_id}`")]
68 SplitNotFound {
69 env_id: greentic_deploy_spec::EnvId,
70 deployment_id: DeploymentId,
71 },
72
73 /// The env's `TrafficSplit` for this deployment has weights that do not
74 /// sum to 10000 basis points. The deployer refuses to enforce a split
75 /// that violates the spec invariant.
76 #[error(
77 "TrafficSplit for deployment `{deployment_id}` violates the sum=10000bps invariant: actual={sum}"
78 )]
79 InvalidSplit {
80 deployment_id: DeploymentId,
81 sum: u64,
82 },
83
84 /// Provider-side failure. The trait does not constrain the message
85 /// shape; impls SHOULD include actionable detail (the kubectl/aws
86 /// command that failed, the response body, …) so operators can act.
87 #[error("provider failure: {0}")]
88 Provider(String),
89}
90
91/// Shared precondition helper: rejects a revision_id that isn't in the env.
92///
93/// Every [`Deployer`] verb starts with this check. Lives here (not on each
94/// impl) so a future Phase D handler can't accidentally drop it; the
95/// conformance bench exercises it via
96/// [`super::conformance::ConformanceFailure::UnknownRevisionAccepted`].
97pub fn require_revision(env: &Environment, revision_id: RevisionId) -> Result<(), DeployerError> {
98 if env.revisions.iter().any(|r| r.revision_id == revision_id) {
99 Ok(())
100 } else {
101 Err(DeployerError::RevisionNotFound {
102 env_id: env.environment_id.clone(),
103 revision_id,
104 })
105 }
106}
107
108/// Shared precondition helper: locates the recorded `TrafficSplit` for
109/// `deployment_id`, enforces the `sum == 10000bps` invariant, and returns a
110/// [`TrafficSplitOutcome`] populated against the env's recorded entries.
111///
112/// Every [`Deployer::apply_traffic_split`] impl wraps this with its own
113/// provider work (kubectl apply, ALB rule update, ...). Pulling the
114/// precondition + outcome construction here keeps the 4-stub conformance
115/// bench and every Phase D impl on one source of truth — and is what the
116/// conformance bench's `CrossDeploymentInterference` check trusts.
117pub fn enforce_split_invariants(
118 env: &Environment,
119 deployment_id: DeploymentId,
120) -> Result<TrafficSplitOutcome, DeployerError> {
121 let split = env
122 .traffic_splits
123 .iter()
124 .find(|s| s.deployment_id == deployment_id)
125 .ok_or_else(|| DeployerError::SplitNotFound {
126 env_id: env.environment_id.clone(),
127 deployment_id,
128 })?;
129 let sum: u64 = split.entries.iter().map(|e| u64::from(e.weight_bps)).sum();
130 if sum != 10_000 {
131 return Err(DeployerError::InvalidSplit { deployment_id, sum });
132 }
133 Ok(TrafficSplitOutcome {
134 applied_deployment_id: deployment_id,
135 applied_entries: split.entries.clone(),
136 })
137}
138
139/// Contract every deployer env-pack implements.
140///
141/// Trait methods model **provider-side effects** — the work that brings
142/// real infrastructure into agreement with the env's recorded intent.
143/// The corresponding lifecycle state transition
144/// (`Inactive→Staged→Warming→Ready→Draining→Inactive→Archived`) is
145/// applied by the operator CLI via
146/// [`crate::environment::lifecycle::apply_revision_transition`] AFTER
147/// the deployer's side-effects succeed. Keeping these orthogonal means a
148/// deployer impl does not own state mutation, and the conformance suite
149/// in [`super::conformance`] stays storage-agnostic.
150///
151/// ## Idempotency
152///
153/// Every verb MUST be idempotent: calling `warm_revision(env, r)` twice
154/// against the same input MUST succeed twice and leave provider state
155/// equivalent. The conformance suite verifies this contract.
156///
157/// ## Returning errors
158///
159/// Pure-spec preconditions (revision not in env, split sum != 10000bps,
160/// no split for the deployment) MUST be checked BEFORE any provider call
161/// and surfaced via the typed [`DeployerError`] variants — never as
162/// [`DeployerError::Provider`]. Provider-side failures use `Provider(...)`.
163///
164/// ## Provider answers
165///
166/// The verbs that materialize parametrized infrastructure — `warm_revision`,
167/// `archive_revision`, `apply_traffic_split` — take `answers: Option<&Value>`:
168/// the deployer binding's recorded wizard answers (flat JSON keyed by question
169/// id, the qa-spec convention). `None` means "use the env-pack's sandbox
170/// defaults". K8s reads namespace / image / replica overrides from it so an
171/// operator's custom binding lands the same objects `op env render` /
172/// `op env reconcile` show. `stage_revision` / `drain_revision` are no-ops that
173/// render nothing, so they carry no answers; a future provider whose stage
174/// uploads to an answer-derived registry adds the param when it grows a body.
175#[async_trait]
176pub trait Deployer: std::fmt::Debug + Send + Sync {
177 /// Stage-time side effects: upload bundle to registry/storage,
178 /// pre-pull images, validate trust-root signatures, etc.
179 ///
180 /// Returns once the revision can transition `Inactive → Staged`.
181 /// Local-process has no upload / no registry — its impl is a no-op.
182 async fn stage_revision(
183 &self,
184 env: &Environment,
185 revision_id: RevisionId,
186 ) -> Result<StageOutcome, DeployerError>;
187
188 /// Warm-time side effects: create the cloud resources that serve the
189 /// revision (K8s Deployment, ECS task-set, systemd unit, …), wait
190 /// until reachable.
191 ///
192 /// Returns once the revision can transition `Staged → Warming → Ready`.
193 /// `answers` carries the binding's wizard answers (see the trait-level
194 /// "Provider answers" note); `None` uses sandbox defaults.
195 async fn warm_revision(
196 &self,
197 env: &Environment,
198 revision_id: RevisionId,
199 answers: Option<&Value>,
200 ) -> Result<WarmOutcome, DeployerError>;
201
202 /// Drain-time side effects: stop accepting new sessions and wait up
203 /// to `drain_seconds` for existing sessions to complete.
204 ///
205 /// Returns once the revision can transition `Ready → Draining → Inactive`.
206 async fn drain_revision(
207 &self,
208 env: &Environment,
209 revision_id: RevisionId,
210 ) -> Result<DrainOutcome, DeployerError>;
211
212 /// Archive-time side effects: tear down the provider resources for
213 /// this revision (delete the K8s Deployment, deregister the ECS task-
214 /// set, remove the systemd unit, …).
215 ///
216 /// Returns once the revision can transition `Inactive → Archived`.
217 /// The operator CLI's archive guard (active-traffic check) runs at
218 /// the storage layer; the deployer is only responsible for the
219 /// provider side. Impls MUST be idempotent against an already-torn-
220 /// down revision so a retried archive is safe.
221 /// `answers` carries the binding's wizard answers (see the trait-level
222 /// "Provider answers" note); `None` uses sandbox defaults.
223 async fn archive_revision(
224 &self,
225 env: &Environment,
226 revision_id: RevisionId,
227 answers: Option<&Value>,
228 ) -> Result<ArchiveOutcome, DeployerError>;
229
230 /// Project the env's `TrafficSplit` for `deployment_id` into a
231 /// provider-native shape (K8s router runtime-config bump, ALB rule
232 /// weights, Cloud Run traffic targets, …) and enforce it.
233 ///
234 /// MUST reject `sum != 10000bps` with [`DeployerError::InvalidSplit`]
235 /// BEFORE any provider call. MUST treat splits for sibling
236 /// deployments as independent — applying a split for deployment A
237 /// MUST NOT perturb deployment B's recorded or enforced split.
238 /// `answers` carries the binding's wizard answers (see the trait-level
239 /// "Provider answers" note); `None` uses sandbox defaults.
240 async fn apply_traffic_split(
241 &self,
242 env: &Environment,
243 deployment_id: DeploymentId,
244 answers: Option<&Value>,
245 ) -> Result<TrafficSplitOutcome, DeployerError>;
246
247 /// The deployer's view of the runtime-config projection.
248 ///
249 /// Default delegates to [`materialize_runtime_config`] — the pure
250 /// projection of the env's `traffic_splits + revisions` into the
251 /// `greentic.runtime-config.v1` shape that `greentic-start` loads.
252 /// Provider impls MAY override to splice in provider-discovered
253 /// values (the K8s router's ClusterIP, the ALB DNS, …) once those
254 /// values exist on the spec side; today the projection is pure.
255 fn report_runtime_config(&self, env: &Environment) -> RuntimeConfig {
256 materialize_runtime_config(env)
257 }
258}