|
@@ -0,0 +1,359 @@
|
|
|
|
|
+//! Recipe repos: git repos holding `recipes/` (+ `docker-images/` build
|
|
|
|
|
+//! contexts), cloned/pulled by the launcher into a local cache and merged
|
|
|
|
|
+//! into the recipe search path (first repo wins on a name collision — see
|
|
|
|
|
+//! `Config.recipe_repos`).
|
|
|
|
|
+//!
|
|
|
|
|
+//! Archive state lives in a separate JSON registry (`repos.json` next to
|
|
|
|
|
+//! the global config): a `[[recipe_repos]]` entry can be archived without
|
|
|
|
|
+//! editing the config (`sm121 archive <url>` / `unarchive`), and the next
|
|
|
|
|
+//! sync skips archived repos. A repo that is unreachable at sync time
|
|
|
|
|
+//! degrades to a warning — a dead playground repo must not take down
|
|
|
|
|
+//! production serving.
|
|
|
|
|
+
|
|
|
|
|
+use crate::cli::ReposCommands;
|
|
|
|
|
+use crate::config::{RecipeRepo, default_config_dir};
|
|
|
|
|
+use anyhow::{Context, Result};
|
|
|
|
|
+use serde::{Deserialize, Serialize};
|
|
|
|
|
+use sha2::{Digest, Sha256};
|
|
|
|
|
+use std::path::{Path, PathBuf};
|
|
|
|
|
+use tracing::{info, warn};
|
|
|
|
|
+
|
|
|
|
|
+/// Stamp file written into a synced checkout after each successful
|
|
|
|
|
+/// clone/pull (unix-epoch seconds; shown by `sm121 repos`).
|
|
|
|
|
+pub const LAST_SYNC_FILE: &str = ".sm121-last-sync";
|
|
|
|
|
+
|
|
|
|
|
+/// Per-repo cache dir root: `~/.cache/sm121/recipe-repos`.
|
|
|
|
|
+pub fn cache_base() -> PathBuf {
|
|
|
|
|
+ dirs::home_dir()
|
|
|
|
|
+ .unwrap_or_else(|| PathBuf::from("."))
|
|
|
|
|
+ .join(".cache/sm121/recipe-repos")
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// `sha256(url)` hex (full; callers truncate for dir names). Stable per
|
|
|
|
|
+/// URL — the cache dir is keyed on the repo URL, not the hostname.
|
|
|
|
|
+pub(crate) fn url_hash(url: &str) -> String {
|
|
|
|
|
+ let digest = Sha256::digest(url.as_bytes());
|
|
|
|
|
+ digest.iter().map(|b| format!("{:02x}", b)).collect()
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// Cache checkout dir for one repo: `<base>/<sha256(url)16>`.
|
|
|
|
|
+pub fn repo_cache_dir(base: &Path, url: &str) -> PathBuf {
|
|
|
|
|
+ let h = url_hash(url);
|
|
|
|
|
+ base.join(&h[..16])
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// Run `git <args>` locally, capturing output. Stdout on success; an
|
|
|
|
|
+/// anyhow error carrying the stderr tail on failure.
|
|
|
|
|
+pub(crate) fn git(args: &[&str]) -> Result<String> {
|
|
|
|
|
+ let out = std::process::Command::new("git")
|
|
|
|
|
+ .args(args)
|
|
|
|
|
+ .output()
|
|
|
|
|
+ .with_context(|| {
|
|
|
|
|
+ format!(
|
|
|
|
|
+ "failed to run git (first arg: '{}')",
|
|
|
|
|
+ args.first().unwrap_or(&"")
|
|
|
|
|
+ )
|
|
|
|
|
+ })?;
|
|
|
|
|
+ if !out.status.success() {
|
|
|
|
|
+ let err = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
|
|
|
|
+ anyhow::bail!(
|
|
|
|
|
+ "git {} failed: {}",
|
|
|
|
|
+ args.first().copied().unwrap_or(""),
|
|
|
|
|
+ err
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+ Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// Clone URL with the optional token embedded (HTTPS only — for ssh URLs
|
|
|
|
|
+/// the token is a no-op and the URL is used as-is). The token lives only
|
|
|
|
|
+/// in the cached checkout's `.git/config`, never in a recipe repo.
|
|
|
|
|
+fn auth_url(repo: &RecipeRepo) -> String {
|
|
|
|
|
+ if let Some(t) = &repo.token
|
|
|
|
|
+ && (repo.url.starts_with("https://") || repo.url.starts_with("http://"))
|
|
|
|
|
+ {
|
|
|
|
|
+ return repo.url.replace("//", &format!("//{t}@"));
|
|
|
|
|
+ }
|
|
|
|
|
+ repo.url.clone()
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// The push URL for a repo (the token embedded for HTTPS pushes) — used
|
|
|
|
|
+/// by `promote` so the push authenticates without a credential store.
|
|
|
|
|
+pub fn push_url(repo: &RecipeRepo) -> String {
|
|
|
|
|
+ auth_url(repo)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// Clone (first time) or `pull --ff-only` (subsequent) one repo into its
|
|
|
|
|
+/// cache dir; stamp `LAST_SYNC_FILE` on success. `force` re-clones from
|
|
|
|
|
+/// scratch (drops the cached checkout first).
|
|
|
|
|
+pub fn sync_one(url: &str, dir: &Path, force: bool) -> Result<()> {
|
|
|
|
|
+ if force && dir.exists() {
|
|
|
|
|
+ std::fs::remove_dir_all(dir)
|
|
|
|
|
+ .with_context(|| format!("removing cached checkout {}", dir.display()))?;
|
|
|
|
|
+ }
|
|
|
|
|
+ if dir.join(".git").is_dir() {
|
|
|
|
|
+ git(&["-C", &dir.display().to_string(), "pull", "--ff-only"])?;
|
|
|
|
|
+ } else {
|
|
|
|
|
+ if dir.exists() {
|
|
|
|
|
+ // partial clone from an interrupted run — start clean
|
|
|
|
|
+ std::fs::remove_dir_all(dir)?;
|
|
|
|
|
+ }
|
|
|
|
|
+ if let Some(parent) = dir.parent() {
|
|
|
|
|
+ std::fs::create_dir_all(parent)?;
|
|
|
|
|
+ }
|
|
|
|
|
+ git(&["clone", "--", url, &dir.display().to_string()])?;
|
|
|
|
|
+ }
|
|
|
|
|
+ let ts = crate::stamp::now_secs().to_string();
|
|
|
|
|
+ let stamp_path = dir.join(LAST_SYNC_FILE);
|
|
|
|
|
+ std::fs::write(&stamp_path, ts)
|
|
|
|
|
+ .with_context(|| format!("stamping {}", stamp_path.display()))?;
|
|
|
|
|
+ Ok(())
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// The archive registry: which `[[recipe_repos]]` URLs are currently
|
|
|
|
|
+/// archived (skipped by sync). Missing file → a fresh empty registry; a
|
|
|
|
|
+/// corrupt file is moved to `repos.json.bak` and replaced (the launcher
|
|
|
|
|
+/// never crashes on the registry).
|
|
|
|
|
+#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
|
|
|
|
+pub struct ReposRegistry {
|
|
|
|
|
+ #[serde(default)]
|
|
|
|
|
+ pub archived: Vec<ArchiveEntry>,
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
|
|
|
+pub struct ArchiveEntry {
|
|
|
|
|
+ pub url: String,
|
|
|
|
|
+ /// Unix-epoch seconds the entry was archived.
|
|
|
|
|
+ #[serde(default)]
|
|
|
|
|
+ pub at: i64,
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+impl ReposRegistry {
|
|
|
|
|
+ pub fn is_archived(&self, url: &str) -> bool {
|
|
|
|
|
+ self.archived.iter().any(|e| e.url == url)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// `repos.json` next to the global config (`~/.config/sm121/` on Linux,
|
|
|
|
|
+/// `~/Library/Application Support/sm121/` on macOS).
|
|
|
|
|
+pub fn registry_path() -> Option<PathBuf> {
|
|
|
|
|
+ default_config_dir().map(|d| d.join("repos.json"))
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// Load the registry at `path` (missing → fresh; corrupt → `.bak` + fresh
|
|
|
|
|
+/// + a warning).
|
|
|
|
|
+pub fn load_registry(path: &Path) -> ReposRegistry {
|
|
|
|
|
+ match std::fs::read_to_string(path) {
|
|
|
|
|
+ Ok(s) => match serde_json::from_str(&s) {
|
|
|
|
|
+ Ok(r) => r,
|
|
|
|
|
+ Err(e) => {
|
|
|
|
|
+ warn!(
|
|
|
|
|
+ "repos registry '{}' is corrupt ({e}) — moving it to '{}' and starting fresh",
|
|
|
|
|
+ path.display(),
|
|
|
|
|
+ path.with_extension("json.bak").display()
|
|
|
|
|
+ );
|
|
|
|
|
+ let bak = path.with_extension("json.bak");
|
|
|
|
|
+ if std::fs::copy(path, &bak).is_err() {
|
|
|
|
|
+ warn!("could not back up corrupt registry {}", path.display());
|
|
|
|
|
+ }
|
|
|
|
|
+ ReposRegistry::default()
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ Err(_) => ReposRegistry::default(),
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+pub fn save_registry(path: &Path, reg: &ReposRegistry) -> Result<()> {
|
|
|
|
|
+ if let Some(parent) = path.parent() {
|
|
|
|
|
+ std::fs::create_dir_all(parent)?;
|
|
|
|
|
+ }
|
|
|
|
|
+ std::fs::write(path, serde_json::to_string_pretty(reg)?)?;
|
|
|
|
|
+ Ok(())
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// `sm121 archive <url>`: flip the entry in `repos.json` (idempotent).
|
|
|
|
|
+pub fn archive_at(path: &Path, url: &str) -> Result<()> {
|
|
|
|
|
+ let mut reg = load_registry(path);
|
|
|
|
|
+ if !reg.is_archived(url) {
|
|
|
|
|
+ reg.archived.push(ArchiveEntry {
|
|
|
|
|
+ url: url.to_string(),
|
|
|
|
|
+ at: crate::stamp::now_secs(),
|
|
|
|
|
+ });
|
|
|
|
|
+ save_registry(path, ®)?;
|
|
|
|
|
+ }
|
|
|
|
|
+ Ok(())
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// `sm121 unarchive <url>`: drop the entry (a no-op when absent).
|
|
|
|
|
+pub fn unarchive_at(path: &Path, url: &str) -> Result<()> {
|
|
|
|
|
+ let mut reg = load_registry(path);
|
|
|
|
|
+ let before = reg.archived.len();
|
|
|
|
|
+ reg.archived.retain(|e| e.url != url);
|
|
|
|
|
+ if reg.archived.len() != before {
|
|
|
|
|
+ save_registry(path, ®)?;
|
|
|
|
|
+ }
|
|
|
|
|
+ Ok(())
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// Clone/pull every non-archived `repos` entry (list order) into `base`,
|
|
|
|
|
+/// best-effort: an unreachable repo warns and is skipped, the reachable
|
|
|
|
|
+/// ones still land. Returns the checkout roots in list order.
|
|
|
|
|
+pub fn sync_repos(
|
|
|
|
|
+ repos: &[RecipeRepo],
|
|
|
|
|
+ base: &Path,
|
|
|
|
|
+ registry: &ReposRegistry,
|
|
|
|
|
+ force: bool,
|
|
|
|
|
+) -> Vec<PathBuf> {
|
|
|
|
|
+ let mut roots = Vec::new();
|
|
|
|
|
+ for repo in repos {
|
|
|
|
|
+ if registry.is_archived(&repo.url) {
|
|
|
|
|
+ info!("recipe repo archived — skipping: {}", repo.url);
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ let dir = repo_cache_dir(base, &repo.url);
|
|
|
|
|
+ match sync_one(&auth_url(repo), &dir, force) {
|
|
|
|
|
+ Ok(()) => {
|
|
|
|
|
+ info!("recipe repo synced: {} → {}", repo.url, dir.display());
|
|
|
|
|
+ roots.push(dir);
|
|
|
|
|
+ }
|
|
|
|
|
+ Err(e) => {
|
|
|
|
|
+ warn!(
|
|
|
|
|
+ "recipe repo '{}' unreachable — skipping ({}); continuing with the reachable repos",
|
|
|
|
|
+ repo.url, e
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ roots
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// Clone/pull the `Config.recipe_repos` entries into the default cache
|
|
|
|
|
+/// base (blocking git work off the async runtime). Best-effort: per-repo
|
|
|
|
|
+/// failures are warnings, never errors — returns the reachable roots.
|
|
|
|
|
+pub async fn sync_recipe_repos(config: &crate::config::Config) -> Vec<PathBuf> {
|
|
|
|
|
+ let repos = config.recipe_repos.clone();
|
|
|
|
|
+ let reg_path = registry_path();
|
|
|
|
|
+ let base = cache_base();
|
|
|
|
|
+ let registry = reg_path.as_deref().map(load_registry).unwrap_or_default();
|
|
|
|
|
+ tokio::task::spawn_blocking(move || {
|
|
|
|
|
+ let _ = std::fs::create_dir_all(&base);
|
|
|
|
|
+ sync_repos(&repos, &base, ®istry, false)
|
|
|
|
|
+ })
|
|
|
|
|
+ .await
|
|
|
|
|
+ .unwrap_or_default()
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// Clone/pull ONE configured repo into its cache dir (hard failure —
|
|
|
|
|
+/// unlike the best-effort batch sync). Used by `promote` to make sure
|
|
|
|
|
+/// both checkouts exist before planning the move.
|
|
|
|
|
+pub fn sync_repo(repo: &RecipeRepo) -> Result<()> {
|
|
|
|
|
+ let base = cache_base();
|
|
|
|
|
+ let _ = std::fs::create_dir_all(&base);
|
|
|
|
|
+ let dir = repo_cache_dir(&base, &repo.url);
|
|
|
|
|
+ sync_one(&auth_url(repo), &dir, false)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// Count the recipe dirs (`<root>/recipes/<name>/recipe.toml`) in a synced
|
|
|
|
|
+/// checkout — shown by `sm121 repos`.
|
|
|
|
|
+fn recipe_count(root: &Path) -> Option<usize> {
|
|
|
|
|
+ let dir = root.join("recipes");
|
|
|
|
|
+ let Ok(entries) = std::fs::read_dir(&dir) else {
|
|
|
|
|
+ return None;
|
|
|
|
|
+ };
|
|
|
|
|
+ Some(
|
|
|
|
|
+ entries
|
|
|
|
|
+ .flatten()
|
|
|
|
|
+ .filter(|e| e.path().join("recipe.toml").is_file())
|
|
|
|
|
+ .count(),
|
|
|
|
|
+ )
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// `sm121 repos` / `sm121 repos sync` / `sm121 archive` / `sm121 unarchive`
|
|
|
|
|
+/// dispatch (offline except `sync`, which shells out to git locally).
|
|
|
|
|
+pub async fn run(config: &crate::config::Config, cmd: &ReposCommands, dry_run: bool) -> Result<()> {
|
|
|
|
|
+ match cmd {
|
|
|
|
|
+ ReposCommands::List => {
|
|
|
|
|
+ if config.recipe_repos.is_empty() {
|
|
|
|
|
+ println!("no [[recipe_repos]] configured");
|
|
|
|
|
+ return Ok(());
|
|
|
|
|
+ }
|
|
|
|
|
+ let reg_path = registry_path().unwrap_or_else(|| PathBuf::from("repos.json"));
|
|
|
|
|
+ let reg = load_registry(®_path);
|
|
|
|
|
+ let base = cache_base();
|
|
|
|
|
+ println!("{:<52} {:<22} {:<18} recipes", "repo", "state", "last sync");
|
|
|
|
|
+ for repo in &config.recipe_repos {
|
|
|
|
|
+ let dir = repo_cache_dir(&base, &repo.url);
|
|
|
|
|
+ let state = if reg.is_archived(&repo.url) {
|
|
|
|
|
+ "archived"
|
|
|
|
|
+ } else if dir.join(".git").is_dir() {
|
|
|
|
|
+ "synced"
|
|
|
|
|
+ } else {
|
|
|
|
|
+ "not cloned"
|
|
|
|
|
+ };
|
|
|
|
|
+ let last = std::fs::read_to_string(dir.join(LAST_SYNC_FILE))
|
|
|
|
|
+ .ok()
|
|
|
|
|
+ .map(|s| crate::stamp::fmt_ts(s.trim().parse().unwrap_or(0)))
|
|
|
|
|
+ .unwrap_or_else(|| "—".to_string());
|
|
|
|
|
+ let n = recipe_count(&dir)
|
|
|
|
|
+ .map(|n| n.to_string())
|
|
|
|
|
+ .unwrap_or_else(|| "—".into());
|
|
|
|
|
+ println!("{:<52} {:<22} {:<18} {n}", repo.url, state, last);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ ReposCommands::Sync { force } => {
|
|
|
|
|
+ let force = *force;
|
|
|
|
|
+ if dry_run {
|
|
|
|
|
+ for repo in &config.recipe_repos {
|
|
|
|
|
+ let dir = repo_cache_dir(&cache_base(), &repo.url);
|
|
|
|
|
+ println!(
|
|
|
|
|
+ "[dry-run] would {} recipe repo {} → {}",
|
|
|
|
|
+ if force { "re-clone" } else { "clone/pull" },
|
|
|
|
|
+ repo.url,
|
|
|
|
|
+ dir.display()
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+ return Ok(());
|
|
|
|
|
+ }
|
|
|
|
|
+ let reg_path = registry_path().unwrap_or_else(|| PathBuf::from("repos.json"));
|
|
|
|
|
+ let reg = load_registry(®_path);
|
|
|
|
|
+ let base = cache_base();
|
|
|
|
|
+ let repos = config.recipe_repos.clone();
|
|
|
|
|
+ let roots = tokio::task::spawn_blocking(move || sync_repos(&repos, &base, ®, force))
|
|
|
|
|
+ .await
|
|
|
|
|
+ .unwrap_or_default();
|
|
|
|
|
+ if roots.is_empty() {
|
|
|
|
|
+ warn!("no recipe repos synced (all unreachable or archived)");
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ Ok(())
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// `sm121 archive <url>` / `sm121 unarchive <url>` — flip the entry in the
|
|
|
|
|
+/// `repos.json` registry (creates the file if missing).
|
|
|
|
|
+pub fn archive(url: &str, unarchive: bool, dry_run: bool) -> Result<()> {
|
|
|
|
|
+ let path = registry_path().ok_or_else(|| {
|
|
|
|
|
+ anyhow::anyhow!("cannot determine the global config dir (dirs::config_dir)")
|
|
|
|
|
+ })?;
|
|
|
|
|
+ if dry_run {
|
|
|
|
|
+ println!(
|
|
|
|
|
+ "[dry-run] would {} '{}' in {}",
|
|
|
|
|
+ if unarchive { "unarchive" } else { "archive" },
|
|
|
|
|
+ url,
|
|
|
|
|
+ path.display()
|
|
|
|
|
+ );
|
|
|
|
|
+ return Ok(());
|
|
|
|
|
+ }
|
|
|
|
|
+ if unarchive {
|
|
|
|
|
+ unarchive_at(&path, url)?;
|
|
|
|
|
+ println!("unarchived '{url}' in {}", path.display());
|
|
|
|
|
+ } else {
|
|
|
|
|
+ archive_at(&path, url)?;
|
|
|
|
|
+ println!("archived '{url}' in {}", path.display());
|
|
|
|
|
+ }
|
|
|
|
|
+ Ok(())
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+#[cfg(test)]
|
|
|
|
|
+#[path = "recipe_repos_tests.rs"]
|
|
|
|
|
+mod tests;
|