Skip to main content

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}