1use std::path::PathBuf;
17
18use greentic_deploy_spec::{EnvId, ExtensionBinding, PackDescriptor, PackId};
19use serde::{Deserialize, Serialize};
20use serde_json::{Value, json};
21
22use crate::environment::{EnvironmentReads, ExtensionKey, LocalFsStore};
23
24use super::{
25 AuditCtx, OpError, OpFlags, OpOutcome, audit_and_record, map_store_err_preserving_noun,
26 resolve_idempotency_key,
27};
28
29const NOUN: &str = "extensions";
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct ExtensionBindingPayload {
34 pub environment_id: String,
35 pub kind: String,
36 pub pack_ref: String,
37 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub instance_id: Option<String>,
39 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub answers_ref: Option<PathBuf>,
41 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub idempotency_key: Option<String>,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct ExtensionRemovePayload {
54 pub environment_id: String,
55 pub kind: String,
56 #[serde(default, skip_serializing_if = "Option::is_none")]
57 pub instance_id: Option<String>,
58 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub idempotency_key: Option<String>,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct ExtensionSummary {
67 pub environment_id: String,
68 pub kind: String,
69 #[serde(skip_serializing_if = "Option::is_none")]
70 pub instance_id: Option<String>,
71 pub pack_ref: String,
72 pub generation: u64,
73 pub has_previous: bool,
74}
75
76impl ExtensionSummary {
77 pub(crate) fn from_binding(env_id: &EnvId, b: &ExtensionBinding) -> Self {
78 Self {
79 environment_id: env_id.as_str().to_string(),
80 kind: b.kind.to_string(),
81 instance_id: b.instance_id.clone(),
82 pack_ref: b.pack_ref.as_str().to_string(),
83 generation: b.generation,
84 has_previous: b.previous_binding_ref.is_some(),
85 }
86 }
87}
88
89pub fn add(
90 store: &LocalFsStore,
91 flags: &OpFlags,
92 payload: Option<ExtensionBindingPayload>,
93) -> Result<OpOutcome, OpError> {
94 if flags.schema_only {
95 return Ok(OpOutcome::new(NOUN, "add", payload_schema()));
96 }
97 let payload = resolve_payload(flags, payload)?;
98 let env_id = parse_env_id(&payload.environment_id)?;
99 let binding = build_binding(&payload, 0, None)?;
100 let idempotency_key = resolve_idempotency_key(payload.idempotency_key)?;
101 let ctx = AuditCtx {
102 env_id: env_id.clone(),
103 noun: NOUN,
104 verb: "add",
105 target: json!({"kind": payload.kind, "instance_id": payload.instance_id}),
106 idempotency_key: Some(idempotency_key.as_str().to_string()),
107 };
108 audit_and_record(store, ctx, |_committed| {
109 let added = store
110 .add_extension_binding(&env_id, binding, idempotency_key)
111 .map_err(map_store_err_preserving_noun)?;
112 let summary = ExtensionSummary::from_binding(&env_id, &added);
113 let outcome = OpOutcome::new(
114 NOUN,
115 "add",
116 serde_json::to_value(summary).expect("ExtensionSummary is json-safe"),
117 );
118 Ok((
119 outcome,
120 super::AuditGens {
121 previous: None,
122 new: Some(0),
123 },
124 ))
125 })
126}
127
128pub fn update(
129 store: &LocalFsStore,
130 flags: &OpFlags,
131 payload: Option<ExtensionBindingPayload>,
132) -> Result<OpOutcome, OpError> {
133 if flags.schema_only {
134 return Ok(OpOutcome::new(NOUN, "update", payload_schema()));
135 }
136 let payload = resolve_payload(flags, payload)?;
137 let env_id = parse_env_id(&payload.environment_id)?;
138 let key = build_key(&payload.kind, &payload.instance_id)?;
139 let binding = build_binding(&payload, 0, None)?;
140 let idempotency_key = resolve_idempotency_key(payload.idempotency_key)?;
141 let ctx = AuditCtx {
142 env_id: env_id.clone(),
143 noun: NOUN,
144 verb: "update",
145 target: json!({"kind": payload.kind, "instance_id": payload.instance_id}),
146 idempotency_key: Some(idempotency_key.as_str().to_string()),
147 };
148 audit_and_record(store, ctx, |_committed| {
149 let (updated, new_generation) = store
150 .update_extension_binding(&env_id, key, binding, idempotency_key)
151 .map_err(map_store_err_preserving_noun)?;
152 let summary = ExtensionSummary::from_binding(&env_id, &updated);
153 let gens = super::AuditGens {
154 previous: new_generation.checked_sub(1),
155 new: Some(new_generation),
156 };
157 let outcome = OpOutcome::new(
158 NOUN,
159 "update",
160 serde_json::to_value(summary).expect("ExtensionSummary is json-safe"),
161 );
162 Ok((outcome, gens))
163 })
164}
165
166pub fn remove(
167 store: &LocalFsStore,
168 flags: &OpFlags,
169 payload: Option<ExtensionRemovePayload>,
170) -> Result<OpOutcome, OpError> {
171 if flags.schema_only {
172 return Ok(OpOutcome::new(NOUN, "remove", remove_schema()));
173 }
174 let payload = resolve_payload::<ExtensionRemovePayload>(flags, payload)?;
175 let env_id = parse_env_id(&payload.environment_id)?;
176 let key = build_key(&payload.kind, &payload.instance_id)?;
177 let idempotency_key = resolve_idempotency_key(payload.idempotency_key)?;
178 let ctx = AuditCtx {
179 env_id: env_id.clone(),
180 noun: NOUN,
181 verb: "remove",
182 target: json!({"kind": payload.kind, "instance_id": payload.instance_id}),
183 idempotency_key: Some(idempotency_key.as_str().to_string()),
184 };
185 audit_and_record(store, ctx, |_committed| {
186 let (removed, generation) = store
187 .remove_extension_binding(&env_id, key, idempotency_key)
188 .map_err(map_store_err_preserving_noun)?;
189 let summary = ExtensionSummary::from_binding(&env_id, &removed);
190 let gens = super::AuditGens {
191 previous: Some(generation),
192 new: None,
193 };
194 let outcome = OpOutcome::new(
195 NOUN,
196 "remove",
197 serde_json::to_value(summary).expect("ExtensionSummary is json-safe"),
198 );
199 Ok((outcome, gens))
200 })
201}
202
203pub fn rollback(
204 store: &LocalFsStore,
205 flags: &OpFlags,
206 payload: Option<ExtensionRemovePayload>,
207) -> Result<OpOutcome, OpError> {
208 if flags.schema_only {
209 return Ok(OpOutcome::new(NOUN, "rollback", remove_schema()));
210 }
211 let payload = resolve_payload::<ExtensionRemovePayload>(flags, payload)?;
212 let env_id = parse_env_id(&payload.environment_id)?;
213 let key = build_key(&payload.kind, &payload.instance_id)?;
214 let idempotency_key = resolve_idempotency_key(payload.idempotency_key)?;
215 let ctx = AuditCtx {
216 env_id: env_id.clone(),
217 noun: NOUN,
218 verb: "rollback",
219 target: json!({"kind": payload.kind, "instance_id": payload.instance_id}),
220 idempotency_key: Some(idempotency_key.as_str().to_string()),
221 };
222 audit_and_record(store, ctx, |_committed| {
223 let (restored, new_generation) = store
224 .rollback_extension_binding(&env_id, key, idempotency_key)
225 .map_err(map_store_err_preserving_noun)?;
226 let summary = ExtensionSummary::from_binding(&env_id, &restored);
227 let gens = super::AuditGens {
228 previous: new_generation.checked_sub(1),
229 new: Some(new_generation),
230 };
231 let outcome = OpOutcome::new(
232 NOUN,
233 "rollback",
234 serde_json::to_value(summary).expect("ExtensionSummary is json-safe"),
235 );
236 Ok((outcome, gens))
237 })
238}
239
240pub fn list(
241 store: &dyn EnvironmentReads,
242 flags: &OpFlags,
243 env_id: &str,
244) -> Result<OpOutcome, OpError> {
245 if flags.schema_only {
246 return Ok(OpOutcome::new(
247 NOUN,
248 "list",
249 json!({"input_schema": "env_id positional"}),
250 ));
251 }
252 let env_id = parse_env_id(env_id)?;
253 if !store.env_exists(&env_id)? {
254 return Err(OpError::NotFound(format!("environment `{env_id}`")));
255 }
256 let env = store.load_env(&env_id)?;
257 let bindings: Vec<ExtensionSummary> = env
258 .extensions
259 .iter()
260 .map(|b| ExtensionSummary::from_binding(&env_id, b))
261 .collect();
262 Ok(OpOutcome::new(
263 NOUN,
264 "list",
265 json!({"environment_id": env_id.as_str(), "extensions": bindings}),
266 ))
267}
268
269pub(crate) fn build_key(kind: &str, instance_id: &Option<String>) -> Result<ExtensionKey, OpError> {
274 let descriptor = PackDescriptor::try_new(kind)
275 .map_err(|e| OpError::InvalidArgument(format!("kind: {e}")))?;
276 Ok(ExtensionKey::new(descriptor.path(), instance_id.clone()))
277}
278
279fn resolve_payload<T: serde::de::DeserializeOwned>(
280 flags: &OpFlags,
281 payload: Option<T>,
282) -> Result<T, OpError> {
283 if let Some(p) = payload {
284 return Ok(p);
285 }
286 if let Some(path) = &flags.answers {
287 return super::load_answers::<T>(path);
288 }
289 Err(OpError::InvalidArgument(
290 "no payload provided: pass --answers <path> or supply the payload directly".to_string(),
291 ))
292}
293
294fn parse_env_id(raw: &str) -> Result<EnvId, OpError> {
295 EnvId::try_from(raw).map_err(|e| OpError::InvalidArgument(format!("environment_id: {e}")))
296}
297
298pub(crate) fn build_binding(
299 payload: &ExtensionBindingPayload,
300 generation: u64,
301 previous_binding_ref: Option<PathBuf>,
302) -> Result<ExtensionBinding, OpError> {
303 let kind = PackDescriptor::try_new(payload.kind.clone())
304 .map_err(|e| OpError::InvalidArgument(format!("kind: {e}")))?;
305 Ok(ExtensionBinding {
306 kind,
307 pack_ref: PackId::new(payload.pack_ref.clone()),
308 instance_id: payload.instance_id.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": "ExtensionBindingPayload",
319 "type": "object",
320 "required": ["environment_id", "kind", "pack_ref"],
321 "additionalProperties": false,
322 "properties": {
323 "environment_id": {"type": "string"},
324 "kind": {"type": "string", "description": "PackDescriptor — `<namespace>.<id>@<semver>`."},
325 "pack_ref": {"type": "string"},
326 "instance_id": {"type": ["string", "null"], "description": "Distinguishes N instances of the same extension; omit for the single default instance."},
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": "ExtensionRemovePayload",
340 "type": "object",
341 "required": ["environment_id", "kind"],
342 "additionalProperties": false,
343 "properties": {
344 "environment_id": {"type": "string"},
345 "kind": {"type": "string", "description": "PackDescriptor — `@<version>` is ignored; the path is the key."},
346 "instance_id": {"type": ["string", "null"]},
347 "idempotency_key": {
348 "type": "string",
349 "description": "Optional A8 §2 caller-supplied key for safe retry replay; minted per-invocation when omitted."
350 }
351 }
352 })
353}
354
355#[cfg(test)]
356mod tests {
357 use super::*;
358 use crate::cli::tests_common::make_env;
359 use crate::environment::EnvironmentStore;
360 use crate::environment::LocalFsStore;
361 use tempfile::tempdir;
362
363 fn payload(kind: &str, instance: Option<&str>) -> ExtensionBindingPayload {
364 ExtensionBindingPayload {
365 environment_id: "local".to_string(),
366 kind: kind.to_string(),
367 pack_ref: kind.split('@').next().unwrap_or(kind).to_string(),
368 instance_id: instance.map(str::to_string),
369 answers_ref: None,
370 idempotency_key: None,
371 }
372 }
373
374 fn extensions(outcome: &OpOutcome) -> Vec<Value> {
375 outcome
376 .result
377 .get("extensions")
378 .and_then(|v| v.as_array())
379 .expect("extensions array")
380 .clone()
381 }
382
383 #[test]
384 fn add_then_list_returns_binding() {
385 let dir = tempdir().unwrap();
386 let store = LocalFsStore::new(dir.path());
387 store.save(&make_env("local")).unwrap();
388 add(
389 &store,
390 &OpFlags::default(),
391 Some(payload("acme.oauth.auth0@1.0.0", None)),
392 )
393 .unwrap();
394 let listed = list(&store, &OpFlags::default(), "local").unwrap();
395 let bindings = extensions(&listed);
396 assert_eq!(bindings.len(), 1);
397 assert_eq!(
398 bindings[0].get("kind").and_then(|v| v.as_str()),
399 Some("acme.oauth.auth0@1.0.0")
400 );
401 }
402
403 #[test]
404 fn add_allows_multiple_instances_same_path() {
405 let dir = tempdir().unwrap();
406 let store = LocalFsStore::new(dir.path());
407 store.save(&make_env("local")).unwrap();
408 add(
410 &store,
411 &OpFlags::default(),
412 Some(payload("acme.oauth.auth0@1.0.0", None)),
413 )
414 .unwrap();
415 add(
416 &store,
417 &OpFlags::default(),
418 Some(payload("acme.oauth.auth0@1.0.0", Some("primary"))),
419 )
420 .unwrap();
421 add(
422 &store,
423 &OpFlags::default(),
424 Some(payload("acme.oauth.auth0@1.0.0", Some("secondary"))),
425 )
426 .unwrap();
427 let listed = list(&store, &OpFlags::default(), "local").unwrap();
428 assert_eq!(extensions(&listed).len(), 3);
429 }
430
431 #[test]
432 fn add_rejects_duplicate_key() {
433 let dir = tempdir().unwrap();
434 let store = LocalFsStore::new(dir.path());
435 store.save(&make_env("local")).unwrap();
436 let p = payload("acme.oauth.auth0@1.0.0", Some("primary"));
437 add(&store, &OpFlags::default(), Some(p.clone())).unwrap();
438 let err = add(&store, &OpFlags::default(), Some(p)).unwrap_err();
439 assert!(matches!(err, OpError::Conflict(_)), "got {err:?}");
440 }
441
442 #[test]
443 fn update_then_rollback_restores_previous() {
444 let dir = tempdir().unwrap();
445 let store = LocalFsStore::new(dir.path());
446 store.save(&make_env("local")).unwrap();
447 add(
448 &store,
449 &OpFlags::default(),
450 Some(payload("acme.oauth.auth0@1.0.0", Some("primary"))),
451 )
452 .unwrap();
453 let updated = update(
454 &store,
455 &OpFlags::default(),
456 Some(payload("acme.oauth.auth0@2.0.0", Some("primary"))),
457 )
458 .unwrap();
459 assert_eq!(
460 updated.result.get("kind").and_then(|v| v.as_str()),
461 Some("acme.oauth.auth0@2.0.0")
462 );
463 assert_eq!(
464 updated.result.get("generation").and_then(|v| v.as_u64()),
465 Some(1)
466 );
467 let rolled = rollback(
468 &store,
469 &OpFlags::default(),
470 Some(ExtensionRemovePayload {
471 environment_id: "local".to_string(),
472 kind: "acme.oauth.auth0@2.0.0".to_string(),
473 instance_id: Some("primary".to_string()),
474 idempotency_key: None,
475 }),
476 )
477 .unwrap();
478 assert_eq!(
479 rolled.result.get("kind").and_then(|v| v.as_str()),
480 Some("acme.oauth.auth0@1.0.0"),
481 "rollback restores the pre-update version"
482 );
483 assert_eq!(
484 rolled.result.get("generation").and_then(|v| v.as_u64()),
485 Some(2)
486 );
487 }
488
489 #[test]
490 fn remove_targets_the_right_instance() {
491 let dir = tempdir().unwrap();
492 let store = LocalFsStore::new(dir.path());
493 store.save(&make_env("local")).unwrap();
494 add(
495 &store,
496 &OpFlags::default(),
497 Some(payload("acme.oauth.auth0@1.0.0", Some("primary"))),
498 )
499 .unwrap();
500 add(
501 &store,
502 &OpFlags::default(),
503 Some(payload("acme.oauth.auth0@1.0.0", Some("secondary"))),
504 )
505 .unwrap();
506 remove(
507 &store,
508 &OpFlags::default(),
509 Some(ExtensionRemovePayload {
510 environment_id: "local".to_string(),
511 kind: "acme.oauth.auth0@9.9.9".to_string(), instance_id: Some("primary".to_string()),
513 idempotency_key: None,
514 }),
515 )
516 .unwrap();
517 let listed = list(&store, &OpFlags::default(), "local").unwrap();
518 let bindings = extensions(&listed);
519 assert_eq!(bindings.len(), 1);
520 assert_eq!(
521 bindings[0].get("instance_id").and_then(|v| v.as_str()),
522 Some("secondary")
523 );
524 }
525
526 #[test]
527 fn remove_absent_extension_errors_not_found() {
528 let dir = tempdir().unwrap();
529 let store = LocalFsStore::new(dir.path());
530 store.save(&make_env("local")).unwrap();
531 let err = remove(
532 &store,
533 &OpFlags::default(),
534 Some(ExtensionRemovePayload {
535 environment_id: "local".to_string(),
536 kind: "acme.oauth.auth0@1.0.0".to_string(),
537 instance_id: None,
538 idempotency_key: None,
539 }),
540 )
541 .unwrap_err();
542 assert!(matches!(err, OpError::NotFound(_)), "got {err:?}");
543 }
544
545 #[test]
546 fn rollback_after_remove_errors_not_found() {
547 let dir = tempdir().unwrap();
551 let store = LocalFsStore::new(dir.path());
552 store.save(&make_env("local")).unwrap();
553 add(
554 &store,
555 &OpFlags::default(),
556 Some(payload("acme.oauth.auth0@1.0.0", Some("primary"))),
557 )
558 .unwrap();
559 remove(
560 &store,
561 &OpFlags::default(),
562 Some(ExtensionRemovePayload {
563 environment_id: "local".to_string(),
564 kind: "acme.oauth.auth0@1.0.0".to_string(),
565 instance_id: Some("primary".to_string()),
566 idempotency_key: None,
567 }),
568 )
569 .unwrap();
570 let err = rollback(
571 &store,
572 &OpFlags::default(),
573 Some(ExtensionRemovePayload {
574 environment_id: "local".to_string(),
575 kind: "acme.oauth.auth0@1.0.0".to_string(),
576 instance_id: Some("primary".to_string()),
577 idempotency_key: None,
578 }),
579 )
580 .unwrap_err();
581 assert!(matches!(err, OpError::NotFound(_)), "got {err:?}");
582 }
583
584 #[test]
585 fn add_rejects_invalid_instance_id_at_save() {
586 let dir = tempdir().unwrap();
587 let store = LocalFsStore::new(dir.path());
588 store.save(&make_env("local")).unwrap();
589 let err = add(
592 &store,
593 &OpFlags::default(),
594 Some(payload("acme.oauth.auth0@1.0.0", Some("Bad_Instance"))),
595 )
596 .unwrap_err();
597 assert!(
598 !matches!(err, OpError::Conflict(_)),
599 "expected a validation error, got {err:?}"
600 );
601 }
602
603 #[test]
606 fn add_schema_lists_idempotency_key() {
607 let schema = payload_schema();
608 assert!(
609 schema.pointer("/properties/idempotency_key").is_some(),
610 "payload_schema must list `idempotency_key` so --schema-driven \
611 callers can supply the A8 retry key (schema: {schema:#})"
612 );
613 }
614
615 #[test]
616 fn update_schema_lists_idempotency_key() {
617 let outcome = update(
619 &LocalFsStore::new(tempdir().unwrap().path()),
620 &OpFlags {
621 schema_only: true,
622 ..OpFlags::default()
623 },
624 None,
625 )
626 .unwrap();
627 assert!(
628 outcome
629 .result
630 .pointer("/properties/idempotency_key")
631 .is_some(),
632 "update --schema must list `idempotency_key`"
633 );
634 }
635
636 #[test]
637 fn remove_schema_lists_idempotency_key() {
638 let schema = remove_schema();
639 assert!(
640 schema.pointer("/properties/idempotency_key").is_some(),
641 "remove_schema must list `idempotency_key` so --schema-driven \
642 callers can supply the A8 retry key (schema: {schema:#})"
643 );
644 }
645
646 #[test]
647 fn rollback_schema_lists_idempotency_key() {
648 let outcome = rollback(
650 &LocalFsStore::new(tempdir().unwrap().path()),
651 &OpFlags {
652 schema_only: true,
653 ..OpFlags::default()
654 },
655 None,
656 )
657 .unwrap();
658 assert!(
659 outcome
660 .result
661 .pointer("/properties/idempotency_key")
662 .is_some(),
663 "rollback --schema must list `idempotency_key`"
664 );
665 }
666}