Skip to main content

xianyun_cli/commands/
registry.rs

1//! `caproni registry`: manage caproni's host-side registry cache.
2//!
3//! Port of Go's `cmd/registry` package (`start`, `status`, `stop`).
4
5use std::io::Write;
6
7use clap::{Args, Subcommand, ValueEnum};
8use thiserror::Error;
9use xianyun_deploy::registrycache::{self, RegistryCacheError, State};
10
11#[derive(Args, Debug)]
12pub struct RegistryArgs {
13    #[command(subcommand)]
14    pub command: RegistryCommands,
15}
16
17#[derive(Subcommand, Debug)]
18pub enum RegistryCommands {
19    /// Show the host-side registry cache state.
20    Status(RegistryStatusArgs),
21
22    /// Start the host-side registry cache (zot process).
23    Start(RegistryStartArgs),
24
25    /// Stop the host-side registry cache (zot process).
26    Stop(RegistryStopArgs),
27}
28
29#[derive(Copy, Clone, Debug, ValueEnum, Default, PartialEq, Eq)]
30pub enum OutputFormat {
31    #[default]
32    Text,
33    Json,
34}
35
36#[derive(Args, Debug)]
37pub struct RegistryStatusArgs {
38    /// Output format: text or json.
39    #[arg(short = 'o', long = "output", default_value = "text")]
40    pub output: OutputFormat,
41}
42
43#[derive(Args, Debug)]
44pub struct RegistryStartArgs {}
45
46#[derive(Args, Debug)]
47pub struct RegistryStopArgs {}
48
49/// Errors from `caproni registry status`. Port of Go's
50/// `registry.ErrUnsupportedOutputFormat`.
51#[derive(Debug, Error)]
52pub enum RegistryStatusError {
53    /// `--output` was neither `text` nor `json`.
54    #[error("unsupported output format {0:?}: expected text or json")]
55    UnsupportedOutputFormat(String),
56}
57
58pub fn run(args: &RegistryArgs) -> anyhow::Result<()> {
59    let mut stdout = std::io::stdout();
60
61    match &args.command {
62        RegistryCommands::Status(status_args) => {
63            let format = match status_args.output {
64                OutputFormat::Text => "text",
65                OutputFormat::Json => "json",
66            };
67            run_status(&mut stdout, format)
68        }
69        RegistryCommands::Start(_) => run_start_cmd(&mut stdout),
70        RegistryCommands::Stop(_) => run_stop(&mut stdout),
71    }
72}
73
74/// Port of Go's `registry.runStartCmd`: respawns the host-side zot
75/// registry cache using the config persisted by a prior `caproni up`.
76///
77/// # Errors
78///
79/// Returns an error if reading the current status fails, or if
80/// starting the zot process fails for a reason other than a missing
81/// persisted config (that case is reported on `stdout` and treated as
82/// a non-error exit, matching Go's `cli.Exit("", 1)` after already
83/// printing the message).
84pub fn run_start_cmd(stdout: &mut impl Write) -> anyhow::Result<()> {
85    let state = registrycache::status()
86        .map_err(|err| anyhow::anyhow!("reading registry cache status: {err}"))?;
87
88    if state.running {
89        writeln!(stdout, "registry cache is already running")?;
90        return Ok(());
91    }
92
93    match registrycache::start() {
94        Ok(()) => {
95            writeln!(stdout, "registry cache started")?;
96            Ok(())
97        }
98        Err(RegistryCacheError::NoPersistedConfig) => {
99            writeln!(stdout, "{}", RegistryCacheError::NoPersistedConfig)?;
100            // Mirrors Go's `cli.Exit("", 1)`: the message was already
101            // printed above, so the returned error carries an empty
102            // message; only the non-zero process exit code matters.
103            Err(anyhow::anyhow!(""))
104        }
105        Err(err) => Err(anyhow::anyhow!("starting registry cache: {err}")),
106    }
107}
108
109/// Port of Go's `registry.runStatus`.
110///
111/// # Errors
112///
113/// Returns [`RegistryStatusError::UnsupportedOutputFormat`] if `format`
114/// is neither `"text"` nor `"json"`, or an error if reading the
115/// registry cache status fails.
116pub fn run_status(stdout: &mut impl Write, format: &str) -> anyhow::Result<()> {
117    if format != "text" && format != "json" {
118        return Err(RegistryStatusError::UnsupportedOutputFormat(format.to_string()).into());
119    }
120
121    let state = registrycache::status()
122        .map_err(|err| anyhow::anyhow!("reading registry cache status: {err}"))?;
123
124    if format == "json" {
125        write_status_json(stdout, &state)
126    } else {
127        write_status_text(stdout, &state);
128        Ok(())
129    }
130}
131
132/// JSON shape of `caproni registry status --output json`. Port of Go's
133/// `registry.statusOutput`.
134#[derive(serde::Serialize)]
135struct StatusOutput {
136    running: bool,
137    pid: i32,
138    port: String,
139    refs: Vec<String>,
140    upstreams: Vec<String>,
141    data_bytes: i64,
142    config_path: String,
143    data_dir: String,
144    log_path: String,
145}
146
147fn write_status_json(stdout: &mut impl Write, state: &State) -> anyhow::Result<()> {
148    let out = StatusOutput {
149        running: state.running,
150        pid: state.pid,
151        port: state.port.clone(),
152        refs: state.refs.clone(),
153        upstreams: state.upstreams.clone(),
154        data_bytes: i64::try_from(state.data_bytes).unwrap_or(i64::MAX),
155        config_path: state.config_path.to_string_lossy().into_owned(),
156        data_dir: state.data_dir.to_string_lossy().into_owned(),
157        log_path: state.log_path.to_string_lossy().into_owned(),
158    };
159
160    let body = serde_json::to_string_pretty(&out)
161        .map_err(|err| anyhow::anyhow!("encoding registry cache status: {err}"))?;
162    writeln!(stdout, "{body}")?;
163
164    Ok(())
165}
166
167fn write_status_text(stdout: &mut impl Write, state: &State) {
168    let status = if state.running {
169        format!("Running (pid={}, port={})", state.pid, state.port)
170    } else {
171        "Stopped".to_string()
172    };
173
174    let _ = writeln!(stdout, "Status:      {status}");
175    let _ = writeln!(stdout, "Refs:        {}", join_or_dash(&state.refs));
176    let _ = writeln!(stdout, "Upstreams:   {}", join_or_dash(&state.upstreams));
177    let _ = writeln!(stdout, "Cache size:  {}", humanize_bytes(state.data_bytes));
178    let _ = writeln!(stdout, "Config:      {}", state.config_path.display());
179    let _ = writeln!(stdout, "Data dir:    {}", state.data_dir.display());
180    let _ = writeln!(stdout, "Log:         {}", state.log_path.display());
181}
182
183fn join_or_dash(items: &[String]) -> String {
184    if items.is_empty() {
185        "(none)".to_string()
186    } else {
187        items.join(", ")
188    }
189}
190
191fn humanize_bytes(n: u64) -> String {
192    const KB: u64 = 1024;
193    const MB: u64 = KB * 1024;
194    const GB: u64 = MB * 1024;
195
196    if n < KB {
197        format!("{n} B")
198    } else if n < MB {
199        format!("{:.1} KiB", n as f64 / KB as f64)
200    } else if n < GB {
201        format!("{:.1} MiB", n as f64 / MB as f64)
202    } else {
203        format!("{:.2} GiB", n as f64 / GB as f64)
204    }
205}
206
207/// Port of Go's `registry.runStop`.
208///
209/// # Errors
210///
211/// Returns an error if reading the registry cache status fails, or if
212/// stopping zot fails.
213pub fn run_stop(stdout: &mut impl Write) -> anyhow::Result<()> {
214    let state = registrycache::status()
215        .map_err(|err| anyhow::anyhow!("reading registry cache status: {err}"))?;
216
217    if !state.running {
218        writeln!(stdout, "registry cache is not running")?;
219        return Ok(());
220    }
221
222    if !state.refs.is_empty() {
223        writeln!(
224            stdout,
225            "registry cache is in use by {} profile(s): {:?}\n\
226            release refs first (e.g. `caproni destroy` in each profile) before stopping",
227            state.refs.len(),
228            state.refs
229        )?;
230        // Mirrors Go's `cli.Exit("", 1)`.
231        return Err(anyhow::anyhow!(""));
232    }
233
234    registrycache::stop().map_err(|err| anyhow::anyhow!("stopping registry cache: {err}"))?;
235
236    writeln!(stdout, "registry cache stopped")?;
237
238    Ok(())
239}