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:
2026-03-20 12:24:21 +00:00
parent d5b963253b
commit 80c67d34cb
19 changed files with 6262 additions and 0 deletions

3
.gitignore vendored
View File

@@ -5,3 +5,6 @@ __pycache__/
dist/
build/
.eggs/
# Rust
/target/

4795
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

56
Cargo.toml Normal file
View 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
View 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
View 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
View 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,
&notify,
&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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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")
}