use colored::Colorize;
use xbp_oci::{
parse_image_ref, resolve_auth_from_candidates, OciAuthRequest, OciClient,
};
use crate::cli::commands::{
OciCmd, OciConfigAction, OciDigestCmd, OciExistsCmd, OciInspectCmd, OciPromoteCmd,
OciPullManifestCmd, OciReferrersCmd, OciSubCommand, OciTagsCmd,
};
use crate::commands::service::load_xbp_config_with_root;
use crate::config::{GlobalOciRegistryCred, SshConfig};
use crate::oci::auth_candidates_for_registry;
use crate::strategies::XbpConfig;
pub async fn run_oci(cmd: OciCmd, _debug: bool) -> Result<(), String> {
let project = try_load_project().await;
match cmd.command {
OciSubCommand::Inspect(c) => run_inspect(c, project.as_ref()).await,
OciSubCommand::Tags(c) => run_tags(c, project.as_ref()).await,
OciSubCommand::Digest(c) => run_digest(c, project.as_ref()).await,
OciSubCommand::Exists(c) => run_exists(c, project.as_ref()).await,
OciSubCommand::Promote(c) => run_promote(c, project.as_ref()).await,
OciSubCommand::PullManifest(c) => run_pull_manifest(c, project.as_ref()).await,
OciSubCommand::Referrers(c) => run_referrers(c, project.as_ref()).await,
}
}
async fn try_load_project() -> Option<XbpConfig> {
load_xbp_config_with_root().await.ok().map(|(_, c)| c)
}
fn client_for(
image: &str,
username: Option<String>,
token: Option<String>,
anonymous: bool,
auth: Option<String>,
project: Option<&XbpConfig>,
) -> Result<OciClient, String> {
let parsed = parse_image_ref(image).map_err(|e| e.to_string())?;
let project_cfg = project.cloned().unwrap_or_else(|| XbpConfig {
project_name: String::new(),
version: String::new(),
port: 0,
build_dir: String::new(),
app_type: None,
build_command: None,
start_command: None,
install_command: None,
environment: None,
services: None,
openapi: None,
workers: None,
systemd_service_name: None,
systemd: None,
kafka_brokers: None,
kafka_topic: None,
kafka_public_url: None,
log_files: None,
monitor_url: None,
monitor_method: None,
monitor_expected_code: None,
monitor_interval: None,
database: None,
oci: None,
kubernetes: None,
deploy: None,
target: None,
branch: None,
crate_name: None,
npm_script: None,
port_storybook: None,
url: None,
url_storybook: None,
linear: None,
github: None,
publish: None,
version_targets: vec![],
version_domains: vec![],
ignore_paths: vec![],
watch_ignore_paths: vec![],
versioning_disabled: vec![],
release_disabled: vec![],
versioning: None,
discord: None,
});
let candidates = auth_candidates_for_registry(&parsed.registry, &project_cfg);
let resolved = resolve_auth_from_candidates(
&OciAuthRequest {
registry: parsed.registry,
username,
token,
anonymous,
auth_provider: auth,
},
&candidates,
);
Ok(OciClient::new(resolved))
}
async fn run_inspect(cmd: OciInspectCmd, project: Option<&XbpConfig>) -> Result<(), String> {
let image = parse_image_ref(&cmd.image).map_err(|e| e.to_string())?;
let client = client_for(
&cmd.image,
cmd.username,
cmd.token,
cmd.anonymous,
cmd.auth,
project,
)?;
let result = client.inspect(&image).await.map_err(|e| e.to_string())?;
if cmd.json {
println!(
"{}",
serde_json::to_string_pretty(&result).map_err(|e| e.to_string())?
);
return Ok(());
}
println!("{} {}", "registry".dimmed(), result.registry);
println!("{} {}", "repository".dimmed(), result.repository);
if let Some(tag) = &result.tag {
println!("{} {tag}", "tag".dimmed());
}
if let Some(d) = &result.resolved_digest {
println!("{} {d}", "digest".dimmed());
}
if let Some(mt) = &result.manifest_media_type {
println!("{} {mt}", "mediaType".dimmed());
}
if let Some(n) = result.layer_count {
println!("{} {n}", "layers".dimmed());
}
println!("{} {}", "auth".dimmed(), result.auth_source);
Ok(())
}
async fn run_tags(cmd: OciTagsCmd, project: Option<&XbpConfig>) -> Result<(), String> {
let image = parse_image_ref(&cmd.image).map_err(|e| e.to_string())?;
let client = client_for(
&cmd.image,
cmd.username,
cmd.token,
cmd.anonymous,
cmd.auth,
project,
)?;
let tags = client
.list_tags(&image, cmd.limit)
.await
.map_err(|e| e.to_string())?;
if cmd.json {
println!(
"{}",
serde_json::to_string_pretty(&tags).map_err(|e| e.to_string())?
);
} else {
for tag in tags {
println!("{tag}");
}
}
Ok(())
}
async fn run_digest(cmd: OciDigestCmd, project: Option<&XbpConfig>) -> Result<(), String> {
let image = parse_image_ref(&cmd.image).map_err(|e| e.to_string())?;
let client = client_for(
&cmd.image,
cmd.username,
cmd.token,
cmd.anonymous,
cmd.auth,
project,
)?;
let digest = client
.resolve_digest(&image)
.await
.map_err(|e| e.to_string())?;
if let Some(path) = &cmd.write_lock {
let mut images = std::collections::BTreeMap::new();
images.insert(
image.repository.clone(),
xbp_deploy::DeployLockImage {
image_ref: cmd.image.clone(),
digest: digest.clone(),
},
);
xbp_deploy::write_lock(
path,
&xbp_deploy::DeployLockFile {
hash: String::new(),
generated_at: chrono::Utc::now().to_rfc3339(),
env: "manual".into(),
target: image.repository.clone(),
git_sha: None,
version: None,
images,
},
)
.map_err(|e| e.to_string())?;
eprintln!("{} {}", "lock".bright_cyan(), path.display());
}
if cmd.json {
println!("{}", serde_json::json!({ "image": cmd.image, "digest": digest }));
} else {
println!("{digest}");
}
Ok(())
}
async fn run_exists(cmd: OciExistsCmd, project: Option<&XbpConfig>) -> Result<(), String> {
let image = parse_image_ref(&cmd.image).map_err(|e| e.to_string())?;
let client = client_for(
&cmd.image,
cmd.username,
cmd.token,
cmd.anonymous,
cmd.auth,
project,
)?;
let exists = client.exists(&image).await.map_err(|e| e.to_string())?;
if cmd.json {
println!("{}", serde_json::json!({ "image": cmd.image, "exists": exists }));
} else if exists {
println!("{} {}", "exists".bright_green(), cmd.image);
} else {
println!("{} {}", "missing".bright_red(), cmd.image);
}
if exists {
Ok(())
} else {
Err(format!("image does not exist: {}", cmd.image))
}
}
async fn run_promote(cmd: OciPromoteCmd, project: Option<&XbpConfig>) -> Result<(), String> {
let source = parse_image_ref(&cmd.source_image).map_err(|e| e.to_string())?;
let target = source.with_tag(&cmd.to);
if cmd.dry_run {
let client = client_for(
&cmd.source_image,
cmd.username.clone(),
cmd.token.clone(),
false,
cmd.auth.clone(),
project,
)?;
let digest = client
.resolve_digest(&source)
.await
.map_err(|e| e.to_string())?;
if cmd.json {
println!(
"{}",
serde_json::json!({
"source": source.reference(),
"digest": digest,
"target": target.reference(),
"dry_run": true
})
);
} else {
println!("{} {}", "source".dimmed(), source.reference());
println!("{} {digest}", "digest".dimmed());
println!("{} {}", "target".dimmed(), target.reference());
println!("{}", "dry-run: no registry mutation".bright_yellow());
}
return Ok(());
}
let client = client_for(
&cmd.source_image,
cmd.username,
cmd.token,
false,
cmd.auth,
project,
)?;
let digest = client
.promote(&source, &target, cmd.force)
.await
.map_err(|e| e.to_string())?;
if cmd.json {
println!(
"{}",
serde_json::json!({
"source": source.reference(),
"digest": digest,
"target": target.reference(),
"dry_run": false
})
);
} else {
println!("{} {}", "source".dimmed(), source.reference());
println!("{} {digest}", "digest".dimmed());
println!("{} {}", "target".dimmed(), target.reference());
println!("{}", "promoted".bright_green().bold());
}
Ok(())
}
async fn run_pull_manifest(
cmd: OciPullManifestCmd,
project: Option<&XbpConfig>,
) -> Result<(), String> {
let image = parse_image_ref(&cmd.image).map_err(|e| e.to_string())?;
let client = client_for(
&cmd.image,
cmd.username,
cmd.token,
cmd.anonymous,
cmd.auth,
project,
)?;
let (manifest, digest) = client
.pull_manifest(&image)
.await
.map_err(|e| e.to_string())?;
let value = serde_json::to_value(&manifest).map_err(|e| e.to_string())?;
if cmd.raw {
println!("{}", serde_json::to_string(&value).map_err(|e| e.to_string())?);
} else if cmd.json {
println!("{}", serde_json::json!({ "digest": digest, "manifest": value }));
} else {
println!(
"{}",
serde_json::to_string_pretty(&value).map_err(|e| e.to_string())?
);
}
Ok(())
}
async fn run_referrers(cmd: OciReferrersCmd, project: Option<&XbpConfig>) -> Result<(), String> {
let image = parse_image_ref(&cmd.image).map_err(|e| e.to_string())?;
let client = client_for(
&cmd.image,
cmd.username,
cmd.token,
cmd.anonymous,
cmd.auth,
project,
)?;
let digest = client
.resolve_digest(&image)
.await
.map_err(|e| e.to_string())?;
let result = serde_json::json!({
"image": cmd.image,
"digest": digest,
"artifact_type": cmd.artifact_type,
"referrers": [],
"note": "referrers listing is best-effort in this build"
});
println!(
"{}",
serde_json::to_string_pretty(&result).map_err(|e| e.to_string())?
);
Ok(())
}
pub async fn run_config_oci(action: Option<OciConfigAction>) -> Result<(), String> {
match action {
None | Some(OciConfigAction::Show) | Some(OciConfigAction::Status) => {
let cfg = SshConfig::load().unwrap_or_else(|_| SshConfig::new());
let oci = cfg.oci.unwrap_or_default();
if oci.registries.is_empty() {
println!("{}", "No OCI registry credentials in global config.".dimmed());
return Ok(());
}
println!("{}", "OCI registries (tokens masked):".bright_cyan().bold());
for (host, cred) in oci.registries {
let user = cred.username.as_deref().unwrap_or("-");
let token = cred
.token
.as_deref()
.map(mask)
.unwrap_or_else(|| "(none)".into());
println!(" {host} user={user} token={token}");
}
Ok(())
}
Some(OciConfigAction::SetRegistryToken {
registry,
username,
token,
}) => {
let token = match token {
Some(t) if !t.trim().is_empty() => t,
_ => rpassword::prompt_password(format!(
"Enter token for registry `{registry}`: "
))
.map_err(|e| e.to_string())?,
};
if token.trim().is_empty() {
return Err("Refusing to store an empty token.".into());
}
let mut cfg = SshConfig::load().unwrap_or_else(|_| SshConfig::new());
let mut oci = cfg.oci.unwrap_or_default();
oci.registries.insert(
registry.clone(),
GlobalOciRegistryCred {
username: Some(username.clone()),
token: Some(token.clone()),
},
);
cfg.oci = Some(oci);
cfg.save()?;
println!(
"Saved OCI credentials for `{registry}` (user={username}, token={})",
mask(&token)
);
Ok(())
}
Some(OciConfigAction::DeleteRegistryToken { registry }) => {
let mut cfg = SshConfig::load().unwrap_or_else(|_| SshConfig::new());
let mut oci = cfg.oci.unwrap_or_default();
let removed = oci.registries.remove(®istry).is_some();
cfg.oci = if oci.registries.is_empty() {
None
} else {
Some(oci)
};
cfg.save()?;
if removed {
println!("Deleted OCI credentials for `{registry}`.");
} else {
println!("No OCI credentials stored for `{registry}`.");
}
Ok(())
}
}
}
fn mask(token: &str) -> String {
let t = token.trim();
if t.len() <= 8 {
"****".into()
} else {
format!("{}…{}", &t[..4], &t[t.len() - 4..])
}
}