agent/cli/src/lib.rs
//! **The courier** (`holger-cli agent`), as a library: `holger-cli` carries it, so there is
//! no separate `holger-agent` program.
use agent_lib::*;
use anyhow::Result;
use clap::{Args, ValueEnum};
// The network Source/Target variants (World / Nexus / Artifactory / Holger) are the
// ONLY ones that construct a reqwest-backed connector, so they are compiled in ONLY
// under the `online` feature. An offline (`--no-default-features`) agent exposes just
// the archive/directory formats — the zero-egress airgap courier — and every match
// arm that reads an online variant is `#[cfg(feature = "online")]` in lockstep.
#[derive(Debug, Clone, ValueEnum)]
pub enum Source {
/// Download from crates.io (requires --lockfile)
#[cfg(feature = "online")]
World,
/// Read from a Nexus repository (requires --nexus-url, uses basic auth)
#[cfg(feature = "online")]
Nexus,
/// Read from a JFrog Artifactory repository (requires --artifactory-url, basic auth)
#[cfg(feature = "online")]
Artifactory,
/// Read from a Holger server (requires --holger-url, uses OIDC/mTLS)
#[cfg(feature = "online")]
Holger,
/// Read from a znippy archive (requires --input)
Znippy,
/// Read from a tar.zst archive (requires --input)
TarZstd,
/// Read from a directory of .crate files (requires --input)
Directory,
}
#[derive(Debug, Clone, ValueEnum)]
pub enum Target {
/// Pack into a znippy archive (requires --output)
Znippy,
/// Pack into a zstd-compressed tar archive (requires --output)
TarZstd,
/// Write .crate files to a directory (requires --output)
Directory,
/// Push to a Nexus repository (requires --nexus-url, uses basic auth)
#[cfg(feature = "online")]
Nexus,
/// Push to a JFrog Artifactory repository (requires --artifactory-url, basic auth)
#[cfg(feature = "online")]
Artifactory,
/// Push to a Holger server (requires --holger-url, uses OIDC/mTLS)
#[cfg(feature = "online")]
Holger,
}
#[derive(Debug, Clone, ValueEnum)]
pub enum AuthMode {
/// OIDC Bearer token auth
Oidc,
/// Mutual TLS (client certificate)
Mtls,
}
/// Package ecosystem lockfile format.
#[derive(Debug, Clone, ValueEnum, Default)]
pub enum LockfileKind {
/// Rust Cargo.lock
#[default]
Cargo,
/// Python requirements.txt with pinned versions (name==version)
RequirementsTxt,
/// Python uv.lock (uv package manager)
UvLock,
}
/// `holger-cli agent`: the courier. Transfer artifacts between registries, archives and
/// directories (this was the `holger-agent` program). Offline by default: the archive and
/// directory routes only; `--features online` adds crates.io, Nexus, Artifactory and Holger.
#[derive(Args, Debug)]
pub struct AgentArgs {
/// Source to read artifacts from
#[cfg(feature = "online")]
#[arg(long, value_enum, default_value_t = Source::World)]
from: Source,
/// Source to read artifacts from (offline build: archive/directory only, so the
/// default is `znippy` — the online default `world` is compiled out here).
#[cfg(not(feature = "online"))]
#[arg(long, value_enum, default_value_t = Source::Znippy)]
from: Source,
/// Destination to write artifacts to
#[arg(long, value_enum, default_value_t = Target::Znippy)]
to: Target,
/// Path to Cargo.lock file (required when --from world)
#[arg(long)]
lockfile: Option<std::path::PathBuf>,
/// Lockfile format (cargo, requirements-txt, uv-lock). Default: cargo
#[arg(long, value_enum, default_value_t = LockfileKind::Cargo)]
lockfile_kind: LockfileKind,
/// Input path: znippy archive or directory (required when --from znippy/directory)
#[arg(long)]
input: Option<std::path::PathBuf>,
/// Output path (required when --to znippy/directory)
#[arg(short, long)]
output: Option<std::path::PathBuf>,
/// Ecosystem override for `--from directory --to znippy`: tag every packed
/// file with this ecosystem (rust, maven, python, npm, nuget, …) so its index
/// is built. Omit to derive each file's ecosystem from its extension.
#[arg(long)]
format: Option<String>,
/// Download repos one at a time instead of in parallel (lower memory, lower network usage)
#[arg(long, default_value_t = false)]
sequential: bool,
/// Strict transfer mode (for --to znippy): any failed asset, hostile path, or
/// size mismatch fails the whole operation with a non-zero exit and leaves no
/// archive under the output name. Default is the legacy best-effort tally mode.
#[arg(long, default_value_t = false)]
strict: bool,
/// MIGRATION PREVIEW (`--from nexus|artifactory --to holger`): connect,
/// list every source repository with its asset count and size, print exactly
/// which holger repositories would be CREATED and which already exist, and
/// then STOP. Nothing is created and no artifact byte is read. A migration
/// moves a whole registry at once, so seeing the repository set it is about
/// to grow is the last cheap moment to say no.
#[arg(long, default_value_t = false)]
dry_run: bool,
/// Write a provenance transfer receipt (JSON) to this path. Suitable for
/// embedding as evidence/source-receipt.json in a signed update bundle.
#[arg(long)]
receipt: Option<std::path::PathBuf>,
// --- Nexus (basic auth) ---
/// Nexus base URL (basic auth with --username/--password)
#[arg(long)]
nexus_url: Option<String>,
/// Nexus repository name
#[arg(long)]
repository: Option<String>,
/// Artifactory base URL up to (not including) /artifactory, e.g.
/// `https://art.example.com`. Basic auth with --username/--password.
#[arg(long, env = "ARTIFACTORY_URL")]
artifactory_url: Option<String>,
/// Nexus username (or set NEXUS_USERNAME env var)
#[arg(long, env = "NEXUS_USERNAME")]
username: Option<String>,
/// Nexus password (or set NEXUS_PASSWORD env var)
#[arg(long, env = "NEXUS_PASSWORD")]
password: Option<String>,
// --- Holger server (strong auth) ---
/// Holger server URL
#[arg(long, env = "HOLGER_URL")]
holger_url: Option<String>,
/// Authentication method for Holger server
#[arg(long, value_enum)]
auth: Option<AuthMode>,
/// OIDC token endpoint URL (required when --auth oidc)
#[arg(long, env = "HOLGER_TOKEN_URL")]
token_url: Option<String>,
/// OIDC client ID / username for token request
#[arg(long, env = "HOLGER_CLIENT_ID")]
client_id: Option<String>,
/// Client certificate PEM path (required when --auth mtls)
#[arg(long, env = "HOLGER_CLIENT_CERT")]
client_cert: Option<std::path::PathBuf>,
/// Client key PEM path (required when --auth mtls)
#[arg(long, env = "HOLGER_CLIENT_KEY")]
client_key: Option<std::path::PathBuf>,
}
/// **Introspection / emit marker** — record one functional-status row for the
/// nornir test matrix. Wraps `nornir_testmatrix::functional_status` behind the
/// `testmatrix` feature (a compiled-out `#[inline]` no-op otherwise, with no
/// nornir dep in a release build). Each transfer route the agent actually runs
/// emits one so `nornir test --features testmatrix` SEES the real command
/// surface fire. Mirrors the emitter in `server/cli/src/main.rs`.
#[inline]
fn functional_status(component: &str, check: &str, ok: bool, detail: &str) {
#[cfg(feature = "testmatrix")]
nornir_testmatrix::functional_status(component, check, ok, detail);
#[cfg(not(feature = "testmatrix"))]
{
let _ = (component, check, ok, detail);
}
}
/// Emit a `.znippy` from a fetched asset list, honoring `--strict` / `--receipt`.
/// With neither flag set this calls the unchanged legacy best-effort path, so
/// default behavior is byte-for-byte preserved. Only the online routes fetch from a
/// connector, so this helper is compiled in with them.
#[cfg(feature = "online")]
async fn emit_znippy(
strict: bool,
receipt: Option<std::path::PathBuf>,
connector: &dyn ConnectorTrait,
source_type: &str,
source_endpoint: &str,
repository: &str,
assets: Vec<RemoteAsset>,
output: std::path::PathBuf,
) -> Result<()> {
if strict {
let have_receipt = receipt.is_some();
let mut opts = TransferOptions::new(TransferMode::Strict, source_type, source_endpoint);
opts.receipt_path = receipt;
let report =
transfer_assets_to_znippy_with_options(connector, repository, &assets, &output, &opts)
.await?;
eprintln!(
"✅ strict transfer: {}/{} assets, {} bytes{}",
report.successful_assets,
report.requested_assets,
report.bytes_transferred,
if have_receipt {
", receipt written"
} else {
""
}
);
} else if receipt.is_some() {
let mut opts = TransferOptions::new(TransferMode::BestEffort, source_type, source_endpoint);
opts.receipt_path = receipt;
transfer_assets_to_znippy_with_options(connector, repository, &assets, &output, &opts)
.await?;
} else {
operations::transfer_to_znippy(connector, assets, output).await?;
}
Ok(())
}
/// **A flag the chosen `--from`/`--to` route needs, or a usage error.** The route
/// decides which flags are required, so clap cannot check them up front; a missing
/// one used to be a panic (`expect`, exit 101 and a backtrace hint). It is now clap's
/// own usage error: `error: <what is missing>` plus the usage line on stderr, and
/// exit code 2, the same as any other bad command line.
fn required<T>(value: Option<T>, what: &str) -> T {
value.unwrap_or_else(|| {
<AgentArgs as Args>::augment_args(clap::Command::new("holger-cli agent"))
.error(clap::error::ErrorKind::MissingRequiredArgument, what)
.exit()
})
}
/// Run the courier.
pub async fn run(cli: AgentArgs) -> Result<()> {
// Extract auth params (used by reference in the online Holger paths only).
#[cfg(feature = "online")]
let auth_mode = cli.auth;
#[cfg(feature = "online")]
let token_url = cli.token_url;
#[cfg(feature = "online")]
let client_id = cli.client_id;
#[cfg(feature = "online")]
let client_cert = cli.client_cert;
#[cfg(feature = "online")]
let client_key = cli.client_key;
match (&cli.from, &cli.to) {
#[cfg(feature = "online")]
(Source::World, Target::Znippy)
| (Source::World, Target::TarZstd)
| (Source::World, Target::Directory) => {
let lockfile = required(cli.lockfile, "--lockfile is required when --from world");
let output = required(
cli.output,
"--output is required when --to znippy/tar-zstd/directory",
);
let assets = match cli.lockfile_kind {
LockfileKind::Cargo => {
let connector = WorldConnector::new()?;
let packages = parse_lockfile(&lockfile)?;
println!("📦 Found {} crates from crates.io", packages.len());
let assets = packages_to_assets(&packages);
match cli.to {
Target::Znippy => {
emit_znippy(
cli.strict,
cli.receipt.clone(),
&connector,
"world",
"https://static.crates.io",
"crates.io",
assets,
output,
)
.await?;
}
Target::TarZstd => {
operations::transfer_to_tar_zstd(&connector, assets, output).await?;
}
Target::Directory => {
operations::transfer_to_directory(&connector, assets, output).await?;
}
_ => unreachable!(),
}
functional_status(
"holger-agent/airgap",
"world_cargo_export",
true,
&format!(
"crates.io Cargo.lock → {:?} ({} crates)",
cli.to,
packages.len()
),
);
return Ok(());
}
LockfileKind::RequirementsTxt => {
let packages = parse_requirements_txt(&lockfile)
.map_err(|e| anyhow::anyhow!("Failed to parse requirements.txt: {}", e))?;
let index = PypiJsonClient::new();
let packages = resolve_with_index(packages, &index).await.map_err(|e| {
anyhow::anyhow!("Failed to resolve packages from PyPI: {}", e)
})?;
println!(
"📦 Found {} Python packages from PyPI",
packages.iter().map(|p| p.files.len()).sum::<usize>()
);
pip_packages_to_assets(&packages)
}
LockfileKind::UvLock => {
let packages = parse_uv_lockfile(&lockfile)
.map_err(|e| anyhow::anyhow!("Failed to parse uv.lock: {}", e))?;
println!(
"📦 Found {} Python packages from uv.lock",
packages.iter().map(|p| p.files.len()).sum::<usize>()
);
pip_packages_to_assets(&packages)
}
};
// For pip cases, we need a connector just for download
let connector = WorldConnector::new()?;
match cli.to {
Target::Znippy => {
emit_znippy(
cli.strict,
cli.receipt.clone(),
&connector,
"world",
"https://pypi.org",
"pypi",
assets,
output,
)
.await?;
}
Target::TarZstd => {
operations::transfer_to_tar_zstd(&connector, assets, output).await?;
}
Target::Directory => {
operations::transfer_to_directory(&connector, assets, output).await?;
}
_ => unreachable!(),
}
}
#[cfg(feature = "online")]
(Source::Nexus, Target::Znippy)
| (Source::Nexus, Target::TarZstd)
| (Source::Nexus, Target::Directory) => {
let nexus_url = required(cli.nexus_url, "--nexus-url is required when --from nexus");
let output = required(
cli.output,
"--output is required when --to znippy/tar-zstd/directory",
);
let connector =
std::sync::Arc::new(NexusConnector::new(nexus_url, cli.username, cli.password)?);
let repos = connector.list_repositories().await?;
let repos: Vec<_> = if let Some(ref filter) = cli.repository {
repos.into_iter().filter(|r| &r.name == filter).collect()
} else {
repos
};
println!(
"📦 Exporting {} repositories ({})",
repos.len(),
if cli.sequential {
"sequential"
} else {
"parallel"
}
);
match cli.to {
Target::Znippy => {
operations::dump_all_repos_to_znippy(connector, repos, output, cli.sequential)
.await?;
}
Target::TarZstd => {
// One archive PER REPO under `output/` — a single shared
// output path would be File::create-truncated once per repo,
// silently keeping only the last repo (data loss).
std::fs::create_dir_all(&output)?;
for repo in &repos {
println!("\n→ {} (format: {})", repo.name, repo.format);
let assets = connector.list_assets(&repo.name).await?;
println!(" {} assets", assets.len());
let repo_output = output.join(format!("{}.tar.zst", repo.name));
operations::transfer_to_tar_zstd(&*connector, assets, repo_output).await?;
}
}
Target::Directory => {
for repo in &repos {
println!("\n→ {} (format: {})", repo.name, repo.format);
let assets = connector.list_assets(&repo.name).await?;
println!(" {} assets", assets.len());
let repo_output = output.join(&repo.name);
operations::transfer_to_directory(&*connector, assets, repo_output).await?;
}
}
_ => unreachable!(),
}
}
#[cfg(feature = "online")]
(Source::Artifactory, Target::Znippy)
| (Source::Artifactory, Target::TarZstd)
| (Source::Artifactory, Target::Directory) => {
let art_url = required(
cli.artifactory_url,
"--artifactory-url is required when --from artifactory",
);
let output = required(
cli.output,
"--output is required when --to znippy/tar-zstd/directory",
);
let connector = std::sync::Arc::new(ArtifactoryConnector::new(
art_url,
cli.username,
cli.password,
)?);
let repos = connector.list_repositories().await?;
let repos: Vec<_> = if let Some(ref filter) = cli.repository {
repos.into_iter().filter(|r| &r.name == filter).collect()
} else {
repos
};
println!(
"📦 Exporting {} repositories from Artifactory ({})",
repos.len(),
if cli.sequential {
"sequential"
} else {
"parallel"
}
);
match cli.to {
Target::Znippy => {
operations::dump_all_repos_to_znippy(connector, repos, output, cli.sequential)
.await?;
}
Target::TarZstd => {
// One archive PER REPO under `output/` — a single shared
// output path would be File::create-truncated once per repo,
// silently keeping only the last repo (data loss).
std::fs::create_dir_all(&output)?;
for repo in &repos {
println!("\n→ {} (format: {})", repo.name, repo.format);
let assets = connector.list_assets(&repo.name).await?;
println!(" {} assets", assets.len());
let repo_output = output.join(format!("{}.tar.zst", repo.name));
operations::transfer_to_tar_zstd(&*connector, assets, repo_output).await?;
}
}
Target::Directory => {
for repo in &repos {
println!("\n→ {} (format: {})", repo.name, repo.format);
let assets = connector.list_assets(&repo.name).await?;
println!(" {} assets", assets.len());
let repo_output = output.join(&repo.name);
operations::transfer_to_directory(&*connector, assets, repo_output).await?;
}
}
_ => unreachable!(),
}
}
#[cfg(feature = "online")]
(Source::Znippy, Target::Artifactory)
| (Source::TarZstd, Target::Artifactory)
| (Source::Directory, Target::Artifactory) => {
let art_url = required(
cli.artifactory_url,
"--artifactory-url is required when --to artifactory",
);
let repository = required(
cli.repository,
"--repository is required when --to artifactory",
);
let connector = ArtifactoryConnector::new(art_url, cli.username, cli.password)?;
match cli.from {
Source::Znippy => {
let input = required(cli.input, "--input is required when --from znippy");
operations::push_znippy_to_connector(&connector, input, &repository).await?;
}
Source::TarZstd => {
let input = required(cli.input, "--input is required when --from tar-zstd");
operations::push_tar_zstd_to_connector(&connector, input, &repository).await?;
}
Source::Directory => {
let input = required(cli.input, "--input is required when --from directory");
operations::push_directory_to_connector(&connector, input, &repository).await?;
}
_ => unreachable!(),
}
}
#[cfg(feature = "online")]
(Source::Holger, Target::Znippy)
| (Source::Holger, Target::TarZstd)
| (Source::Holger, Target::Directory) => {
let holger_url = required(
cli.holger_url,
"--holger-url is required when --from holger",
);
let output = required(
cli.output,
"--output is required when --to znippy/tar-zstd/directory",
);
let connector = std::sync::Arc::new(
build_holger_connector(
&auth_mode,
&holger_url,
&token_url,
&client_id,
&client_cert,
&client_key,
)
.await?,
);
let repos = connector.list_repositories().await?;
let repos: Vec<_> = if let Some(ref filter) = cli.repository {
repos.into_iter().filter(|r| &r.name == filter).collect()
} else {
repos
};
println!(
"🔐 Exporting {} repositories from Holger ({})",
repos.len(),
if cli.sequential {
"sequential"
} else {
"parallel"
}
);
match cli.to {
Target::Znippy => {
operations::dump_all_repos_to_znippy(connector, repos, output, cli.sequential)
.await?;
}
Target::TarZstd => {
// One archive PER REPO under `output/` — a single shared
// output path would be File::create-truncated once per repo,
// silently keeping only the last repo (data loss).
std::fs::create_dir_all(&output)?;
for repo in &repos {
println!("\n→ {} (format: {})", repo.name, repo.format);
let assets = connector.list_assets(&repo.name).await?;
println!(" {} assets", assets.len());
let repo_output = output.join(format!("{}.tar.zst", repo.name));
operations::transfer_to_tar_zstd(&*connector, assets, repo_output).await?;
}
}
Target::Directory => {
for repo in &repos {
println!("\n→ {} (format: {})", repo.name, repo.format);
let assets = connector.list_assets(&repo.name).await?;
println!(" {} assets", assets.len());
let repo_output = output.join(&repo.name);
operations::transfer_to_directory(&*connector, assets, repo_output).await?;
}
}
_ => unreachable!(),
}
}
#[cfg(feature = "online")]
(Source::Znippy, Target::Nexus) => {
let input = required(cli.input, "--input is required when --from znippy");
let nexus_url = required(cli.nexus_url, "--nexus-url is required when --to nexus");
let repository = required(cli.repository, "--repository is required when --to nexus");
let connector = NexusConnector::new(nexus_url, cli.username, cli.password)?;
operations::push_znippy_to_connector(&connector, input, &repository).await?;
}
#[cfg(feature = "online")]
(Source::TarZstd, Target::Nexus) => {
let input = required(cli.input, "--input is required when --from tar-zstd");
let nexus_url = required(cli.nexus_url, "--nexus-url is required when --to nexus");
let repository = required(cli.repository, "--repository is required when --to nexus");
let connector = NexusConnector::new(nexus_url, cli.username, cli.password)?;
operations::push_tar_zstd_to_connector(&connector, input, &repository).await?;
}
#[cfg(feature = "online")]
(Source::Znippy, Target::Holger)
| (Source::Directory, Target::Holger)
| (Source::TarZstd, Target::Holger) => {
let holger_url = required(cli.holger_url, "--holger-url is required when --to holger");
let repository = required(cli.repository, "--repository is required when --to holger");
let connector = build_holger_connector(
&auth_mode,
&holger_url,
&token_url,
&client_id,
&client_cert,
&client_key,
)
.await?;
match cli.from {
Source::Znippy => {
let input = required(cli.input, "--input is required when --from znippy");
operations::push_znippy_to_connector(&connector, input, &repository).await?;
}
Source::TarZstd => {
let input = required(cli.input, "--input is required when --from tar-zstd");
operations::push_tar_zstd_to_connector(&connector, input, &repository).await?;
}
Source::Directory => {
let input = required(cli.input, "--input is required when --from directory");
operations::push_directory_to_connector(&connector, input, &repository).await?;
}
_ => unreachable!(),
}
}
#[cfg(feature = "online")]
(Source::Directory, Target::Nexus) => {
let input = required(cli.input, "--input is required when --from directory");
let nexus_url = required(cli.nexus_url, "--nexus-url is required when --to nexus");
let repository = required(cli.repository, "--repository is required when --to nexus");
let connector = NexusConnector::new(nexus_url, cli.username, cli.password)?;
operations::push_directory_to_connector(&connector, input, &repository).await?;
}
// ─── MIGRATE: an existing registry into holger, ONE TIME, in one pass ───
//
// These two combinations used to fall through to the wildcard below and
// `bail!("Unsupported source/target combination.")`. The move was possible
// — `--from nexus --to znippy` then `--from znippy --to holger` — but it
// cost a full second copy of the registry on a local disk and a manual
// step between two commands. The archive in the middle is the AIRGAP
// courier, and it earns its place exactly when the two ends are on
// different networks. When both ends are reachable at once it is a tax.
//
// This is a MIGRATION: it runs once and it finishes. It is not a mirror,
// and widening it into one would be a different feature with a different
// shape (a schedule, a watermark, a standing relationship).
//
// Re-running is safe and is the intended way to recover: every asset is
// checked against the destination before it is fetched, keyed on path +
// the sha256 the SOURCE published, so what already landed is skipped.
#[cfg(feature = "online")]
(Source::Nexus, Target::Holger) | (Source::Artifactory, Target::Holger) => {
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
let holger_url = required(cli.holger_url, "--holger-url is required when --to holger");
let source: Arc<dyn ConnectorTrait> = match cli.from {
Source::Nexus => {
let url = required(cli.nexus_url, "--nexus-url is required when --from nexus");
Arc::new(NexusConnector::new(url, cli.username, cli.password)?)
}
Source::Artifactory => {
let url = required(
cli.artifactory_url,
"--artifactory-url is required when --from artifactory",
);
Arc::new(ArtifactoryConnector::new(url, cli.username, cli.password)?)
}
_ => unreachable!(),
};
let target: Arc<dyn ConnectorTrait> = Arc::new(
build_holger_connector(
&auth_mode,
&holger_url,
&token_url,
&client_id,
&client_cert,
&client_key,
)
.await?,
);
println!("🔎 Discovering {} …", source.name());
let destination_repos = target.list_repositories().await.map_err(|e| {
anyhow::anyhow!("could not list the destination holger repositories: {e}")
})?;
let (plan, assets) = migrate::discover(
source.as_ref(),
&destination_repos,
cli.repository.as_deref(),
)
.await?;
// The preview is printed on EVERY run, not only under --dry-run: an
// operator who is about to move a registry should read the repository
// set first whether or not he asked for it.
print!("{}", plan.dry_run_report());
if cli.dry_run {
functional_status(
"holger-agent/migrate",
"migrate_dry_run",
plan.refuse_collisions().is_ok(),
&format!(
"{:?} → holger: {} repos, {} would be created",
cli.from,
plan.repos.len(),
plan.to_create().len()
),
);
return Ok(());
}
// Refuse BEFORE a byte moves. `run_migration` checks this too; doing
// it here makes the CLI exit non-zero with the reason rather than
// with a generic engine error.
plan.refuse_collisions()?;
// Ctrl-C is a CANCEL, not a kill: it stops admitting new transfers and
// lets the in-flight ones finish, because tearing down a half-written
// upload is how a destination gets a truncated artifact. Re-run to
// resume.
let cancel = Arc::new(AtomicBool::new(false));
let ctrlc = cancel.clone();
tokio::spawn(async move {
if tokio::signal::ctrl_c().await.is_ok() {
eprintln!("\n⏹ cancelling — finishing transfers already in flight. Re-run to resume.");
ctrlc.store(true, Ordering::Relaxed);
}
});
let opts = MigrationOptions {
// `--sequential` means one at a time here too: lower memory, lower
// load on a source that is still serving its own users.
concurrency: if cli.sequential {
1
} else {
DEFAULT_MIGRATION_CONCURRENCY
},
verify_digests: true,
};
println!("\n🚚 Migrating …");
let sink = Arc::new(migrate::ConnectorSink::new(target));
let report = migrate::run_migration(source, sink, &plan, &assets, &opts, cancel, |p| {
if p.repo_finished {
println!(" → {}", p.message);
}
})
.await?;
for failure in &report.failures {
println!(" ✗ {failure}");
}
println!(
"\n{} migration: {} moved, {} already present, {} failed, {} bytes, {} repositories created{}",
if report.is_complete() { "✅" } else { "⚠️ " },
report.moved,
report.skipped,
report.failed,
report.bytes,
report.repos_created.len(),
if report.cancelled { " (CANCELLED — re-run to resume)" } else { "" },
);
functional_status(
"holger-agent/migrate",
"migrate_completed",
report.is_complete(),
&format!(
"{:?} → holger: {} moved, {} failed",
cli.from, report.moved, report.failed
),
);
if !report.is_complete() {
anyhow::bail!(
"migration did not complete: {} assets failed, cancelled={}. \
Re-run the same command to resume — everything already moved is skipped.",
report.failed,
report.cancelled
);
}
return Ok(());
}
#[cfg(feature = "online")]
(Source::World, Target::Nexus)
| (Source::World, Target::Holger)
| (Source::World, Target::Artifactory) => {
anyhow::bail!("Cannot push directly from world to registry. Airgap first, then push.");
}
#[cfg(feature = "online")]
(Source::Nexus, Target::Nexus)
| (Source::Holger, Target::Holger)
| (Source::Artifactory, Target::Artifactory) => {
anyhow::bail!("Cannot transfer directly between same type. Export first, then import.");
}
(Source::Znippy, Target::Znippy)
| (Source::Directory, Target::Directory)
| (Source::TarZstd, Target::TarZstd) => {
anyhow::bail!("Source and destination are the same format — nothing to do.");
}
(Source::Directory, Target::Znippy) => {
let input = required(cli.input, "--input is required when --from directory");
let output = required(cli.output, "--output is required when --to znippy");
operations::pack_directory_to_znippy(input, output, cli.format.as_deref()).await?;
}
(Source::Znippy, Target::Directory) => {
let input = required(cli.input, "--input is required when --from znippy");
let output = required(cli.output, "--output is required when --to directory");
operations::unpack_znippy_to_directory(input, output).await?;
}
(Source::TarZstd, Target::Directory) => {
let input = required(cli.input, "--input is required when --from tar-zstd");
let output = required(cli.output, "--output is required when --to directory");
operations::unpack_tar_zstd_to_directory(input, output).await?;
}
(Source::Directory, Target::TarZstd) => {
let input = required(cli.input, "--input is required when --from directory");
let output = required(cli.output, "--output is required when --to tar-zstd");
operations::pack_directory_to_tar_zstd(input, output).await?;
}
(Source::Znippy, Target::TarZstd) => {
let input = required(cli.input, "--input is required when --from znippy");
let output = required(cli.output, "--output is required when --to tar-zstd");
operations::convert_znippy_to_tar_zstd(input, output).await?;
}
(Source::TarZstd, Target::Znippy) => {
let input = required(cli.input, "--input is required when --from tar-zstd");
let output = required(cli.output, "--output is required when --to znippy");
operations::convert_tar_zstd_to_znippy(input, output, cli.format.as_deref()).await?;
}
// Online-only: with the online variants compiled in, the 3×3 offline arms
// above are not exhaustive, so this catches the unsupported online combos.
// In an offline build the offline arms ARE exhaustive over the remaining
// (archive/directory) variants, so this wildcard would be unreachable — hence
// it is compiled out there rather than left as a dead pattern.
#[cfg(feature = "online")]
_ => {
anyhow::bail!("Unsupported source/target combination.");
}
}
// Reaching here means a real transfer route completed (every dead-end
// combination `bail!`s above, so no green row is emitted for a no-op). Record
// the executed route as the agent's command-surface functional check.
functional_status(
"holger-agent/transfer",
"route_completed",
true,
&format!("{:?} → {:?}", cli.from, cli.to),
);
Ok(())
}
/// Build a NexusConnector with OIDC or mTLS auth for Holger server. Online-only —
/// it constructs a reqwest-backed connector, absent from the offline build.
#[cfg(feature = "online")]
async fn build_holger_connector(
auth: &Option<AuthMode>,
holger_url: &str,
token_url: &Option<String>,
client_id: &Option<String>,
client_cert: &Option<std::path::PathBuf>,
client_key: &Option<std::path::PathBuf>,
) -> Result<NexusConnector> {
match auth {
Some(AuthMode::Oidc) => {
let token_url = required(
token_url.as_ref(),
"--token-url is required when --auth oidc",
);
let client_id = required(
client_id.as_ref(),
"--client-id is required when --auth oidc",
);
println!("🔑 Fetching OIDC token from {}", token_url);
let token = fetch_oidc_token(token_url, client_id, "").await?;
println!("✅ Got token");
NexusConnector::with_oidc_token(holger_url.to_string(), token)
}
Some(AuthMode::Mtls) => {
let cert_path = required(
client_cert.as_ref(),
"--client-cert is required when --auth mtls",
);
let key_path = required(
client_key.as_ref(),
"--client-key is required when --auth mtls",
);
let cert_pem = std::fs::read(cert_path)?;
let key_pem = std::fs::read(key_path)?;
println!("🔐 Using mTLS client cert: {}", cert_path.display());
NexusConnector::with_mtls(holger_url.to_string(), &cert_pem, &key_pem)
}
None => {
anyhow::bail!("--auth (oidc or mtls) is required when using --from/--to holger");
}
}
}