Explorar el Código

feat: [registry] — push built images to a registry (ghcr.io) after build; docker push via tmux on the node, localized (6 locales), --no-push opt-out

Alexander Korolev hace 1 mes
padre
commit
9c39603968

+ 18 - 0
AGENTS.md

@@ -627,6 +627,24 @@ commands = ["sudo nvidia-smi -lgc 0,2000"]
 commands = ["cmd", { command = "cmd", on_failure = "warn" }]
 ```
 
+## Image registry push (`[registry]`)
+
+```toml
+[registry]
+base = "ghcr.io/kilork"   # push ref: {base}/{image-name}:{tag}
+```
+
+After a **successful local build** (`sm121 build` or `serve --build-node`),
+the image is pushed from the build node as `{base}/{image-name}:{tag}`
+(e.g. `ghcr.io/kilork/glm53-jovian-r35:local`). The push runs in a tmux
+session on the node (`sm121-push-<recipe>`, log `/tmp/<recipe>-push.log`)
+with the same 30 s poll / 90 min cap as the build — a 30 GB push must not
+ride on one SSH session. **The launcher stores no registry credentials**:
+the node must be logged in once (`ssh <node> && docker login ghcr.io`);
+a failed push fails the build with the log tail + a `docker login` hint.
+`--no-push` (build + serve) skips the push even when `[registry]` is set.
+Without the section, builds behave exactly as before.
+
 ## Power
 
 - `off`: SSH shutdown → wait → HA `turn_off` per-node entities + cluster extras

+ 8 - 0
locales/de/main.ftl

@@ -217,3 +217,11 @@ health-col-nodes = Knoten
 health-status-online = ● online
 health-status-offline = ✗ offline
 health-container-line =     { $name }  { $status }  { $image }
+
+dry-run-would-push = [dry-run] würde nach dem Build { $ref } pushen
+push-mapping-failed = kann [registry].base { $base } nicht auf einen Push-Ref für { $image } abbilden
+pushing-image = Image nach { $ref } wird gepusht (tmux: { $session })...
+push-timeout = Push von { $ref} auf { $node} ist nach { $elapsed} abgelaufen — tmux-Session { $session} / Log { $log} auf dem Knoten prüfen.
+push-failed = Push von { $ref} auf { $node} ist nach { $elapsed} fehlgeschlagen.\n{ $log }\nHinweis: Wenn das Log unauthorized/denied zeigt — `docker login { $host }` auf { $node} ausführen (der Launcher speichert keine Registry-Credentials).
+pushed-image = Image { $ref} gepusht ({ $elapsed })
+push-running = Push läuft seit { $elapsed }...

+ 8 - 0
locales/en/main.ftl

@@ -236,3 +236,11 @@ report-col-tps2 = Tok/s c2
 stop-not-verified = Containers not verified stopped on: { $nodes }
 report-col-up = Up
 report-col-launches = Launches
+
+dry-run-would-push = [dry-run] would push { $ref } after the build
+push-mapping-failed = cannot map [registry].base { $base } to a push ref for { $image }
+pushing-image = Pushing image to { $ref } (tmux: { $session })...
+push-timeout = Push of { $ref } on { $node } timed out after { $elapsed } — inspect tmux session { $session } / log { $log } on the node.
+push-failed = Push of { $ref } on { $node } failed after { $elapsed }.\n{ $log }\nHint: if the log says unauthorized/denied — run `docker login { $host }` on { $node } (the launcher stores no registry credentials).
+pushed-image = Pushed { $ref } ({ $elapsed })
+push-running = Push running for { $elapsed }...

+ 8 - 0
locales/es/main.ftl

@@ -217,3 +217,11 @@ health-col-nodes = Nodos
 health-status-online = ● en línea
 health-status-offline = ✗ fuera de línea
 health-container-line =     { $name }  { $status }  { $image }
+
+dry-run-would-push = [dry-run] haría push de { $ref } tras el build
+push-mapping-failed = no se puede mapear [registry].base { $base } a un ref de push para { $image }
+pushing-image = Haciendo push de la imagen a { $ref} (tmux: { $session })...
+push-timeout = El push de { $ref} en { $node} expiró tras { $elapsed} — inspeccionar la sesión tmux { $session} / el log { $log} en el nodo.
+push-failed = El push de { $ref} en { $node} falló tras { $elapsed}.\n{ $log }\nSugerencia: si el log dice unauthorized/denied — ejecutar `docker login { $host }` en { $node} (el launcher no guarda credenciales del registro).
+pushed-image = Imagen { $ref} con push hecho ({ $elapsed })
+push-running = Push en curso desde hace { $elapsed }...

+ 8 - 0
locales/fr/main.ftl

@@ -217,3 +217,11 @@ health-col-nodes = Nœuds
 health-status-online = ● en ligne
 health-status-offline = ✗ hors ligne
 health-container-line =     { $name }  { $status }  { $image }
+
+dry-run-would-push = [dry-run] pousserait { $ref } après le build
+push-mapping-failed = impossible d'associer [registry].base { $base } à un ref de push pour { $image }
+pushing-image = Push de l'image vers { $ref} (tmux : { $session })...
+push-timeout = Le push de { $ref} sur { $node} a expiré après { $elapsed} — examiner la session tmux { $session} / le log { $log} sur le nœud.
+push-failed = Le push de { $ref} sur { $node} a échoué après { $elapsed}.\n{ $log }\nAstuce : si le log affiche unauthorized/denied — exécuter `docker login { $host }` sur { $node} (le launcher ne stocke aucune credential de registre).
+pushed-image = Image { $ref} poussée ({ $elapsed })
+push-running = Push en cours depuis { $elapsed }...

+ 8 - 0
locales/it/main.ftl

@@ -217,3 +217,11 @@ health-col-nodes = Nodi
 health-status-online = ● online
 health-status-offline = ✗ offline
 health-container-line =     { $name }  { $status }  { $image }
+
+dry-run-would-push = [dry-run] farebbe push di { $ref } dopo il build
+push-mapping-failed = impossibile mappare [registry].base { $base } a un ref di push per { $image}
+pushing-image = Push dell'immagine su { $ref} (tmux: { $session })...
+push-timeout = Il push di { $ref} su { $node} è scaduto dopo { $elapsed} — ispezionare la sessione tmux { $session} / il log { $log} sul nodo.
+push-failed = Il push di { $ref} su { $node} è fallito dopo { $elapsed}.\n{ $log }\nSuggerimento: se il log dice unauthorized/denied — eseguire `docker login { $host }` su { $node} (il launcher non salva credenziali del registro).
+pushed-image = Immagine { $ref} pushata ({ $elapsed })
+push-running = Push in corso da { $elapsed }...

+ 8 - 0
locales/ru/main.ftl

@@ -246,3 +246,11 @@ report-col-points = Очки
 report-col-score = Счёт /100
 report-col-tps1 = Tok/s c1
 report-col-tps2 = Tok/s c2
+
+dry-run-would-push = (dry-run) после сборки запушу { $ref }
+push-mapping-failed = нельзя сопоставить [registry].base { $base } с push-референсом для { $image }
+pushing-image = Пушим образ в { $ref } (tmux: { $session })...
+push-timeout = Push образа { $ref } на { $node} не завершился за { $elapsed } — смотрите tmux-сессию { $session } / лог { $log } на ноде.
+push-failed = Push образа { $ref} на { $node} не удался за { $elapsed }.\n{ $log }\nПодсказка: если в логе unauthorized/denied — выполните `docker login { $host }` на { $node} (launcher не хранит креденшелы реестра).
+pushed-image = Образ { $ref} запушен ({ $elapsed })
+push-running = Push идёт { $elapsed }...

+ 1 - 0
src/after_stop.rs

@@ -181,6 +181,7 @@ mod tests {
             recipe_sources: Vec::new(),
             docker_image_sources: Vec::new(),
             cluster_sources: Vec::new(),
+            registry: None,
             global_mods_dir: None,
         }
     }

+ 1 - 0
src/before_serve.rs

@@ -232,6 +232,7 @@ mod tests {
             recipe_sources: Vec::new(),
             docker_image_sources: Vec::new(),
             cluster_sources: Vec::new(),
+            registry: None,
         }
     }
 

+ 12 - 80
src/cli.rs

@@ -95,6 +95,11 @@ pub enum Commands {
         /// nodes before containers start (repo-based mounts untouched).
         #[arg(long)]
         clear: bool,
+
+        /// Skip the post-build registry push (only meaningful when `[registry]`
+        /// is configured and the image needs (re)building).
+        #[arg(long)]
+        no_push: bool,
     },
 
     /// Show container logs
@@ -278,6 +283,11 @@ pub enum Commands {
         /// Force rebuild even if checksum matches.
         #[arg(long)]
         force_build: bool,
+
+        /// Skip the post-build registry push (only meaningful when `[registry]`
+        /// is configured).
+        #[arg(long)]
+        no_push: bool,
     },
 
     /// Power on/off nodes via Home Assistant (smart plugs / Wake-on-LAN).
@@ -417,84 +427,6 @@ pub enum TmuxCommands {
         cluster: String,
     },
 }
-
 #[cfg(test)]
-mod tests {
-    use super::*;
-
-    /// Parse `sm121 health <args>` and return the parsed `--format`.
-    fn health_format(args: &[&str]) -> Result<(OutputFormat, bool), clap::Error> {
-        let cli = Cli::try_parse_from(
-            std::iter::once("sm121")
-                .chain(std::iter::once("health"))
-                .chain(args.iter().copied()),
-        )?;
-        let Commands::Health {
-            fingerprint,
-            format,
-            ..
-        } = cli.command
-        else {
-            panic!("expected Health")
-        };
-        Ok((format, fingerprint))
-    }
-
-    #[test]
-    fn health_format_defaults_to_plain() {
-        assert_eq!(health_format(&[]).unwrap(), (OutputFormat::Plain, false));
-    }
-
-    #[test]
-    fn health_format_parses_each_variant() {
-        assert_eq!(
-            health_format(&["--format", "plain"]).unwrap().0,
-            OutputFormat::Plain
-        );
-        assert_eq!(
-            health_format(&["--format", "md"]).unwrap().0,
-            OutputFormat::Md
-        );
-        assert_eq!(
-            health_format(&["--format", "html"]).unwrap().0,
-            OutputFormat::Html
-        );
-    }
-
-    #[test]
-    fn health_format_combines_with_cluster_and_fingerprint() {
-        let (fmt, fp) = health_format(&["--cluster", "c1", "--format", "md"]).unwrap();
-        assert_eq!((fmt, fp), (OutputFormat::Md, false));
-        let (fmt, fp) = health_format(&["--fingerprint", "--format", "html"]).unwrap();
-        assert_eq!((fmt, fp), (OutputFormat::Html, true));
-    }
-
-    #[test]
-    fn health_format_rejects_invalid_value() {
-        assert!(health_format(&["--format", "bogus"]).is_err());
-    }
-
-    #[test]
-    fn report_flag_defaults_and_overrides() {
-        let cli = Cli::try_parse_from(["sm121", "report"]).unwrap();
-        let Commands::Report {
-            serve,
-            port,
-            open,
-            archive: _,
-            unarchive: _,
-            archived: _,
-        } = cli.command
-        else {
-            unreachable!()
-        };
-        assert!(!serve && !open);
-        assert_eq!(port, 3101);
-        let cli = Cli::try_parse_from(["sm121", "report", "--open", "--port", "4000"]).unwrap();
-        let Commands::Report { open, port, .. } = cli.command else {
-            unreachable!()
-        };
-        assert!(open);
-        assert_eq!(port, 4000);
-    }
-}
+#[path = "cli_tests.rs"]
+mod tests;

+ 78 - 0
src/cli_tests.rs

@@ -0,0 +1,78 @@
+use super::*;
+use clap::Parser;
+
+/// Parse `sm121 health <args>` and return the parsed `--format`.
+fn health_format(args: &[&str]) -> Result<(OutputFormat, bool), clap::Error> {
+    let cli = Cli::try_parse_from(
+        std::iter::once("sm121")
+            .chain(std::iter::once("health"))
+            .chain(args.iter().copied()),
+    )?;
+    let Commands::Health {
+        fingerprint,
+        format,
+        ..
+    } = cli.command
+    else {
+        panic!("expected Health")
+    };
+    Ok((format, fingerprint))
+}
+
+#[test]
+fn health_format_defaults_to_plain() {
+    assert_eq!(health_format(&[]).unwrap(), (OutputFormat::Plain, false));
+}
+
+#[test]
+fn health_format_parses_each_variant() {
+    assert_eq!(
+        health_format(&["--format", "plain"]).unwrap().0,
+        OutputFormat::Plain
+    );
+    assert_eq!(
+        health_format(&["--format", "md"]).unwrap().0,
+        OutputFormat::Md
+    );
+    assert_eq!(
+        health_format(&["--format", "html"]).unwrap().0,
+        OutputFormat::Html
+    );
+}
+
+#[test]
+fn health_format_combines_with_cluster_and_fingerprint() {
+    let (fmt, fp) = health_format(&["--cluster", "c1", "--format", "md"]).unwrap();
+    assert_eq!((fmt, fp), (OutputFormat::Md, false));
+    let (fmt, fp) = health_format(&["--fingerprint", "--format", "html"]).unwrap();
+    assert_eq!((fmt, fp), (OutputFormat::Html, true));
+}
+
+#[test]
+fn health_format_rejects_invalid_value() {
+    assert!(health_format(&["--format", "bogus"]).is_err());
+}
+
+#[test]
+fn report_flag_defaults_and_overrides() {
+    let cli = Cli::try_parse_from(["sm121", "report"]).unwrap();
+    let Commands::Report {
+        serve,
+        port,
+        open,
+        archive: _,
+        unarchive: _,
+        archived: _,
+    } = cli.command
+    else {
+        unreachable!()
+    };
+    assert!(!serve && !open);
+    assert_eq!(port, 3101);
+    let cli = Cli::try_parse_from(["sm121", "report", "--open", "--port", "4000"]).unwrap();
+    let Commands::Report { open, port, .. } = cli.command else {
+        unreachable!()
+    };
+    assert!(open);
+    assert_eq!(port, 4000);
+}

+ 5 - 0
src/cluster_state/tests.rs

@@ -213,6 +213,7 @@ fn select_smallest_with_enough_free() {
         recipe_sources: Vec::new(),
         docker_image_sources: Vec::new(),
         cluster_sources: Vec::new(),
+        registry: None,
         global_mods_dir: None,
     };
 
@@ -244,6 +245,7 @@ fn select_cluster_not_found_errors() {
         recipe_sources: Vec::new(),
         docker_image_sources: Vec::new(),
         cluster_sources: Vec::new(),
+        registry: None,
         global_mods_dir: None,
     };
     assert!(select_cluster(&cfg, "nonexistent", None, &state).is_err());
@@ -279,6 +281,7 @@ fn no_cluster_has_enough_free_errors() {
         recipe_sources: Vec::new(),
         docker_image_sources: Vec::new(),
         cluster_sources: Vec::new(),
+        registry: None,
         global_mods_dir: None,
     };
     assert!(select_cluster(&cfg, "auto", Some(1), &state).is_err());
@@ -310,6 +313,7 @@ fn single_node_allows_cluster_without_nccl() {
         recipe_sources: Vec::new(),
         docker_image_sources: Vec::new(),
         cluster_sources: Vec::new(),
+        registry: None,
         global_mods_dir: None,
     };
     // Single-node recipe on a cluster without NCCL config → OK
@@ -351,6 +355,7 @@ fn multi_node_requires_nccl() {
         recipe_sources: Vec::new(),
         docker_image_sources: Vec::new(),
         cluster_sources: Vec::new(),
+        registry: None,
         global_mods_dir: None,
     };
     // Multi-node recipe on a cluster without NCCL config → error

+ 10 - 0
src/config/tests.rs

@@ -46,9 +46,19 @@ fn tcfg() -> Config {
         recipe_sources: Vec::new(),
         docker_image_sources: Vec::new(),
         cluster_sources: Vec::new(),
+        registry: None,
     }
 }
 
+#[test]
+fn registry_section_parses_and_defaults_to_none() {
+    let with_reg: Config =
+        toml::from_str("ssh_user = \"u\"\n[registry]\nbase = \"ghcr.io/kilork\"").unwrap();
+    assert_eq!(with_reg.registry.as_ref().unwrap().base, "ghcr.io/kilork");
+    let without: Config = toml::from_str("ssh_user = \"u\"").unwrap();
+    assert!(without.registry.is_none());
+}
+
 fn tnccl() -> NcclConfig {
     NcclConfig {
         ib_hca: "rocep1s0f0".to_string(),

+ 13 - 0
src/config/types.rs

@@ -92,6 +92,19 @@ pub struct Config {
     /// Extra cluster def files (`[[clusters]]`, `[nccl.<name>]`); this file wins.
     #[serde(default)]
     pub cluster_sources: Vec<String>,
+    /// Optional image-registry push target: after a successful local build
+    /// the image is pushed as `{base}/{image-name}:{tag}` on the build node.
+    #[serde(default)]
+    pub registry: Option<RegistryConfig>,
+}
+
+/// `[registry]` — where freshly built images are pushed (the build node
+/// must already be authenticated: `docker login <host>` once on the node).
+#[derive(Clone, Debug, Deserialize)]
+pub struct RegistryConfig {
+    /// Repository base, e.g. `ghcr.io/kilork` → push ref
+    /// `ghcr.io/kilork/<image-name>:<tag>`.
+    pub base: String,
 }
 
 #[derive(Clone, Deserialize)]

+ 1 - 0
src/detect.rs

@@ -159,6 +159,7 @@ mod tests {
             recipe_sources: Vec::new(),
             docker_image_sources: Vec::new(),
             cluster_sources: Vec::new(),
+            registry: None,
         };
         for dry in [false, true] {
             let r = resolve_nccl_for_node(&config, &node, Some(&nccl), dry)

+ 47 - 14
src/docker/build.rs

@@ -1,5 +1,6 @@
 use super::ServeOpts;
 use super::build_dir::{build_checksum, resolve_build_dir};
+use super::push;
 use super::shq;
 use crate::config::{Cluster, Config, Node, Recipe};
 use crate::ssh::run_ssh;
@@ -9,13 +10,8 @@ use base64::engine::general_purpose::STANDARD;
 use std::path::Path;
 use tracing::info;
 
-/// Default upper bound on a single remote image build before the launcher
-/// gives up (90 min). A hung build (stuck tmux, network stall) would
-/// otherwise loop the 30s poll forever, hanging the launcher.
 pub const DEFAULT_MAX_BUILD_SECS: u64 = 90 * 60;
 
-/// Start a Docker image build on a remote node in a tmux session.
-/// After the build completes, stamps the checksum label on the image.
 pub async fn build_image_on_node(
     config: &Config,
     node: &Node,
@@ -183,13 +179,6 @@ pub async fn build_image_on_node(
     Ok(())
 }
 
-/// Decide whether to build and dispatch the build for the serve flow.
-///
-/// Returns `true` when an image was actually (re)built on the node, `false` when
-/// the build was skipped (checksum match) or it was a dry-run. Callers use the
-/// return value to treat the freshly built image as the authoritative sync
-/// source instead of re-running the "most recently created" heuristic.
-/// Where a recipe's image is built from.
 pub async fn handle_build(
     config: &Config,
     cluster: &Cluster,
@@ -220,6 +209,15 @@ pub async fn handle_build(
             "[dry-run] would build '{}' on '{}' from {}{}",
             recipe.docker_image, build_node_name, review, overrides
         );
+        if !opts.no_push
+            && let Some(reg) = config.registry.as_ref()
+            && let Some(push_ref) = push::registry_ref(&reg.base, &recipe.docker_image)
+        {
+            println!(
+                "{}",
+                fluent_i18n::t!("dry-run-would-push", { "ref" => &push_ref })
+            );
+        }
         return Ok(false);
     }
     let image_dir = recipe
@@ -298,12 +296,47 @@ pub async fn handle_build(
             merged_env.as_deref(),
         )
         .await?;
+        // Push to the configured registry (no-op without `[registry]`).
+        if !opts.no_push {
+            push::push_image_on_node(config, build_node, &recipe.docker_image, recipe_name).await?;
+        }
     }
     Ok(should_build)
 }
 
-/// Format a `Duration` as a compact human string: `45s`, `1m 30s`, `12m 04s`.
-fn format_elapsed(d: std::time::Duration) -> String {
+pub async fn handle_build_cmd(
+    config: &Config,
+    recipe: &str,
+    build_node: &str,
+    cluster: &str,
+    force_build: bool,
+    no_push: bool,
+    dry_run: bool,
+) -> Result<()> {
+    let recipe_obj = crate::config::load_recipe(recipe, &config.recipe_sources).await?;
+    let cluster = crate::config::resolve_build_cluster(config, cluster, build_node)?;
+    let opts = super::ServeOpts {
+        keep: false,
+        skip_before_serve: true,
+        no_wait_ready: false,
+        dry_run,
+        auto_power_on: false,
+        sync_image: false,
+        sync_image_source: None,
+        no_push,
+        build_node: Some(build_node.to_string()),
+        force_build,
+        force: false,
+        solo: None,
+        mirror: None,
+        fit: None,
+        clear: false,
+    };
+    handle_build(config, &cluster, recipe, &recipe_obj, &opts, build_node).await?;
+    Ok(())
+}
+
+pub(crate) fn format_elapsed(d: std::time::Duration) -> String {
     let total_secs = d.as_secs();
     let mins = total_secs / 60;
     let secs = total_secs % 60;

+ 1 - 0
src/docker/command_tests.rs

@@ -53,6 +53,7 @@ fn minimal_config() -> Config {
         recipe_sources: Vec::new(),
         docker_image_sources: Vec::new(),
         cluster_sources: Vec::new(),
+        registry: None,
     }
 }
 

+ 1 - 0
src/docker/mirror.rs

@@ -166,6 +166,7 @@ mod tests {
             recipe_sources: Vec::new(),
             docker_image_sources: Vec::new(),
             cluster_sources: Vec::new(),
+            registry: None,
         }
     }
 

+ 3 - 0
src/docker/mod.rs

@@ -8,6 +8,7 @@ pub mod logs;
 pub mod mirror;
 pub mod mods;
 pub(crate) mod path_ref;
+pub mod push;
 pub use logs::show_logs;
 pub mod build_dir;
 pub mod stale;
@@ -33,6 +34,8 @@ pub struct ServeOpts {
     pub auto_power_on: bool,
     pub sync_image: bool,
     pub sync_image_source: Option<String>,
+    /// Skip the post-build registry push even when `[registry]` is set.
+    pub no_push: bool,
     pub build_node: Option<String>,
     pub force_build: bool,
     /// Ignore occupied nodes during cluster selection (`--force`).

+ 179 - 0
src/docker/push.rs

@@ -0,0 +1,179 @@
+//! Post-build registry push (`[registry]` config section).
+
+use super::build::{DEFAULT_MAX_BUILD_SECS, format_elapsed};
+use super::shq;
+use crate::config::{Config, Node};
+use crate::ssh::run_ssh;
+use anyhow::Result;
+use tracing::info;
+
+/// Push a freshly built image to the configured `[registry]` (no-op when
+/// the section is absent). Runs `docker push` in a tmux session on the
+/// build node — a 30 GB push must not ride on a single SSH session — and
+/// polls it the same way the build itself is polled (30 s loop, 90 min cap).
+pub async fn push_image_on_node(
+    config: &Config,
+    node: &Node,
+    image_tag: &str,
+    recipe_name: &str,
+) -> Result<()> {
+    let Some(registry) = config.registry.as_ref() else {
+        return Ok(());
+    };
+    let Some(push_ref) = registry_ref(&registry.base, image_tag) else {
+        anyhow::bail!(
+            "{}",
+            fluent_i18n::t!("push-mapping-failed", { "base" => registry.base.clone(), "image" => image_tag.to_string() })
+        );
+    };
+    let host = registry.base.split('/').next().unwrap_or(&registry.base);
+
+    let session_name = format!("sm121-push-{}", recipe_name);
+    let logfile = format!("/tmp/{}-push.log", recipe_name);
+
+    run_ssh(
+        config,
+        node,
+        format!(
+            "tmux kill-session -t {} 2>/dev/null || true",
+            shq(&session_name)
+        ),
+        false,
+    )
+    .await?;
+    let marker = format!("Push done: {}", push_ref);
+    let tmux_cmd = format!(
+        "tmux new-session -d -s {} \"docker push {} > {} 2>&1 && echo '{}' >> {}\"",
+        shq(&session_name),
+        shq(&push_ref),
+        shq(&logfile),
+        shq(&marker),
+        shq(&logfile)
+    );
+    info!(
+        "{}",
+        fluent_i18n::t!("pushing-image", { "ref" => &push_ref, "session" => &session_name })
+    );
+    run_ssh(config, node, tmux_cmd, false).await?;
+
+    let start = std::time::Instant::now();
+    loop {
+        tokio::time::sleep(std::time::Duration::from_secs(30)).await;
+        let elapsed = start.elapsed();
+        if elapsed.as_secs() >= DEFAULT_MAX_BUILD_SECS {
+            anyhow::bail!(
+                "{}",
+                fluent_i18n::t!("push-timeout", {
+                    "ref" => &push_ref,
+                    "node" => &node.name,
+                    "elapsed" => format_elapsed(elapsed),
+                    "session" => &session_name,
+                    "log" => &logfile
+                })
+            );
+        }
+        let alive = run_ssh(
+            config,
+            node,
+            format!(
+                "tmux has-session -t {} 2>&1 || echo DEAD",
+                shq(&session_name)
+            ),
+            false,
+        )
+        .await?;
+        let dead = alive.trim().contains("DEAD");
+        if let Ok(logs) = run_ssh(
+            config,
+            node,
+            format!(
+                "tail -10 {} 2>/dev/null || echo '(no log yet)'",
+                shq(&logfile)
+            ),
+            false,
+        )
+        .await
+        {
+            for line in logs.lines().filter(|l| !l.is_empty()) {
+                info!("  [push] {}", line.trim());
+            }
+        }
+        if dead {
+            let ok = run_ssh(
+                config,
+                node,
+                format!(
+                    "grep -F -- {} {} >/dev/null 2>&1 && echo OK || echo NO",
+                    shq(&marker),
+                    shq(&logfile)
+                ),
+                false,
+            )
+            .await
+            .map(|s| s.trim() == "OK")
+            .unwrap_or(false);
+            if ok {
+                let digest = run_ssh(
+                    config,
+                    node,
+                    format!(
+                        "grep -oE 'digest: sha256:[0-9a-f]+ size: [0-9]+' {} | tail -1",
+                        shq(&logfile)
+                    ),
+                    false,
+                )
+                .await
+                .unwrap_or_default();
+                let digest_suffix = digest
+                    .trim()
+                    .lines()
+                    .last()
+                    .map(|d| format!(" — {}", d))
+                    .unwrap_or_default();
+                info!(
+                    "{}{}",
+                    fluent_i18n::t!("pushed-image", { "ref" => &push_ref, "elapsed" => format_elapsed(elapsed) }),
+                    digest_suffix
+                );
+            } else {
+                let exit_log = run_ssh(config, node, format!("tail -20 {}", shq(&logfile)), false)
+                    .await
+                    .unwrap_or_default();
+                anyhow::bail!(
+                    "{}",
+                    fluent_i18n::t!("push-failed", {
+                        "ref" => &push_ref,
+                        "node" => &node.name,
+                        "elapsed" => format_elapsed(elapsed),
+                        "log" => exit_log.trim().to_string(),
+                        "host" => host.to_string()
+                    })
+                );
+            }
+            return Ok(());
+        }
+        info!(
+            "{}",
+            fluent_i18n::t!("push-running", { "elapsed" => format_elapsed(elapsed) })
+        );
+    }
+}
+
+/// Map a local `name:tag` image to `{base}/{repo}:{tag}` (`repo` = the
+/// image name's last path segment; an untagged image pushes as `latest`).
+/// `None` when the mapping is meaningless (empty base or image name).
+pub(crate) fn registry_ref(base: &str, image_tag: &str) -> Option<String> {
+    let base = base.trim().trim_end_matches('/');
+    if base.is_empty() || image_tag.trim().is_empty() {
+        return None;
+    }
+    let (name, tag) = match image_tag.rsplit_once(':') {
+        Some((n, t)) if !t.trim().is_empty() && !t.contains('/') => (n, t),
+        _ => (image_tag, "latest"),
+    };
+    let repo = name.trim().rsplit('/').next()?.trim();
+    if repo.is_empty() {
+        return None;
+    }
+    Some(format!("{}/{repo}:{tag}", base))
+}

+ 1 - 0
src/exec.rs

@@ -174,6 +174,7 @@ mod tests {
             recipe_sources: Vec::new(),
             docker_image_sources: Vec::new(),
             cluster_sources: Vec::new(),
+            registry: None,
             global_mods_dir: None,
         }
     }

+ 5 - 32
src/main.rs

@@ -92,6 +92,7 @@ async fn main() -> Result<()> {
             mirror,
             fit,
             clear,
+            no_push,
         } => {
             handle_serve(
                 &config,
@@ -106,6 +107,7 @@ async fn main() -> Result<()> {
                     auto_power_on,
                     sync_image,
                     sync_image_source,
+                    no_push,
                     build_node,
                     force_build,
                     force,
@@ -124,13 +126,15 @@ async fn main() -> Result<()> {
             build_node,
             cluster,
             force_build,
+            no_push,
         } => {
-            handle_build_cmd(
+            docker::build::handle_build_cmd(
                 &config,
                 &recipe,
                 &build_node,
                 &cluster,
                 force_build,
+                no_push,
                 dry_run,
             )
             .await?;
@@ -340,37 +344,6 @@ pub(crate) async fn handle_serve(
     Ok(())
 }
 
-/// Handle `build` command: build a Docker image on a remote node.
-async fn handle_build_cmd(
-    config: &Config,
-    recipe: &str,
-    build_node: &str,
-    cluster: &str,
-    force_build: bool,
-    dry_run: bool,
-) -> Result<()> {
-    let recipe_obj = crate::config::load_recipe(recipe, &config.recipe_sources).await?;
-    let cluster = crate::config::resolve_build_cluster(config, cluster, build_node)?;
-    let opts = docker::ServeOpts {
-        keep: false,
-        skip_before_serve: true,
-        no_wait_ready: false,
-        dry_run,
-        auto_power_on: false,
-        sync_image: false,
-        sync_image_source: None,
-        build_node: Some(build_node.to_string()),
-        force_build,
-        force: false,
-        solo: None,
-        mirror: None,
-        fit: None,
-        clear: false,
-    };
-    docker::build::handle_build(config, &cluster, recipe, &recipe_obj, &opts, build_node).await?;
-    Ok(())
-}
-
 /// Handle `logs` command: show container logs.
 async fn handle_logs(
     config: &Config,

+ 1 - 0
src/orchestrate/tests.rs

@@ -93,6 +93,7 @@ fn test_config(clusters: Vec<Cluster>) -> Config {
         recipe_sources: Vec::new(),
         docker_image_sources: Vec::new(),
         cluster_sources: Vec::new(),
+        registry: None,
         global_mods_dir: None,
     }
 }

+ 1 - 0
src/web/api.rs

@@ -68,6 +68,7 @@ pub(crate) async fn serve(
         auto_power_on: false,
         sync_image: true,
         sync_image_source: None,
+        no_push: false,
         build_node: None,
         force_build: false,
         force: req.force,

+ 1 - 0
src/web/events.rs

@@ -237,6 +237,7 @@ mod tests {
             recipe_sources: Vec::new(),
             docker_image_sources: Vec::new(),
             cluster_sources: Vec::new(),
+            registry: None,
         }
     }
 

+ 1 - 0
src/web/tests.rs

@@ -47,6 +47,7 @@ pub(crate) fn fake_config() -> Config {
         recipe_sources: Vec::new(),
         docker_image_sources: Vec::new(),
         cluster_sources: Vec::new(),
+        registry: None,
     }
 }
 

+ 1 - 0
src/web/topo.rs

@@ -183,6 +183,7 @@ mod tests {
             recipe_sources: Vec::new(),
             docker_image_sources: Vec::new(),
             cluster_sources: Vec::new(),
+            registry: None,
             clusters: vec![
                 // fabric: blocking switch + ib nodes → 200g links
                 cluster(