#[cfg(not(unix))]
compile_error!("the engenho daemon is unix-only: its stop path is SIGTERM/SIGINT (see StopCause)");
use std::fmt;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use engenho_apiserver::{PkiFile, load_or_generate_ca};
use engenho_config::{
ConfigError, ConfigTier, EngenhoConfig, OverrideLayer, ProgressiveResolution, SocketDefaults,
TieredConfig, render_provenance,
};
use engenho_control_server::{
AuditLog, AuthorizedSet, ControlIdentity, GrantPolicy, Pins, RemoteListener, Router, identity,
serve_uds,
};
use engenho_kube_client::{emit_kubeconfig, emit_kubeconfig_with_admin};
use engenho_runtime::census::{self, ApiSource, Catalog, DataDirSource, Predicate};
use engenho_runtime::control::{
DaemonControl, DaemonControlParts, LogLayer, OverrideStore, RemoteFacts, SocketFacts,
};
use engenho_runtime::lifecycle::{
ConfigSource, ControlBootstrap, ControlDir, ExitIntent, ResolvedConfig, Supervisor,
SupervisorConfig,
};
use engenho_serve::stop_channel;
use tokio::signal::unix::{Signal, SignalKind, signal};
use tracing_subscriber::EnvFilter;
use tracing_subscriber::Layer;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
mod ctl;
mod face;
mod remote;
mod status;
mod unit_run;
const SUBCOMMAND_NAMES: [&str; 9] = [
"daemon",
"status",
"ctl",
"remote",
"kubeconfig",
"config-show",
"config-diff",
"census",
"unit-run",
];
#[derive(Debug, PartialEq, Eq)]
enum Command {
Daemon,
Kubeconfig(Vec<String>),
ConfigShow(Option<String>),
ConfigDiff(String, String),
Census(CensusCommand),
Ctl(Vec<String>),
Status(Vec<String>),
Remote(Vec<String>),
UnitRun(Vec<String>),
Help,
Version,
}
impl Command {
fn parse(mut args: impl Iterator<Item = String>) -> anyhow::Result<Self> {
match args.next().as_deref() {
None | Some("daemon") => Ok(Command::Daemon),
Some("--help" | "-h" | "help") => Ok(Command::Help),
Some("--version" | "-V" | "version") => Ok(Command::Version),
Some("kubeconfig") => Ok(Command::Kubeconfig(args.collect())),
Some("config-show") => Ok(Command::ConfigShow(args.next())),
Some("census") => Ok(Command::Census(CensusCommand::parse(args)?)),
Some("ctl") => Ok(Command::Ctl(args.collect())),
Some("status") => Ok(Command::Status(args.collect())),
Some("unit-run") => Ok(Command::UnitRun(args.collect())),
Some("remote") => Ok(Command::Remote(args.collect())),
Some("config-diff") => match (args.next(), args.next()) {
(Some(from), Some(to)) => Ok(Command::ConfigDiff(from, to)),
_ => Err(anyhow::anyhow!(
"config-diff requires two tier args: config-diff <from> <to> (bare|discovered|default|<yaml-path>)"
)),
},
Some(other) => Err(anyhow::anyhow!(
"unknown subcommand {other:?} (supported: {}) — run `engenho --help` for usage",
SUBCOMMAND_NAMES.join(", ")
)),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum StopCause {
Interrupt,
Terminate,
}
impl StopCause {
fn kind(self) -> SignalKind {
match self {
Self::Interrupt => SignalKind::interrupt(),
Self::Terminate => SignalKind::terminate(),
}
}
const fn signal_name(self) -> &'static str {
match self {
Self::Interrupt => "SIGINT",
Self::Terminate => "SIGTERM",
}
}
fn subscribe(self) -> Result<Signal, SignalSubscribeError> {
signal(self.kind()).map_err(|source| SignalSubscribeError {
cause: self,
source,
})
}
}
impl fmt::Display for StopCause {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.signal_name())
}
}
#[derive(Debug)]
struct SignalSubscribeError {
cause: StopCause,
source: std::io::Error,
}
impl fmt::Display for SignalSubscribeError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"cannot subscribe to {} (the daemon would die on it instead of stopping cleanly)",
self.cause
)
}
}
impl std::error::Error for SignalSubscribeError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
Some(&self.source)
}
}
struct StopSignals {
interrupt: Signal,
terminate: Signal,
}
impl StopSignals {
fn subscribe() -> Result<Self, SignalSubscribeError> {
Ok(Self {
interrupt: StopCause::Interrupt.subscribe()?,
terminate: StopCause::Terminate.subscribe()?,
})
}
async fn next(&mut self) -> StopCause {
tokio::select! {
Some(()) = self.interrupt.recv() => StopCause::Interrupt,
Some(()) = self.terminate.recv() => StopCause::Terminate,
else => std::future::pending().await,
}
}
}
fn main() -> anyhow::Result<()> {
let command = Command::parse(std::env::args().skip(1))?;
if let Command::UnitRun(args) = command {
std::process::exit(unit_run::run(args));
}
run_async(command)
}
#[tokio::main]
async fn run_async(command: Command) -> anyhow::Result<()> {
match command {
Command::Daemon => {
let intent = run_daemon().await?;
tracing::info!(?intent, code = intent.code(), "engenho exiting");
std::process::exit(intent.code())
}
Command::Kubeconfig(flags) => run_kubeconfig(flags.into_iter()),
Command::ConfigShow(tier) => run_config_show(tier),
Command::ConfigDiff(from, to) => run_config_diff(&from, &to),
Command::Census(census) => run_census(census).await,
Command::Ctl(args) => std::process::exit(i32::from(ctl::run(args).await)),
Command::Status(args) => std::process::exit(i32::from(status::run(args).await)),
Command::UnitRun(args) => std::process::exit(unit_run::run(args)),
Command::Remote(args) => std::process::exit(i32::from(remote::run(&args))),
Command::Help => {
print!("{}", help_text());
Ok(())
}
Command::Version => {
println!("engenho {}", env!("CARGO_PKG_VERSION"));
Ok(())
}
}
}
const DEFAULT_LOG_DIRECTIVES: &str = "engenho=info,engenho_runtime=info,engenho_store=info,\
engenho_controllers=info,engenho_kubelet=info";
async fn run_daemon() -> anyhow::Result<ExitIntent> {
let directives = std::env::var("RUST_LOG").unwrap_or_else(|_| DEFAULT_LOG_DIRECTIVES.into());
let filter = || EnvFilter::try_new(&directives).unwrap_or_else(|_| EnvFilter::new("info"));
let (log_layer, logs) = LogLayer::new();
tracing_subscriber::registry()
.with(tracing_subscriber::fmt::layer().with_filter(filter()))
.with(log_layer.with_filter(filter()))
.init();
tracing::info!(
version = env!("CARGO_PKG_VERSION"),
"engenho — typed, attested, Rust-native Kubernetes runtime"
);
let mut stop = StopSignals::subscribe()?;
let bootstrap = ControlBootstrap::discover();
let euid = engenho_control_server::daemon_euid();
let socket_config = bootstrap.control.socket.clone();
let socket_path = socket_config.resolved_path(&SocketDefaults::for_process(euid));
tracing::info!(
data_dir = %bootstrap.data_dir.display(),
source = ?bootstrap.data_dir_source,
socket = %socket_path.display(),
"control state"
);
let data_dir = bootstrap.data_dir.clone();
let overrides = Arc::new(OverrideStore::open(ControlDir::under(&data_dir).root()));
let source = ConfigSource::layered(resolve_declared_config, overrides);
let (supervisor, handle) = Supervisor::new(SupervisorConfig {
data_dir: data_dir.clone(),
source: source.clone(),
backend: None,
declared: bootstrap.declared.clone(),
})?;
let identity =
ControlIdentity::load_or_create(&ControlDir::under(&data_dir).root().join(identity::DIR))
.map(Arc::new)
.map_err(|e| e.to_string());
if let Err(why) = &identity {
tracing::warn!(error = %why, "no control identity: remote control is unavailable");
}
let remote_config = bootstrap.control.remote.clone();
let pins = Pins::new(
AuthorizedSet::from_config(&remote_config.authorized_clients).unwrap_or_default(),
);
let (remote_state, remote_watch) = RemoteListener::channel();
let audit = Arc::new(AuditLog::open(
&data_dir.join(ControlDir::NAME).join("audit"),
)?);
let control = DaemonControl::new(DaemonControlParts {
supervisor: handle.clone(),
source,
data_dir,
data_dir_source: bootstrap.data_dir_source,
declared: bootstrap.declared,
socket: SocketFacts {
path: socket_path.clone(),
access: socket_config.access,
group_tier: socket_config.group_tier,
},
remote: RemoteFacts {
state: remote_watch,
identity: identity.clone(),
pins: pins.clone(),
},
logs,
audit: Arc::clone(&audit),
git_rev: option_env!("ENGENHO_GIT_REV")
.unwrap_or("unknown")
.to_owned(),
});
let bound = engenho_control_server::bind(&socket_path, socket_config.access, euid)?;
let router = Arc::new(Router::new(
Arc::new(control),
GrantPolicy::new(euid, socket_config.access, socket_config.group_tier),
audit,
));
let (control_stop, control_signal) = stop_channel();
let serving = tokio::spawn(serve_uds(
bound.listener,
Arc::clone(&router),
control_signal.clone(),
engenho_control_server::GRACE,
));
tracing::info!(socket = %socket_path.display(), "control socket serving");
let remote = RemoteListener::spawn(
&remote_config,
identity,
pins.clone(),
router,
control_signal,
remote_state,
);
tokio::spawn(follow_pins(handle.declared_changes(), pins));
tokio::spawn(async move {
loop {
let cause = stop.next().await;
tracing::info!(signal = %cause, "shutdown signal received");
if let Err(err) = handle.exit(ExitIntent::Halt).await {
tracing::warn!(signal = %cause, error = %err, "exit request not taken");
}
}
});
let intent = supervisor.run().await;
control_stop.stop();
if let Err(err) = serving.await {
tracing::warn!(error = %err, "the control socket's server ended abnormally");
}
remote.stopped().await;
drop(bound.guard);
Ok(intent)
}
async fn follow_pins(mut changes: tokio::sync::watch::Receiver<u64>, pins: Pins) {
while changes.changed().await.is_ok() {
let remote = ControlBootstrap::discover().control.remote;
match AuthorizedSet::from_config(&remote.authorized_clients) {
Ok(set) => {
tracing::info!(
clients = remote.authorized_clients.len(),
"remote control pins reloaded"
);
pins.replace(set);
}
Err(why) => {
tracing::warn!(error = %why, "the declared remote pins do not parse; keeping those in force");
}
}
}
}
fn resolve_declared_config(
overrides: Option<&OverrideLayer>,
) -> Result<ResolvedConfig, ConfigError> {
let (config, provenance) = EngenhoConfig::resolve_progressively_with(overrides)?.into_parts();
tracing::info!(
cluster = %config.cluster.name,
node = %config.runtime.node_name,
listen = %config.runtime.listen_addr,
durable = config.runtime.durable,
tls = config.runtime.tls.enabled,
"loaded config"
);
let node_name_tier = provenance
.provenance_of(&["runtime", "node_name"])
.map_or("?", |p| p.tier().as_str());
tracing::info!(
leaves = provenance.len(),
tiers = %provenance
.contributing_tiers()
.iter()
.map(|t| t.as_str())
.collect::<Vec<_>>()
.join(", "),
node_name_from = node_name_tier,
"config provenance",
);
Ok(ResolvedConfig {
config,
provenance: Some(provenance),
})
}
fn run_kubeconfig(args: impl Iterator<Item = String>) -> anyhow::Result<()> {
let mut data_dir: Option<PathBuf> = None;
let mut server: Option<String> = None;
let mut args = args.peekable();
while let Some(flag) = args.next() {
match flag.as_str() {
"--data-dir" => {
data_dir = Some(PathBuf::from(args.next().ok_or_else(|| {
anyhow::anyhow!("--data-dir requires a path argument")
})?));
}
"--server" => {
server = Some(
args.next()
.ok_or_else(|| anyhow::anyhow!("--server requires a URL argument"))?,
);
}
other => {
return Err(anyhow::anyhow!(
"unknown flag {other:?} (supported: --data-dir, --server)"
));
}
}
}
let control_data_dir = data_dir
.clone()
.unwrap_or_else(|| ControlBootstrap::discover().data_dir);
let config = resolve_with_override_tier(&control_data_dir)?.into_value();
let data_dir = data_dir.unwrap_or_else(|| config.runtime.data_dir.clone());
let ca = load_or_generate_ca(&data_dir).map_err(|e| anyhow::anyhow!("load cluster CA: {e}"))?;
let server_url = server.unwrap_or_else(|| default_server_url(&config));
let admin_cert = std::fs::read(PkiFile::AdminCert.path(&data_dir)).ok();
let admin_key = std::fs::read(PkiFile::AdminKey.path(&data_dir)).ok();
let yaml = match (admin_cert, admin_key) {
(Some(cert), Some(key)) => emit_kubeconfig_with_admin(
&config.cluster.name,
&server_url,
ca.cert_pem().as_bytes(),
&cert,
&key,
),
_ => emit_kubeconfig(&config.cluster.name, &server_url, ca.cert_pem().as_bytes()),
}
.map_err(|e| anyhow::anyhow!("emit kubeconfig: {e}"))?;
print!("{yaml}");
Ok(())
}
fn resolve_with_override_tier(
data_dir: &Path,
) -> Result<ProgressiveResolution<EngenhoConfig>, ConfigError> {
let overrides = OverrideStore::open(ControlDir::under(data_dir).root());
let layer = match overrides.layer() {
Ok(layer) => Some(layer),
Err(err) => {
eprintln!("engenho: without the override tier: {err}");
None
}
};
EngenhoConfig::resolve_progressively_with(layer.as_ref())
}
fn default_server_url(config: &EngenhoConfig) -> String {
let port = config
.runtime
.listen_addr
.rsplit(':')
.next()
.and_then(|p| p.parse::<u16>().ok())
.filter(|p| *p != 0)
.unwrap_or(6443);
let mut url = String::from("https://127.0.0.1:");
url.push_str(&port.to_string());
url
}
fn run_config_show(tier_arg: Option<String>) -> anyhow::Result<()> {
let tier = match tier_arg {
Some(s) => ConfigTier::from_str_or_default(&s),
None => ConfigTier::from_env("ENGENHO_TIER"),
};
match tier {
ConfigTier::Default => {
let resolution = resolve_with_override_tier(&ControlBootstrap::discover().data_dir)?;
print!("{}", resolution.value().to_yaml()?);
print!("{}", render_provenance(resolution.provenance()));
}
other => {
print!("{}", EngenhoConfig::resolve_tier(other).to_yaml()?);
}
}
Ok(())
}
fn run_config_diff(from: &str, to: &str) -> anyhow::Result<()> {
let from_cfg = EngenhoConfig::resolve_tier(ConfigTier::from_str_or_default(from));
let to_cfg = EngenhoConfig::resolve_tier(ConfigTier::from_str_or_default(to));
print!("{}", to_cfg.diff_against(&from_cfg).render_unified());
Ok(())
}
#[derive(Debug, PartialEq, Eq)]
enum CensusCommand {
List,
Run {
predicate: Predicate,
source: CensusSource,
},
}
#[derive(Debug, PartialEq, Eq)]
enum CensusSource {
Apiserver(PathBuf),
DataDir(PathBuf),
}
#[derive(Debug, PartialEq, Eq)]
enum CensusUsage {
MissingValue(&'static str),
UnknownPredicate(String),
UnknownFlag(String),
Repeated(&'static str),
NoPredicate,
NoSource,
TwoSources,
ListTakesNothing,
}
impl fmt::Display for CensusUsage {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingValue(flag) => write!(f, "census: {flag} needs a value"),
Self::UnknownPredicate(name) => {
write!(f, "census: no predicate {name:?} in the catalog (")?;
for (i, p) in Predicate::ALL.iter().enumerate() {
if i > 0 {
f.write_str(", ")?;
}
f.write_str(p.name())?;
}
f.write_str(") — run `engenho census --list`")
}
Self::UnknownFlag(flag) => write!(
f,
"census: unknown flag {flag:?} (supported: --predicate, --kubeconfig, --data-dir, --list)"
),
Self::Repeated(flag) => write!(f, "census: {flag} given twice"),
Self::NoPredicate => {
f.write_str("census: name a check with --predicate <name>, or run `census --list`")
}
Self::NoSource => f.write_str(
"census: name a source: --kubeconfig <path> (a running apiserver) or \
--data-dir <dir> (a copy of a stopped node's data directory)",
),
Self::TwoSources => {
f.write_str("census: --kubeconfig and --data-dir are two sources; name one")
}
Self::ListTakesNothing => f.write_str("census: --list takes no other flag"),
}
}
}
impl std::error::Error for CensusUsage {}
impl CensusCommand {
fn parse(mut args: impl Iterator<Item = String>) -> Result<Self, CensusUsage> {
let mut list = false;
let mut predicate: Option<Predicate> = None;
let mut kubeconfig: Option<PathBuf> = None;
let mut data_dir: Option<PathBuf> = None;
while let Some(flag) = args.next() {
match flag.as_str() {
"--list" => list = true,
"--predicate" => {
let name = args
.next()
.ok_or(CensusUsage::MissingValue("--predicate"))?;
let p =
Predicate::from_name(&name).ok_or(CensusUsage::UnknownPredicate(name))?;
if predicate.replace(p).is_some() {
return Err(CensusUsage::Repeated("--predicate"));
}
}
"--kubeconfig" => {
let path = args
.next()
.ok_or(CensusUsage::MissingValue("--kubeconfig"))?;
if kubeconfig.replace(PathBuf::from(path)).is_some() {
return Err(CensusUsage::Repeated("--kubeconfig"));
}
}
"--data-dir" => {
let path = args.next().ok_or(CensusUsage::MissingValue("--data-dir"))?;
if data_dir.replace(PathBuf::from(path)).is_some() {
return Err(CensusUsage::Repeated("--data-dir"));
}
}
_ => return Err(CensusUsage::UnknownFlag(flag)),
}
}
match (list, predicate, kubeconfig, data_dir) {
(true, None, None, None) => Ok(Self::List),
(true, ..) => Err(CensusUsage::ListTakesNothing),
(false, None, ..) => Err(CensusUsage::NoPredicate),
(false, Some(_), None, None) => Err(CensusUsage::NoSource),
(false, Some(_), Some(_), Some(_)) => Err(CensusUsage::TwoSources),
(false, Some(predicate), Some(path), None) => Ok(Self::Run {
predicate,
source: CensusSource::Apiserver(path),
}),
(false, Some(predicate), None, Some(dir)) => Ok(Self::Run {
predicate,
source: CensusSource::DataDir(dir),
}),
}
}
}
async fn run_census(command: CensusCommand) -> anyhow::Result<()> {
let (predicate, source) = match command {
CensusCommand::List => {
print!("{Catalog}");
return Ok(());
}
CensusCommand::Run { predicate, source } => (predicate, source),
};
tracing_subscriber::fmt()
.with_writer(std::io::stderr)
.with_env_filter(
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("warn")),
)
.init();
let report = match source {
CensusSource::Apiserver(kubeconfig) => {
let source = ApiSource::from_kubeconfig(&kubeconfig).await?;
census::run(predicate, &source).await?
}
CensusSource::DataDir(dir) => {
let source = DataDirSource::open(&dir).await?;
let report = census::run(predicate, &source).await;
let closed = source.close().await;
let report = report?;
closed?;
report
}
};
print!("{report}");
Ok(())
}
fn help_text() -> String {
format!(
"\
engenho {version} — a Rust-native Kubernetes runtime.
Runs the control plane as a single binary: API server, scheduler, controllers
and kubelet in one process. Real kubectl drives it.
USAGE:
engenho [SUBCOMMAND]
SUBCOMMANDS:
daemon Boot the runtime. This is the default when no
subcommand is given, so bare `engenho` runs it.
status [--remote <NAME>] [--json] [--events <N>]
One report on the daemon the client speaks to:
lifecycle, store, children, boot, PKI,
kubeconfigs, config drift, control listeners and
recent events. Exits 0 healthy, 1 degraded,
4 unreachable.
ctl <RESOURCE> <VERB> Talk to the running daemon over its control
socket: its lifecycle, boots, init state,
config, children, PKI, store, logs and audit.
`ctl --list` prints every resource and verb;
`ctl --remote <NAME>` talks to another machine's.
remote list | keygen <NAME> | fingerprint <NAME>
This machine's keys for remote daemons, and the
daemons remotes.yaml names.
kubeconfig [FLAGS] Print a kubeconfig for the persisted cluster CA.
Flags: --data-dir <dir>, --server <url>
config-show [TIER] Print the resolved config and each leaf's
provenance. TIER overrides $ENGENHO_TIER.
config-diff <FROM> <TO> Unified diff between two resolved config tiers.
Tiers: bare | discovered | default | <yaml-path>
unit-run --unit <PATH> [--user <NAME>] [--group <NAME>] [--root <DIR>] [--check]
Run a rendered systemd .service file as this
pod's workload: its user, directories,
environment, credentials and ExecStartPre=
steps, then exec its ExecStart=. --user is
REQUIRED for a DynamicUser=yes unit. --check
prints the plan and does nothing.
census --predicate <NAME> (--kubeconfig <PATH> | --data-dir <DIR>)
Count the live objects a planned rule would
refuse, read-only, over a running apiserver or a
copy of a stopped node's data directory.
`census --list` prints every predicate.
OPTIONS:
-h, --help Print this message.
-V, --version Print the version.
GETTING STARTED:
engenho & # boot the runtime
engenho kubeconfig > /tmp/engenho.kubeconfig # write a kubeconfig
KUBECONFIG=/tmp/engenho.kubeconfig kubectl get --raw /api
Docs: https://github.com/pleme-io/engenho
",
version = env!("CARGO_PKG_VERSION"),
)
}
#[cfg(test)]
mod tests {
use super::{
CensusCommand, CensusSource, CensusUsage, Command, Predicate, StopCause, StopSignals,
};
fn parse(argv: &[&str]) -> anyhow::Result<Command> {
Command::parse(argv.iter().map(|s| (*s).to_string()))
}
#[test]
fn the_default_log_filter_admits_the_controllers_and_the_kubelet() {
let filter = tracing_subscriber::EnvFilter::try_new(super::DEFAULT_LOG_DIRECTIVES)
.expect("the default directives parse");
let shown = filter.to_string();
for target in [
"engenho_controllers=info",
"engenho_kubelet=info",
"engenho=info",
] {
assert!(shown.contains(target), "{target} missing from {shown}");
}
}
#[test]
fn stop_cause_is_logged_by_signal_name() {
assert_eq!(StopCause::Interrupt.to_string(), "SIGINT");
assert_eq!(StopCause::Terminate.to_string(), "SIGTERM");
}
#[tokio::test]
async fn each_subscribed_signal_is_read_as_its_own_cause() {
use nix::sys::signal::{Signal, raise};
use std::time::Duration;
let mut stop = StopSignals::subscribe().expect("subscribe the stop signals");
for (signal, cause) in [
(Signal::SIGTERM, StopCause::Terminate),
(Signal::SIGINT, StopCause::Interrupt),
] {
raise(signal).expect("raise the signal");
let read = tokio::time::timeout(Duration::from_secs(5), stop.next())
.await
.expect("a raised stop signal is delivered");
assert_eq!(read, cause, "{signal:?} was read as the wrong cause");
}
}
#[test]
fn bare_no_args_is_daemon() {
assert_eq!(parse(&[]).unwrap(), Command::Daemon);
}
#[test]
fn every_help_spelling_is_help() {
for argv in [&["--help"], &["-h"], &["help"]] {
assert_eq!(parse(argv).unwrap(), Command::Help, "argv {argv:?}");
}
}
#[test]
fn every_version_spelling_is_version() {
for argv in [&["--version"], &["-V"], &["version"]] {
assert_eq!(parse(argv).unwrap(), Command::Version, "argv {argv:?}");
}
}
#[test]
fn help_and_version_never_boot_the_daemon() {
for argv in [
&["--help"],
&["-h"],
&["help"],
&["--version"],
&["-V"],
&["version"],
] {
assert_ne!(parse(argv).unwrap(), Command::Daemon, "argv {argv:?}");
}
}
#[test]
fn help_text_names_every_subcommand() {
let help = super::help_text();
for verb in super::SUBCOMMAND_NAMES {
assert!(
help.contains(verb),
"help text omits the {verb:?} subcommand"
);
}
for flag in ["--help", "--version"] {
assert!(help.contains(flag), "help text omits {flag:?}");
}
}
#[test]
fn help_text_is_actionable() {
let help = super::help_text();
assert!(
help.contains(env!("CARGO_PKG_VERSION")),
"help omits the version"
);
assert!(help.contains("kubectl"), "help gives no working next step");
}
#[test]
fn unknown_subcommand_points_at_help() {
let err = parse(&["frobnicate"]).unwrap_err().to_string();
assert!(
err.contains("--help"),
"unknown-verb error does not mention --help: {err}"
);
}
#[test]
fn subcommand_list_matches_parse() {
let err = parse(&["frobnicate"]).unwrap_err().to_string();
for verb in super::SUBCOMMAND_NAMES {
if let Err(e) = parse(&[verb]) {
let msg = e.to_string();
assert!(
!msg.contains("unknown subcommand"),
"{verb:?} is advertised but Command::parse rejects it as unknown: {msg}"
);
}
assert!(
err.contains(verb),
"unknown-verb error omits the advertised verb {verb:?}: {err}"
);
}
}
#[test]
fn daemon_subcommand_is_daemon() {
assert_eq!(parse(&["daemon"]).unwrap(), Command::Daemon);
}
#[test]
fn bare_and_daemon_subcommand_agree() {
assert_eq!(parse(&[]).unwrap(), parse(&["daemon"]).unwrap());
}
#[test]
fn kubeconfig_subcommand_carries_flags() {
assert_eq!(
parse(&["kubeconfig", "--data-dir", "/var/lib/engenho-rio"]).unwrap(),
Command::Kubeconfig(vec![
"--data-dir".to_string(),
"/var/lib/engenho-rio".to_string(),
]),
);
}
#[test]
fn unknown_subcommand_errors() {
let err = parse(&["frobnicate"]).unwrap_err().to_string();
assert!(
err.contains("frobnicate"),
"error should name the bad verb: {err}"
);
assert!(err.contains("daemon"), "error should list `daemon`: {err}");
assert!(
err.contains("kubeconfig"),
"error should list `kubeconfig`: {err}"
);
assert!(
err.contains("config-show"),
"error should list `config-show`: {err}"
);
assert!(
err.contains("config-diff"),
"error should list `config-diff`: {err}"
);
}
#[test]
fn config_show_without_tier() {
assert_eq!(parse(&["config-show"]).unwrap(), Command::ConfigShow(None));
}
#[test]
fn config_show_with_tier() {
assert_eq!(
parse(&["config-show", "bare"]).unwrap(),
Command::ConfigShow(Some("bare".to_string())),
);
}
#[test]
fn config_diff_carries_two_tiers() {
assert_eq!(
parse(&["config-diff", "bare", "default"]).unwrap(),
Command::ConfigDiff("bare".to_string(), "default".to_string()),
);
}
#[test]
fn config_diff_requires_two_args() {
assert!(parse(&["config-diff", "bare"]).is_err());
assert!(parse(&["config-diff"]).is_err());
}
fn census(argv: &[&str]) -> Result<CensusCommand, CensusUsage> {
CensusCommand::parse(argv.iter().map(|s| (*s).to_string()))
}
#[test]
fn census_takes_one_catalog_predicate_over_one_source() {
assert_eq!(
parse(&["census", "--list"]).unwrap(),
Command::Census(CensusCommand::List)
);
for p in Predicate::ALL {
assert_eq!(
census(&["--predicate", p.name(), "--data-dir", "/copy"]).unwrap(),
CensusCommand::Run {
predicate: *p,
source: CensusSource::DataDir("/copy".into()),
}
);
}
assert_eq!(
census(&["--kubeconfig", "/kc", "--predicate", "node-overcommitted"]).unwrap(),
CensusCommand::Run {
predicate: Predicate::NodeOvercommitted,
source: CensusSource::Apiserver("/kc".into()),
}
);
}
#[test]
fn census_refuses_a_predicate_outside_the_catalog() {
let err = census(&["--predicate", "spec.replicas > 3", "--data-dir", "/d"]).unwrap_err();
assert_eq!(
err,
CensusUsage::UnknownPredicate("spec.replicas > 3".into())
);
let msg = err.to_string();
for p in Predicate::ALL {
assert!(msg.contains(p.name()), "the refusal omits {p}: {msg}");
}
}
#[test]
fn census_refuses_every_malformed_invocation() {
let p = "deployment-would-roll";
for (argv, want) in [
(vec!["--predicate", p], CensusUsage::NoSource),
(
vec!["--predicate", p, "--kubeconfig", "/k", "--data-dir", "/d"],
CensusUsage::TwoSources,
),
(vec!["--data-dir", "/d"], CensusUsage::NoPredicate),
(vec![], CensusUsage::NoPredicate),
(
vec!["--predicate"],
CensusUsage::MissingValue("--predicate"),
),
(
vec!["--predicate", p, "--data-dir"],
CensusUsage::MissingValue("--data-dir"),
),
(
vec!["--predicate", p, "--predicate", p, "--data-dir", "/d"],
CensusUsage::Repeated("--predicate"),
),
(
vec!["--frobnicate"],
CensusUsage::UnknownFlag("--frobnicate".into()),
),
(
vec!["--list", "--data-dir", "/d"],
CensusUsage::ListTakesNothing,
),
] {
assert_eq!(census(&argv).unwrap_err(), want, "argv {argv:?}");
}
}
}