|
|
@@ -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(®istry.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(®istry.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))
|
|
|
+}
|