Skip to main content

greentic_deployer/environment/
mutations.rs

1//! [`EnvironmentMutations`] — typed-verb trait for state mutations on a
2//! Greentic environment.
3//!
4//! Phase D rescope (2026-06-09): this trait replaces the closure-based
5//! [`LocalFsStore::transact`](super::LocalFsStore::transact) pattern with one
6//! method per logical CLI verb. See `plans/next-gen-deployment.md` §13.5 #2
7//! for the architectural rationale; in short: closures can't cross the A8
8//! HTTP wire contract (`greentic_deploy_spec::remote`), so the deployer-CLI's
9//! seam against a remote store has to be typed verbs, not opaque
10//! `FnOnce(&Locked)` closures.
11//!
12//! This module ships in **PR-3a.1 as signatures only** — there are no impls,
13//! and no callers reach the trait yet. PR-3a.2..3a.16 migrate one verb group
14//! at a time, each adding the `LocalFsStore` impl + flipping the matching
15//! CLI helper from `store.transact(|locked| …)` to a typed call. PR-3b lands
16//! `HttpEnvironmentStore` implementing the same trait over the A8 HTTP
17//! contract.
18//!
19//! Signatures here are derived from the existing 33 `LocalFsStore::transact`
20//! sites in `src/cli/*` (15 logical verb groups, ~28 methods). They may
21//! tweak as PR-3a.2..16 add impls — flag drift in code review.
22
23use greentic_deploy_spec::{
24    BundleDeployment, BundleId, CapabilitySlot, DeploymentId, EnvId, EnvPackBinding, Environment,
25    EnvironmentHostConfig, ExtensionBinding, IdempotencyKey, MessagingEndpoint,
26    MessagingEndpointId, Revision, RevisionId,
27};
28
29use super::StoreError;
30
31// PR-4.2a/4.2b/4.2c: the env-lifecycle payload shapes (`ExtensionKey`,
32// `FieldUpdate`, `UpdateEnvironmentPayload`, `MigrateSeedPayload`,
33// `MigrateMergePayload`), the revision verb group's
34// (`StageRevisionPayload`, `WarmRevisionPayload`,
35// `RevisionTransitionOutcome`) and the traffic verb group's
36// (`SetTrafficSplitPayload`, `ApplyTrafficSplitOutcome`,
37// `RollbackTrafficSplitOutcome`) moved to `greentic_deploy_spec::engine` so
38// the operator-store-server applies the same verb semantics (and wire
39// encoding) as `LocalFsStore`. Re-exported here so every existing
40// `environment::mutations::…` path keeps working. The revision payloads no
41// longer carry `idempotency_key` — the key rides the trait methods (and the
42// A8 `Idempotency-Key` header), matching every other verb group. (The
43// traffic outcomes gained an `environment` snapshot: the CLI emits
44// `TrafficSplitApplied` telemetry from it, identical local and remote.)
45// PR-4.2f: the trust-root group's wire shapes (`AddTrustedKeyPayload`,
46// `TrustRootSeed`, `TrustRootAddOutcome`, `TrustRootRemoveOutcome`)
47// followed — shapes only; the pure transforms need crypto and live in
48// `greentic-operator-trust`.
49// PR-4.2g: the bundles group's shapes (`AddBundlePayload`,
50// `UpdateBundlePayload`, `RemoveBundleOutcome`) followed, dropping their
51// embedded `idempotency_key` — the key rides the trait methods (and the A8
52// `Idempotency-Key` header), matching every other verb group.
53// PR-4.2h: the messaging group's shapes (`AddMessagingEndpointPayload`,
54// `SetMessagingWelcomeFlowPayload`) followed, likewise dropping their
55// embedded `idempotency_key` — though in this group the key is also DOMAIN
56// state (replay detection via the `updated_by` stamp), so the engine
57// transforms receive it as a parameter.
58pub use greentic_deploy_spec::engine::{
59    AddBundlePayload, AddMessagingEndpointPayload, AddTrustedKeyPayload, ApplyTrafficSplitOutcome,
60    ExtensionKey, FieldUpdate, MigrateMergePayload, MigrateSeedPayload, RemoveBundleOutcome,
61    RevisionTransitionOutcome, RollbackTrafficSplitOutcome, SetMessagingWelcomeFlowPayload,
62    SetTrafficSplitPayload, StageRevisionPayload, TrustRootAddOutcome, TrustRootRemoveOutcome,
63    TrustRootSeed, UpdateBundlePayload, UpdateEnvironmentPayload, WarmRevisionPayload,
64};
65
66/// The typed-verb persistence operations a Greentic environment store
67/// performs in response to `op …` CLI verbs.
68///
69/// Replaces the closure-based `LocalFsStore::transact` pattern with one
70/// method per logical verb. All methods take `&self` — concurrency is the
71/// impl's responsibility (flock for `LocalFsStore`, optimistic CAS via
72/// `If-Match` for `HttpEnvironmentStore` against an A8-compliant server).
73///
74/// Methods that need idempotency replay (per A8 §2) take an
75/// [`IdempotencyKey`]; methods that are intrinsically idempotent (e.g.
76/// `seed_trust_root_if_absent`) do not.
77///
78/// **Errors**: all methods return [`StoreError`]. Impls may map their
79/// transport-specific errors into the existing variants; new variants land
80/// alongside the impl that needs them.
81pub trait EnvironmentMutations: Send + Sync {
82    // -------------------------------------------------------------
83    // Environment lifecycle
84    //   `op env create | update | set-public-url`
85    //   `op config set`
86    //
87    // `op env init` / `gtc setup` is NOT on this trait — it's local-FS
88    // only (default-binding heal + runtime stub), so it stays inherent
89    // on `LocalFsStore`. The HTTP backend has its own bootstrap path.
90    // -------------------------------------------------------------
91
92    /// Create a fresh environment with empty bundles/revisions/packs.
93    /// Rejects if the env already exists.
94    fn create_environment(
95        &self,
96        env_id: &EnvId,
97        name: String,
98        host_config: EnvironmentHostConfig,
99    ) -> Result<Environment, StoreError>;
100
101    /// Patch the named scalar fields on an existing environment.
102    /// [`FieldUpdate::Keep`] fields are skipped, [`FieldUpdate::Set`]
103    /// writes the new value, [`FieldUpdate::Clear`] resets optional
104    /// fields to `None`. The full updated `Environment` is returned.
105    /// Covers what was previously split across `update_environment` (no
106    /// `listen_addr`), `set_public_url` (single field), and `set_config`
107    /// (host-level fields including `listen_addr`). One verb, one HTTP
108    /// endpoint, one impl body per backend.
109    fn update_environment(
110        &self,
111        env_id: &EnvId,
112        patch: UpdateEnvironmentPayload,
113    ) -> Result<Environment, StoreError>;
114
115    // -------------------------------------------------------------
116    // Reads
117    //   (no dedicated CLI verb — used by the remote dispatch to
118    //    evaluate client-side preconditions before a mutation)
119    // -------------------------------------------------------------
120
121    /// Read the current environment state. This is the single READ verb on an
122    /// otherwise mutation-only trait: the remote dispatch needs it to evaluate
123    /// client-side preconditions — e.g. the `warm` health-gate's
124    /// `expected_lifecycle`, which the local closure-based path reads inline
125    /// under its flock. Maps to `GET /environments/{env_id}` on the HTTP
126    /// backend and delegates to
127    /// [`EnvironmentStore::load`](super::EnvironmentStore::load) on
128    /// `LocalFsStore`.
129    fn load_environment(&self, env_id: &EnvId) -> Result<Environment, StoreError>;
130
131    /// True when the env's trust root holds at least one operator key. Maps to
132    /// `GET /environments/{env_id}/trust-root` on the HTTP backend and to a
133    /// trust-root file read on `LocalFsStore`. A read-only probe (like
134    /// [`Self::load_environment`]) — remote `env apply --check` uses it to diff
135    /// a declared `trust_root` instead of assuming it is seeded (the env
136    /// document does not carry trust-root state).
137    fn trust_root_is_seeded(&self, env_id: &EnvId) -> Result<bool, StoreError>;
138
139    // -------------------------------------------------------------
140    // Migration
141    //   `op env migrate-dev --apply`
142    // -------------------------------------------------------------
143
144    /// Merge pack bindings and extension bindings into `target_env_id`.
145    /// Skips slots / extension keys already bound in the target. Returns
146    /// the list of newly-merged slots + extension key strings (in the
147    /// form `"<kind_path>::<instance_id>"`).
148    ///
149    /// `payload.seed_if_missing` is the optional seed-on-create-target
150    /// branch used by `op env migrate-dev` to migrate a legacy `dev` env
151    /// into a fresh `local` target atomically — load, fallback-to-seed,
152    /// merge, save all happen under one lock.
153    fn migrate_merge_bindings(
154        &self,
155        target_env_id: &EnvId,
156        payload: MigrateMergePayload,
157    ) -> Result<(Vec<String>, Vec<String>), StoreError>;
158
159    // -------------------------------------------------------------
160    // Revision lifecycle
161    //   `op revisions stage | warm | drain | archive`
162    // -------------------------------------------------------------
163
164    /// Stage a fresh revision under `deployment_id`. The caller supplies
165    /// the pinned artifact pointers; `LocalFsStore`'s CLI helper resolves
166    /// them from a local `.gtbundle` upstream of this call so the trait
167    /// stays storage-only.
168    ///
169    /// `idempotency_key`: A8 §2 — same-key replay returns the originally
170    /// staged `Revision` without re-minting the ULID or advancing the
171    /// sequence. Local impl accepts and ignores; HTTP backends cache it.
172    fn stage_revision(
173        &self,
174        env_id: &EnvId,
175        payload: StageRevisionPayload,
176        idempotency_key: IdempotencyKey,
177    ) -> Result<Revision, StoreError>;
178
179    /// Transition a revision through its `warm` lifecycle chain, applying the
180    /// client-evaluated health-gate outcome. The deployer CLI runs the
181    /// runner-side health checks locally and ships the result in
182    /// [`WarmRevisionPayload::health_gate`]: `Ok(())` advances the revision
183    /// to `Ready`; `Err(failure)` flips it to `Failed` atomically. This
184    /// replaces the closure-based gate so the operation can cross the A8
185    /// HTTP wire contract.
186    fn warm_revision(
187        &self,
188        env_id: &EnvId,
189        payload: WarmRevisionPayload,
190        idempotency_key: IdempotencyKey,
191    ) -> Result<RevisionTransitionOutcome, StoreError>;
192
193    /// Drain a `Ready` revision (graceful step-down → `Drained`).
194    /// `idempotency_key` is required for A8 mutation consistency even though
195    /// drain is logically idempotent — the key enables audit-event replay.
196    fn drain_revision(
197        &self,
198        env_id: &EnvId,
199        revision_id: RevisionId,
200        idempotency_key: IdempotencyKey,
201    ) -> Result<RevisionTransitionOutcome, StoreError>;
202
203    /// Archive a `Drained` / `Failed` revision (terminal).
204    /// `idempotency_key` is required for A8 mutation consistency even though
205    /// archive is logically idempotent — the key enables audit-event replay.
206    fn archive_revision(
207        &self,
208        env_id: &EnvId,
209        revision_id: RevisionId,
210        idempotency_key: IdempotencyKey,
211    ) -> Result<RevisionTransitionOutcome, StoreError>;
212
213    // -------------------------------------------------------------
214    // Bundle deployment CRUD
215    //   `op bundles add | update | remove`
216    // -------------------------------------------------------------
217
218    /// `idempotency_key` is required for A8 §2 mutation replay; the local
219    /// impl accepts and ignores, the HTTP backend caches the original
220    /// outcome.
221    fn add_bundle(
222        &self,
223        env_id: &EnvId,
224        payload: AddBundlePayload,
225        idempotency_key: IdempotencyKey,
226    ) -> Result<BundleDeployment, StoreError>;
227
228    /// `idempotency_key` is required for A8 §2 mutation replay; the local
229    /// impl accepts and ignores, the HTTP backend caches the original
230    /// outcome.
231    fn update_bundle(
232        &self,
233        env_id: &EnvId,
234        payload: UpdateBundlePayload,
235        idempotency_key: IdempotencyKey,
236    ) -> Result<BundleDeployment, StoreError>;
237
238    /// Remove a bundle deployment from the env. Refuses if the deployment
239    /// still has live traffic splits or non-archived revisions —
240    /// callers must `op traffic clear` and archive revisions first.
241    ///
242    /// Also drops archived revisions for the same `deployment_id` so the
243    /// env stays compact. The pruned IDs are surfaced on the outcome so
244    /// the destructive side effect is explicit on the contract —
245    /// HTTP backends can apply a separate authorization check against
246    /// the prune set, the CLI logs the IDs in the audit target.
247    ///
248    /// `idempotency_key` is required for A8 §2 mutation replay; the local
249    /// impl accepts and ignores, the HTTP backend caches the original
250    /// outcome.
251    fn remove_bundle(
252        &self,
253        env_id: &EnvId,
254        deployment_id: DeploymentId,
255        idempotency_key: IdempotencyKey,
256    ) -> Result<RemoveBundleOutcome, StoreError>;
257
258    // -------------------------------------------------------------
259    // Env-pack binding CRUD
260    //   `op env-packs add | update | remove | rollback`
261    // -------------------------------------------------------------
262
263    fn add_pack_binding(
264        &self,
265        env_id: &EnvId,
266        binding: EnvPackBinding,
267        idempotency_key: IdempotencyKey,
268    ) -> Result<EnvPackBinding, StoreError>;
269
270    /// Returns `(new_binding, new_generation)` — the bumped audit generation
271    /// is surfaced for downstream observability.
272    fn update_pack_binding(
273        &self,
274        env_id: &EnvId,
275        slot: CapabilitySlot,
276        binding: EnvPackBinding,
277        idempotency_key: IdempotencyKey,
278    ) -> Result<(EnvPackBinding, u64), StoreError>;
279
280    fn remove_pack_binding(
281        &self,
282        env_id: &EnvId,
283        slot: CapabilitySlot,
284        idempotency_key: IdempotencyKey,
285    ) -> Result<(EnvPackBinding, u64), StoreError>;
286
287    fn rollback_pack_binding(
288        &self,
289        env_id: &EnvId,
290        slot: CapabilitySlot,
291        idempotency_key: IdempotencyKey,
292    ) -> Result<(EnvPackBinding, u64), StoreError>;
293
294    // -------------------------------------------------------------
295    // Extension binding CRUD
296    //   `op extensions add | update | remove | rollback`
297    // -------------------------------------------------------------
298
299    fn add_extension_binding(
300        &self,
301        env_id: &EnvId,
302        binding: ExtensionBinding,
303        idempotency_key: IdempotencyKey,
304    ) -> Result<ExtensionBinding, StoreError>;
305
306    fn update_extension_binding(
307        &self,
308        env_id: &EnvId,
309        key: ExtensionKey,
310        binding: ExtensionBinding,
311        idempotency_key: IdempotencyKey,
312    ) -> Result<(ExtensionBinding, u64), StoreError>;
313
314    fn remove_extension_binding(
315        &self,
316        env_id: &EnvId,
317        key: ExtensionKey,
318        idempotency_key: IdempotencyKey,
319    ) -> Result<(ExtensionBinding, u64), StoreError>;
320
321    fn rollback_extension_binding(
322        &self,
323        env_id: &EnvId,
324        key: ExtensionKey,
325        idempotency_key: IdempotencyKey,
326    ) -> Result<(ExtensionBinding, u64), StoreError>;
327
328    // -------------------------------------------------------------
329    // Traffic
330    //   `op traffic set | rollback`
331    // -------------------------------------------------------------
332
333    fn set_traffic_split(
334        &self,
335        env_id: &EnvId,
336        payload: SetTrafficSplitPayload,
337        idempotency_key: IdempotencyKey,
338    ) -> Result<ApplyTrafficSplitOutcome, StoreError>;
339
340    fn rollback_traffic_split(
341        &self,
342        env_id: &EnvId,
343        deployment_id: DeploymentId,
344        idempotency_key: IdempotencyKey,
345    ) -> Result<RollbackTrafficSplitOutcome, StoreError>;
346
347    // -------------------------------------------------------------
348    // Messaging endpoints
349    //   `op messaging endpoint add | link-bundle | unlink-bundle
350    //                  | set-welcome-flow | remove | rotate-webhook-secret`
351    // -------------------------------------------------------------
352
353    /// `idempotency_key` is both A8 §2 transport metadata AND domain state
354    /// in this group: the key is embedded in the endpoint's `updated_by`
355    /// stamp and drives same-key replay detection (see
356    /// `greentic_deploy_spec::engine::messaging`).
357    fn add_messaging_endpoint(
358        &self,
359        env_id: &EnvId,
360        payload: AddMessagingEndpointPayload,
361        idempotency_key: IdempotencyKey,
362    ) -> Result<MessagingEndpoint, StoreError>;
363
364    fn link_messaging_bundle(
365        &self,
366        env_id: &EnvId,
367        endpoint_id: MessagingEndpointId,
368        bundle_id: BundleId,
369        updated_by: String,
370        idempotency_key: IdempotencyKey,
371    ) -> Result<MessagingEndpoint, StoreError>;
372
373    fn unlink_messaging_bundle(
374        &self,
375        env_id: &EnvId,
376        endpoint_id: MessagingEndpointId,
377        bundle_id: BundleId,
378        updated_by: String,
379        idempotency_key: IdempotencyKey,
380    ) -> Result<MessagingEndpoint, StoreError>;
381
382    /// `idempotency_key` semantics as on
383    /// [`Self::add_messaging_endpoint`] — transport metadata that doubles
384    /// as the replay-detection stamp.
385    fn set_messaging_welcome_flow(
386        &self,
387        env_id: &EnvId,
388        payload: SetMessagingWelcomeFlowPayload,
389        idempotency_key: IdempotencyKey,
390    ) -> Result<MessagingEndpoint, StoreError>;
391
392    fn remove_messaging_endpoint(
393        &self,
394        env_id: &EnvId,
395        endpoint_id: MessagingEndpointId,
396    ) -> Result<MessagingEndpointId, StoreError>;
397
398    fn rotate_messaging_webhook_secret(
399        &self,
400        env_id: &EnvId,
401        endpoint_id: MessagingEndpointId,
402        updated_by: String,
403        idempotency_key: IdempotencyKey,
404    ) -> Result<MessagingEndpoint, StoreError>;
405
406    // -------------------------------------------------------------
407    // Trust root
408    //   `op trust-root bootstrap | add | remove`
409    //   (`seed_trust_root_if_absent` is called from `op env init`)
410    // -------------------------------------------------------------
411
412    /// Load (or generate) the backend's operator signing key and add it to
413    /// the env trust root. Idempotent re-grant: a second call with the same
414    /// operator key is a no-op on the key set (case-insensitive `key_id`
415    /// dedup), and it never rejects on an existing trust root.
416    fn bootstrap_trust_root(&self, env_id: &EnvId) -> Result<TrustRootSeed, StoreError>;
417
418    /// Idempotent variant called from `op env init`: returns `Some(seed)`
419    /// when a key was minted, `None` when a trust root already existed.
420    fn seed_trust_root_if_absent(
421        &self,
422        env_id: &EnvId,
423    ) -> Result<Option<TrustRootSeed>, StoreError>;
424
425    /// `idempotency_key` is required for A8 mutation consistency even though
426    /// `add_trusted_key` is intrinsically idempotent on `key_id` collision —
427    /// the key enables HTTP-backend audit-event replay. Local-FS impls accept
428    /// and ignore it; HTTP impls cache it for replay.
429    fn add_trusted_key(
430        &self,
431        env_id: &EnvId,
432        key_id: String,
433        public_key_pem: String,
434        idempotency_key: IdempotencyKey,
435    ) -> Result<TrustRootAddOutcome, StoreError>;
436
437    /// `idempotency_key` is required for A8 mutation consistency. `remove`
438    /// is logically idempotent on the trust-root state, but the wire-shape
439    /// `removed_public_key_pem` field would be `null` on retry (the original
440    /// PEM is gone) — so a retry without replay loses recovery material AND
441    /// audit fidelity. The key enables HTTP-backend replay of the original
442    /// response/audit; local-FS impls accept and ignore it.
443    fn remove_trusted_key(
444        &self,
445        env_id: &EnvId,
446        key_id: String,
447        idempotency_key: IdempotencyKey,
448    ) -> Result<TrustRootRemoveOutcome, StoreError>;
449}
450
451#[cfg(test)]
452mod tests {
453    use super::*;
454
455    /// Compile-time guard: the trait stays object-safe so future code can
456    /// hold `&dyn EnvironmentMutations` / `Box<dyn EnvironmentMutations>`
457    /// (e.g. for runtime selection between `LocalFsStore` and
458    /// `HttpEnvironmentStore` in PR-3c).
459    #[allow(dead_code)]
460    fn _is_object_safe(_: &dyn EnvironmentMutations) {}
461
462    // `FieldUpdate` / `UpdateEnvironmentPayload` / `ExtensionKey` unit tests
463    // moved to `greentic_deploy_spec::engine` alongside the types (PR-4.2a).
464}