use super::{Ctx, Resource, now_secs, parse_ref};
use crate::output::{self, OutputFormat, Table};
use crate::types::{
AuthProviderView, CertStatus, DeploymentStatus, DiskInfo, DiskInventory, JobRecord,
MetricsResponse, NamespaceEntry, SecretSummary, WorkflowList, WorkflowView,
};
use anyhow::{Context, Result, bail};
use clap::Args;
use serde_json::Value;
use std::time::Duration;
#[derive(Args, Debug)]
pub struct GetArgs {
#[arg(value_name = "RESOURCE", required = true)]
pub args: Vec<String>,
#[arg(long, short = 'd', value_name = "NAME")]
pub deployment: Option<String>,
#[arg(long, short = 'n', value_name = "NAMESPACE")]
pub namespace: Option<String>,
#[arg(long)]
pub fleet: bool,
#[arg(long, short = 'w')]
pub watch: bool,
#[arg(long, value_name = "SECS", default_value_t = 2, requires = "watch")]
pub interval: u64,
}
pub fn get(ctx: &Ctx, args: &GetArgs) -> Result<()> {
let (kind, names) = parse_ref(&args.args, None)?;
if args.watch {
if ctx.out.is_machine() {
bail!("--watch renders a table; drop `-o {}`", format_name(ctx.out));
}
return watch(Duration::from_secs(args.interval.max(1)), || {
get_once(ctx, kind, &names, args)
});
}
get_once(ctx, kind, &names, args)
}
fn get_once(ctx: &Ctx, kind: Resource, names: &[String], args: &GetArgs) -> Result<()> {
if args.fleet {
if !matches!(kind, Resource::Deployment) || !names.is_empty() || args.deployment.is_some() {
bail!("--fleet supports `get deployments` without names or --deployment; use --namespace to narrow it");
}
return get_fleet_deployments(ctx, args.namespace.as_deref());
}
match kind {
Resource::Deployment => get_deployments(ctx, names, args.namespace.as_deref()),
Resource::Vm => get_vms(ctx, names, args.deployment.as_deref()),
Resource::Cert => get_certs(ctx),
Resource::Secret => get_secrets(ctx, names, args.namespace.as_deref()),
Resource::Workflow => get_workflows(ctx, names),
Resource::Job => get_jobs(ctx, names, args.deployment.as_deref()),
Resource::Disk => get_disks(ctx, names, args.deployment.as_deref()),
Resource::Namespace => get_namespaces(ctx),
Resource::AuthProvider => get_auth_providers(ctx, names, args.namespace.as_deref()),
Resource::All => {
get_deployments(ctx, &[], args.namespace.as_deref())?;
println!();
get_vms(ctx, &[], None)
}
}
}
fn get_fleet_deployments(ctx: &Ctx, namespace: Option<&str>) -> Result<()> {
let raw = ctx.client.raw().fleet_deployments(namespace)?;
if raw.get("configured").and_then(Value::as_bool) != Some(true) {
bail!("this gateway has no fleet configured; use a regional context without --fleet");
}
let rows = raw["rows"].as_array().context("fleet response has no rows")?;
if ctx.out.is_machine() {
let names = rows.iter().map(|r| format!("deployment/{}/{}",
r["namespace"].as_str().unwrap_or("?"), r["id"].as_str().unwrap_or("?"))).collect::<Vec<_>>();
return output::emit(&raw, ctx.out, &names);
}
let mut table = Table::new(["NAMESPACE", "NAME", "GATEWAY", "REGION", "READY", "PENDING", "HEALTH", "ERROR"]);
for row in rows {
for cell in row["cells"].as_array().context("fleet row has no cells")? {
let text = |v: &Value| match v { Value::Null => "—".to_owned(), Value::String(s) => s.clone(), v => v.to_string() };
table.row([text(&row["namespace"]), text(&row["id"]), text(&cell["gateway"]),
text(&cell["region"]), text(&cell["ready"]), text(&cell["pending"]),
text(&cell["health"]), text(&cell["error"])]);
}
}
table.print();
for gateway in raw["gateways"].as_array().context("fleet response has no gateways")? {
if let Some(error) = gateway["error"].as_str() {
eprintln!("Warning: gateway {} unavailable: {error}; its inventory is unknown", gateway["id"]);
}
if gateway["truncated"].as_bool() == Some(true) {
eprintln!("Warning: gateway {} inventory is truncated", gateway["id"]);
}
}
Ok(())
}
fn fetch_deployments(
ctx: &Ctx,
names: &[String],
namespace: Option<&str>,
) -> Result<(Value, Vec<DeploymentStatus>)> {
let raw = if names.is_empty() {
match namespace {
Some(ns) => ctx.client.raw().deployments_in(ns)?,
None => ctx.client.raw().deployments()?,
}
} else {
let mut out = Vec::new();
for name in names {
out.push(
ctx.client
.raw()
.deployment(name)
.with_context(|| format!("getting deployment {name:?}"))?,
);
}
if out.len() == 1 {
out.into_iter().next().expect("len == 1")
} else {
Value::Array(out)
}
};
let parsed: Vec<DeploymentStatus> = match &raw {
Value::Array(items) => items
.iter()
.map(|v| serde_json::from_value(v.clone()))
.collect::<Result<_, _>>()
.context("parsing the deployment list")?,
other => vec![serde_json::from_value(other.clone()).context("parsing the deployment")?],
};
Ok((raw, parsed))
}
fn get_deployments(ctx: &Ctx, names: &[String], namespace: Option<&str>) -> Result<()> {
let (raw, deployments) = fetch_deployments(ctx, names, namespace)?;
if ctx.out.is_machine() {
let names: Vec<String> = deployments
.iter()
.map(|d| format!("deployment/{}", d.spec.id))
.collect();
return output::emit(&raw, ctx.out, &names);
}
if deployments.is_empty() {
match namespace {
Some(ns) => println!("No deployments in namespace {ns:?}."),
None => println!("No deployments registered."),
}
return Ok(());
}
let mut table = if ctx.out.is_wide() {
Table::new([
"NAME", "KIND", "ROUTES", "DESIRED", "READY", "PENDING", "IN-FLIGHT", "MIN", "MAX",
"WARM", "TARGET", "BACKEND", "SOURCE", "AUTH",
])
} else {
Table::new(["NAME", "KIND", "ROUTES", "DESIRED", "READY", "PENDING", "IN-FLIGHT"])
};
for d in &deployments {
let mut row = vec![
d.spec.id.clone(),
d.kind.clone(),
d.spec.routes_summary(),
if d.spec.is_static() || d.spec.is_site() {
"—".to_string()
} else {
d.desired_replicas.to_string()
},
d.ready.to_string(),
d.pending.to_string(),
d.total_in_flight.to_string(),
];
if ctx.out.is_wide() {
let s = &d.spec.scaling;
if d.spec.is_static() || d.spec.is_site() {
row.extend(["—".to_string(), "—".to_string(), "—".to_string(), "—".to_string()]);
} else {
row.extend([
s.min_replicas.to_string(),
s.max_replicas.to_string(),
s.warm_pool.to_string(),
s.target_concurrency.to_string(),
]);
}
row.push(d.spec.backend_summary());
row.push(match (&d.spec.build, &d.spec.artifact, &d.spec.update) {
(Some(b), _, _) => b.summary(),
(None, Some(a), _) => a.summary(),
(None, None, Some(u)) => u.summary(),
(None, None, None) => "—".into(),
});
row.push(match &d.spec.auth {
Some(a) => a.providers().join("+"),
None => "—".into(),
});
}
table.row(row);
}
table.print();
Ok(())
}
fn get_vms(ctx: &Ctx, names: &[String], filter: Option<&str>) -> Result<()> {
let scope: Vec<String> = filter.into_iter().map(str::to_string).collect();
let (_, deployments) = fetch_deployments(ctx, &scope, None)?;
let wanted = |id: &str| names.is_empty() || names.iter().any(|n| n == id);
if ctx.out.is_machine() {
let mut rows = Vec::new();
let mut refs = Vec::new();
for d in &deployments {
for vm in d.vms.iter().filter(|v| wanted(&v.sandbox_id)) {
refs.push(format!("vm/{}", vm.sandbox_id));
rows.push(serde_json::json!({
"deployment": d.spec.id,
"sandbox_id": vm.sandbox_id,
"addr": vm.addr,
"in_flight": vm.in_flight,
"healthy": vm.healthy,
"draining": vm.draining,
}));
}
}
return output::emit(&Value::Array(rows), ctx.out, &refs);
}
let mut table = Table::new(["DEPLOYMENT", "SANDBOX", "ADDRESS", "STATUS", "IN-FLIGHT"]);
for d in &deployments {
for vm in d.vms.iter().filter(|v| wanted(&v.sandbox_id)) {
table.row([
d.spec.id.clone(),
vm.sandbox_id.clone(),
vm.addr.clone(),
vm.status().to_string(),
vm.in_flight.to_string(),
]);
}
}
if table.is_empty() {
println!("No VMs in the pool. (`heyctl top vms` shows resource usage for running VMs.)");
return Ok(());
}
table.print();
Ok(())
}
fn get_namespaces(ctx: &Ctx) -> Result<()> {
let raw = ctx.client.raw().namespaces()?;
let namespaces: Vec<NamespaceEntry> =
serde_json::from_value(raw.clone()).context("parsing the namespace list")?;
if ctx.out.is_machine() {
let names: Vec<String> = namespaces
.iter()
.map(|n| format!("namespace/{}", n.namespace))
.collect();
return output::emit(&raw, ctx.out, &names);
}
if namespaces.is_empty() {
println!(
"No namespaces. Every deployment is in \"default\" until one says \
otherwise — put a deployment in a namespace with \
`heyctl create deployment <NAME> --namespace <NS>`, or a \
`\"namespace\"` field in a spec passed to `heyctl apply`."
);
return Ok(());
}
let mut table = if ctx.out.is_wide() {
Table::new(["NAMESPACE", "DEPLOYMENTS", "DECLARED", "CREATED", "DESCRIPTION"])
} else {
Table::new(["NAMESPACE", "DEPLOYMENTS", "DECLARED"])
};
let now = now_secs();
for n in &namespaces {
let mut row = vec![
n.namespace.clone(),
n.deployments.to_string(),
if n.declared { "yes".into() } else { "—".to_string() },
];
if ctx.out.is_wide() {
row.push(match n.created_at {
Some(t) => format!("{} ago", output::duration(now.saturating_sub(t))),
None => "—".into(),
});
row.push(n.description.clone().unwrap_or_else(|| "—".into()));
}
table.row(row);
}
table.print();
if !namespaces.iter().any(|n| n.declared) {
println!(
"\nNone of these are declared — they exist because deployments name them. \
`heyctl create namespace <NAME>` makes one that stands on its own."
);
}
Ok(())
}
fn get_certs(ctx: &Ctx) -> Result<()> {
let raw = ctx.client.raw().certs()?;
let certs: Vec<CertStatus> =
serde_json::from_value(raw.clone()).context("parsing the certificate list")?;
if ctx.out.is_machine() {
let names: Vec<String> = certs.iter().map(|c| format!("cert/{}", c.host)).collect();
return output::emit(&raw, ctx.out, &names);
}
if certs.is_empty() {
println!(
"No certificates issued. (ACME is off unless APP_LB_ACME_EMAIL is set; \
`host_suffix` routes cannot be covered by ACME.)"
);
return Ok(());
}
let mut table = Table::new(["HOST", "ISSUER", "NOT-AFTER", "RENEWAL"]);
for c in &certs {
table.row([
c.host.clone(),
c.issuer.clone(),
c.not_after.clone(),
if c.needs_renewal { "due".into() } else { "ok".to_string() },
]);
}
table.print();
Ok(())
}
fn get_disks(ctx: &Ctx, names: &[String], deployment: Option<&str>) -> Result<()> {
let raw = ctx.client.raw().disks()?;
let inv: DiskInventory =
serde_json::from_value(raw.clone()).context("parsing the disk inventory")?;
let mut disks: Vec<&DiskInfo> = inv
.disks
.iter()
.filter(|d| names.is_empty() || names.iter().any(|n| n == &d.sandbox_id))
.filter(|d| deployment.is_none_or(|want| d.deployment.as_deref() == Some(want)))
.collect();
disks.sort_by_key(|d| std::cmp::Reverse(d.bytes));
if ctx.out.is_machine() {
let ids: Vec<String> = disks.iter().map(|d| format!("disk/{}", d.sandbox_id)).collect();
return output::emit(&raw, ctx.out, &ids);
}
if !inv.complete {
println!(
"warning: the daemon did not answer both listings{}; nothing is classified as an \
orphan and the sweep will not run.",
inv.incomplete_reason
.as_deref()
.map(|r| format!(" ({r})"))
.unwrap_or_default(),
);
}
if disks.is_empty() {
match (names.is_empty(), deployment) {
(true, None) => println!("No per-sandbox disks on this host ({}).", inv.data_dir),
(_, Some(d)) => println!("No disks belong to deployment {d:?}."),
_ => println!("No disks match."),
}
return Ok(());
}
let now = now_secs();
let mut table = Table::new([
"SANDBOX",
"DEPLOYMENT",
"STATE",
"ON-DISK",
"APPARENT",
"AGE",
"HELD",
]);
for d in &disks {
table.row([
d.sandbox_id.clone(),
d.deployment.clone().unwrap_or_else(|| "—".into()),
d.state.label().to_string(),
output::bytes(d.bytes),
output::bytes(d.apparent_bytes),
output::duration(now.saturating_sub(d.modified_at)),
held_reason(d, now),
]);
}
table.print();
let t = &inv.totals;
println!();
println!(
"{} disks, {} on disk ({} apparent) — {} running, {} stopped, {} orphan, {} retained.",
t.disks,
output::bytes(t.bytes),
output::bytes(t.apparent_bytes),
t.running,
t.stopped,
t.orphan,
t.retained,
);
if let Some(free) = inv.free_bytes {
let capacity = match inv.filesystem_bytes {
Some(total) if total > 0 => format!(" of {}", output::bytes(total)),
_ => String::new(),
};
println!("Host filesystem: {} free{capacity}.", output::bytes(free));
}
if inv.totals.orphan > 0 && inv.orphan_ttl_secs > 0 {
println!(
"{} orphaned (no daemon record) — reclaimed after {}, not the {} TTL.",
inv.totals.orphan,
output::duration(inv.orphan_ttl_secs),
output::duration(inv.ttl_secs),
);
}
if inv.ttl_secs == 0 {
println!("Expiry is off, so nothing is reclaimed automatically.");
} else if t.expiring_now > 0 {
println!(
"The sweep would reclaim {} now, freeing {}.",
t.expiring_now,
output::bytes(t.reclaimable_bytes),
);
}
Ok(())
}
fn held_reason(d: &DiskInfo, now: u64) -> String {
if let Some(reason) = d.held_by.as_deref() {
return reason.to_string();
}
match d.expires_at {
Some(at) if at <= now => "expiring".into(),
Some(at) => format!("in {}", output::duration(at.saturating_sub(now))),
None => "—".into(),
}
}
fn get_workflows(ctx: &Ctx, names: &[String]) -> Result<()> {
let raw = if names.is_empty() {
ctx.client.raw().workflows()?
} else {
let mut out = Vec::new();
for name in names {
out.push(
ctx.client
.raw()
.workflow(name)
.with_context(|| format!("getting workflow {name:?}"))?,
);
}
Value::Array(out)
};
let workflows: Vec<WorkflowView> = match &raw {
Value::Array(items) => items
.iter()
.map(|v| serde_json::from_value(v.clone()))
.collect::<Result<_, _>>()
.context("parsing the workflow list")?,
Value::Object(map) if map.contains_key("workflows") => {
serde_json::from_value::<WorkflowList>(raw.clone())
.context("parsing the workflow list")?
.workflows
}
other => vec![serde_json::from_value(other.clone()).context("parsing the workflow")?],
};
if ctx.out.is_machine() {
let refs: Vec<String> = workflows
.iter()
.map(|w| format!("workflow/{}", w.id))
.collect();
return output::emit(&raw, ctx.out, &refs);
}
if workflows.is_empty() {
println!(
"No CI workflows. (`heyctl create workflow build --repo <url> \
--network <net>` registers one.)"
);
return Ok(());
}
let mut table = if ctx.out.is_wide() {
Table::new(["NAME", "REPO", "REF", "PATH", "NETWORK", "ENABLED"])
} else {
Table::new(["NAME", "REPO", "REF", "NETWORK"])
};
for w in &workflows {
let mut row = vec![w.id.clone(), w.repo.clone(), w.git_ref.clone()];
if ctx.out.is_wide() {
row.push(w.path.clone());
}
row.push(w.network.clone());
if ctx.out.is_wide() {
row.push(if w.enabled { "yes" } else { "no" }.to_string());
}
table.row(row);
}
table.print();
Ok(())
}
fn get_secrets(ctx: &Ctx, names: &[String], namespace: Option<&str>) -> Result<()> {
let raw = if names.is_empty() {
ctx.client.raw().secrets_in(namespace)?
} else {
let mut out = Vec::new();
for name in names {
out.push(
ctx.client
.raw()
.secret_in(namespace, name)
.with_context(|| format!("getting secret {name:?}"))?,
);
}
Value::Array(out)
};
let secrets: Vec<SecretSummary> = match &raw {
Value::Array(items) => items
.iter()
.map(|v| serde_json::from_value(v.clone()))
.collect::<Result<_, _>>()
.context("parsing the secret list")?,
other => vec![serde_json::from_value(other.clone()).context("parsing the secret")?],
};
if ctx.out.is_machine() {
let refs: Vec<String> = secrets.iter().map(|s| format!("secret/{}", s.id)).collect();
return output::emit(&raw, ctx.out, &refs);
}
if secrets.is_empty() {
println!(
"No secrets stored. (`heyctl create secret github --from-stdin token` \
stores one; values are never readable back.)"
);
return Ok(());
}
let mut table = if ctx.out.is_wide() {
Table::new(["NAME", "KEYS", "AT-REST", "UPDATED", "DESCRIPTION"])
} else {
Table::new(["NAME", "KEYS", "UPDATED"])
};
for s in &secrets {
let mut row = vec![
s.id.clone(),
if s.keys.is_empty() {
"<none>".into()
} else {
s.keys.join(",")
},
];
if ctx.out.is_wide() {
row.push(if s.encrypted_at_rest {
"encrypted".into()
} else {
"plaintext".into()
});
}
row.push(if s.updated_at == 0 {
"—".into()
} else {
format!("{} (server clock)", s.updated_at)
});
if ctx.out.is_wide() {
row.push(output::opt_str(s.description.as_deref()));
}
table.row(row);
}
table.print();
Ok(())
}
fn get_auth_providers(ctx: &Ctx, names: &[String], namespace: Option<&str>) -> Result<()> {
let ns = namespace.unwrap_or(crate::DEFAULT_NAMESPACE);
let raw = if names.is_empty() {
ctx.client.raw().auth_providers(namespace)?
} else {
let mut out = Vec::new();
for name in names {
out.push(
ctx.client
.raw()
.auth_provider(ns, name)
.with_context(|| format!("getting auth provider {name:?} in namespace {ns:?}"))?,
);
}
Value::Array(out)
};
let providers: Vec<AuthProviderView> = match &raw {
Value::Array(items) => items
.iter()
.map(|v| serde_json::from_value(v.clone()))
.collect::<Result<_, _>>()
.context("parsing the auth provider list")?,
other => vec![serde_json::from_value(other.clone()).context("parsing the auth provider")?],
};
if ctx.out.is_machine() {
let refs: Vec<String> = providers
.iter()
.map(|p| format!("auth-provider/{}/{}", p.namespace, p.name))
.collect();
return output::emit(&raw, ctx.out, &refs);
}
if providers.is_empty() {
println!(
"No auth providers declared{}. (`heyctl create auth-provider heyo --preset heyo \
--secret heyo-auth/jwt_secret` declares one; deployments inherit it with \
`heyctl set auth <deployment> --provider-ref heyo`.)",
match namespace {
Some(ns) => format!(" in namespace {ns}"),
None => String::new(),
}
);
return Ok(());
}
let mut table = if ctx.out.is_wide() {
Table::new(["NAME", "NAMESPACE", "PROVIDER", "TRUST", "ADMITS", "DESCRIPTION"])
} else {
Table::new(["NAME", "NAMESPACE", "PROVIDER", "TRUST", "ADMITS"])
};
for p in &providers {
let mut row = vec![
p.name.clone(),
p.namespace.clone(),
p.providers().join("+"),
p.trust_summary(),
p.admits(),
];
if ctx.out.is_wide() {
row.push(output::opt_str(p.description.as_deref()));
}
table.row(row);
}
table.print();
Ok(())
}
fn get_jobs(ctx: &Ctx, names: &[String], deployment: Option<&str>) -> Result<()> {
let raw = match (names, deployment) {
([id], _) => ctx.client.raw().job(id)?,
([], Some(d)) => ctx.client.raw().deployment_jobs(d)?,
([], None) => ctx.client.raw().jobs()?,
_ => bail!("get job takes at most one job id; use -d to scope by deployment"),
};
let jobs: Vec<JobRecord> = match &raw {
Value::Array(items) => items
.iter()
.map(|v| serde_json::from_value(v.clone()))
.collect::<Result<_, _>>()
.context("parsing the job list")?,
other => vec![serde_json::from_value(other.clone()).context("parsing the job")?],
};
if ctx.out.is_machine() {
let refs: Vec<String> = jobs.iter().map(|j| format!("job/{}", j.id)).collect();
return output::emit(&raw, ctx.out, &refs);
}
if jobs.is_empty() {
println!(
"No jobs yet. (`heyctl set build <deployment> --repo <url>` records where an \
image comes from; `heyctl set update <deployment> --workdir <dir> --command \
'<cmd>'` records how a static one is updated.)"
);
return Ok(());
}
if let ([_], Some(record)) = (names, jobs.first())
&& jobs.len() == 1
{
return describe_job(record);
}
let mut table = Table::new([
"JOB", "DEPLOYMENT", "KIND", "STATUS", "TARGET", "RESULT", "TOOK",
]);
let now = jobs
.iter()
.map(|j| j.finished_at.unwrap_or(j.started_at))
.max()
.unwrap_or(0);
for j in &jobs {
table.row([
j.id.clone(),
j.deployment.clone(),
j.kind.clone(),
j.status.clone(),
j.target_summary(),
j.result_summary(),
output::duration(j.elapsed_secs(now)),
]);
}
table.print();
Ok(())
}
fn describe_job(j: &JobRecord) -> Result<()> {
output::top_field("Job", &j.id);
output::top_field("Deployment", &j.deployment);
output::top_field("Kind", &j.kind);
output::section("Source");
if j.is_update() {
output::field("Working dir", output::opt_str(j.working_dir.as_deref()));
output::field(
"Commands",
match (j.commands_run, j.commands_total) {
(Some(run), Some(total)) => format!("{run} of {total} completed"),
(_, Some(total)) => format!("{total}"),
_ => "—".into(),
},
);
} else if j.is_mount_pull() {
for m in &j.mounts {
let digest = m
.digest
.as_ref()
.map(|d| d.chars().take(12).collect::<String>())
.unwrap_or_else(|| "—".into());
let moved = match (m.bytes, m.reused) {
(_, true) => "already on this host".to_string(),
(Some(0), false) => "hardlinked from a local store".to_string(),
(Some(n), false) => output::bytes(n),
(None, false) => "—".into(),
};
output::field(
&m.path,
format!(
"{} — {digest}, {moved}{}",
m.summary(),
if m.changed { " (changed)" } else { "" }
),
);
}
if j.mounts.is_empty() {
output::field("Mounts", "—");
}
} else if j.is_pull() {
output::field("Store", output::opt_str(j.store.as_deref()));
output::field("Ref", output::opt_str(j.artifact_ref.as_deref()));
output::field("Digest", output::opt_str(j.digest.as_deref()));
output::field(
"Transferred",
match (j.bytes, j.reused) {
(Some(0), true) => "nothing — the image was already on the host".to_string(),
(Some(n), _) => output::bytes(n),
(None, _) => "—".into(),
},
);
} else {
output::field("Repo", &j.repo);
output::field("Ref", j.git_ref.as_deref().unwrap_or("(default branch)"));
output::field("Commit", output::opt_str(j.commit.as_deref()));
output::field("Dockerfile", output::opt_str(j.dockerfile.as_deref()));
}
output::section("Result");
output::field("Status", &j.status);
if j.is_update() {
output::field(
"Upstreams",
match j.verified {
Some(true) => "healthy after the update",
Some(false) => "did NOT come back — the host has already been changed",
None if j.is_running() => "not checked yet",
None if j.succeeded() => "not checked (verify_timeout_secs is 0)",
None => "not checked — the job failed before that point",
},
);
} else if j.is_mount_pull() {
output::field(
"Trees",
format!(
"{} on this host ({})",
j.mounts.len(),
output::bytes(j.mounts.iter().filter_map(|m| m.unpacked).sum::<u64>()),
),
);
output::field(
"Rolled out",
if j.rolled_out {
"yes — vm.mounts digests updated, pool recycled"
} else if j.is_running() {
"not yet"
} else {
"no — the pool already had these trees"
},
);
} else {
output::field("Image", output::opt_str(j.image.as_deref()));
output::field(
"Rolled out",
if j.rolled_out {
"yes — vm.image updated, pool recycled"
} else if j.is_running() {
"not yet"
} else {
"no"
},
);
}
output::field(
"Took",
output::duration(j.elapsed_secs(j.finished_at.unwrap_or(j.started_at))),
);
if let Some(e) = &j.error {
output::field("Error", e);
}
output::section("Log");
if j.log.is_empty() {
println!(" (no output yet)");
}
for line in &j.log {
println!(" {line}");
}
Ok(())
}
#[derive(Args, Debug)]
pub struct DescribeArgs {
#[arg(value_name = "RESOURCE", required = true)]
pub args: Vec<String>,
#[arg(long, short = 'n', value_name = "NAMESPACE")]
pub namespace: Option<String>,
}
pub fn describe(ctx: &Ctx, args: &DescribeArgs) -> Result<()> {
let (kind, names) = parse_ref(&args.args, Some(Resource::Deployment))?;
if kind == Resource::AuthProvider {
return describe_auth_providers(ctx, &names, args.namespace.as_deref());
}
if kind != Resource::Deployment {
bail!("describe works on deployments and auth providers");
}
if names.is_empty() {
bail!("describe needs a name, e.g. `heyctl describe deployment web`");
}
let metrics: Option<MetricsResponse> = if ctx.out.is_machine() {
None
} else {
ctx.client
.raw()
.metrics(&crate::MetricsQuery::new())
.ok()
.and_then(|v| serde_json::from_value(v).ok())
};
for (i, name) in names.iter().enumerate() {
if i > 0 {
println!();
}
let raw = ctx.client.raw().deployment(name)?;
if ctx.out.is_machine() {
output::emit(&raw, ctx.out, &[format!("deployment/{name}")])?;
continue;
}
let d: DeploymentStatus = serde_json::from_value(raw)?;
describe_one(&d, metrics.as_ref());
}
Ok(())
}
fn describe_auth_providers(ctx: &Ctx, names: &[String], namespace: Option<&str>) -> Result<()> {
if names.is_empty() {
bail!(
"describe needs a name, e.g. `heyctl describe auth-provider heyo` \
(add -n <namespace> when it is not `default`)"
);
}
let ns = namespace.unwrap_or(crate::DEFAULT_NAMESPACE);
for (i, name) in names.iter().enumerate() {
if i > 0 {
println!();
}
let raw = ctx
.client
.raw()
.auth_provider(ns, name)
.with_context(|| format!("getting auth provider {name:?} in namespace {ns:?}"))?;
if ctx.out.is_machine() {
output::emit(&raw, ctx.out, &[format!("auth-provider/{ns}/{name}")])?;
continue;
}
let p: AuthProviderView = serde_json::from_value(raw)?;
describe_provider(&p);
}
Ok(())
}
fn describe_provider(p: &AuthProviderView) {
output::top_field("Name", &p.name);
output::top_field("Namespace", &p.namespace);
if let Some(d) = &p.description {
output::top_field("Description", d);
}
if p.created_at > 0 {
output::top_field("Declared", output::timestamp(p.created_at));
}
output::section("Identity");
output::field("Provider", p.providers().join(" or "));
if let Some(client_id) = &p.client_id {
output::field("Client id", client_id);
}
if let Some(secret) = &p.client_secret {
output::field("Client secret", format!("secret {}", secret.render_in(&p.namespace)));
}
if p.providers().iter().any(|q| q == "google") {
output::field("Who may enter", p.admits());
}
if let Some(jwt) = &p.jwt {
output::field("JWT issuer", &jwt.issuer);
output::field("JWT audience", jwt.audience.as_deref().unwrap_or("(not checked)"));
output::field(
"JWT key",
match (&jwt.secret, &jwt.public_key, &jwt.jwks_url) {
(Some(r), _, _) => format!("shared secret {}", r.render_in(&p.namespace)),
(_, Some(_), _) => "an inline public key".to_string(),
(_, _, Some(url)) => format!("the key set at {url}"),
_ => "<none>".to_string(),
},
);
output::field("JWT algorithms", jwt.algorithms.join(", "));
output::field("JWT admits", jwt.require_summary());
output::field(
"JWT subject claim",
format!("{} -> x-auth-request-user", jwt.subject_claim),
);
if let Some(cookie) = &jwt.cookie {
output::field("JWT cookie", format!("{cookie} (when no Authorization header)"));
}
match (&jwt.login_url, &jwt.cookie) {
_ if jwt.authorize_url.is_some() => output::field(
"Browser sign-in",
format!(
"scoped: {} asks for namespace access, then a host-only app-lb session",
jwt.authorize_url.as_deref().unwrap_or_default()
),
),
(Some(url), Some(cookie)) => {
output::field(
"Browser sign-in",
format!(
"{url}?{}=<the url they asked for> — that page sets {cookie}",
jwt.login_redirect_param.as_deref().unwrap_or("redirect_uri")
),
);
}
(None, _) => output::field(
"Browser sign-in",
"none — a token-less browser gets 401. Set --login-url and --cookie \
to send it somewhere",
),
_ => {}
}
}
if let Some(domain) = &p.cookie_domain {
output::field("Session realm", format!("{domain} (shared across the namespace's gates)"));
}
output::section("Inheriting it");
output::field(
"In a spec",
format!("auth: {{ provider_ref: {} }}", p.name),
);
output::field(
"With heyctl",
format!("heyctl set auth <deployment> --provider-ref {}", p.name),
);
output::field(
"The gate still owns",
"public_paths, base_path, cookie_name, session_ttl_secs, forward_identity",
);
}
fn describe_one(d: &DeploymentStatus, metrics: Option<&MetricsResponse>) {
output::top_field("Name", &d.spec.id);
output::top_field(
"Kind",
if d.spec.is_site() {
"site (files served off disk)".to_string()
} else if d.spec.is_static() {
if d.spec.discovery.is_some() {
"static (proxy_pass to discovered upstreams)".to_string()
} else {
"static (proxy_pass to fixed upstreams)".to_string()
}
} else {
let driver = d.spec.vm.as_ref().map(|v| v.driver.clone()).unwrap_or_default();
format!("vm (managed {driver} pool)")
},
);
output::section("Routes");
if d.spec.routes.is_empty() {
println!(" <none>");
}
for r in &d.spec.routes {
println!(" {}", r.render());
}
if let Some(site) = &d.spec.site {
output::section("Site");
output::field("Root", &site.root);
output::field(
"Index",
if site.index.is_empty() { "none (directories 404)" } else { &site.index },
);
output::field("404 page", site.not_found.as_deref().unwrap_or("none (plain text)"));
output::field(
"Unmatched paths",
if site.spa {
"served the index (SPA mode)"
} else {
"404"
},
);
output::field("Cache-Control", &site.cache_control);
}
if d.spec.is_static() {
output::section("Upstreams");
if let Some(discovery) = &d.spec.discovery {
output::field("Discovery service", &discovery.service_id);
}
for u in &d.spec.upstreams {
println!(" {u}");
}
if let Some(update) = &d.spec.update {
output::section("Update (on the app-lb host)");
output::field("Working dir", &update.working_dir);
output::field(
"Verify",
match update.verify_timeout_secs {
Some(0) => "off — a successful job only means the commands exited 0".into(),
Some(s) => format!("re-probe the upstreams, up to {}", output::duration(s)),
None => "re-probe the upstreams, up to 1m".into(),
},
);
if let Some(t) = update.timeout_secs {
output::field("Command timeout", output::duration(t));
}
if let Some(env) = &update.env {
let keys: Vec<&str> = env.keys().map(String::as_str).collect();
if !keys.is_empty() {
output::field("Env", keys.join(", "));
}
}
if !update.env_from.is_empty() {
let refs: Vec<String> = update.env_from.iter().map(|e| e.render()).collect();
output::field("Env from secrets", refs.join(", "));
}
if let Some(auth) = &update.auth {
output::field("Git credential", format!("secret {}", auth.render()));
}
println!(" Commands:");
for (i, c) in update.commands.iter().enumerate() {
println!(" {}. {c}", i + 1);
}
}
} else if let Some(vm) = &d.spec.vm {
output::section("VM template");
output::field("Driver", &vm.driver);
output::field("Image", output::opt_str(vm.image.as_deref()));
output::field("Port", vm.port.to_string());
output::field("Size class", output::opt_str(vm.size_class.as_deref()));
if let Some(gb) = vm.disk_size_gb {
output::field("Disk", format!("{gb} GB"));
}
if let Some(cmd) = &vm.start_command {
output::field("Start command", cmd);
}
if let Some(dir) = &vm.working_directory {
output::field("Working dir", dir);
}
if !vm.open_ports.is_empty() {
let ports: Vec<String> = vm.open_ports.iter().map(u16::to_string).collect();
output::field("Open ports", ports.join(","));
}
output::field("TTL", output::duration(vm.ttl_seconds));
if let Some(env) = &vm.env_vars {
let keys: Vec<&str> = env.keys().map(String::as_str).collect();
output::field(
"Env",
if keys.is_empty() {
"<none>".to_string()
} else {
format!("{} ({})", keys.join(", "), "values hidden; use -o json")
},
);
}
if let Some(hooks) = &vm.setup_hooks
&& !hooks.is_empty()
{
output::field("Setup hooks", hooks.join(" ; "));
}
if !vm.mounts.is_empty() {
output::section("Mounts");
for m in &vm.mounts {
let state = match &m.digest {
Some(d) => format!("digest {}", d.chars().take(12).collect::<String>()),
None => "NOT PULLED — the pool cannot start until it is".to_string(),
};
output::field(&m.path, format!("{} — {state}", m.summary()));
if let Some(n) = m.strip_components {
output::field(" strip_components", n.to_string());
}
if let Some(auth) = &m.auth {
output::field(" Store credential", format!("secret {}", auth.render()));
}
}
}
if let Some(ws) = &d.workspace {
output::section("Workspace");
let short = |d: &Option<String>| match d {
Some(d) => d.chars().take(12).collect::<String>(),
None => "(empty)".to_string(),
};
output::field(&ws.path, format!("{} ({})", ws.store, ws.phase));
output::field(
" Snapshot",
match ws.captured_at {
Some(at) => format!(
"{} — {} files, {}, captured from {} at {}",
short(&ws.digest),
ws.files,
output::bytes(ws.bytes),
ws.captured_from.as_deref().unwrap_or("?"),
output::timestamp(at),
),
None => short(&ws.digest),
},
);
output::field(
" In store",
if ws.push_pending {
format!("NOT YET — push pending (store holds {})", short(&ws.pushed))
} else {
match ws.pushed_at {
Some(at) => format!("{} at {}", short(&ws.pushed), output::timestamp(at)),
None => short(&ws.pushed),
}
},
);
let interval = d
.spec
.vm
.as_ref()
.and_then(|vm| vm.workspace.as_ref())
.and_then(|w| w.snapshot_interval_secs);
output::field(
" Snapshot every",
match interval {
Some(n) => output::duration(n),
None => "only when the replica retires (snapshot_interval_secs unset)".to_string(),
},
);
for p in &ws.pending {
output::field(
" Capture queued",
format!(
"{} (then {}{})",
p.sandbox_id,
p.then,
if p.attempts > 0 {
format!(", {} failed attempt(s)", p.attempts)
} else {
String::new()
}
),
);
}
if let Some(why) = &ws.blocked {
output::field(" Pool held", why);
}
if let Some(e) = &ws.last_error {
output::field(" Last error", e);
}
}
if let Some(build) = &d.spec.build {
output::section("Build source");
output::field("Repo", &build.repo);
output::field("Ref", build.git_ref.as_deref().unwrap_or("(default branch)"));
output::field(
"Dockerfile",
build
.dockerfile
.as_deref()
.unwrap_or("(found in the checkout)"),
);
if let Some(c) = &build.context {
output::field("Context", c);
}
output::field(
"Image name",
build
.image_name
.as_deref()
.unwrap_or("(the deployment id) + commit"),
);
if let Some(mb) = build.image_size_mb {
output::field("Rootfs size", format!("{mb} MB"));
}
output::field(
"Credential",
match &build.auth {
Some(a) => format!("secret {}", a.render()),
None => "none (public repo, or host ssh keys)".to_string(),
},
);
}
if let Some(artifact) = &d.spec.artifact {
output::section("Artifact source");
let lb_store = artifact.uses_lb_store();
output::field(
"Store",
if lb_store { "app-lb's global artifact store" } else { &artifact.store },
);
let remote = artifact.store.starts_with("http://")
|| artifact.store.starts_with("https://");
output::field(
"Transport",
if lb_store {
"pulled by app-lb with its own key, digest verified on arrival"
} else if remote {
"streamed over HTTP, digest verified on arrival"
} else {
"materialized locally by `art` (hole-aware)"
},
);
output::field(
"Ref",
match artifact.artifact_ref.len() == 64
&& artifact.artifact_ref.bytes().all(|b| b.is_ascii_hexdigit())
{
true => format!("{} (a digest — pinned)", artifact.artifact_ref),
false => format!("{} (a tag — resolved at pull time)", artifact.artifact_ref),
},
);
output::field(
"Image name",
artifact
.image_name
.as_deref()
.unwrap_or("(the deployment id) + digest"),
);
output::field(
"Grow to",
match artifact.grow_gb {
Some(gb) => format!("{gb} GiB (sparse)"),
None => "(the image's stored size)".to_string(),
},
);
output::field(
"Credential",
match (&artifact.auth, remote) {
(None, _) if lb_store => "app-lb's own (none needed)".to_string(),
(Some(a), _) if lb_store => {
format!("secret {} — app-lb's own store needs none", a.render())
}
(Some(a), true) => format!("secret {}", a.render()),
(Some(a), false) => {
format!("secret {} — UNUSED, a local store has no API key", a.render())
}
(None, _) => "none (an ungated store)".to_string(),
},
);
}
output::section("Scaling");
let s = &d.spec.scaling;
output::field("Desired", d.desired_replicas.to_string());
output::field("Min / Max", format!("{} / {}", s.min_replicas, s.max_replicas));
output::field("Warm pool", s.warm_pool.to_string());
output::field("Target concurrency", s.target_concurrency.to_string());
output::field(
"Scale to zero after",
output::duration(s.scale_to_zero_after_secs),
);
output::field("Cold start timeout", output::duration(s.cold_start_timeout_secs));
output::field("Drain timeout", output::duration(s.drain_timeout_secs));
output::field(
"When idle",
match s.idle_action.as_str() {
"retain" => "retain — stop the VM, keeping its /workspace disk".to_string(),
"destroy" | "" => "destroy — kill the VM and its disks".to_string(),
other => other.to_string(),
},
);
}
if let Some(auth) = &d.spec.auth {
output::section("Sign-in gate");
if let Some(provider_ref) = &auth.provider_ref {
output::field(
"Identity from",
format!(
"auth provider {provider_ref} in namespace {} \
(`heyctl describe auth-provider {provider_ref} -n {}`)",
d.spec.namespace(),
d.spec.namespace(),
),
);
}
if auth.provider_ref.is_none() {
output::field("Provider", auth.providers().join(" or "));
if let Some(client_id) = &auth.client_id {
output::field("Client id", client_id);
}
if let Some(secret) = &auth.client_secret {
output::field("Client secret", format!("secret {}", secret.render()));
}
if auth.providers().iter().any(|p| p == "google") {
output::field("Who may enter", auth.allow_summary());
}
}
if let Some(jwt) = &auth.jwt {
output::field("JWT issuer", &jwt.issuer);
output::field(
"JWT audience",
jwt.audience.as_deref().unwrap_or("(not checked)"),
);
output::field("JWT key", jwt.key_summary());
output::field("JWT algorithms", jwt.algorithms.join(", "));
output::field("JWT admits", jwt.require_summary());
output::field(
"JWT subject claim",
format!("{} -> x-auth-request-user", jwt.subject_claim),
);
if let Some(cookie) = &jwt.cookie {
output::field("JWT cookie", format!("{cookie} (when no Authorization header)"));
}
if !auth.accepts_jwt() {
output::field(
"JWT",
"NOT IN USE — `jwt` is not among this gate's providers",
);
}
}
if !auth.public_paths.is_empty() {
output::field(
"Public paths",
auth.public_paths
.iter()
.map(|p| format!("{} ({})", p.path, p.scope))
.collect::<Vec<_>>()
.join(", "),
);
}
let host = d
.spec
.routes
.iter()
.find_map(|r| r.host.clone())
.unwrap_or_else(|| "<this deployment's hostname>".into());
output::field("Redirect URI", match &auth.redirect_url {
Some(u) => format!("{u} (overridden)"),
None => auth.callback_url(&host),
});
output::field("Session lifetime", output::duration(auth.session_ttl_secs));
output::field(
"Identity headers",
if auth.forward_identity {
"x-auth-request-email, -user, -name"
} else {
"not forwarded"
},
);
}
output::section("Health check");
output::field(
"Probe",
match &d.spec.health.path {
Some(p) => format!("GET {p}"),
None => "TCP connect".to_string(),
},
);
if let Some(port) = d.spec.health.port {
output::field("Port", port.to_string());
}
output::field("Timeout", output::duration(d.spec.health.timeout_secs));
output::section("Backends");
output::field("Ready / Pending", format!("{} / {}", d.ready, d.pending));
output::field("In flight", d.total_in_flight.to_string());
if d.vms.is_empty() {
println!(" (no backends)");
} else if d.spec.is_static() {
let mut table = Table::indented(["UPSTREAM", "STATUS", "IN-FLIGHT"], 2);
for vm in &d.vms {
table.row([
vm.addr.clone(),
vm.status().to_string(),
vm.in_flight.to_string(),
]);
}
table.print();
} else {
let view = metrics.and_then(|m| m.deployments.iter().find(|v| v.id == d.spec.id));
let mut table = Table::indented(
["SANDBOX", "ADDRESS", "STATUS", "IN-FLIGHT", "CPU%", "MEMORY", "UPTIME"],
2,
);
for vm in &d.vms {
let live = view.and_then(|v| v.vms.iter().find(|x| x.sandbox_id == vm.sandbox_id));
table.row([
vm.sandbox_id.clone(),
vm.addr.clone(),
vm.status().to_string(),
vm.in_flight.to_string(),
output::opt_percent(live.and_then(|l| l.cpu_percent)),
output::opt_bytes(live.and_then(|l| l.memory_bytes)),
live.map(|l| output::duration(l.uptime_secs))
.unwrap_or_else(|| "—".into()),
]);
}
table.print();
}
match metrics.and_then(|m| m.deployments.iter().find(|v| v.id == d.spec.id)) {
None => {
output::section("Traffic");
println!(" (no metrics — /metrics is gated or unreachable for this user)");
}
Some(view) => {
let m = &view.metrics;
output::section("Traffic (since app-lb started)");
output::field(
"Requests",
format!(
"{} total — {} 2xx, {} 3xx, {} 4xx, {} 5xx, {} errors",
m.requests.total,
m.requests.c2xx,
m.requests.c3xx,
m.requests.c4xx,
m.requests.c5xx,
m.requests.errors
),
);
output::field(
"Latency",
format!(
"p50 {} p90 {} p99 {}",
output::millis(m.latency_ms.p50),
output::millis(m.latency_ms.p90),
output::millis(m.latency_ms.p99)
),
);
output::field("Utilization", output::ratio_percent(view.pool.utilization));
output::field(
"Pool CPU / memory",
format!(
"{} / {}",
output::opt_percent(view.pool.cpu_percent),
output::opt_bytes(view.pool.memory_bytes)
),
);
let a = &m.autoscale;
output::field(
"Autoscaler",
format!(
"{} VMs created, {} drained, {} reaped ({} up / {} down events)",
a.vms_created, a.vms_drained, a.vms_reaped, a.scale_up_events, a.scale_down_events
),
);
output::field(
"Cold starts",
format!(
"{} waits — {} served, {} timed out (p50 {:.1}s)",
a.cold_start_waits, a.cold_start_hits, a.cold_start_timeouts, m.cold_start_s.p50
),
);
if a.boot_timeouts > 0 {
output::field(
"Boot failures",
format!(
"{} VMs never passed their health check inside the boot timeout",
a.boot_timeouts
),
);
}
if let Some(wait) = view.pool.boot_backoff_secs {
output::field(
"Boot backoff",
format!(
"{} failed boots in a row; next VM in {} (any spec edit retries now)",
view.pool.boot_failures,
output::duration(wait)
),
);
}
if a.create_failures > 0 {
output::field(
"Create failures",
match a.last_create_error.as_deref() {
Some(err) => format!(
"{} refused by the VM daemon — last error: {err}",
a.create_failures
),
None => format!("{} refused by the VM daemon", a.create_failures),
},
);
}
}
}
}
pub fn watch(interval: Duration, mut render: impl FnMut() -> Result<()>) -> Result<()> {
loop {
print!("\x1b[2J\x1b[H");
render()?;
println!("\n(watching every {}s — Ctrl-C to stop)", interval.as_secs());
std::thread::sleep(interval);
}
}
pub fn format_name(f: OutputFormat) -> &'static str {
match f {
OutputFormat::Table => "table",
OutputFormat::Wide => "wide",
OutputFormat::Json => "json",
OutputFormat::Yaml => "yaml",
OutputFormat::Name => "name",
}
}