pub mod artifact;
pub mod auth;
pub mod feed;
pub mod observe;
pub mod plugins;
pub mod read;
pub mod session;
pub mod token;
pub mod write;
use crate::blocking::Client;
use clap::Args;
use std::path::PathBuf;
use crate::config::{Config, Endpoint, resolve_endpoint};
use crate::output::OutputFormat;
use anyhow::{Result, bail};
use std::time::Duration;
pub const DEFAULT_SERVER: &str = "http://127.0.0.1:9090";
#[derive(Args, Debug, Clone)]
pub struct GlobalOpts {
#[arg(
long,
short = 'o',
global = true,
value_enum,
default_value_t = OutputFormat::Table,
value_name = "FORMAT",
help_heading = "Global options"
)]
pub output: OutputFormat,
#[arg(long, global = true, env = "HEYCTL_CONFIG", value_name = "PATH", help_heading = "Global options")]
pub config: Option<PathBuf>,
#[arg(long, global = true, env = "HEYCTL_CONTEXT", value_name = "NAME", help_heading = "Global options")]
pub context: Option<String>,
#[arg(long, global = true, env = "HEYCTL_SERVER", value_name = "URL", help_heading = "Global options")]
pub server: Option<String>,
#[arg(long, global = true, env = "HEYCTL_USER", value_name = "NAME", help_heading = "Global options")]
pub user: Option<String>,
#[arg(
long,
global = true,
env = "HEYCTL_PASSWORD",
value_name = "PASSWORD",
hide_env_values = true,
help_heading = "Global options"
)]
pub password: Option<String>,
#[arg(
long,
global = true,
env = "HEYCTL_TOKEN",
value_name = "TOKEN",
hide_env_values = true,
help_heading = "Global options"
)]
pub token: Option<String>,
#[arg(long, global = true, help_heading = "Global options")]
pub insecure_skip_tls_verify: bool,
#[arg(long, global = true, value_name = "SECS", default_value_t = 30, help_heading = "Global options")]
pub request_timeout: u64,
}
pub struct Ctx {
pub client: Client,
pub endpoint: Endpoint,
pub out: OutputFormat,
}
impl Ctx {
pub fn new(globals: &GlobalOpts) -> Result<Self> {
let path = Config::path(globals.config.as_deref())?;
let config = Config::load(&path)?;
let endpoint = resolve_endpoint(
&config,
globals.context.as_deref(),
globals.server.as_deref(),
globals.user.as_deref(),
globals.password.as_deref(),
globals.token.as_deref(),
globals.insecure_skip_tls_verify,
)?;
let client = Client::connect(
&endpoint.server,
endpoint.user.as_deref(),
endpoint.password.as_deref(),
endpoint.token.as_deref(),
endpoint.insecure_skip_tls_verify,
Duration::from_secs(globals.request_timeout),
)?;
Ok(Self {
client,
endpoint,
out: globals.output,
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Resource {
Deployment,
Vm,
Cert,
Secret,
Workflow,
Job,
Disk,
Namespace,
AuthProvider,
All,
}
impl Resource {
pub fn parse(word: &str) -> Option<Self> {
match word.to_ascii_lowercase().trim_end_matches('s') {
"deployment" | "deploy" | "dep" | "d" | "app" => Some(Self::Deployment),
"vm" | "instance" | "backend" | "replica" => Some(Self::Vm),
"cert" | "certificate" | "tl" => Some(Self::Cert),
"secret" | "sec" => Some(Self::Secret),
"workflow" | "wf" | "flow" | "ci" => Some(Self::Workflow),
"job" | "build" | "bld" | "pull" | "update" | "run" => Some(Self::Job),
"disk" | "pv" | "volume" | "vol" | "storage" => Some(Self::Disk),
"namespace" | "n" => Some(Self::Namespace),
"auth-provider" | "authprovider" | "provider" | "idp" => Some(Self::AuthProvider),
"all" => Some(Self::All),
_ => None,
}
}
pub fn singular(self) -> &'static str {
match self {
Self::Deployment => "deployment",
Self::Vm => "vm",
Self::Cert => "cert",
Self::Secret => "secret",
Self::Workflow => "workflow",
Self::Job => "job",
Self::Disk => "disk",
Self::Namespace => "namespace",
Self::AuthProvider => "auth-provider",
Self::All => "all",
}
}
}
pub fn parse_ref(args: &[String], default_kind: Option<Resource>) -> Result<(Resource, Vec<String>)> {
let Some(first) = args.first() else {
bail!("no resource given");
};
let (kind, mut names): (Resource, Vec<String>) = if let Some((k, name)) = first.split_once('/') {
let kind = Resource::parse(k)
.ok_or_else(|| anyhow::anyhow!("unknown resource type {k:?} in {first:?}"))?;
(kind, vec![name.to_string()])
} else if let Some(kind) = Resource::parse(first) {
(kind, Vec::new())
} else if let Some(kind) = default_kind {
(kind, vec![first.clone()])
} else {
bail!(
"unknown resource type {first:?} — expected deployments, vms, certs, secrets or jobs"
);
};
for arg in &args[1..] {
match arg.split_once('/') {
Some((k, name)) => {
let other = Resource::parse(k)
.ok_or_else(|| anyhow::anyhow!("unknown resource type {k:?} in {arg:?}"))?;
if other != kind {
bail!("cannot mix {} and {} in one command", kind.singular(), other.singular());
}
names.push(name.to_string());
}
None => names.push(arg.clone()),
}
}
Ok((kind, names))
}
pub fn deployment_name(arg: &str) -> Result<String> {
let (kind, names) = parse_ref(std::slice::from_ref(&arg.to_string()), Some(Resource::Deployment))?;
if kind != Resource::Deployment {
bail!("expected a deployment, got {}", kind.singular());
}
match names.len() {
1 => Ok(names.into_iter().next().expect("len == 1")),
_ => bail!("expected a deployment name, e.g. `web` or `deployment/web`"),
}
}
pub(crate) fn now_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
fn args(v: &[&str]) -> Vec<String> {
v.iter().map(|s| s.to_string()).collect()
}
#[test]
fn plural_and_singular_and_slash_forms_all_work() {
assert_eq!(parse_ref(&args(&["deployments"]), None).unwrap().0, Resource::Deployment);
assert_eq!(parse_ref(&args(&["deploy"]), None).unwrap().0, Resource::Deployment);
let (kind, names) = parse_ref(&args(&["deployment/web"]), None).unwrap();
assert_eq!((kind, names), (Resource::Deployment, vec!["web".to_string()]));
let (_, names) = parse_ref(&args(&["deployment", "web", "api"]), None).unwrap();
assert_eq!(names, vec!["web".to_string(), "api".to_string()]);
}
#[test]
fn namespaces_are_listable_under_the_names_people_reach_for() {
for word in ["namespace", "namespaces", "ns"] {
assert_eq!(
parse_ref(&args(&[word]), None).unwrap().0,
Resource::Namespace,
"{word}",
);
}
assert_eq!(Resource::parse("n"), Some(Resource::Namespace));
assert_eq!(Resource::Namespace.singular(), "namespace");
}
#[test]
fn auth_providers_are_addressable_under_the_words_people_reach_for() {
for word in ["auth-provider", "auth-providers", "provider", "providers", "idp"] {
assert_eq!(
parse_ref(&args(&[word]), None).unwrap().0,
Resource::AuthProvider,
"{word}",
);
}
let (kind, names) = parse_ref(&args(&["auth-provider/heyo"]), None).unwrap();
assert_eq!((kind, names), (Resource::AuthProvider, vec!["heyo".to_string()]));
assert_eq!(Resource::AuthProvider.singular(), "auth-provider");
}
#[test]
fn an_object_with_no_kind_is_still_a_deployment() {
use serde_json::json;
let kind_of = |v: &serde_json::Value| {
v.get("kind")
.and_then(serde_json::Value::as_str)
.unwrap_or("deployment")
.to_string()
};
assert_eq!(kind_of(&json!({"id": "web", "routes": []})), "deployment");
assert_eq!(kind_of(&json!({"kind": "namespace", "name": "sam"})), "namespace");
assert_eq!(kind_of(&json!({"kind": "deployment", "id": "web"})), "deployment");
}
#[test]
fn a_bare_name_needs_a_default_kind() {
assert!(parse_ref(&args(&["web"]), None).is_err());
let (kind, names) = parse_ref(&args(&["web"]), Some(Resource::Deployment)).unwrap();
assert_eq!((kind, names), (Resource::Deployment, vec!["web".to_string()]));
}
#[test]
fn mixing_kinds_is_refused() {
assert!(parse_ref(&args(&["deployment/web", "vm/abc"]), None).is_err());
}
#[test]
fn deployment_name_accepts_both_spellings() {
assert_eq!(deployment_name("web").unwrap(), "web");
assert_eq!(deployment_name("deployment/web").unwrap(), "web");
assert_eq!(deployment_name("deploy/web").unwrap(), "web");
assert!(deployment_name("vm/web").is_err());
}
}