xianyun_deploy/registrycache/
mod.rs1#![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
23pub const DEFAULT_PORT: &str = "5555";
28
29#[derive(Debug, Error)]
31pub enum RegistryCacheError {
32 #[error("zot binary not found on PATH: {0}")]
37 ZotNotInstalled(std::io::Error),
38 #[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 #[error(transparent)]
47 Config(#[from] ConfigError),
48 #[error(transparent)]
50 Path(#[from] PathError),
51 #[error(transparent)]
53 Lock(#[from] lock::LockError),
54 #[error(transparent)]
56 Refs(#[from] refs::RefsError),
57 #[error(transparent)]
59 Process(#[from] process::ProcessError),
60 #[error("creating data dir: {0}")]
62 CreateDataDir(std::io::Error),
63 #[error("writing zot config: {0}")]
65 WriteConfig(std::io::Error),
66 #[error("checking persisted config: {0}")]
68 CheckConfig(std::io::Error),
69}
70
71#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct Endpoint {
77 pub port: String,
79}
80
81impl Endpoint {
82 #[must_use]
85 pub fn host_port(&self, host: &str) -> String {
86 format!("{host}:{}", self.port)
87 }
88}
89
90#[derive(Debug, Clone, Default)]
92pub struct State {
93 pub running: bool,
95 pub pid: i32,
97 pub port: String,
99 pub refs: Vec<String>,
101 pub upstreams: Vec<String>,
104 pub data_bytes: u64,
107 pub config_path: PathBuf,
109 pub data_dir: PathBuf,
111 pub log_path: PathBuf,
113}
114
115fn lookup_zot() -> Result<PathBuf, RegistryCacheError> {
116 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
141pub 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
158fn 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
179fn write_config(
182 data_dir: &std::path::Path,
183 upstreams: &[String],
184) -> Result<PathBuf, RegistryCacheError> {
185 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
197fn 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
217pub 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
237pub 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
280pub fn stop() -> Result<(), RegistryCacheError> {
289 lock::with_lock(|| {
290 process::stop_process()?;
291 tracing::info!("registry cache zot stopped");
292 Ok(())
293 })
294}
295
296pub 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
324fn 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
361fn 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
376fn 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#[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 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 unsafe {
441 std::env::set_var("HOME", dir.path());
442 }
443
444 let result = f(dir.path());
445
446 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 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 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 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 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 #[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 #[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 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 #[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 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 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 #[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 #[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 #[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 let refs = refs::list_refs().unwrap();
684 assert_eq!(refs, vec!["alpha".to_string()]);
685 });
686 }
687
688 #[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 #[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 #[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 #[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 let refs = refs::list_refs().unwrap();
750 assert!(refs.is_empty());
751
752 let _ = process::stop_process();
753 });
754 }
755}