Skip to main content

xianyun_deploy/registrycache/
mod.rs

1//! Manages caproni's host-side pull-through zot registry. Owns the zot
2//! binary lifecycle (start on `caproni up`, stop on the last
3//! `caproni destroy`), config templating, and the flock-protected ref
4//! counting that lets multiple profiles share a single zot instance.
5//!
6//! Ported from `internal/registrycache/registrycache.go`.
7
8#![allow(dead_code)]
9
10pub mod config;
11pub mod lock;
12pub mod paths;
13pub mod process;
14pub mod refs;
15
16use std::path::PathBuf;
17
18use thiserror::Error;
19
20pub use config::ConfigError;
21pub use paths::PathError;
22
23/// The port zot listens on. Caproni picks a non-default to avoid
24/// collisions with macOS `AirPlay` (which binds 5000). Exported so
25/// drivers can compose their own mirror URL alongside their preferred
26/// host name; see [`Endpoint`] and each driver's mirror-wiring code.
27pub const DEFAULT_PORT: &str = "5555";
28
29/// Errors from registry-cache orchestration.
30#[derive(Debug, Error)]
31pub enum RegistryCacheError {
32    /// The `zot` binary is not on the user's `PATH`. Caproni does not
33    /// pin zot itself; users install it from
34    /// <https://github.com/project-zot/zot/releases> or run
35    /// `mise use -g aqua:project-zot/zot` for a user-wide install.
36    #[error("zot binary not found on PATH: {0}")]
37    ZotNotInstalled(std::io::Error),
38    /// No `ensure` has ever been invoked for this user, so there's no
39    /// `config.json` to spawn zot with.
40    #[error(
41        "no persisted registry cache config; run `caproni up` in a project with \
42        `cluster.registry_mirror.managed: true` first"
43    )]
44    NoPersistedConfig,
45    /// Called with an empty upstream list.
46    #[error(transparent)]
47    Config(#[from] ConfigError),
48    /// Failed resolving a registry-cache path.
49    #[error(transparent)]
50    Path(#[from] PathError),
51    /// Failed acquiring the registry-cache flock.
52    #[error(transparent)]
53    Lock(#[from] lock::LockError),
54    /// Failed a ref-marker file operation.
55    #[error(transparent)]
56    Refs(#[from] refs::RefsError),
57    /// Failed a zot process operation.
58    #[error(transparent)]
59    Process(#[from] process::ProcessError),
60    /// Failed creating the data directory.
61    #[error("creating data dir: {0}")]
62    CreateDataDir(std::io::Error),
63    /// Failed writing the zot config to disk.
64    #[error("writing zot config: {0}")]
65    WriteConfig(std::io::Error),
66    /// Failed checking whether a persisted config exists.
67    #[error("checking persisted config: {0}")]
68    CheckConfig(std::io::Error),
69}
70
71/// Describes the running zot from the cluster's perspective. Each
72/// driver composes its own mirror URL by calling [`Endpoint::host_port`]
73/// with the host name resolvable from inside its own cluster (colima
74/// uses `host.docker.internal`, k3d uses `host.k3d.internal`).
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct Endpoint {
77    /// The port zot listens on (currently always [`DEFAULT_PORT`]).
78    pub port: String,
79}
80
81impl Endpoint {
82    /// Returns `"<host>:<port>"` suitable for use in a registry mirror
83    /// URL.
84    #[must_use]
85    pub fn host_port(&self, host: &str) -> String {
86        format!("{host}:{}", self.port)
87    }
88}
89
90/// Captures the result of [`status`]. Powers `caproni registry status`.
91#[derive(Debug, Clone, Default)]
92pub struct State {
93    /// Whether zot is currently running.
94    pub running: bool,
95    /// The zot process's PID, when running.
96    pub pid: i32,
97    /// The port zot listens on.
98    pub port: String,
99    /// Profile names holding zot up.
100    pub refs: Vec<String>,
101    /// Configured upstreams (parsed from the running zot config); empty
102    /// if the config is absent or unreadable.
103    pub upstreams: Vec<String>,
104    /// Size of the data dir in bytes; zero when the dir does not exist
105    /// yet.
106    pub data_bytes: u64,
107    /// Path to the generated zot config.
108    pub config_path: PathBuf,
109    /// Path to the zot blob/manifest cache.
110    pub data_dir: PathBuf,
111    /// Path to the zot stdout/stderr log.
112    pub log_path: PathBuf,
113}
114
115fn lookup_zot() -> Result<PathBuf, RegistryCacheError> {
116    // Mirrors Go's `exec.LookPath("zot")`: search `$PATH` for an
117    // executable named `zot`.
118    let path_var = std::env::var_os("PATH").unwrap_or_default();
119    for dir in std::env::split_paths(&path_var) {
120        let candidate = dir.join("zot");
121        if is_executable(&candidate) {
122            return Ok(candidate);
123        }
124    }
125
126    Err(RegistryCacheError::ZotNotInstalled(std::io::Error::new(
127        std::io::ErrorKind::NotFound,
128        "zot: executable file not found in $PATH",
129    )))
130}
131
132fn is_executable(path: &std::path::Path) -> bool {
133    use std::os::unix::fs::PermissionsExt;
134
135    match std::fs::metadata(path) {
136        Ok(meta) => meta.is_file() && meta.permissions().mode() & 0o111 != 0,
137        Err(_) => false,
138    }
139}
140
141/// Starts the host-side zot if not running, registers the given
142/// profile's ref, regenerates the config, and returns the endpoint
143/// drivers should configure as a mirror. Idempotent. Concurrency-safe:
144/// holds an exclusive flock for the duration.
145///
146/// `upstreams` must be non-empty.
147pub fn ensure(profile: &str, upstreams: &[String]) -> Result<Endpoint, RegistryCacheError> {
148    if upstreams.is_empty() {
149        return Err(RegistryCacheError::Config(ConfigError::NoUpstreams));
150    }
151
152    let binary = lookup_zot()?;
153    let binary = binary.to_string_lossy().into_owned();
154
155    lock::with_lock(|| ensure_locked(&binary, profile, upstreams))
156}
157
158/// Performs the state-mutating work of [`ensure`] while the package
159/// flock is held. Extracted to keep cognitive complexity low.
160fn ensure_locked(
161    binary: &str,
162    profile: &str,
163    upstreams: &[String],
164) -> Result<Endpoint, RegistryCacheError> {
165    let data_dir = paths::data_dir()?;
166    std::fs::create_dir_all(&data_dir).map_err(RegistryCacheError::CreateDataDir)?;
167
168    let config_path = write_config(&data_dir, upstreams)?;
169
170    refs::add_ref(profile)?;
171
172    start_if_not_running(binary, &config_path)?;
173
174    Ok(Endpoint {
175        port: DEFAULT_PORT.to_string(),
176    })
177}
178
179/// Renders the zot config and writes it to disk. Returns the config
180/// file path on success.
181fn write_config(
182    data_dir: &std::path::Path,
183    upstreams: &[String],
184) -> Result<PathBuf, RegistryCacheError> {
185    // Regenerate the config every `ensure` call so an upstream change
186    // in caproni.yaml is picked up on the next `caproni up`. An
187    // existing zot won't see the new config until it restarts --
188    // documented as a v1 limitation.
189    let body = config::render_config(&data_dir.to_string_lossy(), DEFAULT_PORT, upstreams)?;
190
191    let config_path = paths::config_path()?;
192    std::fs::write(&config_path, body).map_err(RegistryCacheError::WriteConfig)?;
193
194    Ok(config_path)
195}
196
197/// Spawns zot when no live process is detected.
198fn start_if_not_running(
199    binary: &str,
200    config_path: &std::path::Path,
201) -> Result<(), RegistryCacheError> {
202    if process::is_running()? {
203        return Ok(());
204    }
205
206    let args = vec![
207        "serve".to_string(),
208        config_path.to_string_lossy().into_owned(),
209    ];
210    let pid = process::spawn_process(binary, &args)?;
211
212    tracing::info!(pid, port = DEFAULT_PORT, "registry cache zot started");
213
214    Ok(())
215}
216
217/// Releases the profile's hold on the host-side registry cache. The
218/// cache process itself is left running so the next `caproni up` (for
219/// this or any profile) starts warm. Use [`stop`] to explicitly tear
220/// down zot. Concurrency-safe.
221pub fn ensure_stopped_for_profile(profile: &str) -> Result<(), RegistryCacheError> {
222    lock::with_lock(|| {
223        refs::remove_ref(profile)?;
224
225        let remaining = refs::list_refs()?;
226
227        tracing::debug!(
228            removed_ref = profile,
229            ?remaining,
230            "registry cache ref released"
231        );
232
233        Ok(())
234    })
235}
236
237/// Spawns the host-side zot process using the previously persisted
238/// config at [`paths::config_path`]. Returns `Ok(())` immediately if
239/// zot is already running. Does not touch refs -- this is a
240/// process-level operation, not a profile lifecycle event.
241///
242/// Use this from `caproni registry start` for explicit recovery after
243/// zot has died (crash, OOM, user kill). For normal lifecycle, use
244/// [`ensure`].
245///
246/// # Errors
247///
248/// - [`RegistryCacheError::ZotNotInstalled`] if `zot` is not on `PATH`.
249/// - [`RegistryCacheError::NoPersistedConfig`] if no [`ensure`] has
250///   ever run for this user.
251pub fn start() -> Result<(), RegistryCacheError> {
252    let binary = lookup_zot()?;
253    let binary = binary.to_string_lossy().into_owned();
254
255    lock::with_lock(|| start_inner(&binary))
256}
257
258fn start_inner(binary: &str) -> Result<(), RegistryCacheError> {
259    if process::is_running()? {
260        tracing::debug!("registry cache zot already running, skipping start");
261        return Ok(());
262    }
263
264    let config_path = paths::config_path()?;
265    if !config_path.exists() {
266        return Err(RegistryCacheError::NoPersistedConfig);
267    }
268
269    let args = vec![
270        "serve".to_string(),
271        config_path.to_string_lossy().into_owned(),
272    ];
273    let pid = process::spawn_process(binary, &args)?;
274
275    tracing::info!(pid, "registry cache zot started");
276
277    Ok(())
278}
279
280/// Stops the host-side zot process. Refs are preserved (callers that
281/// still expect the cache running can notice via [`status`]). Cached
282/// data on disk is preserved so the next [`ensure`] restarts warm.
283/// Concurrency-safe.
284///
285/// `caproni registry stop` gates this on having zero refs; this
286/// function does not. Direct callers are expected to enforce their own
287/// policy.
288pub fn stop() -> Result<(), RegistryCacheError> {
289    lock::with_lock(|| {
290        process::stop_process()?;
291        tracing::info!("registry cache zot stopped");
292        Ok(())
293    })
294}
295
296/// Reports the current state. Read-only; safe to call without the
297/// lock (concurrent mutations are short and will momentarily
298/// disagree).
299pub fn status() -> Result<State, RegistryCacheError> {
300    let running = process::is_running()?;
301
302    let mut state = State {
303        running,
304        port: DEFAULT_PORT.to_string(),
305        ..Default::default()
306    };
307
308    if running && let Ok(pid) = process::read_pid() {
309        state.pid = pid;
310    }
311
312    state.refs = refs::list_refs()?;
313
314    state.config_path = paths::config_path().unwrap_or_default();
315    state.data_dir = paths::data_dir().unwrap_or_default();
316    state.log_path = paths::log_path().unwrap_or_default();
317
318    state.upstreams = read_upstreams_from_config(&state.config_path);
319    state.data_bytes = dir_size(&state.data_dir);
320
321    Ok(state)
322}
323
324/// Parses the upstream hostnames out of the generated zot config JSON
325/// for display. Returns an empty vec when the file is missing,
326/// unreadable, or malformed -- a config-display feature shouldn't
327/// surface I/O errors.
328fn read_upstreams_from_config(path: &std::path::Path) -> Vec<String> {
329    if path.as_os_str().is_empty() {
330        return Vec::new();
331    }
332
333    let Ok(body) = std::fs::read(path) else {
334        return Vec::new();
335    };
336
337    let Ok(doc) = serde_json::from_slice::<serde_json::Value>(&body) else {
338        return Vec::new();
339    };
340
341    let registries = doc
342        .get("extensions")
343        .and_then(|v| v.get("sync"))
344        .and_then(|v| v.get("registries"))
345        .and_then(|v| v.as_array())
346        .cloned()
347        .unwrap_or_default();
348
349    registries
350        .into_iter()
351        .filter_map(|r| {
352            r.get("urls")
353                .and_then(|u| u.as_array())
354                .and_then(|arr| arr.first())
355                .and_then(|u| u.as_str())
356                .map(hostname_from_url)
357        })
358        .collect()
359}
360
361/// Reverses `upstream_url` for display: strips the scheme and undoes
362/// the `docker.io` quirk so users see what they wrote in caproni.yaml.
363fn hostname_from_url(u: &str) -> String {
364    let host = u
365        .strip_prefix("https://")
366        .or_else(|| u.strip_prefix("http://"))
367        .unwrap_or(u);
368
369    if host == "registry-1.docker.io" {
370        "docker.io".to_string()
371    } else {
372        host.to_string()
373    }
374}
375
376/// Walks the directory and returns the total size of regular files in
377/// bytes. Returns 0 when the directory does not exist or any error
378/// occurs during the walk -- this is a best-effort display value.
379fn dir_size(dir: &std::path::Path) -> u64 {
380    if dir.as_os_str().is_empty() {
381        return 0;
382    }
383
384    fn walk(dir: &std::path::Path, total: &mut u64) {
385        let Ok(entries) = std::fs::read_dir(dir) else {
386            return;
387        };
388
389        for entry in entries.flatten() {
390            let Ok(file_type) = entry.file_type() else {
391                continue;
392            };
393
394            if file_type.is_dir() {
395                walk(&entry.path(), total);
396            } else if file_type.is_file()
397                && let Ok(meta) = entry.metadata()
398            {
399                *total += meta.len();
400            }
401        }
402    }
403
404    let mut total = 0;
405    walk(dir, &mut total);
406    total
407}
408
409/// Test-only helpers shared across every `registrycache` submodule.
410///
411/// The upstream Go test suite relies on `t.Setenv("HOME", ...)` plus
412/// Go's default of running same-package tests sequentially (no
413/// `t.Parallel()` in this package) to make mutating `$HOME`/`$PATH`
414/// safe. Rust's default test harness runs tests from the same binary
415/// concurrently, so we serialize every test that mutates process-wide
416/// environment state behind one mutex and restore the previous value
417/// afterwards.
418#[cfg(test)]
419pub(crate) mod test_support {
420    use std::path::{Path, PathBuf};
421    use std::sync::{Mutex, OnceLock};
422
423    fn env_lock() -> &'static Mutex<()> {
424        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
425        LOCK.get_or_init(|| Mutex::new(()))
426    }
427
428    /// Runs `f` with `$HOME` set to a fresh temporary directory,
429    /// serialized against every other `with_home`/env-mutating test in
430    /// this crate. Restores the previous `$HOME` (or unsets it) on
431    /// return.
432    pub(crate) fn with_home<T>(f: impl FnOnce(&Path) -> T) -> T {
433        let _guard = env_lock().lock().unwrap_or_else(|e| e.into_inner());
434
435        let dir = tempfile::tempdir().unwrap();
436        let previous = std::env::var_os("HOME");
437
438        // SAFETY: serialized by `env_lock` above; no other thread in
439        // this test binary observes `$HOME` while we hold the guard.
440        unsafe {
441            std::env::set_var("HOME", dir.path());
442        }
443
444        let result = f(dir.path());
445
446        // SAFETY: same as above.
447        unsafe {
448            match &previous {
449                Some(v) => std::env::set_var("HOME", v),
450                None => std::env::remove_var("HOME"),
451            }
452        }
453
454        result
455    }
456
457    /// Like [`with_home`], but also prepends `path_prefix` (typically a
458    /// directory containing a stub binary) to `$PATH` for the duration
459    /// of `f`.
460    pub(crate) fn with_home_and_path<T>(path_prefix: &Path, f: impl FnOnce(&Path) -> T) -> T {
461        let _guard = env_lock().lock().unwrap_or_else(|e| e.into_inner());
462
463        let dir = tempfile::tempdir().unwrap();
464        let previous_home = std::env::var_os("HOME");
465        let previous_path = std::env::var_os("PATH");
466
467        let new_path = match &previous_path {
468            Some(p) => {
469                let mut joined = std::ffi::OsString::from(path_prefix);
470                joined.push(":");
471                joined.push(p);
472                joined
473            }
474            None => path_prefix.as_os_str().to_owned(),
475        };
476
477        // SAFETY: serialized by `env_lock` above.
478        unsafe {
479            std::env::set_var("HOME", dir.path());
480            std::env::set_var("PATH", &new_path);
481        }
482
483        let result = f(dir.path());
484
485        // SAFETY: same as above.
486        unsafe {
487            match &previous_home {
488                Some(v) => std::env::set_var("HOME", v),
489                None => std::env::remove_var("HOME"),
490            }
491            match &previous_path {
492                Some(v) => std::env::set_var("PATH", v),
493                None => std::env::remove_var("PATH"),
494            }
495        }
496
497        result
498    }
499
500    /// Renames the stub binary produced by
501    /// `process::tests::write_stub_binary`-style helpers to `zot` in
502    /// place, returning its new path, so `lookup_zot` finds it.
503    pub(crate) fn install_as_zot(stub: &Path) -> PathBuf {
504        let zot_path = stub.with_file_name("zot");
505        std::fs::rename(stub, &zot_path).unwrap();
506        zot_path
507    }
508}
509
510#[cfg(test)]
511mod tests {
512    use std::os::unix::fs::PermissionsExt;
513    use std::path::Path;
514    use std::time::{Duration, Instant};
515
516    use super::*;
517    use test_support::{install_as_zot, with_home, with_home_and_path};
518
519    fn write_stub_binary() -> tempfile::TempDir {
520        let dir = tempfile::tempdir().unwrap();
521        let bin = dir.path().join("zot-stub");
522
523        let script = format!(
524            "#!/bin/sh\n\
525            trap \"exit 0\" INT TERM\n\
526            echo woof > {}/started\n\
527            i=0; while [ \"$i\" -lt 60 ]; do sleep 1; i=$((i + 1)); done\n",
528            dir.path().display()
529        );
530
531        std::fs::write(&bin, script).unwrap();
532        std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
533
534        dir
535    }
536
537    fn wait_for_process_started(bin: &Path) {
538        let dir = bin.parent().unwrap();
539        let started = dir.join("started");
540
541        let deadline = Instant::now() + Duration::from_secs(5);
542        while Instant::now() < deadline {
543            if started.exists() {
544                return;
545            }
546            std::thread::sleep(Duration::from_millis(10));
547        }
548
549        panic!("timed out waiting for stub process to start");
550    }
551
552    // Ported from internal/registrycache/registrycache_test.go:13 (TestEnsureWritesConfigAndStartsAndAddsRef)
553    #[test]
554    fn test_ensure_writes_config_and_starts_and_adds_ref() {
555        let stub_dir = write_stub_binary();
556        let stub = stub_dir.path().join("zot-stub");
557        let zot = install_as_zot(&stub);
558
559        with_home_and_path(zot.parent().unwrap(), |_home| {
560            let ep = ensure("alpha", &["docker.io".to_string()]).unwrap();
561            assert_eq!(ep.port, DEFAULT_PORT);
562            assert_eq!(
563                ep.host_port("host.docker.internal"),
564                format!("host.docker.internal:{DEFAULT_PORT}")
565            );
566
567            wait_for_process_started(&zot);
568
569            let config_path = paths::config_path().unwrap();
570            assert!(config_path.exists());
571
572            let refs = refs::list_refs().unwrap();
573            assert_eq!(refs, vec!["alpha".to_string()]);
574
575            let running = process::is_running().unwrap();
576            assert!(running);
577
578            let _ = process::stop_process();
579        });
580    }
581
582    // Ported from internal/registrycache/registrycache_test.go:49 (TestEnsureIdempotent)
583    #[test]
584    fn test_ensure_idempotent() {
585        let stub_dir = write_stub_binary();
586        let stub = stub_dir.path().join("zot-stub");
587        let zot = install_as_zot(&stub);
588
589        with_home_and_path(zot.parent().unwrap(), |_home| {
590            ensure("alpha", &["docker.io".to_string()]).unwrap();
591
592            let pid_path = paths::pid_path().unwrap();
593            let first = std::fs::read_to_string(&pid_path).unwrap();
594
595            // Second ensure for the same profile: should not respawn.
596            ensure("alpha", &["docker.io".to_string()]).unwrap();
597
598            let second = std::fs::read_to_string(&pid_path).unwrap();
599            assert_eq!(
600                first, second,
601                "ensure must not respawn an already-running zot"
602            );
603
604            let _ = process::stop_process();
605        });
606    }
607
608    // Ported from internal/registrycache/registrycache_test.go:77 (TestEnsureFailsWhenZotMissing)
609    #[test]
610    fn test_ensure_fails_when_zot_missing() {
611        with_home(|_home| {
612            let empty_dir = tempfile::tempdir().unwrap();
613            let previous_path = std::env::var_os("PATH");
614
615            // SAFETY: serialized inside `with_home`'s guard.
616            unsafe {
617                std::env::set_var("PATH", empty_dir.path());
618            }
619
620            let err = ensure("alpha", &["docker.io".to_string()]).unwrap_err();
621            assert!(matches!(err, RegistryCacheError::ZotNotInstalled(_)));
622
623            // SAFETY: same as above.
624            unsafe {
625                match &previous_path {
626                    Some(v) => std::env::set_var("PATH", v),
627                    None => std::env::remove_var("PATH"),
628                }
629            }
630        });
631    }
632
633    // Ported from internal/registrycache/registrycache_test.go:90 (TestEnsureNoUpstreamsIsError)
634    #[test]
635    fn test_ensure_no_upstreams_is_error() {
636        with_home(|_home| {
637            let err = ensure("alpha", &[]).unwrap_err();
638            assert!(matches!(
639                err,
640                RegistryCacheError::Config(ConfigError::NoUpstreams)
641            ));
642        });
643    }
644
645    // Ported from internal/registrycache/registrycache_test.go:100 (TestEnsureStoppedForProfileRemovesRefButLeavesZotRunning)
646    #[test]
647    fn test_ensure_stopped_for_profile_removes_ref_but_leaves_zot_running() {
648        let stub_dir = write_stub_binary();
649        let stub = stub_dir.path().join("zot-stub");
650        let zot = install_as_zot(&stub);
651
652        with_home_and_path(zot.parent().unwrap(), |_home| {
653            ensure("alpha", &["docker.io".to_string()]).unwrap();
654
655            ensure_stopped_for_profile("alpha").unwrap();
656
657            let refs = refs::list_refs().unwrap();
658            assert!(refs.is_empty());
659
660            let running = process::is_running().unwrap();
661            assert!(running);
662
663            let _ = process::stop_process();
664        });
665    }
666
667    // Ported from internal/registrycache/registrycache_test.go:127 (TestStopKillsZotEvenWithRefs)
668    #[test]
669    fn test_stop_kills_zot_even_with_refs() {
670        let stub_dir = write_stub_binary();
671        let stub = stub_dir.path().join("zot-stub");
672        let zot = install_as_zot(&stub);
673
674        with_home_and_path(zot.parent().unwrap(), |_home| {
675            ensure("alpha", &["docker.io".to_string()]).unwrap();
676
677            stop().unwrap();
678
679            let running = process::is_running().unwrap();
680            assert!(!running);
681
682            // Refs are not cleared by stop -- that's the diagnostic story.
683            let refs = refs::list_refs().unwrap();
684            assert_eq!(refs, vec!["alpha".to_string()]);
685        });
686    }
687
688    // Ported from internal/registrycache/registrycache_test.go:152 (TestStatusReportsRunningAndRefs)
689    #[test]
690    fn test_status_reports_running_and_refs() {
691        let stub_dir = write_stub_binary();
692        let stub = stub_dir.path().join("zot-stub");
693        let zot = install_as_zot(&stub);
694
695        with_home_and_path(zot.parent().unwrap(), |_home| {
696            ensure("alpha", &["docker.io".to_string(), "quay.io".to_string()]).unwrap();
697
698            let state = status().unwrap();
699            assert!(state.running);
700            assert!(state.pid > 0);
701            assert_eq!(state.port, DEFAULT_PORT);
702            assert_eq!(state.refs, vec!["alpha".to_string()]);
703
704            let _ = process::stop_process();
705        });
706    }
707
708    // Ported from internal/registrycache/registrycache_test.go:175 (TestStatusReportsStopped)
709    #[test]
710    fn test_status_reports_stopped() {
711        with_home(|_home| {
712            let state = status().unwrap();
713            assert!(!state.running);
714            assert!(state.refs.is_empty());
715        });
716    }
717
718    // Ported from internal/registrycache/registrycache_test.go:186 (TestStart_NoPersistedConfig)
719    #[test]
720    fn test_start_no_persisted_config() {
721        let stub_dir = write_stub_binary();
722        let stub = stub_dir.path().join("zot-stub");
723        let zot = install_as_zot(&stub);
724
725        with_home_and_path(zot.parent().unwrap(), |_home| {
726            let err = start().unwrap_err();
727            assert!(matches!(err, RegistryCacheError::NoPersistedConfig));
728        });
729    }
730
731    // Ported from internal/registrycache/registrycache_test.go:200 (TestStart_SpawnsWhenConfigExists)
732    #[test]
733    fn test_start_spawns_when_config_exists() {
734        let stub_dir = write_stub_binary();
735        let stub = stub_dir.path().join("zot-stub");
736        let zot = install_as_zot(&stub);
737
738        with_home_and_path(zot.parent().unwrap(), |_home| {
739            let config_path = paths::config_path().unwrap();
740            std::fs::create_dir_all(config_path.parent().unwrap()).unwrap();
741            std::fs::write(&config_path, r#"{"distSpecVersion":"1.1.1"}"#).unwrap();
742
743            start().unwrap();
744
745            let running = process::is_running().unwrap();
746            assert!(running);
747
748            // Refs are NOT touched by start.
749            let refs = refs::list_refs().unwrap();
750            assert!(refs.is_empty());
751
752            let _ = process::stop_process();
753        });
754    }
755}