1use std::path::PathBuf;
15
16use greentic_deploy_spec::{CapabilitySlot, EnvId, EnvPackBinding, PackDescriptor, PackId};
17use serde::{Deserialize, Serialize};
18use serde_json::{Value, json};
19
20use crate::environment::{EnvironmentReads, LocalFsStore};
21
22use super::{
23 AuditCtx, OpError, OpFlags, OpOutcome, audit_and_record, map_store_err_preserving_noun,
24 resolve_idempotency_key,
25};
26
27const NOUN: &str = "env-packs";
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct EnvPackBindingPayload {
32 pub environment_id: String,
33 pub slot: CapabilitySlot,
34 pub kind: String,
35 pub pack_ref: String,
36 #[serde(default, skip_serializing_if = "Option::is_none")]
37 pub answers_ref: Option<PathBuf>,
38 #[serde(default, skip_serializing_if = "Option::is_none")]
43 pub idempotency_key: Option<String>,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct BindingSummary {
49 pub environment_id: String,
50 pub slot: CapabilitySlot,
51 pub kind: String,
52 pub pack_ref: String,
53 pub generation: u64,
54 pub has_previous: bool,
55}
56
57impl BindingSummary {
58 pub(crate) fn from_binding(env_id: &EnvId, b: &EnvPackBinding) -> Self {
59 Self {
60 environment_id: env_id.as_str().to_string(),
61 slot: b.slot,
62 kind: b.kind.to_string(),
63 pack_ref: b.pack_ref.as_str().to_string(),
64 generation: b.generation,
65 has_previous: b.previous_binding_ref.is_some(),
66 }
67 }
68}
69
70pub fn add(
71 store: &LocalFsStore,
72 flags: &OpFlags,
73 payload: Option<EnvPackBindingPayload>,
74) -> Result<OpOutcome, OpError> {
75 if flags.schema_only {
76 return Ok(OpOutcome::new(NOUN, "add", payload_schema()));
77 }
78 let payload = resolve_payload(flags, payload)?;
79 let env_id = parse_env_id(&payload.environment_id)?;
80 let binding = build_binding(&payload, 0, None)?;
81 let target = json!({"slot": payload.slot, "kind": payload.kind});
82 let idempotency_key = resolve_idempotency_key(payload.idempotency_key)?;
83 let ctx = AuditCtx {
84 env_id: env_id.clone(),
85 noun: NOUN,
86 verb: "add",
87 target,
88 idempotency_key: Some(idempotency_key.as_str().to_string()),
89 };
90 audit_and_record(store, ctx, |_committed| {
91 let added = store
92 .add_pack_binding(&env_id, binding, idempotency_key)
93 .map_err(map_store_err_preserving_noun)?;
94 let summary = BindingSummary::from_binding(&env_id, &added);
95 let outcome = OpOutcome::new(
96 NOUN,
97 "add",
98 serde_json::to_value(summary).expect("BindingSummary is json-safe"),
99 );
100 Ok((
101 outcome,
102 super::AuditGens {
103 previous: None,
104 new: Some(0),
105 },
106 ))
107 })
108}
109
110pub fn update(
111 store: &LocalFsStore,
112 flags: &OpFlags,
113 payload: Option<EnvPackBindingPayload>,
114) -> Result<OpOutcome, OpError> {
115 if flags.schema_only {
116 return Ok(OpOutcome::new(NOUN, "update", payload_schema()));
117 }
118 let payload = resolve_payload(flags, payload)?;
119 let env_id = parse_env_id(&payload.environment_id)?;
120 let slot = payload.slot;
121 let binding = build_binding(&payload, 0, None)?;
122 let target = json!({"slot": payload.slot, "kind": payload.kind});
123 let idempotency_key = resolve_idempotency_key(payload.idempotency_key)?;
124 let ctx = AuditCtx {
125 env_id: env_id.clone(),
126 noun: NOUN,
127 verb: "update",
128 target,
129 idempotency_key: Some(idempotency_key.as_str().to_string()),
130 };
131 audit_and_record(store, ctx, |_committed| {
132 let (new_binding, new_generation) = store
133 .update_pack_binding(&env_id, slot, binding, idempotency_key)
134 .map_err(map_store_err_preserving_noun)?;
135 let summary = BindingSummary::from_binding(&env_id, &new_binding);
136 let gens = super::AuditGens {
137 previous: Some(new_generation.saturating_sub(1)),
138 new: Some(new_generation),
139 };
140 let outcome = OpOutcome::new(
141 NOUN,
142 "update",
143 serde_json::to_value(summary).expect("BindingSummary is json-safe"),
144 );
145 Ok((outcome, gens))
146 })
147}
148
149#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct EnvPackRemovePayload {
151 pub environment_id: String,
152 pub slot: CapabilitySlot,
153 #[serde(default, skip_serializing_if = "Option::is_none")]
156 pub idempotency_key: Option<String>,
157}
158
159pub fn remove(
160 store: &LocalFsStore,
161 flags: &OpFlags,
162 payload: Option<EnvPackRemovePayload>,
163) -> Result<OpOutcome, OpError> {
164 if flags.schema_only {
165 return Ok(OpOutcome::new(NOUN, "remove", remove_schema()));
166 }
167 let payload = resolve_payload::<EnvPackRemovePayload>(flags, payload)?;
168 let env_id = parse_env_id(&payload.environment_id)?;
169 let slot = payload.slot;
170 let idempotency_key = resolve_idempotency_key(payload.idempotency_key)?;
171 let ctx = AuditCtx {
172 env_id: env_id.clone(),
173 noun: NOUN,
174 verb: "remove",
175 target: json!({"slot": slot}),
176 idempotency_key: Some(idempotency_key.as_str().to_string()),
177 };
178 audit_and_record(store, ctx, |_committed| {
179 let (removed, removed_generation) = store
180 .remove_pack_binding(&env_id, slot, idempotency_key)
181 .map_err(map_store_err_preserving_noun)?;
182 let summary = BindingSummary::from_binding(&env_id, &removed);
183 let gens = super::AuditGens {
184 previous: Some(removed_generation),
185 new: None,
186 };
187 let outcome = OpOutcome::new(
188 NOUN,
189 "remove",
190 serde_json::to_value(summary).expect("BindingSummary is json-safe"),
191 );
192 Ok((outcome, gens))
193 })
194}
195
196pub fn rollback(
197 store: &LocalFsStore,
198 flags: &OpFlags,
199 payload: Option<EnvPackRemovePayload>,
200) -> Result<OpOutcome, OpError> {
201 if flags.schema_only {
202 return Ok(OpOutcome::new(NOUN, "rollback", remove_schema()));
203 }
204 let payload = resolve_payload::<EnvPackRemovePayload>(flags, payload)?;
205 let env_id = parse_env_id(&payload.environment_id)?;
206 let slot = payload.slot;
207 let idempotency_key = resolve_idempotency_key(payload.idempotency_key)?;
208 let ctx = AuditCtx {
209 env_id: env_id.clone(),
210 noun: NOUN,
211 verb: "rollback",
212 target: json!({"slot": slot}),
213 idempotency_key: Some(idempotency_key.as_str().to_string()),
214 };
215 audit_and_record(store, ctx, |_committed| {
216 let (restored, new_generation) = store
217 .rollback_pack_binding(&env_id, slot, idempotency_key)
218 .map_err(map_store_err_preserving_noun)?;
219 let summary = BindingSummary::from_binding(&env_id, &restored);
220 let gens = super::AuditGens {
221 previous: Some(new_generation.saturating_sub(1)),
222 new: Some(new_generation),
223 };
224 let outcome = OpOutcome::new(
225 NOUN,
226 "rollback",
227 serde_json::to_value(summary).expect("BindingSummary is json-safe"),
228 );
229 Ok((outcome, gens))
230 })
231}
232
233pub fn list(
234 store: &dyn EnvironmentReads,
235 flags: &OpFlags,
236 env_id: &str,
237) -> Result<OpOutcome, OpError> {
238 if flags.schema_only {
239 return Ok(OpOutcome::new(
240 NOUN,
241 "list",
242 json!({"input_schema": "env_id positional"}),
243 ));
244 }
245 let env_id = parse_env_id(env_id)?;
246 if !store.env_exists(&env_id)? {
247 return Err(OpError::NotFound(format!("environment `{env_id}`")));
248 }
249 let env = store.load_env(&env_id)?;
250 let bindings: Vec<BindingSummary> = env
251 .packs
252 .iter()
253 .map(|b| BindingSummary::from_binding(&env_id, b))
254 .collect();
255 Ok(OpOutcome::new(
256 NOUN,
257 "list",
258 json!({"environment_id": env_id.as_str(), "bindings": bindings}),
259 ))
260}
261
262fn resolve_payload<T: serde::de::DeserializeOwned>(
265 flags: &OpFlags,
266 payload: Option<T>,
267) -> Result<T, OpError> {
268 if let Some(p) = payload {
269 return Ok(p);
270 }
271 if let Some(path) = &flags.answers {
272 return super::load_answers::<T>(path);
273 }
274 Err(OpError::InvalidArgument(
275 "no payload provided: pass --answers <path> or supply the payload directly".to_string(),
276 ))
277}
278
279fn parse_env_id(raw: &str) -> Result<EnvId, OpError> {
280 EnvId::try_from(raw).map_err(|e| OpError::InvalidArgument(format!("environment_id: {e}")))
281}
282
283pub(crate) fn build_binding(
284 payload: &EnvPackBindingPayload,
285 generation: u64,
286 previous_binding_ref: Option<PathBuf>,
287) -> Result<EnvPackBinding, OpError> {
288 if !payload.slot.binds_in_packs() {
293 let noun = match payload.slot {
294 CapabilitySlot::Messaging => "op messaging endpoint",
295 CapabilitySlot::Extension => "op extensions",
296 _ => unreachable!("binds_in_packs() is false only for Messaging/Extension"),
297 };
298 return Err(OpError::InvalidArgument(format!(
299 "slot `{}` is N-per-env and is not bound via `op env-packs`; use `{noun}` instead",
300 payload.slot
301 )));
302 }
303 let kind = PackDescriptor::try_new(payload.kind.clone())
304 .map_err(|e| OpError::InvalidArgument(format!("kind: {e}")))?;
305 Ok(EnvPackBinding {
306 slot: payload.slot,
307 kind,
308 pack_ref: PackId::new(payload.pack_ref.clone()),
309 answers_ref: payload.answers_ref.clone(),
310 generation,
311 previous_binding_ref,
312 })
313}
314
315fn payload_schema() -> Value {
316 json!({
317 "$schema": "https://json-schema.org/draft/2020-12/schema",
318 "title": "EnvPackBindingPayload",
319 "type": "object",
320 "required": ["environment_id", "slot", "kind", "pack_ref"],
321 "additionalProperties": false,
322 "properties": {
323 "environment_id": {"type": "string"},
324 "slot": {"type": "string", "enum": ["deployer", "secrets", "telemetry", "sessions", "state", "revocation"]},
325 "kind": {"type": "string", "description": "PackDescriptor — `<namespace>.<id>@<semver>`."},
326 "pack_ref": {"type": "string"},
327 "answers_ref": {"type": ["string", "null"]},
328 "idempotency_key": {
329 "type": "string",
330 "description": "Optional A8 §2 caller-supplied key for safe retry replay; minted per-invocation when omitted."
331 }
332 }
333 })
334}
335
336fn remove_schema() -> Value {
337 json!({
338 "$schema": "https://json-schema.org/draft/2020-12/schema",
339 "title": "EnvPackRemovePayload",
340 "type": "object",
341 "required": ["environment_id", "slot"],
342 "additionalProperties": false,
343 "properties": {
344 "environment_id": {"type": "string"},
345 "slot": {"type": "string", "enum": ["deployer", "secrets", "telemetry", "sessions", "state", "revocation"]},
346 "idempotency_key": {
347 "type": "string",
348 "description": "Optional A8 §2 caller-supplied key for safe retry replay; minted per-invocation when omitted."
349 }
350 }
351 })
352}
353
354#[cfg(test)]
361mod tests {
362 use super::*;
363 use crate::cli::tests_common::make_env;
364 use crate::environment::EnvironmentStore;
365 use crate::environment::LocalFsStore;
366 use tempfile::tempdir;
367
368 fn local_payload(slot: CapabilitySlot, kind: &str) -> EnvPackBindingPayload {
369 EnvPackBindingPayload {
370 environment_id: "local".to_string(),
371 slot,
372 kind: kind.to_string(),
373 pack_ref: kind.split('@').next().unwrap_or(kind).to_string(),
374 answers_ref: None,
375 idempotency_key: None,
376 }
377 }
378
379 #[test]
380 fn add_then_list_returns_binding() {
381 let dir = tempdir().unwrap();
382 let store = LocalFsStore::new(dir.path());
383 store.save(&make_env("local")).unwrap();
384 let outcome = add(
385 &store,
386 &OpFlags::default(),
387 Some(local_payload(
388 CapabilitySlot::Secrets,
389 "greentic.secrets.dev-store@1.0.0",
390 )),
391 )
392 .unwrap();
393 assert_eq!(outcome.op, "add");
394 let list_outcome = list(&store, &OpFlags::default(), "local").unwrap();
395 let bindings = list_outcome
396 .result
397 .get("bindings")
398 .and_then(|v| v.as_array())
399 .expect("bindings array");
400 assert_eq!(bindings.len(), 1);
401 assert_eq!(
402 bindings[0].get("slot").and_then(|v| v.as_str()),
403 Some("secrets")
404 );
405 }
406
407 #[test]
408 fn add_rejects_duplicate_slot() {
409 let dir = tempdir().unwrap();
410 let store = LocalFsStore::new(dir.path());
411 store.save(&make_env("local")).unwrap();
412 let p = local_payload(CapabilitySlot::Secrets, "greentic.secrets.dev-store@1.0.0");
413 add(&store, &OpFlags::default(), Some(p.clone())).unwrap();
414 let err = add(&store, &OpFlags::default(), Some(p)).unwrap_err();
415 assert!(matches!(err, OpError::Conflict(_)), "got {err:?}");
416 }
417
418 #[test]
419 fn update_then_rollback_restores_previous() {
420 let dir = tempdir().unwrap();
421 let store = LocalFsStore::new(dir.path());
422 store.save(&make_env("local")).unwrap();
423 let p1 = local_payload(CapabilitySlot::Secrets, "greentic.secrets.dev-store@1.0.0");
424 add(&store, &OpFlags::default(), Some(p1.clone())).unwrap();
425
426 let p2 = local_payload(CapabilitySlot::Secrets, "greentic.secrets.aws-sm@1.0.0");
427 let updated = update(&store, &OpFlags::default(), Some(p2.clone())).unwrap();
428 assert_eq!(
429 updated.result.get("kind").and_then(|v| v.as_str()),
430 Some("greentic.secrets.aws-sm@1.0.0")
431 );
432 assert_eq!(
433 updated.result.get("generation").and_then(|v| v.as_u64()),
434 Some(1)
435 );
436 assert_eq!(
437 updated.result.get("has_previous").and_then(|v| v.as_bool()),
438 Some(true)
439 );
440
441 let rolled = rollback(
442 &store,
443 &OpFlags::default(),
444 Some(EnvPackRemovePayload {
445 environment_id: "local".to_string(),
446 slot: CapabilitySlot::Secrets,
447 idempotency_key: None,
448 }),
449 )
450 .unwrap();
451 assert_eq!(
452 rolled.result.get("kind").and_then(|v| v.as_str()),
453 Some("greentic.secrets.dev-store@1.0.0")
454 );
455 assert_eq!(
456 rolled.result.get("generation").and_then(|v| v.as_u64()),
457 Some(2),
458 "rollback bumps the generation past the update"
459 );
460 }
461
462 #[test]
463 fn remove_then_list_empty() {
464 let dir = tempdir().unwrap();
465 let store = LocalFsStore::new(dir.path());
466 store.save(&make_env("local")).unwrap();
467 let p = local_payload(CapabilitySlot::Telemetry, "greentic.telemetry.stdout@1.0.0");
468 add(&store, &OpFlags::default(), Some(p)).unwrap();
469 remove(
470 &store,
471 &OpFlags::default(),
472 Some(EnvPackRemovePayload {
473 environment_id: "local".to_string(),
474 slot: CapabilitySlot::Telemetry,
475 idempotency_key: None,
476 }),
477 )
478 .unwrap();
479 let list_outcome = list(&store, &OpFlags::default(), "local").unwrap();
480 let bindings = list_outcome
481 .result
482 .get("bindings")
483 .and_then(|v| v.as_array())
484 .expect("bindings array");
485 assert!(bindings.is_empty());
486 }
487
488 #[test]
489 fn rollback_without_history_errors() {
490 let dir = tempdir().unwrap();
491 let store = LocalFsStore::new(dir.path());
492 store.save(&make_env("local")).unwrap();
493 let p = local_payload(CapabilitySlot::State, "greentic.state.in-memory@1.0.0");
494 add(&store, &OpFlags::default(), Some(p)).unwrap();
495 let err = rollback(
496 &store,
497 &OpFlags::default(),
498 Some(EnvPackRemovePayload {
499 environment_id: "local".to_string(),
500 slot: CapabilitySlot::State,
501 idempotency_key: None,
502 }),
503 )
504 .unwrap_err();
505 assert!(matches!(err, OpError::Conflict(_)), "got {err:?}");
506 }
507
508 #[test]
509 fn add_rejects_n_per_env_slots() {
510 let dir = tempdir().unwrap();
511 let store = LocalFsStore::new(dir.path());
512 store.save(&make_env("local")).unwrap();
513 for slot in [CapabilitySlot::Extension, CapabilitySlot::Messaging] {
517 let err = add(
518 &store,
519 &OpFlags::default(),
520 Some(local_payload(slot, "acme.oauth.auth0@1.0.0")),
521 )
522 .unwrap_err();
523 assert!(
524 matches!(err, OpError::InvalidArgument(_)),
525 "slot {slot} should be rejected, got {err:?}"
526 );
527 }
528 }
529
530 #[test]
531 fn concurrent_pack_adds_both_land() {
532 use std::sync::Arc;
539 use std::thread;
540 let dir = tempdir().unwrap();
541 let store = Arc::new(LocalFsStore::new(dir.path()));
542 store.save(&make_env("local")).unwrap();
543 let store_a = Arc::clone(&store);
544 let store_b = Arc::clone(&store);
545 let h_a = thread::spawn(move || {
548 add(
549 &store_a,
550 &OpFlags::default(),
551 Some(local_payload(
552 CapabilitySlot::Secrets,
553 "greentic.secrets.dev-store@1.0.0",
554 )),
555 )
556 });
557 let h_b = thread::spawn(move || {
558 add(
559 &store_b,
560 &OpFlags::default(),
561 Some(local_payload(
562 CapabilitySlot::Telemetry,
563 "greentic.telemetry.stdout@1.0.0",
564 )),
565 )
566 });
567 h_a.join().unwrap().unwrap();
568 h_b.join().unwrap().unwrap();
569 let listed = list(store.as_ref(), &OpFlags::default(), "local").unwrap();
570 let bindings = listed
571 .result
572 .get("bindings")
573 .and_then(|v| v.as_array())
574 .expect("bindings array");
575 assert_eq!(
576 bindings.len(),
577 2,
578 "both slot bindings must survive concurrent transact()s"
579 );
580 }
581
582 #[test]
583 fn update_records_previous_and_new_generation_in_audit() {
584 let dir = tempdir().unwrap();
585 let store = LocalFsStore::new(dir.path());
586 store.save(&make_env("local")).unwrap();
587 let p1 = local_payload(CapabilitySlot::Secrets, "greentic.secrets.dev-store@1.0.0");
588 add(&store, &OpFlags::default(), Some(p1)).unwrap();
589 let p2 = local_payload(CapabilitySlot::Secrets, "greentic.secrets.aws-sm@1.0.0");
590 update(&store, &OpFlags::default(), Some(p2)).unwrap();
591 let log = dir.path().join("local").join("audit").join("events.jsonl");
592 let raw = std::fs::read_to_string(&log).unwrap();
593 let lines: Vec<&str> = raw.lines().collect();
594 assert_eq!(lines.len(), 2, "add + update each emit one audit event");
595 let update_event: crate::environment::AuditEvent = serde_json::from_str(lines[1]).unwrap();
596 assert_eq!(update_event.verb, "update");
597 assert_eq!(update_event.previous_generation, Some(0));
598 assert_eq!(update_event.new_generation, Some(1));
599 }
600
601 #[test]
607 fn add_schema_lists_idempotency_key() {
608 let schema = payload_schema();
609 assert!(
610 schema.pointer("/properties/idempotency_key").is_some(),
611 "payload_schema must list `idempotency_key` (schema: {schema:#})"
612 );
613 }
614
615 #[test]
617 fn remove_schema_lists_idempotency_key() {
618 let schema = remove_schema();
619 assert!(
620 schema.pointer("/properties/idempotency_key").is_some(),
621 "remove_schema must list `idempotency_key` (schema: {schema:#})"
622 );
623 }
624
625 #[test]
628 fn add_audit_event_carries_idempotency_key() {
629 let dir = tempdir().unwrap();
630 let store = LocalFsStore::new(dir.path());
631 store.save(&make_env("local")).unwrap();
632 let p = local_payload(CapabilitySlot::Secrets, "greentic.secrets.dev-store@1.0.0");
633 add(&store, &OpFlags::default(), Some(p)).unwrap();
634 let log = dir.path().join("local").join("audit").join("events.jsonl");
635 let raw = std::fs::read_to_string(&log).unwrap();
636 let event: crate::environment::AuditEvent =
637 serde_json::from_str(raw.lines().next().unwrap()).unwrap();
638 assert!(
639 event.idempotency_key.is_some(),
640 "add audit event must carry an idempotency_key"
641 );
642 }
643
644 #[test]
647 fn remove_missing_slot_returns_not_found() {
648 let dir = tempdir().unwrap();
649 let store = LocalFsStore::new(dir.path());
650 store.save(&make_env("local")).unwrap();
651 let err = remove(
652 &store,
653 &OpFlags::default(),
654 Some(EnvPackRemovePayload {
655 environment_id: "local".to_string(),
656 slot: CapabilitySlot::Secrets,
657 idempotency_key: None,
658 }),
659 )
660 .unwrap_err();
661 assert!(matches!(err, OpError::NotFound(_)), "got {err:?}");
662 }
663
664 #[test]
667 fn rollback_missing_slot_returns_not_found() {
668 let dir = tempdir().unwrap();
669 let store = LocalFsStore::new(dir.path());
670 store.save(&make_env("local")).unwrap();
671 let err = rollback(
672 &store,
673 &OpFlags::default(),
674 Some(EnvPackRemovePayload {
675 environment_id: "local".to_string(),
676 slot: CapabilitySlot::Secrets,
677 idempotency_key: None,
678 }),
679 )
680 .unwrap_err();
681 assert!(matches!(err, OpError::NotFound(_)), "got {err:?}");
682 }
683}