use crate::cmd::GlobalOpts;
use crate::artifact::{self, RegistryClient};
use crate::config::{Config, PasswordSource, RegistryEntry, resolve_registry_endpoint};
use crate::output::{self, Table};
use anyhow::{Context, Result, bail};
use clap::{Args, Subcommand};
use serde_json::{Value, json};
use std::io::Write;
use std::path::PathBuf;
use std::time::Duration;
#[derive(Subcommand, Debug)]
pub enum ArtifactCmd {
Login(LoginArgs),
Logout(LogoutArgs),
#[command(visible_alias = "contexts")]
Registries,
#[command(visible_alias = "use-registry")]
Use(UseArgs),
Push(PushArgs),
#[command(visible_alias = "push-df")]
PushDockerfile(PushDockerfileArgs),
#[command(visible_alias = "tags")]
Ls,
Describe(DescribeArgs),
Usage,
#[command(visible_alias = "rm-tag")]
Untag(UntagArgs),
}
#[derive(Args, Debug, Clone)]
pub struct RegistryOpts {
#[arg(long, global = true, env = "HEYCTL_REGISTRY", value_name = "NAME")]
pub registry: Option<String>,
#[arg(long, global = true, env = "HEYCTL_ART_URL", value_name = "URL")]
pub registry_url: Option<String>,
#[arg(
long,
global = true,
env = "HEYCTL_ART_API_KEY",
value_name = "KEY",
hide_env_values = true
)]
pub api_key: Option<String>,
}
#[derive(Args, Debug)]
pub struct LoginArgs {
#[arg(value_name = "URL")]
pub url: String,
#[arg(long, value_name = "KEY", hide_env_values = true)]
pub api_key: Option<String>,
#[arg(long, conflicts_with = "api_key")]
pub api_key_stdin: bool,
#[arg(long, value_name = "CMD", conflicts_with_all = ["api_key", "api_key_stdin"])]
pub api_key_command: Option<String>,
#[arg(long)]
pub no_store_key: bool,
#[arg(long, value_name = "NAME")]
pub name: Option<String>,
#[arg(long)]
pub insecure_skip_tls_verify: bool,
#[arg(long)]
pub no_switch: bool,
}
#[derive(Args, Debug)]
pub struct LogoutArgs {
#[arg(value_name = "NAME")]
pub name: Option<String>,
#[arg(long)]
pub key_only: bool,
}
#[derive(Args, Debug)]
pub struct UseArgs {
#[arg(value_name = "NAME")]
pub name: String,
}
#[derive(Args, Debug)]
pub struct PushArgs {
#[arg(value_name = "FILE", required_unless_present = "image")]
pub file: Option<PathBuf>,
#[arg(long, value_name = "NAME", conflicts_with = "file")]
pub image: Option<String>,
#[arg(long, value_name = "NAME")]
pub tag: Option<String>,
#[arg(long, conflicts_with = "tag")]
pub no_tag: bool,
#[arg(long)]
pub force: bool,
}
#[derive(Args, Debug)]
pub struct PushDockerfileArgs {
#[arg(value_name = "FILE")]
pub file: PathBuf,
#[arg(long = "build-context", value_name = "PATH")]
pub build_context: Option<PathBuf>,
#[arg(long, value_name = "NAME")]
pub tag: Option<String>,
#[arg(long, conflicts_with = "tag")]
pub no_tag: bool,
#[arg(long, value_name = "NAME")]
pub image_name: Option<String>,
#[arg(long = "size-mb", value_name = "MB")]
pub image_size_mb: Option<u64>,
#[arg(long, value_name = "TEXT")]
pub source: Option<String>,
#[arg(long)]
pub force: bool,
}
#[derive(Args, Debug)]
pub struct DescribeArgs {
#[arg(value_name = "REF")]
pub reference: String,
}
#[derive(Args, Debug)]
pub struct UntagArgs {
#[arg(value_name = "NAME")]
pub name: String,
}
pub fn run(globals: &GlobalOpts, opts: &RegistryOpts, cmd: &ArtifactCmd) -> Result<()> {
match cmd {
ArtifactCmd::Logout(args) => logout(globals, args),
ArtifactCmd::Registries => registries(globals),
ArtifactCmd::Use(args) => use_registry(globals, args),
ArtifactCmd::Login(args) => login(globals, args),
ArtifactCmd::Push(args) => push(globals, opts, args),
ArtifactCmd::PushDockerfile(args) => push_dockerfile(globals, opts, args),
ArtifactCmd::Ls => ls(globals, opts),
ArtifactCmd::Describe(args) => describe(globals, opts, args),
ArtifactCmd::Usage => usage(globals, opts),
ArtifactCmd::Untag(args) => untag(globals, opts, args),
}
}
fn client(globals: &GlobalOpts, opts: &RegistryOpts) -> Result<(RegistryClient, String, PasswordSource)> {
let path = Config::path(globals.config.as_deref())?;
let config = Config::load(&path)?;
let ep = resolve_registry_endpoint(
&config,
opts.registry.as_deref(),
opts.registry_url.as_deref(),
opts.api_key.as_deref(),
globals.insecure_skip_tls_verify,
)?;
let c = RegistryClient::new(
&ep.url,
ep.api_key.as_deref(),
ep.insecure_skip_tls_verify,
Duration::from_secs(globals.request_timeout),
)?;
Ok((c, ep.name, ep.api_key_source))
}
fn login(globals: &GlobalOpts, args: &LoginArgs) -> Result<()> {
let path = Config::path(globals.config.as_deref())?;
let mut config = Config::load(&path)?;
let insecure = args.insecure_skip_tls_verify || globals.insecure_skip_tls_verify;
let timeout = Duration::from_secs(globals.request_timeout);
let anon = RegistryClient::new(&args.url, None, insecure, timeout)?;
anon.healthz()
.with_context(|| format!("cannot reach an artifact store at {}", args.url))?;
let open = anon.tags().is_ok();
if open {
println!(
"{} has no API key configured — every route is open.\n\
(Set ART_API_KEY on the store to gate it.)",
anon.url()
);
return save_registry(
&mut config,
&path,
args,
RegistryEntry {
url: anon.url().to_string(),
api_key: None,
api_key_command: None,
insecure_skip_tls_verify: insecure,
},
);
}
let key = match (&args.api_key_command, &args.api_key, args.api_key_stdin) {
(Some(cmd), _, _) => run_command(cmd)?,
(None, Some(k), _) => k.clone(),
(None, None, true) => {
let mut buf = String::new();
std::io::Read::read_to_string(&mut std::io::stdin(), &mut buf)
.context("reading the API key from stdin")?;
buf.trim_end_matches(['\n', '\r']).to_string()
}
(None, None, false) => rpassword::prompt_password(format!("API key for {}: ", args.url))
.context("reading the API key")?,
};
if key.is_empty() {
bail!("an empty API key will not authenticate against the store");
}
let c = RegistryClient::new(&args.url, Some(&key), insecure, timeout)?;
c.tags().context("verifying the API key against GET /tags")?;
println!("Logged in to {}.", c.url());
if args.no_store_key {
println!(" key not stored — set HEYCTL_ART_API_KEY for later commands");
}
save_registry(
&mut config,
&path,
args,
RegistryEntry {
url: c.url().to_string(),
api_key: (!args.no_store_key && args.api_key_command.is_none()).then(|| key.clone()),
api_key_command: args.api_key_command.clone(),
insecure_skip_tls_verify: insecure,
},
)
}
fn save_registry(
config: &mut Config,
path: &std::path::Path,
args: &LoginArgs,
entry: RegistryEntry,
) -> Result<()> {
let name = args
.name
.clone()
.unwrap_or_else(|| registry_name_for(&entry.url));
config.registries.insert(name.clone(), entry);
if !args.no_switch {
config.current_registry = Some(name.clone());
}
config.save(path)?;
println!("Registry {name:?} saved to {}.", path.display());
Ok(())
}
fn registry_name_for(url: &str) -> String {
let host = url
.split("://")
.nth(1)
.unwrap_or(url)
.split('/')
.next()
.unwrap_or(url);
let bare = host.rsplit_once(':').map(|(h, _)| h).unwrap_or(host);
match bare {
"127.0.0.1" | "localhost" | "::1" | "[::1]" => "local".to_string(),
other => other.to_string(),
}
}
fn logout(globals: &GlobalOpts, args: &LogoutArgs) -> Result<()> {
let path = Config::path(globals.config.as_deref())?;
let mut config = Config::load(&path)?;
let name = match &args.name {
Some(n) => n.clone(),
None => config
.resolve_registry(None)?
.map(|(n, _)| n)
.context("no registry to log out of")?,
};
if !config.registries.contains_key(&name) {
bail!("no registry named {name:?}");
}
if args.key_only {
if let Some(entry) = config.registries.get_mut(&name) {
entry.api_key = None;
entry.api_key_command = None;
}
config.save(&path)?;
println!("Dropped the API key for registry {name:?}; the url is kept.");
return Ok(());
}
config.registries.remove(&name);
if config.current_registry.as_deref() == Some(name.as_str()) {
config.current_registry = None;
}
config.save(&path)?;
println!("Registry {name:?} removed.");
Ok(())
}
fn registries(globals: &GlobalOpts) -> Result<()> {
let path = Config::path(globals.config.as_deref())?;
let config = Config::load(&path)?;
if globals.output.is_machine() {
let rows: Vec<Value> = config
.registries
.iter()
.map(|(name, e)| {
json!({
"name": name,
"url": e.url,
"current": config.current_registry.as_deref() == Some(name.as_str()),
"has_key": e.api_key.is_some() || e.api_key_command.is_some(),
})
})
.collect();
return output::emit(&Value::Array(rows), globals.output, &[]);
}
if config.registries.is_empty() {
println!("No artifact stores configured. `heyctl artifact login <url>` adds one.");
return Ok(());
}
let mut table = Table::new(["CURRENT", "NAME", "URL", "KEY"]);
for (name, e) in &config.registries {
let current = config.current_registry.as_deref() == Some(name.as_str());
table.row([
if current { "*" } else { "" },
name,
&e.url,
match (&e.api_key, &e.api_key_command) {
(_, Some(_)) => "command",
(Some(_), None) => "stored",
(None, None) => "none",
},
]);
}
table.print();
Ok(())
}
fn use_registry(globals: &GlobalOpts, args: &UseArgs) -> Result<()> {
let path = Config::path(globals.config.as_deref())?;
let mut config = Config::load(&path)?;
if !config.registries.contains_key(&args.name) {
bail!(
"no registry named {:?} — `heyctl artifact registries` lists them",
args.name
);
}
config.current_registry = Some(args.name.clone());
config.save(&path)?;
println!("Now using registry {:?}.", args.name);
Ok(())
}
fn push(globals: &GlobalOpts, opts: &RegistryOpts, args: &PushArgs) -> Result<()> {
let path = match (&args.file, &args.image) {
(Some(f), _) => f.clone(),
(None, Some(name)) => artifact::heyvm_image_path(name)?,
(None, None) => bail!("give a path to an .ext4 file, or --image <name>"),
};
let meta = std::fs::metadata(&path)
.with_context(|| format!("reading {}", path.display()))?;
if !meta.is_file() {
bail!("{} is not a file", path.display());
}
let tag = if args.no_tag {
None
} else {
let t = match &args.tag {
Some(t) => t.clone(),
None => artifact::default_tag_for(&path).with_context(|| {
format!(
"cannot derive a tag from {} — pass --tag, or --no-tag to push \
without one",
path.display()
)
})?,
};
if !artifact::is_valid_tag(&t) {
bail!(
"{t:?} is not a usable tag: tags are [A-Za-z0-9._-] and may not start with \
`-` or `.`"
);
}
Some(t)
};
let (c, registry, _) = client(globals, opts)?;
let quiet = globals.output.is_machine();
if !quiet {
eprintln!("Hashing {} ({})...", path.display(), output::bytes(meta.len()));
}
let (digest, size) = artifact::hash_file(&path, |done, total| {
if !quiet {
progress("hashing", done, total);
}
})?;
if !quiet {
clear_progress();
}
let already = if args.force { None } else { c.blob_exists(&digest)? };
let uploaded = match already {
Some(_) => {
if !quiet {
println!("{digest} is already in {registry}; skipping the upload");
}
false
}
None => {
if !quiet {
eprintln!("Uploading {} to {}...", output::bytes(size), c.url());
}
c.put_blob(&digest, &path, size)?
}
};
let image_name = tag.clone().unwrap_or_else(|| digest[..12].to_string());
let manifest = artifact::rootfs_manifest(&digest, size, &image_name);
let manifest_digest = c.put_manifest(&manifest)?;
if let Some(t) = &tag {
c.put_tag(t, &manifest_digest)?;
}
let result = json!({
"registry": registry,
"store": c.url(),
"path": path.display().to_string(),
"digest": digest,
"manifest": manifest_digest,
"size": size,
"tag": tag,
"uploaded": uploaded,
});
if globals.output.is_machine() {
return output::emit(&result, globals.output, &[]);
}
output::section("Pushed");
output::field("store", c.url());
output::field("digest", &digest);
output::field("manifest", &manifest_digest);
output::field("size", output::bytes(size));
match &tag {
Some(t) => output::field("tag", t),
None => output::field("tag", "(none — name the manifest digest to pull it)"),
}
println!();
let reference = tag.as_deref().unwrap_or(&manifest_digest);
println!("Pull it with:");
println!(" heyctl set artifact <deployment> --store {} --ref {reference}", c.url());
println!(" heyctl pull <deployment> --wait");
Ok(())
}
fn push_dockerfile(
globals: &GlobalOpts,
opts: &RegistryOpts,
args: &PushDockerfileArgs,
) -> Result<()> {
let meta = std::fs::metadata(&args.file)
.with_context(|| format!("reading {}", args.file.display()))?;
if !meta.is_file() {
bail!("{} is not a file", args.file.display());
}
artifact::check_dockerfile_size(&args.file, meta.len())?;
let tag = if args.no_tag {
None
} else {
let t = match &args.tag {
Some(t) => t.clone(),
None => default_recipe_tag(&args.file).with_context(|| {
format!(
"cannot derive a tag from {} — pass --tag, or --no-tag to push without one",
args.file.display()
)
})?,
};
if !artifact::is_valid_tag(&t) {
bail!(
"{t:?} is not a usable tag: tags are [A-Za-z0-9._-] and may not start with \
`-` or `.`"
);
}
Some(t)
};
let quiet = globals.output.is_machine();
let packed = match &args.build_context {
Some(c) if c.is_dir() => {
if !quiet {
eprintln!("Packing {}...", c.display());
}
let dest = Scratch::new(std::env::temp_dir().join(format!(
"heyctl-context-{}.tar.gz",
std::process::id()
)));
let size = artifact::pack_context(c, dest.path())?;
if !quiet {
println!("packed {} into {}", c.display(), output::bytes(size));
}
Some(dest)
}
_ => None,
};
let archive: Option<&std::path::Path> = match (&packed, &args.build_context) {
(Some(p), _) => Some(p.path()),
(None, Some(c)) => Some(c.as_path()),
(None, None) => None,
};
let (c, registry, _) = client(globals, opts)?;
let recipe = upload(&c, &args.file, args.force, quiet, "Dockerfile")?;
let context = match archive {
Some(p) => Some(upload(&c, p, args.force, quiet, "context")?),
None => None,
};
let manifest = artifact::dockerfile_manifest(
(&recipe.digest, recipe.size),
context.as_ref().map(|c| (c.digest.as_str(), c.size)),
args.image_name.as_deref(),
args.image_size_mb,
args.source.as_deref(),
);
let manifest_digest = c.put_manifest(&manifest)?;
if let Some(t) = &tag {
c.put_tag(t, &manifest_digest)?;
}
let result = json!({
"registry": registry,
"store": c.url(),
"path": args.file.display().to_string(),
"manifest": manifest_digest,
"dockerfile": { "digest": recipe.digest, "size": recipe.size, "uploaded": recipe.uploaded },
"context": context.as_ref().map(|c| json!({
"digest": c.digest, "size": c.size, "uploaded": c.uploaded,
})),
"tag": tag,
});
if globals.output.is_machine() {
return output::emit(&result, globals.output, &[]);
}
output::section("Pushed");
output::field("store", c.url());
output::field("manifest", &manifest_digest);
output::field(
"Dockerfile",
format!("{} ({})", recipe.digest, output::bytes(recipe.size)),
);
match &context {
Some(c) => output::field(
"context",
format!("{} ({})", c.digest, output::bytes(c.size)),
),
None => output::field("context", "(none — this recipe copies nothing in)"),
}
match &tag {
Some(t) => output::field("tag", t),
None => output::field("tag", "(none — name the manifest digest to build it)"),
}
println!();
let reference = tag.as_deref().unwrap_or(&manifest_digest);
println!("Build it with:");
println!(
" heyctl set build <deployment> --store {} --ref {reference}",
c.url()
);
println!(" heyctl build <deployment> --wait");
Ok(())
}
struct Uploaded {
digest: String,
size: u64,
uploaded: bool,
}
fn upload(
c: &RegistryClient,
path: &std::path::Path,
force: bool,
quiet: bool,
what: &str,
) -> Result<Uploaded> {
let (digest, size) = artifact::hash_file(path, |done, total| {
if !quiet {
progress(&format!("hashing {what}"), done, total);
}
})?;
if !quiet {
clear_progress();
}
let already = if force { None } else { c.blob_exists(&digest)? };
let uploaded = match already {
Some(_) => {
if !quiet {
println!("{what} {digest} is already in the store; skipping the upload");
}
false
}
None => {
if !quiet {
eprintln!("Uploading {what} ({})...", output::bytes(size));
}
c.put_blob(&digest, path, size)?
}
};
Ok(Uploaded {
digest,
size,
uploaded,
})
}
fn default_recipe_tag(path: &std::path::Path) -> Option<String> {
let dir = path.parent().filter(|p| !p.as_os_str().is_empty());
let name = match dir {
Some(d) => d.canonicalize().ok()?.file_name()?.to_str()?.to_string(),
None => return None,
};
artifact::is_valid_tag(&name).then_some(name)
}
struct Scratch(PathBuf);
impl Scratch {
fn new(path: PathBuf) -> Self {
Scratch(path)
}
fn path(&self) -> &std::path::Path {
&self.0
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.0);
}
}
fn progress(what: &str, done: u64, total: u64) {
if total == 0 {
return;
}
let pct = (done as f64 / total as f64 * 100.0).min(100.0);
eprint!(
"\r {what} {:>6.1}% {} / {} ",
pct,
output::bytes(done),
output::bytes(total)
);
let _ = std::io::stderr().flush();
}
fn clear_progress() {
eprint!("\r{:60}\r", "");
let _ = std::io::stderr().flush();
}
fn ls(globals: &GlobalOpts, opts: &RegistryOpts) -> Result<()> {
let (c, _, _) = client(globals, opts)?;
let tags = c.tags()?;
if globals.output.is_machine() {
return output::emit(&tags, globals.output, &[]);
}
let rows = tags.as_array().map(Vec::as_slice).unwrap_or_default();
if rows.is_empty() {
println!("No tags in {}.", c.url());
return Ok(());
}
let mut table = Table::new(["TAG", "DIGEST"]);
for r in rows {
table.row([
r.get("tag").and_then(Value::as_str).unwrap_or("?"),
r.get("digest").and_then(Value::as_str).unwrap_or("?"),
]);
}
table.print();
Ok(())
}
fn describe(globals: &GlobalOpts, opts: &RegistryOpts, args: &DescribeArgs) -> Result<()> {
let (c, _, _) = client(globals, opts)?;
let manifest = c.manifest(&args.reference)?;
if globals.output.is_machine() {
return output::emit(&manifest, globals.output, &[]);
}
output::section(&format!("Manifest {}", args.reference));
output::field(
"kind",
manifest.get("kind").and_then(Value::as_str).unwrap_or("?"),
);
if let Some(entries) = manifest.get("entries").and_then(Value::as_array) {
let mut table = Table::indented(["NAME", "DIGEST", "SIZE"], 2);
for e in entries {
table.row([
e.get("name").and_then(Value::as_str).unwrap_or("?").to_string(),
e.get("digest").and_then(Value::as_str).unwrap_or("?").to_string(),
output::bytes(e.get("size").and_then(Value::as_u64).unwrap_or(0)),
]);
}
println!();
table.print();
}
if let Some(ann) = manifest.get("annotations").and_then(Value::as_object)
&& !ann.is_empty()
{
println!();
output::section("Annotations");
for (k, v) in ann {
output::field(k, v.as_str().unwrap_or_default());
}
}
Ok(())
}
fn usage(globals: &GlobalOpts, opts: &RegistryOpts) -> Result<()> {
let (c, _, _) = client(globals, opts)?;
let u = c.usage()?;
if globals.output.is_machine() {
return output::emit(&u, globals.output, &[]);
}
let n = |key: &str| u.get(key).and_then(Value::as_u64);
output::section(&format!("Store {}", c.url()));
for (key, label) in [("blobs", "Blobs"), ("manifests", "Manifests"), ("tags", "Tags")] {
if let Some(v) = n(key) {
output::field(label, v.to_string());
}
}
output::section("Space");
if let Some(logical) = n("logical") {
output::field("Logical", output::bytes(logical));
if let Some(allocated) = n("allocated") {
output::field(
"Stored",
match logical {
0 => output::bytes(allocated),
_ => format!(
"{} ({:.1}% of logical)",
output::bytes(allocated),
allocated as f64 / logical as f64 * 100.0
),
},
);
}
} else if let Some(allocated) = n("allocated") {
output::field("Stored", output::bytes(allocated));
}
if let (Some(avail), Some(total)) = (n("fsAvailable"), n("fsTotal")) {
output::field(
"Filesystem",
format!("{} free of {}", output::bytes(avail), output::bytes(total)),
);
}
Ok(())
}
fn untag(globals: &GlobalOpts, opts: &RegistryOpts, args: &UntagArgs) -> Result<()> {
let (c, _, _) = client(globals, opts)?;
c.delete_tag(&args.name)?;
println!(
"Tag {:?} removed from {}. The blob it named stays until the store's `art gc` runs.",
args.name,
c.url()
);
Ok(())
}
fn run_command(cmd: &str) -> Result<String> {
let out = std::process::Command::new("sh")
.arg("-c")
.arg(cmd)
.output()
.with_context(|| format!("running {cmd:?}"))?;
if !out.status.success() {
bail!("{cmd:?} failed: {}", String::from_utf8_lossy(&out.stderr).trim());
}
Ok(String::from_utf8(out.stdout)
.context("the api key command produced non-UTF-8 output")?
.trim_end_matches(['\n', '\r'])
.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_recipe_is_tagged_after_its_directory_not_its_filename() {
let dir = std::env::temp_dir().join(format!("heyctl-tag-{}", std::process::id()));
let project = dir.join("web-frontend");
std::fs::create_dir_all(&project).unwrap();
let df = project.join("Dockerfile");
std::fs::write(&df, b"FROM debian\n").unwrap();
assert_eq!(default_recipe_tag(&df).as_deref(), Some("web-frontend"));
let odd = dir.join(".hidden");
std::fs::create_dir_all(&odd).unwrap();
let df = odd.join("Dockerfile");
std::fs::write(&df, b"FROM debian\n").unwrap();
assert_eq!(default_recipe_tag(&df), None);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_registry_is_named_after_its_host() {
assert_eq!(registry_name_for("http://art.example.com:8080"), "art.example.com");
assert_eq!(registry_name_for("http://127.0.0.1:8080"), "local");
assert_eq!(registry_name_for("https://art.example.com"), "art.example.com");
assert_eq!(registry_name_for("localhost:8080"), "local");
}
}