feat: Rust rewrite scaffolding with embedded kustomize+helm
Phase 0 of Python-to-Rust CLI rewrite: - Cargo.toml with all dependencies (kube-rs, reqwest, russh, rcgen, lettre, etc.) - build.rs: downloads kustomize v5.8.1 + helm v4.1.0 at compile time, embeds as bytes, sets SUNBEAM_COMMIT from git - src/main.rs: tokio main with anyhow error formatting - src/cli.rs: full clap derive struct tree matching all Python argparse subcommands - src/config.rs: SunbeamConfig serde struct, load/save ~/.sunbeam.json - src/output.rs: step/ok/warn/table with exact Python format strings - src/tools.rs: embedded kustomize+helm extraction to cache dir - src/kube.rs: parse_target, domain_replace, context management - src/manifests.rs: filter_by_namespace with full test coverage - Stub modules for all remaining features (cluster, secrets, images, services, checks, gitea, users, update) 23 tests pass, cargo check clean.
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -5,3 +5,6 @@ __pycache__/
|
||||
dist/
|
||||
build/
|
||||
.eggs/
|
||||
|
||||
# Rust
|
||||
/target/
|
||||
|
||||
4795
Cargo.lock
generated
Normal file
4795
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
56
Cargo.toml
Normal file
56
Cargo.toml
Normal file
@@ -0,0 +1,56 @@
|
||||
[package]
|
||||
name = "sunbeam"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
description = "Sunbeam local dev stack manager"
|
||||
|
||||
[dependencies]
|
||||
# Core
|
||||
anyhow = "1"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
serde_yaml = "0.9"
|
||||
|
||||
# Kubernetes
|
||||
kube = { version = "0.99", features = ["client", "runtime", "derive", "ws"] }
|
||||
k8s-openapi = { version = "0.24", features = ["v1_32"] }
|
||||
|
||||
# HTTP + TLS
|
||||
reqwest = { version = "0.12", features = ["json", "rustls-tls"] }
|
||||
rustls = "0.23"
|
||||
|
||||
# SSH
|
||||
russh = "0.46"
|
||||
russh-keys = "0.46"
|
||||
|
||||
# Crypto
|
||||
rsa = "0.9"
|
||||
pkcs8 = { version = "0.10", features = ["pem"] }
|
||||
pkcs1 = { version = "0.7", features = ["pem"] }
|
||||
sha2 = "0.10"
|
||||
hmac = "0.12"
|
||||
base64 = "0.22"
|
||||
rand = "0.8"
|
||||
|
||||
# Certificate generation
|
||||
rcgen = "0.14"
|
||||
|
||||
# SMTP
|
||||
lettre = { version = "0.11", default-features = false, features = ["smtp-transport", "tokio1-rustls-tls", "builder", "hostname"] }
|
||||
|
||||
# Archive handling
|
||||
flate2 = "1"
|
||||
tar = "0.4"
|
||||
|
||||
# Utility
|
||||
tempfile = "3"
|
||||
dirs = "5"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
|
||||
[build-dependencies]
|
||||
reqwest = { version = "0.12", features = ["blocking", "rustls-tls"] }
|
||||
sha2 = "0.10"
|
||||
flate2 = "1"
|
||||
tar = "0.4"
|
||||
127
build.rs
Normal file
127
build.rs
Normal file
@@ -0,0 +1,127 @@
|
||||
use flate2::read::GzDecoder;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::io::Read;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use tar::Archive;
|
||||
|
||||
const KUSTOMIZE_VERSION: &str = "v5.8.1";
|
||||
const HELM_VERSION: &str = "v4.1.0";
|
||||
|
||||
fn main() {
|
||||
let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap());
|
||||
let target = env::var("TARGET").unwrap_or_default();
|
||||
let (os, arch) = parse_target(&target);
|
||||
|
||||
download_and_embed("kustomize", KUSTOMIZE_VERSION, &os, &arch, &out_dir);
|
||||
download_and_embed("helm", HELM_VERSION, &os, &arch, &out_dir);
|
||||
|
||||
// Set version info from git
|
||||
let commit = git_commit_sha();
|
||||
println!("cargo:rustc-env=SUNBEAM_COMMIT={commit}");
|
||||
|
||||
// Rebuild if git HEAD changes
|
||||
println!("cargo:rerun-if-changed=.git/HEAD");
|
||||
}
|
||||
|
||||
fn parse_target(target: &str) -> (String, String) {
|
||||
let os = if target.contains("darwin") {
|
||||
"darwin"
|
||||
} else if target.contains("linux") {
|
||||
"linux"
|
||||
} else if cfg!(target_os = "macos") {
|
||||
"darwin"
|
||||
} else {
|
||||
"linux"
|
||||
};
|
||||
|
||||
let arch = if target.contains("aarch64") || target.contains("arm64") {
|
||||
"arm64"
|
||||
} else if target.contains("x86_64") || target.contains("amd64") {
|
||||
"amd64"
|
||||
} else if cfg!(target_arch = "aarch64") {
|
||||
"arm64"
|
||||
} else {
|
||||
"amd64"
|
||||
};
|
||||
|
||||
(os.to_string(), arch.to_string())
|
||||
}
|
||||
|
||||
fn download_and_embed(tool: &str, version: &str, os: &str, arch: &str, out_dir: &PathBuf) {
|
||||
let dest = out_dir.join(tool);
|
||||
if dest.exists() {
|
||||
return;
|
||||
}
|
||||
|
||||
let url = match tool {
|
||||
"kustomize" => format!(
|
||||
"https://github.com/kubernetes-sigs/kustomize/releases/download/\
|
||||
kustomize%2F{version}/kustomize_{version}_{os}_{arch}.tar.gz"
|
||||
),
|
||||
"helm" => format!(
|
||||
"https://get.helm.sh/helm-{version}-{os}-{arch}.tar.gz"
|
||||
),
|
||||
_ => panic!("Unknown tool: {tool}"),
|
||||
};
|
||||
|
||||
let extract_path = match tool {
|
||||
"kustomize" => "kustomize".to_string(),
|
||||
"helm" => format!("{os}-{arch}/helm"),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
eprintln!("cargo:warning=Downloading {tool} {version} for {os}/{arch}...");
|
||||
|
||||
let response = reqwest::blocking::get(&url)
|
||||
.unwrap_or_else(|e| panic!("Failed to download {tool}: {e}"));
|
||||
let bytes = response
|
||||
.bytes()
|
||||
.unwrap_or_else(|e| panic!("Failed to read {tool} response: {e}"));
|
||||
|
||||
let decoder = GzDecoder::new(&bytes[..]);
|
||||
let mut archive = Archive::new(decoder);
|
||||
|
||||
for entry in archive.entries().expect("Failed to read tar entries") {
|
||||
let mut entry = entry.expect("Failed to read tar entry");
|
||||
let path = entry
|
||||
.path()
|
||||
.expect("Failed to read entry path")
|
||||
.to_path_buf();
|
||||
if path.to_string_lossy() == extract_path {
|
||||
let mut data = Vec::new();
|
||||
entry
|
||||
.read_to_end(&mut data)
|
||||
.expect("Failed to read binary");
|
||||
fs::write(&dest, &data).expect("Failed to write binary");
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(&dest, fs::Permissions::from_mode(0o755))
|
||||
.expect("Failed to set permissions");
|
||||
}
|
||||
|
||||
eprintln!("cargo:warning=Embedded {tool} ({} bytes)", data.len());
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
panic!("Could not find {extract_path} in {tool} archive");
|
||||
}
|
||||
|
||||
fn git_commit_sha() -> String {
|
||||
Command::new("git")
|
||||
.args(["rev-parse", "--short=8", "HEAD"])
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|o| {
|
||||
if o.status.success() {
|
||||
Some(String::from_utf8_lossy(&o.stdout).trim().to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| "unknown".to_string())
|
||||
}
|
||||
5
src/checks.rs
Normal file
5
src/checks.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
use anyhow::Result;
|
||||
|
||||
pub async fn cmd_check(_target: Option<&str>) -> Result<()> {
|
||||
todo!("cmd_check: concurrent health checks via reqwest + kube-rs")
|
||||
}
|
||||
582
src/cli.rs
Normal file
582
src/cli.rs
Normal file
@@ -0,0 +1,582 @@
|
||||
use anyhow::{bail, Result};
|
||||
use clap::{Parser, Subcommand, ValueEnum};
|
||||
|
||||
/// Sunbeam local dev stack manager.
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(name = "sunbeam", about = "Sunbeam local dev stack manager")]
|
||||
pub struct Cli {
|
||||
/// Target environment.
|
||||
#[arg(long, default_value = "local")]
|
||||
pub env: Env,
|
||||
|
||||
/// kubectl context override.
|
||||
#[arg(long)]
|
||||
pub context: Option<String>,
|
||||
|
||||
/// Domain suffix for production deploys (e.g. sunbeam.pt).
|
||||
#[arg(long, default_value = "")]
|
||||
pub domain: String,
|
||||
|
||||
/// ACME email for cert-manager (e.g. ops@sunbeam.pt).
|
||||
#[arg(long, default_value = "")]
|
||||
pub email: String,
|
||||
|
||||
#[command(subcommand)]
|
||||
pub verb: Option<Verb>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, ValueEnum)]
|
||||
pub enum Env {
|
||||
Local,
|
||||
Production,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Env {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Env::Local => write!(f, "local"),
|
||||
Env::Production => write!(f, "production"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum Verb {
|
||||
/// Full cluster bring-up.
|
||||
Up,
|
||||
|
||||
/// Pod health (optionally scoped).
|
||||
Status {
|
||||
/// namespace or namespace/name
|
||||
target: Option<String>,
|
||||
},
|
||||
|
||||
/// kustomize build + domain subst + kubectl apply.
|
||||
Apply {
|
||||
/// Limit apply to one namespace.
|
||||
namespace: Option<String>,
|
||||
/// Apply all namespaces without confirmation.
|
||||
#[arg(long = "all")]
|
||||
apply_all: bool,
|
||||
/// Domain suffix (e.g. sunbeam.pt).
|
||||
#[arg(long, default_value = "")]
|
||||
domain: String,
|
||||
/// ACME email for cert-manager.
|
||||
#[arg(long, default_value = "")]
|
||||
email: String,
|
||||
},
|
||||
|
||||
/// Generate/store all credentials in OpenBao.
|
||||
Seed,
|
||||
|
||||
/// E2E VSO + OpenBao integration test.
|
||||
Verify,
|
||||
|
||||
/// kubectl logs for a service.
|
||||
Logs {
|
||||
/// namespace/name
|
||||
target: String,
|
||||
/// Stream logs.
|
||||
#[arg(short, long)]
|
||||
follow: bool,
|
||||
},
|
||||
|
||||
/// Raw kubectl get for a pod (ns/name).
|
||||
Get {
|
||||
/// namespace/name
|
||||
target: String,
|
||||
/// Output format.
|
||||
#[arg(short, long, default_value = "yaml", value_parser = ["yaml", "json", "wide"])]
|
||||
output: String,
|
||||
},
|
||||
|
||||
/// Rolling restart of services.
|
||||
Restart {
|
||||
/// namespace or namespace/name
|
||||
target: Option<String>,
|
||||
},
|
||||
|
||||
/// Build an artifact.
|
||||
Build {
|
||||
/// What to build.
|
||||
what: BuildTarget,
|
||||
/// Push image to registry after building.
|
||||
#[arg(long)]
|
||||
push: bool,
|
||||
/// Apply manifests and rollout restart after pushing (implies --push).
|
||||
#[arg(long)]
|
||||
deploy: bool,
|
||||
},
|
||||
|
||||
/// Functional service health checks.
|
||||
Check {
|
||||
/// namespace or namespace/name
|
||||
target: Option<String>,
|
||||
},
|
||||
|
||||
/// Mirror amd64-only La Suite images.
|
||||
Mirror,
|
||||
|
||||
/// Create Gitea orgs/repos; bootstrap services.
|
||||
Bootstrap,
|
||||
|
||||
/// Manage sunbeam configuration.
|
||||
Config {
|
||||
#[command(subcommand)]
|
||||
action: Option<ConfigAction>,
|
||||
},
|
||||
|
||||
/// kubectl passthrough.
|
||||
K8s {
|
||||
/// arguments forwarded verbatim to kubectl
|
||||
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
|
||||
kubectl_args: Vec<String>,
|
||||
},
|
||||
|
||||
/// bao CLI passthrough (runs inside OpenBao pod with root token).
|
||||
Bao {
|
||||
/// arguments forwarded verbatim to bao
|
||||
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
|
||||
bao_args: Vec<String>,
|
||||
},
|
||||
|
||||
/// User/identity management.
|
||||
User {
|
||||
#[command(subcommand)]
|
||||
action: Option<UserAction>,
|
||||
},
|
||||
|
||||
/// Self-update from latest mainline commit.
|
||||
Update,
|
||||
|
||||
/// Print version info.
|
||||
Version,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, ValueEnum)]
|
||||
pub enum BuildTarget {
|
||||
Proxy,
|
||||
Integration,
|
||||
KratosAdmin,
|
||||
Meet,
|
||||
DocsFrontend,
|
||||
PeopleFrontend,
|
||||
People,
|
||||
Messages,
|
||||
MessagesBackend,
|
||||
MessagesFrontend,
|
||||
MessagesMtaIn,
|
||||
MessagesMtaOut,
|
||||
MessagesMpa,
|
||||
MessagesSocksProxy,
|
||||
Tuwunel,
|
||||
Calendars,
|
||||
Projects,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for BuildTarget {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let s = match self {
|
||||
BuildTarget::Proxy => "proxy",
|
||||
BuildTarget::Integration => "integration",
|
||||
BuildTarget::KratosAdmin => "kratos-admin",
|
||||
BuildTarget::Meet => "meet",
|
||||
BuildTarget::DocsFrontend => "docs-frontend",
|
||||
BuildTarget::PeopleFrontend => "people-frontend",
|
||||
BuildTarget::People => "people",
|
||||
BuildTarget::Messages => "messages",
|
||||
BuildTarget::MessagesBackend => "messages-backend",
|
||||
BuildTarget::MessagesFrontend => "messages-frontend",
|
||||
BuildTarget::MessagesMtaIn => "messages-mta-in",
|
||||
BuildTarget::MessagesMtaOut => "messages-mta-out",
|
||||
BuildTarget::MessagesMpa => "messages-mpa",
|
||||
BuildTarget::MessagesSocksProxy => "messages-socks-proxy",
|
||||
BuildTarget::Tuwunel => "tuwunel",
|
||||
BuildTarget::Calendars => "calendars",
|
||||
BuildTarget::Projects => "projects",
|
||||
};
|
||||
write!(f, "{s}")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum ConfigAction {
|
||||
/// Set configuration values.
|
||||
Set {
|
||||
/// Production SSH host (e.g. user@server.example.com).
|
||||
#[arg(long, default_value = "")]
|
||||
host: String,
|
||||
/// Infrastructure directory root.
|
||||
#[arg(long, default_value = "")]
|
||||
infra_dir: String,
|
||||
/// ACME email for Let's Encrypt certificates.
|
||||
#[arg(long, default_value = "")]
|
||||
acme_email: String,
|
||||
},
|
||||
/// Get current configuration.
|
||||
Get,
|
||||
/// Clear configuration.
|
||||
Clear,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum UserAction {
|
||||
/// List identities.
|
||||
List {
|
||||
/// Filter by email.
|
||||
#[arg(long, default_value = "")]
|
||||
search: String,
|
||||
},
|
||||
/// Get identity by email or ID.
|
||||
Get {
|
||||
/// Email or identity ID.
|
||||
target: String,
|
||||
},
|
||||
/// Create identity.
|
||||
Create {
|
||||
/// Email address.
|
||||
email: String,
|
||||
/// Display name.
|
||||
#[arg(long, default_value = "")]
|
||||
name: String,
|
||||
/// Schema ID.
|
||||
#[arg(long, default_value = "default")]
|
||||
schema: String,
|
||||
},
|
||||
/// Delete identity.
|
||||
Delete {
|
||||
/// Email or identity ID.
|
||||
target: String,
|
||||
},
|
||||
/// Generate recovery link.
|
||||
Recover {
|
||||
/// Email or identity ID.
|
||||
target: String,
|
||||
},
|
||||
/// Disable identity + revoke sessions (lockout).
|
||||
Disable {
|
||||
/// Email or identity ID.
|
||||
target: String,
|
||||
},
|
||||
/// Re-enable a disabled identity.
|
||||
Enable {
|
||||
/// Email or identity ID.
|
||||
target: String,
|
||||
},
|
||||
/// Set password for an identity.
|
||||
SetPassword {
|
||||
/// Email or identity ID.
|
||||
target: String,
|
||||
/// New password.
|
||||
password: String,
|
||||
},
|
||||
/// Onboard new user (create + welcome email).
|
||||
Onboard {
|
||||
/// Email address.
|
||||
email: String,
|
||||
/// Display name (First Last).
|
||||
#[arg(long, default_value = "")]
|
||||
name: String,
|
||||
/// Schema ID.
|
||||
#[arg(long, default_value = "employee")]
|
||||
schema: String,
|
||||
/// Skip sending welcome email.
|
||||
#[arg(long)]
|
||||
no_email: bool,
|
||||
/// Send welcome email to this address instead.
|
||||
#[arg(long, default_value = "")]
|
||||
notify: String,
|
||||
/// Job title.
|
||||
#[arg(long, default_value = "")]
|
||||
job_title: String,
|
||||
/// Department.
|
||||
#[arg(long, default_value = "")]
|
||||
department: String,
|
||||
/// Office location.
|
||||
#[arg(long, default_value = "")]
|
||||
office_location: String,
|
||||
/// Hire date (YYYY-MM-DD).
|
||||
#[arg(long, default_value = "", value_parser = validate_date)]
|
||||
hire_date: String,
|
||||
/// Manager name or email.
|
||||
#[arg(long, default_value = "")]
|
||||
manager: String,
|
||||
},
|
||||
/// Offboard user (disable + revoke all).
|
||||
Offboard {
|
||||
/// Email or identity ID.
|
||||
target: String,
|
||||
},
|
||||
}
|
||||
|
||||
fn validate_date(s: &str) -> Result<String, String> {
|
||||
if s.is_empty() {
|
||||
return Ok(s.to_string());
|
||||
}
|
||||
chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d")
|
||||
.map(|_| s.to_string())
|
||||
.map_err(|_| format!("Invalid date: '{s}' (expected YYYY-MM-DD)"))
|
||||
}
|
||||
|
||||
/// Default kubectl context per environment.
|
||||
fn default_context(env: &Env) -> &'static str {
|
||||
match env {
|
||||
Env::Local => "sunbeam",
|
||||
Env::Production => "production",
|
||||
}
|
||||
}
|
||||
|
||||
/// Main dispatch function — parse CLI args and route to subcommands.
|
||||
pub async fn dispatch() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
|
||||
let ctx = cli
|
||||
.context
|
||||
.as_deref()
|
||||
.unwrap_or_else(|| default_context(&cli.env));
|
||||
|
||||
// For production, resolve SSH host
|
||||
let ssh_host = match cli.env {
|
||||
Env::Production => {
|
||||
let host = crate::config::get_production_host();
|
||||
if host.is_empty() {
|
||||
bail!(
|
||||
"Production host not configured. \
|
||||
Use `sunbeam config set --host` or set SUNBEAM_SSH_HOST."
|
||||
);
|
||||
}
|
||||
Some(host)
|
||||
}
|
||||
Env::Local => None,
|
||||
};
|
||||
|
||||
// Initialize kube context
|
||||
crate::kube::set_context(ctx, ssh_host.as_deref().unwrap_or(""));
|
||||
|
||||
match cli.verb {
|
||||
None => {
|
||||
// Print help via clap
|
||||
use clap::CommandFactory;
|
||||
Cli::command().print_help()?;
|
||||
println!();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Some(Verb::Up) => crate::cluster::cmd_up().await,
|
||||
|
||||
Some(Verb::Status { target }) => {
|
||||
crate::services::cmd_status(target.as_deref()).await
|
||||
}
|
||||
|
||||
Some(Verb::Apply {
|
||||
namespace,
|
||||
apply_all,
|
||||
domain,
|
||||
email,
|
||||
}) => {
|
||||
let env_str = cli.env.to_string();
|
||||
let domain = if domain.is_empty() {
|
||||
cli.domain.clone()
|
||||
} else {
|
||||
domain
|
||||
};
|
||||
let email = if email.is_empty() {
|
||||
cli.email.clone()
|
||||
} else {
|
||||
email
|
||||
};
|
||||
let ns = namespace.unwrap_or_default();
|
||||
|
||||
// Production full-apply requires --all or confirmation
|
||||
if matches!(cli.env, Env::Production) && ns.is_empty() && !apply_all {
|
||||
crate::output::warn(
|
||||
"This will apply ALL namespaces to production.",
|
||||
);
|
||||
eprint!(" Continue? [y/N] ");
|
||||
let mut answer = String::new();
|
||||
std::io::stdin().read_line(&mut answer)?;
|
||||
if !matches!(answer.trim().to_lowercase().as_str(), "y" | "yes") {
|
||||
println!("Aborted.");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
crate::manifests::cmd_apply(&env_str, &domain, &email, &ns).await
|
||||
}
|
||||
|
||||
Some(Verb::Seed) => crate::secrets::cmd_seed().await,
|
||||
|
||||
Some(Verb::Verify) => crate::secrets::cmd_verify().await,
|
||||
|
||||
Some(Verb::Logs { target, follow }) => {
|
||||
crate::services::cmd_logs(&target, follow).await
|
||||
}
|
||||
|
||||
Some(Verb::Get { target, output }) => {
|
||||
crate::services::cmd_get(&target, &output).await
|
||||
}
|
||||
|
||||
Some(Verb::Restart { target }) => {
|
||||
crate::services::cmd_restart(target.as_deref()).await
|
||||
}
|
||||
|
||||
Some(Verb::Build { what, push, deploy }) => {
|
||||
let push = push || deploy;
|
||||
crate::images::cmd_build(&what, push, deploy).await
|
||||
}
|
||||
|
||||
Some(Verb::Check { target }) => {
|
||||
crate::checks::cmd_check(target.as_deref()).await
|
||||
}
|
||||
|
||||
Some(Verb::Mirror) => crate::images::cmd_mirror().await,
|
||||
|
||||
Some(Verb::Bootstrap) => crate::gitea::cmd_bootstrap().await,
|
||||
|
||||
Some(Verb::Config { action }) => match action {
|
||||
None => {
|
||||
use clap::CommandFactory;
|
||||
// Print config subcommand help
|
||||
let mut cmd = Cli::command();
|
||||
let sub = cmd
|
||||
.find_subcommand_mut("config")
|
||||
.expect("config subcommand");
|
||||
sub.print_help()?;
|
||||
println!();
|
||||
Ok(())
|
||||
}
|
||||
Some(ConfigAction::Set {
|
||||
host,
|
||||
infra_dir,
|
||||
acme_email,
|
||||
}) => {
|
||||
let mut config = crate::config::load_config();
|
||||
if !host.is_empty() {
|
||||
config.production_host = host;
|
||||
}
|
||||
if !infra_dir.is_empty() {
|
||||
config.infra_directory = infra_dir;
|
||||
}
|
||||
if !acme_email.is_empty() {
|
||||
config.acme_email = acme_email;
|
||||
}
|
||||
crate::config::save_config(&config)
|
||||
}
|
||||
Some(ConfigAction::Get) => {
|
||||
let config = crate::config::load_config();
|
||||
let host_display = if config.production_host.is_empty() {
|
||||
"(not set)"
|
||||
} else {
|
||||
&config.production_host
|
||||
};
|
||||
let infra_display = if config.infra_directory.is_empty() {
|
||||
"(not set)"
|
||||
} else {
|
||||
&config.infra_directory
|
||||
};
|
||||
let email_display = if config.acme_email.is_empty() {
|
||||
"(not set)"
|
||||
} else {
|
||||
&config.acme_email
|
||||
};
|
||||
crate::output::ok(&format!("Production host: {host_display}"));
|
||||
crate::output::ok(&format!(
|
||||
"Infrastructure directory: {infra_display}"
|
||||
));
|
||||
crate::output::ok(&format!("ACME email: {email_display}"));
|
||||
|
||||
let effective = crate::config::get_production_host();
|
||||
if !effective.is_empty() {
|
||||
crate::output::ok(&format!(
|
||||
"Effective production host: {effective}"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Some(ConfigAction::Clear) => crate::config::clear_config(),
|
||||
},
|
||||
|
||||
Some(Verb::K8s { kubectl_args }) => {
|
||||
crate::kube::cmd_k8s(&kubectl_args).await
|
||||
}
|
||||
|
||||
Some(Verb::Bao { bao_args }) => {
|
||||
crate::kube::cmd_bao(&bao_args).await
|
||||
}
|
||||
|
||||
Some(Verb::User { action }) => match action {
|
||||
None => {
|
||||
use clap::CommandFactory;
|
||||
let mut cmd = Cli::command();
|
||||
let sub = cmd
|
||||
.find_subcommand_mut("user")
|
||||
.expect("user subcommand");
|
||||
sub.print_help()?;
|
||||
println!();
|
||||
Ok(())
|
||||
}
|
||||
Some(UserAction::List { search }) => {
|
||||
crate::users::cmd_user_list(&search).await
|
||||
}
|
||||
Some(UserAction::Get { target }) => {
|
||||
crate::users::cmd_user_get(&target).await
|
||||
}
|
||||
Some(UserAction::Create {
|
||||
email,
|
||||
name,
|
||||
schema,
|
||||
}) => crate::users::cmd_user_create(&email, &name, &schema).await,
|
||||
Some(UserAction::Delete { target }) => {
|
||||
crate::users::cmd_user_delete(&target).await
|
||||
}
|
||||
Some(UserAction::Recover { target }) => {
|
||||
crate::users::cmd_user_recover(&target).await
|
||||
}
|
||||
Some(UserAction::Disable { target }) => {
|
||||
crate::users::cmd_user_disable(&target).await
|
||||
}
|
||||
Some(UserAction::Enable { target }) => {
|
||||
crate::users::cmd_user_enable(&target).await
|
||||
}
|
||||
Some(UserAction::SetPassword { target, password }) => {
|
||||
crate::users::cmd_user_set_password(&target, &password).await
|
||||
}
|
||||
Some(UserAction::Onboard {
|
||||
email,
|
||||
name,
|
||||
schema,
|
||||
no_email,
|
||||
notify,
|
||||
job_title,
|
||||
department,
|
||||
office_location,
|
||||
hire_date,
|
||||
manager,
|
||||
}) => {
|
||||
crate::users::cmd_user_onboard(
|
||||
&email,
|
||||
&name,
|
||||
&schema,
|
||||
!no_email,
|
||||
¬ify,
|
||||
&job_title,
|
||||
&department,
|
||||
&office_location,
|
||||
&hire_date,
|
||||
&manager,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Some(UserAction::Offboard { target }) => {
|
||||
crate::users::cmd_user_offboard(&target).await
|
||||
}
|
||||
},
|
||||
|
||||
Some(Verb::Update) => crate::update::cmd_update().await,
|
||||
|
||||
Some(Verb::Version) => {
|
||||
crate::update::cmd_version();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
5
src/cluster.rs
Normal file
5
src/cluster.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
use anyhow::Result;
|
||||
|
||||
pub async fn cmd_up() -> Result<()> {
|
||||
todo!("cmd_up: full cluster bring-up via kube-rs")
|
||||
}
|
||||
153
src/config.rs
Normal file
153
src/config.rs
Normal file
@@ -0,0 +1,153 @@
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Sunbeam configuration stored at ~/.sunbeam.json.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct SunbeamConfig {
|
||||
#[serde(default)]
|
||||
pub production_host: String,
|
||||
#[serde(default)]
|
||||
pub infra_directory: String,
|
||||
#[serde(default)]
|
||||
pub acme_email: String,
|
||||
}
|
||||
|
||||
fn config_path() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".sunbeam.json")
|
||||
}
|
||||
|
||||
/// Load configuration from ~/.sunbeam.json, return default if not found.
|
||||
pub fn load_config() -> SunbeamConfig {
|
||||
let path = config_path();
|
||||
if !path.exists() {
|
||||
return SunbeamConfig::default();
|
||||
}
|
||||
match std::fs::read_to_string(&path) {
|
||||
Ok(content) => serde_json::from_str(&content).unwrap_or_else(|e| {
|
||||
crate::output::warn(&format!(
|
||||
"Failed to parse config from {}: {e}",
|
||||
path.display()
|
||||
));
|
||||
SunbeamConfig::default()
|
||||
}),
|
||||
Err(e) => {
|
||||
crate::output::warn(&format!(
|
||||
"Failed to read config from {}: {e}",
|
||||
path.display()
|
||||
));
|
||||
SunbeamConfig::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Save configuration to ~/.sunbeam.json.
|
||||
pub fn save_config(config: &SunbeamConfig) -> Result<()> {
|
||||
let path = config_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).with_context(|| {
|
||||
format!(
|
||||
"Failed to create config directory: {}",
|
||||
parent.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
let content = serde_json::to_string_pretty(config)?;
|
||||
std::fs::write(&path, content)
|
||||
.with_context(|| format!("Failed to save config to {}", path.display()))?;
|
||||
crate::output::ok(&format!("Configuration saved to {}", path.display()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get production host from config or SUNBEAM_SSH_HOST environment variable.
|
||||
pub fn get_production_host() -> String {
|
||||
let config = load_config();
|
||||
if !config.production_host.is_empty() {
|
||||
return config.production_host;
|
||||
}
|
||||
std::env::var("SUNBEAM_SSH_HOST").unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Get infrastructure directory from config.
|
||||
pub fn get_infra_directory() -> String {
|
||||
load_config().infra_directory
|
||||
}
|
||||
|
||||
/// Infrastructure manifests directory as a Path.
|
||||
///
|
||||
/// Prefers the configured infra_directory; falls back to a path relative to
|
||||
/// the current executable (works when running from the development checkout).
|
||||
pub fn get_infra_dir() -> PathBuf {
|
||||
let configured = load_config().infra_directory;
|
||||
if !configured.is_empty() {
|
||||
return PathBuf::from(configured);
|
||||
}
|
||||
// Dev fallback: walk up from the executable to find monorepo root
|
||||
std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|p| p.canonicalize().ok())
|
||||
.and_then(|p| {
|
||||
let mut dir = p.as_path();
|
||||
for _ in 0..10 {
|
||||
dir = dir.parent()?;
|
||||
if dir.join("infrastructure").is_dir() {
|
||||
return Some(dir.join("infrastructure"));
|
||||
}
|
||||
}
|
||||
None
|
||||
})
|
||||
.unwrap_or_else(|| PathBuf::from("infrastructure"))
|
||||
}
|
||||
|
||||
/// Monorepo root directory (parent of the infrastructure directory).
|
||||
pub fn get_repo_root() -> PathBuf {
|
||||
get_infra_dir()
|
||||
.parent()
|
||||
.map(|p| p.to_path_buf())
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
}
|
||||
|
||||
/// Clear configuration file.
|
||||
pub fn clear_config() -> Result<()> {
|
||||
let path = config_path();
|
||||
if path.exists() {
|
||||
std::fs::remove_file(&path)
|
||||
.with_context(|| format!("Failed to remove {}", path.display()))?;
|
||||
crate::output::ok(&format!(
|
||||
"Configuration cleared from {}",
|
||||
path.display()
|
||||
));
|
||||
} else {
|
||||
crate::output::warn("No configuration file found to clear");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_default_config() {
|
||||
let config = SunbeamConfig::default();
|
||||
assert!(config.production_host.is_empty());
|
||||
assert!(config.infra_directory.is_empty());
|
||||
assert!(config.acme_email.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_roundtrip() {
|
||||
let config = SunbeamConfig {
|
||||
production_host: "user@example.com".to_string(),
|
||||
infra_directory: "/path/to/infra".to_string(),
|
||||
acme_email: "ops@example.com".to_string(),
|
||||
};
|
||||
let json = serde_json::to_string(&config).unwrap();
|
||||
let loaded: SunbeamConfig = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(loaded.production_host, "user@example.com");
|
||||
assert_eq!(loaded.infra_directory, "/path/to/infra");
|
||||
assert_eq!(loaded.acme_email, "ops@example.com");
|
||||
}
|
||||
}
|
||||
5
src/gitea.rs
Normal file
5
src/gitea.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
use anyhow::Result;
|
||||
|
||||
pub async fn cmd_bootstrap() -> Result<()> {
|
||||
todo!("cmd_bootstrap: Gitea admin + org setup via kube-rs exec + reqwest")
|
||||
}
|
||||
10
src/images.rs
Normal file
10
src/images.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
use crate::cli::BuildTarget;
|
||||
use anyhow::Result;
|
||||
|
||||
pub async fn cmd_build(_what: &BuildTarget, _push: bool, _deploy: bool) -> Result<()> {
|
||||
todo!("cmd_build: BuildKit gRPC builds")
|
||||
}
|
||||
|
||||
pub async fn cmd_mirror() -> Result<()> {
|
||||
todo!("cmd_mirror: containerd-client + reqwest mirror")
|
||||
}
|
||||
107
src/kube.rs
Normal file
107
src/kube.rs
Normal file
@@ -0,0 +1,107 @@
|
||||
use anyhow::{bail, Result};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
static CONTEXT: OnceLock<String> = OnceLock::new();
|
||||
static SSH_HOST: OnceLock<String> = OnceLock::new();
|
||||
|
||||
/// Set the active kubectl context and optional SSH host for production tunnel.
|
||||
pub fn set_context(ctx: &str, ssh_host: &str) {
|
||||
let _ = CONTEXT.set(ctx.to_string());
|
||||
let _ = SSH_HOST.set(ssh_host.to_string());
|
||||
}
|
||||
|
||||
/// Get the active context.
|
||||
pub fn context() -> &'static str {
|
||||
CONTEXT.get().map(|s| s.as_str()).unwrap_or("sunbeam")
|
||||
}
|
||||
|
||||
/// Get the SSH host (empty for local).
|
||||
pub fn ssh_host() -> &'static str {
|
||||
SSH_HOST.get().map(|s| s.as_str()).unwrap_or("")
|
||||
}
|
||||
|
||||
/// Parse 'ns/name' -> (Some(ns), Some(name)), 'ns' -> (Some(ns), None), None -> (None, None).
|
||||
pub fn parse_target(s: Option<&str>) -> Result<(Option<&str>, Option<&str>)> {
|
||||
match s {
|
||||
None => Ok((None, None)),
|
||||
Some(s) => {
|
||||
let parts: Vec<&str> = s.splitn(3, '/').collect();
|
||||
match parts.len() {
|
||||
1 => Ok((Some(parts[0]), None)),
|
||||
2 => Ok((Some(parts[0]), Some(parts[1]))),
|
||||
_ => bail!("Invalid target {s:?}: expected 'namespace' or 'namespace/name'"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace all occurrences of DOMAIN_SUFFIX with domain.
|
||||
pub fn domain_replace(text: &str, domain: &str) -> String {
|
||||
text.replace("DOMAIN_SUFFIX", domain)
|
||||
}
|
||||
|
||||
/// Transparent kubectl passthrough for the active context.
|
||||
pub async fn cmd_k8s(_kubectl_args: &[String]) -> Result<()> {
|
||||
todo!("cmd_k8s: kubectl passthrough via kube-rs")
|
||||
}
|
||||
|
||||
/// Run bao CLI inside the OpenBao pod with the root token.
|
||||
pub async fn cmd_bao(_bao_args: &[String]) -> Result<()> {
|
||||
todo!("cmd_bao: bao passthrough via kube-rs exec")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_target_none() {
|
||||
let (ns, name) = parse_target(None).unwrap();
|
||||
assert!(ns.is_none());
|
||||
assert!(name.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_target_namespace_only() {
|
||||
let (ns, name) = parse_target(Some("ory")).unwrap();
|
||||
assert_eq!(ns, Some("ory"));
|
||||
assert!(name.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_target_namespace_and_name() {
|
||||
let (ns, name) = parse_target(Some("ory/kratos")).unwrap();
|
||||
assert_eq!(ns, Some("ory"));
|
||||
assert_eq!(name, Some("kratos"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_target_too_many_parts() {
|
||||
assert!(parse_target(Some("too/many/parts")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_target_empty_string() {
|
||||
let (ns, name) = parse_target(Some("")).unwrap();
|
||||
assert_eq!(ns, Some(""));
|
||||
assert!(name.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_domain_replace_single() {
|
||||
let result = domain_replace("src.DOMAIN_SUFFIX/foo", "192.168.1.1.sslip.io");
|
||||
assert_eq!(result, "src.192.168.1.1.sslip.io/foo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_domain_replace_multiple() {
|
||||
let result = domain_replace("DOMAIN_SUFFIX and DOMAIN_SUFFIX", "x.sslip.io");
|
||||
assert_eq!(result, "x.sslip.io and x.sslip.io");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_domain_replace_none() {
|
||||
let result = domain_replace("no match here", "x.sslip.io");
|
||||
assert_eq!(result, "no match here");
|
||||
}
|
||||
}
|
||||
28
src/main.rs
Normal file
28
src/main.rs
Normal file
@@ -0,0 +1,28 @@
|
||||
mod checks;
|
||||
mod cli;
|
||||
mod cluster;
|
||||
mod config;
|
||||
mod gitea;
|
||||
mod images;
|
||||
mod kube;
|
||||
mod manifests;
|
||||
mod output;
|
||||
mod secrets;
|
||||
mod services;
|
||||
mod tools;
|
||||
mod update;
|
||||
mod users;
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
if let Err(e) = run().await {
|
||||
eprintln!("\nERROR: {e:#}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async fn run() -> Result<()> {
|
||||
cli::dispatch().await
|
||||
}
|
||||
152
src/manifests.rs
Normal file
152
src/manifests.rs
Normal file
@@ -0,0 +1,152 @@
|
||||
use anyhow::Result;
|
||||
|
||||
pub const MANAGED_NS: &[&str] = &[
|
||||
"data",
|
||||
"devtools",
|
||||
"ingress",
|
||||
"lasuite",
|
||||
"matrix",
|
||||
"media",
|
||||
"monitoring",
|
||||
"ory",
|
||||
"storage",
|
||||
"vault-secrets-operator",
|
||||
];
|
||||
|
||||
/// Return only the YAML documents that belong to the given namespace.
|
||||
pub fn filter_by_namespace(manifests: &str, namespace: &str) -> String {
|
||||
let mut kept = Vec::new();
|
||||
for doc in manifests.split("\n---") {
|
||||
let doc = doc.trim();
|
||||
if doc.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let has_ns = doc.contains(&format!("namespace: {namespace}"));
|
||||
let is_ns_resource =
|
||||
doc.contains("kind: Namespace") && doc.contains(&format!("name: {namespace}"));
|
||||
if has_ns || is_ns_resource {
|
||||
kept.push(doc);
|
||||
}
|
||||
}
|
||||
if kept.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
format!("---\n{}\n", kept.join("\n---\n"))
|
||||
}
|
||||
|
||||
pub async fn cmd_apply(_env: &str, _domain: &str, _email: &str, _namespace: &str) -> Result<()> {
|
||||
todo!("cmd_apply: kustomize build + kube-rs apply pipeline")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const MULTI_DOC: &str = "\
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: meet-config
|
||||
namespace: lasuite
|
||||
data:
|
||||
FOO: bar
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: meet-backend
|
||||
namespace: lasuite
|
||||
spec:
|
||||
replicas: 1
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: lasuite
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: pingora-config
|
||||
namespace: ingress
|
||||
data:
|
||||
config.toml: |
|
||||
hello
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: pingora
|
||||
namespace: ingress
|
||||
spec:
|
||||
replicas: 1
|
||||
";
|
||||
|
||||
#[test]
|
||||
fn test_keeps_matching_namespace() {
|
||||
let result = filter_by_namespace(MULTI_DOC, "lasuite");
|
||||
assert!(result.contains("name: meet-config"));
|
||||
assert!(result.contains("name: meet-backend"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_excludes_other_namespaces() {
|
||||
let result = filter_by_namespace(MULTI_DOC, "lasuite");
|
||||
assert!(!result.contains("namespace: ingress"));
|
||||
assert!(!result.contains("name: pingora-config"));
|
||||
assert!(!result.contains("name: pingora\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_includes_namespace_resource_itself() {
|
||||
let result = filter_by_namespace(MULTI_DOC, "lasuite");
|
||||
assert!(result.contains("kind: Namespace"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ingress_filter() {
|
||||
let result = filter_by_namespace(MULTI_DOC, "ingress");
|
||||
assert!(result.contains("name: pingora-config"));
|
||||
assert!(result.contains("name: pingora"));
|
||||
assert!(!result.contains("namespace: lasuite"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unknown_namespace_returns_empty() {
|
||||
let result = filter_by_namespace(MULTI_DOC, "nonexistent");
|
||||
assert!(result.trim().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_input_returns_empty() {
|
||||
let result = filter_by_namespace("", "lasuite");
|
||||
assert!(result.trim().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_result_starts_with_separator() {
|
||||
let result = filter_by_namespace(MULTI_DOC, "lasuite");
|
||||
assert!(result.starts_with("---"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_does_not_include_namespace_resource_for_wrong_ns() {
|
||||
let result = filter_by_namespace(MULTI_DOC, "ingress");
|
||||
assert!(!result.contains("kind: Namespace"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_single_doc_matching() {
|
||||
let doc = "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: x\n namespace: ory\n";
|
||||
let result = filter_by_namespace(doc, "ory");
|
||||
assert!(result.contains("name: x"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_single_doc_not_matching() {
|
||||
let doc = "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: x\n namespace: ory\n";
|
||||
let result = filter_by_namespace(doc, "lasuite");
|
||||
assert!(result.trim().is_empty());
|
||||
}
|
||||
}
|
||||
92
src/output.rs
Normal file
92
src/output.rs
Normal file
@@ -0,0 +1,92 @@
|
||||
/// Print a step header.
|
||||
pub fn step(msg: &str) {
|
||||
println!("\n==> {msg}");
|
||||
}
|
||||
|
||||
/// Print a success/info line.
|
||||
pub fn ok(msg: &str) {
|
||||
println!(" {msg}");
|
||||
}
|
||||
|
||||
/// Print a warning to stderr.
|
||||
pub fn warn(msg: &str) {
|
||||
eprintln!(" WARN: {msg}");
|
||||
}
|
||||
|
||||
/// Return an aligned text table. Columns padded to max width.
|
||||
pub fn table(rows: &[Vec<String>], headers: &[&str]) -> String {
|
||||
if headers.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let mut col_widths: Vec<usize> = headers.iter().map(|h| h.len()).collect();
|
||||
for row in rows {
|
||||
for (i, cell) in row.iter().enumerate() {
|
||||
if i < col_widths.len() {
|
||||
col_widths[i] = col_widths[i].max(cell.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let header_line: String = headers
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, h)| format!("{:<width$}", h, width = col_widths[i]))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
|
||||
let separator: String = col_widths
|
||||
.iter()
|
||||
.map(|&w| "-".repeat(w))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
|
||||
let mut lines = vec![header_line, separator];
|
||||
|
||||
for row in rows {
|
||||
let cells: Vec<String> = (0..headers.len())
|
||||
.map(|i| {
|
||||
let val = row.get(i).map(|s| s.as_str()).unwrap_or("");
|
||||
format!("{:<width$}", val, width = col_widths[i])
|
||||
})
|
||||
.collect();
|
||||
lines.push(cells.join(" "));
|
||||
}
|
||||
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_table_basic() {
|
||||
let rows = vec![
|
||||
vec!["abc".to_string(), "def".to_string()],
|
||||
vec!["x".to_string(), "longer".to_string()],
|
||||
];
|
||||
let result = table(&rows, &["Col1", "Col2"]);
|
||||
assert!(result.contains("Col1"));
|
||||
assert!(result.contains("Col2"));
|
||||
assert!(result.contains("abc"));
|
||||
assert!(result.contains("longer"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_table_empty_headers() {
|
||||
let result = table(&[], &[]);
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_table_column_widths() {
|
||||
let rows = vec![vec!["short".to_string(), "x".to_string()]];
|
||||
let result = table(&rows, &["LongHeader", "H2"]);
|
||||
// Header should set minimum width
|
||||
for line in result.lines().skip(2) {
|
||||
// Data row: "short" should be padded to "LongHeader" width
|
||||
assert!(line.starts_with("short "));
|
||||
}
|
||||
}
|
||||
}
|
||||
9
src/secrets.rs
Normal file
9
src/secrets.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
use anyhow::Result;
|
||||
|
||||
pub async fn cmd_seed() -> Result<()> {
|
||||
todo!("cmd_seed: OpenBao KV seeding via HTTP API")
|
||||
}
|
||||
|
||||
pub async fn cmd_verify() -> Result<()> {
|
||||
todo!("cmd_verify: VSO E2E verification via kube-rs")
|
||||
}
|
||||
17
src/services.rs
Normal file
17
src/services.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
use anyhow::Result;
|
||||
|
||||
pub async fn cmd_status(_target: Option<&str>) -> Result<()> {
|
||||
todo!("cmd_status: pod health via kube-rs")
|
||||
}
|
||||
|
||||
pub async fn cmd_logs(_target: &str, _follow: bool) -> Result<()> {
|
||||
todo!("cmd_logs: stream pod logs via kube-rs")
|
||||
}
|
||||
|
||||
pub async fn cmd_get(_target: &str, _output: &str) -> Result<()> {
|
||||
todo!("cmd_get: get pod via kube-rs")
|
||||
}
|
||||
|
||||
pub async fn cmd_restart(_target: Option<&str>) -> Result<()> {
|
||||
todo!("cmd_restart: rollout restart via kube-rs")
|
||||
}
|
||||
51
src/tools.rs
Normal file
51
src/tools.rs
Normal file
@@ -0,0 +1,51 @@
|
||||
use anyhow::{Context, Result};
|
||||
use std::path::PathBuf;
|
||||
|
||||
static KUSTOMIZE_BIN: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/kustomize"));
|
||||
static HELM_BIN: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/helm"));
|
||||
|
||||
fn cache_dir() -> PathBuf {
|
||||
dirs::data_dir()
|
||||
.unwrap_or_else(|| dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")))
|
||||
.join("sunbeam")
|
||||
.join("bin")
|
||||
}
|
||||
|
||||
/// Extract an embedded binary to the cache directory if not already present.
|
||||
fn extract_embedded(data: &[u8], name: &str) -> Result<PathBuf> {
|
||||
let dir = cache_dir();
|
||||
std::fs::create_dir_all(&dir)
|
||||
.with_context(|| format!("Failed to create cache dir: {}", dir.display()))?;
|
||||
|
||||
let dest = dir.join(name);
|
||||
|
||||
// Skip if already extracted and same size
|
||||
if dest.exists() {
|
||||
if let Ok(meta) = std::fs::metadata(&dest) {
|
||||
if meta.len() == data.len() as u64 {
|
||||
return Ok(dest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::fs::write(&dest, data)
|
||||
.with_context(|| format!("Failed to write {}", dest.display()))?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&dest, std::fs::Permissions::from_mode(0o755))?;
|
||||
}
|
||||
|
||||
Ok(dest)
|
||||
}
|
||||
|
||||
/// Ensure kustomize is extracted and return its path.
|
||||
pub fn ensure_kustomize() -> Result<PathBuf> {
|
||||
extract_embedded(KUSTOMIZE_BIN, "kustomize")
|
||||
}
|
||||
|
||||
/// Ensure helm is extracted and return its path.
|
||||
pub fn ensure_helm() -> Result<PathBuf> {
|
||||
extract_embedded(HELM_BIN, "helm")
|
||||
}
|
||||
12
src/update.rs
Normal file
12
src/update.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
use anyhow::Result;
|
||||
|
||||
/// Compile-time commit SHA set by build.rs.
|
||||
pub const COMMIT: &str = env!("SUNBEAM_COMMIT");
|
||||
|
||||
pub async fn cmd_update() -> Result<()> {
|
||||
todo!("cmd_update: self-update from latest mainline commit via Gitea API")
|
||||
}
|
||||
|
||||
pub fn cmd_version() {
|
||||
println!("sunbeam {COMMIT}");
|
||||
}
|
||||
53
src/users.rs
Normal file
53
src/users.rs
Normal file
@@ -0,0 +1,53 @@
|
||||
use anyhow::Result;
|
||||
|
||||
pub async fn cmd_user_list(_search: &str) -> Result<()> {
|
||||
todo!("cmd_user_list: ory-kratos-client SDK")
|
||||
}
|
||||
|
||||
pub async fn cmd_user_get(_target: &str) -> Result<()> {
|
||||
todo!("cmd_user_get: ory-kratos-client SDK")
|
||||
}
|
||||
|
||||
pub async fn cmd_user_create(_email: &str, _name: &str, _schema_id: &str) -> Result<()> {
|
||||
todo!("cmd_user_create: ory-kratos-client SDK")
|
||||
}
|
||||
|
||||
pub async fn cmd_user_delete(_target: &str) -> Result<()> {
|
||||
todo!("cmd_user_delete: ory-kratos-client SDK")
|
||||
}
|
||||
|
||||
pub async fn cmd_user_recover(_target: &str) -> Result<()> {
|
||||
todo!("cmd_user_recover: ory-kratos-client SDK")
|
||||
}
|
||||
|
||||
pub async fn cmd_user_disable(_target: &str) -> Result<()> {
|
||||
todo!("cmd_user_disable: ory-kratos-client SDK")
|
||||
}
|
||||
|
||||
pub async fn cmd_user_enable(_target: &str) -> Result<()> {
|
||||
todo!("cmd_user_enable: ory-kratos-client SDK")
|
||||
}
|
||||
|
||||
pub async fn cmd_user_set_password(_target: &str, _password: &str) -> Result<()> {
|
||||
todo!("cmd_user_set_password: ory-kratos-client SDK")
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn cmd_user_onboard(
|
||||
_email: &str,
|
||||
_name: &str,
|
||||
_schema_id: &str,
|
||||
_send_email: bool,
|
||||
_notify: &str,
|
||||
_job_title: &str,
|
||||
_department: &str,
|
||||
_office_location: &str,
|
||||
_hire_date: &str,
|
||||
_manager: &str,
|
||||
) -> Result<()> {
|
||||
todo!("cmd_user_onboard: ory-kratos-client SDK + lettre SMTP")
|
||||
}
|
||||
|
||||
pub async fn cmd_user_offboard(_target: &str) -> Result<()> {
|
||||
todo!("cmd_user_offboard: ory-kratos-client + ory-hydra-client SDK")
|
||||
}
|
||||
Reference in New Issue
Block a user