Skip to main content

systemprompt_cli/commands/admin/config/
planes.rs

1//! `admin config planes`: list, diff and apply declared configuration planes.
2//!
3//! Every subcommand runs headless against the active profile's database; no
4//! browser session is involved. `apply` writes through the same audited path
5//! as the `config_reconcile` job, recording each changed entity in
6//! `config_change_audit` with actor `cli` and the given reason.
7//!
8//! Copyright (c) systemprompt.io — Business Source License 1.1.
9//! See <https://systemprompt.io> for licensing details.
10
11use std::collections::HashMap;
12use std::sync::Arc;
13
14use anyhow::{Result, bail};
15use clap::{Args, Subcommand, ValueEnum};
16use serde::Serialize;
17use systemprompt_extension::prelude::{ApplyMode, ChangeActor, ChangeRequest};
18use systemprompt_runtime::AppContext;
19use systemprompt_scheduler::services::ConfigReconcileService;
20
21use crate::CliConfig;
22use crate::shared::{CommandOutput, render_result};
23
24#[derive(Debug, Subcommand)]
25pub enum PlanesCommands {
26    #[command(about = "List registered config planes with authority, hash and last reconcile")]
27    List,
28
29    #[command(about = "Show the drift between a plane's declaration and the database")]
30    Diff(DiffArgs),
31
32    #[command(about = "Apply a plane's declaration to the database (audited)")]
33    Apply(ApplyArgs),
34}
35
36#[derive(Debug, Clone, Args)]
37pub struct DiffArgs {
38    #[arg(long)]
39    pub plane: String,
40}
41
42#[derive(Debug, Clone, Args)]
43pub struct ApplyArgs {
44    #[arg(long)]
45    pub plane: String,
46
47    #[arg(
48        long,
49        help = "Why the declaration is being applied; recorded in the audit"
50    )]
51    pub reason: String,
52
53    #[arg(long, value_enum, default_value_t = ModeArg::Overwrite)]
54    pub mode: ModeArg,
55}
56
57#[derive(Debug, Clone, Copy, ValueEnum)]
58pub enum ModeArg {
59    Overwrite,
60    InsertOnly,
61}
62
63impl From<ModeArg> for ApplyMode {
64    fn from(mode: ModeArg) -> Self {
65        match mode {
66            ModeArg::Overwrite => Self::Overwrite,
67            ModeArg::InsertOnly => Self::InsertOnly,
68        }
69    }
70}
71
72#[derive(Debug, Serialize)]
73struct PlaneListRow {
74    plane: &'static str,
75    declared_path: &'static str,
76    authority: &'static str,
77    hash: Option<String>,
78    drift_rows: Option<i32>,
79    outcome: Option<String>,
80    reconciled_at: Option<String>,
81}
82
83pub async fn execute(command: PlanesCommands, config: &CliConfig) -> Result<()> {
84    let ctx = Arc::new(AppContext::new().await?);
85    let service = ConfigReconcileService::from_app_context(
86        &ctx,
87        systemprompt_scheduler::repository::ConfigReconcileRepository::new(ctx.db_pool()),
88    )?;
89    let output = match command {
90        PlanesCommands::List => list(&service).await?,
91        PlanesCommands::Diff(args) => diff(&service, &args.plane).await?,
92        PlanesCommands::Apply(args) => apply(&service, &args).await?,
93    };
94    render_result(&output, config);
95    Ok(())
96}
97
98async fn list(service: &ConfigReconcileService) -> Result<CommandOutput> {
99    let mut states: HashMap<String, _> = service
100        .repository()
101        .list_states()
102        .await?
103        .into_iter()
104        .map(|row| (row.plane.clone(), row))
105        .collect();
106    let set = service.planes();
107    let mut rows = Vec::new();
108    for plane in set.planes() {
109        let state = states.remove(plane.name());
110        let hash = match plane.load_declared(set.services_root()) {
111            Ok(declared) => declared.map(|d| d.hash),
112            Err(error) => Some(format!("unreadable: {error}")),
113        };
114        rows.push(PlaneListRow {
115            plane: plane.name(),
116            declared_path: plane.declared_path(),
117            authority: set.authority_of(plane.as_ref()).as_str(),
118            hash,
119            drift_rows: state.as_ref().map(|s| s.drift_rows),
120            outcome: state.as_ref().map(|s| s.outcome.clone()),
121            reconciled_at: state.map(|s| s.reconciled_at.to_rfc3339()),
122        });
123    }
124    Ok(CommandOutput::table_of(
125        vec![
126            "plane",
127            "declared_path",
128            "authority",
129            "hash",
130            "drift_rows",
131            "outcome",
132            "reconciled_at",
133        ],
134        &rows,
135    )
136    .with_title("Config planes"))
137}
138
139async fn diff(service: &ConfigReconcileService, plane: &str) -> Result<CommandOutput> {
140    let diff = service.diff(plane).await?;
141    let title = format!(
142        "{} ({}, {}): {} drift rows",
143        diff.plane,
144        diff.declared_path,
145        diff.authority.as_str(),
146        diff.rows.len()
147    );
148    Ok(
149        CommandOutput::table_of(vec!["entity", "kind", "declared", "current"], &diff.rows)
150            .with_title(title),
151    )
152}
153
154async fn apply(service: &ConfigReconcileService, args: &ApplyArgs) -> Result<CommandOutput> {
155    if args.reason.trim().is_empty() {
156        bail!("--reason must not be empty; it is recorded in config_change_audit");
157    }
158    let change = ChangeRequest {
159        actor: ChangeActor::Cli,
160        reason: args.reason.trim(),
161    };
162    let applied = service
163        .apply(&args.plane, args.mode.into(), &change)
164        .await?;
165    Ok(CommandOutput::card_value(
166        format!(
167            "Applied {} ({} entities changed)",
168            applied.plane, applied.changes
169        ),
170        &applied,
171    ))
172}