Skip to main content

greentic_deployer/cli/
extensions.rs

1//! `gtc op extensions {add,update,remove,rollback,list}` (`Path 3`).
2//!
3//! Manages `Environment.extensions: Vec<ExtensionBinding>` — the env's
4//! open-namespace capability bindings. Unlike `op env-packs` (which manages the
5//! closed, 1-per-slot core `packs`), extensions are **N-per-env**: their slot is
6//! always [`CapabilitySlot::Extension`](greentic_deploy_spec::CapabilitySlot),
7//! and identity is `(kind.path(), instance_id)` — the descriptor path plus an
8//! optional instance selector for N instances of the same extension type.
9//!
10//! A workload resolves a binding by name at runtime via
11//! `ext://<path>[/<instance>]`; no typed host interface is wired. Mutations bump
12//! [`ExtensionBinding::generation`](greentic_deploy_spec::ExtensionBinding) and
13//! stash the prior binding inline so `rollback` can restore it, reusing the same
14//! one-step machinery as `env-packs`.
15
16use 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/// Payload for `op extensions add` / `op extensions update`.
32#[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    /// Caller-supplied A8 §2 idempotency key. Optional on the CLI
42    /// surface; when absent, the verb mints one per invocation. Operators
43    /// wanting safe lost-response retries (HTTP backend, PR-3b) supply a
44    /// stable key.
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub idempotency_key: Option<String>,
47}
48
49/// Payload for `op extensions remove` / `op extensions rollback`. Identifies a
50/// binding by `(kind.path(), instance_id)` — the descriptor `@<version>` is
51/// ignored for matching (the path is the version-independent key).
52#[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    /// Caller-supplied A8 §2 idempotency key. Optional on the CLI
59    /// surface; when absent, the verb mints one per invocation.
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub idempotency_key: Option<String>,
62}
63
64/// Returned by every mutating call.
65#[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
269// --- internals -----------------------------------------------------------
270
271/// Build the lookup key from a remove/rollback payload's `kind` (path is the
272/// version-independent key) and `instance_id`.
273pub(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        // default (unnamed) + two named instances on the SAME path all coexist.
409        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(), // version ignored
512                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        // Contract (parity with `env-packs`): `rollback` reverts the previous
548        // `update`; `remove` is terminal. After a remove there is no binding to
549        // roll back to — restore by re-adding, not by rollback.
550        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        // Uppercase is outside the instance-id charset; validate() at save
590        // rejects it.
591        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    // --- PR-3a.9 schema regression tests ---
604
605    #[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        // `update` reuses `payload_schema`, but verify via the public path.
618        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        // `rollback` reuses `remove_schema`, but verify via the public path.
649        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}