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/
|
dist/
|
||||||
build/
|
build/
|
||||||
.eggs/
|
.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