feat(cli): wire sunbeam-net into sunbeam connect/disconnect/vpn
Adds the foreground VPN client commands. The daemon runs in-process
inside the CLI for the lifetime of `sunbeam connect` — no separate
background daemon yet, that can come later if needed.
- Cargo.toml: add sunbeam-net as a workspace dep, plus hostname/whoami
for building a per-machine netmap label like "sienna@laptop"
- src/config.rs: new `vpn-url` and `vpn-auth-key` fields on Context
- src/cli.rs: `Connect`, `Disconnect`, and `Vpn { Status }` verbs
- src/vpn_cmds.rs: command handlers
- cmd_connect reads VPN config from the active context, starts the
daemon at ~/.sunbeam/vpn, polls for Running, then blocks on ^C
before calling DaemonHandle::shutdown
- cmd_disconnect / cmd_vpn_status are placeholders that report based
on the control socket; actually talking to a backgrounded daemon
needs an IPC client (not yet exposed from sunbeam-net)
- src/workflows/mod.rs: `..Default::default()` on Context literals so
the new fields don't break the existing tests
This commit is contained in:
62
Cargo.lock
generated
62
Cargo.lock
generated
@@ -1471,7 +1471,7 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"wasi",
|
||||
"wasi 0.11.1+wasi-snapshot-preview1",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
@@ -2463,7 +2463,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"wasi",
|
||||
"wasi 0.11.1+wasi-snapshot-preview1",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
@@ -2586,6 +2586,24 @@ version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3"
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-foundation"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-system-configuration"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396"
|
||||
dependencies = [
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "object"
|
||||
version = "0.37.3"
|
||||
@@ -4158,7 +4176,7 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
"tracing",
|
||||
"uuid",
|
||||
"whoami",
|
||||
"whoami 1.6.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4197,7 +4215,7 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
"tracing",
|
||||
"uuid",
|
||||
"whoami",
|
||||
"whoami 1.6.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4334,6 +4352,7 @@ dependencies = [
|
||||
"flate2",
|
||||
"futures",
|
||||
"hmac",
|
||||
"hostname",
|
||||
"indicatif",
|
||||
"k8s-openapi",
|
||||
"kube",
|
||||
@@ -4351,6 +4370,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"sha2",
|
||||
"sunbeam-net",
|
||||
"sunbeam-sdk",
|
||||
"tar",
|
||||
"tempfile",
|
||||
@@ -4364,6 +4384,7 @@ dependencies = [
|
||||
"wfe-core",
|
||||
"wfe-sqlite",
|
||||
"wfe-yaml",
|
||||
"whoami 2.1.1",
|
||||
"wiremock",
|
||||
]
|
||||
|
||||
@@ -5028,6 +5049,15 @@ version = "0.11.1+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.14.7+wasi-0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c"
|
||||
dependencies = [
|
||||
"wasip2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasip2"
|
||||
version = "1.0.2+wasi-0.2.9"
|
||||
@@ -5052,6 +5082,15 @@ version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
|
||||
|
||||
[[package]]
|
||||
name = "wasite"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42"
|
||||
dependencies = [
|
||||
"wasi 0.14.7+wasi-0.2.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.114"
|
||||
@@ -5272,7 +5311,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d"
|
||||
dependencies = [
|
||||
"libredox",
|
||||
"wasite",
|
||||
"wasite 0.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "whoami"
|
||||
version = "2.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6a5b12f9df4f978d2cfdb1bd3bac52433f44393342d7ee9c25f5a1c14c0f45d"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"libredox",
|
||||
"objc2-system-configuration",
|
||||
"wasite 1.0.2",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -15,6 +15,10 @@ members = ["sunbeam-sdk", "sunbeam-net"]
|
||||
resolver = "3"
|
||||
|
||||
[dependencies]
|
||||
# Workspace
|
||||
sunbeam-sdk = { path = "sunbeam-sdk" }
|
||||
sunbeam-net = { path = "sunbeam-net" }
|
||||
|
||||
# Core
|
||||
thiserror = "2"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
@@ -78,6 +82,8 @@ wfe-core = { version = "1.6.3", registry = "sunbeam", features = ["test-support"
|
||||
wfe-sqlite = { version = "1.6.3", registry = "sunbeam" }
|
||||
wfe-yaml = { version = "1.6.3", registry = "sunbeam" }
|
||||
async-trait = "0.1"
|
||||
hostname = "0.4.2"
|
||||
whoami = "2.1.1"
|
||||
|
||||
[dev-dependencies]
|
||||
wiremock = "0.6"
|
||||
|
||||
146
src/cli.rs
146
src/cli.rs
@@ -149,6 +149,42 @@ pub enum Verb {
|
||||
action: crate::workflows::cmd::WorkflowAction,
|
||||
},
|
||||
|
||||
/// Deploy service(s) — apply manifests + rollout restart.
|
||||
Deploy {
|
||||
/// Service name, category, or namespace (e.g. "hydra", "auth", "ory").
|
||||
/// Use --all for everything.
|
||||
target: Option<String>,
|
||||
/// Deploy all services.
|
||||
#[arg(long)]
|
||||
all: bool,
|
||||
},
|
||||
|
||||
/// View secrets for a service.
|
||||
Secrets {
|
||||
/// Service name (e.g. "hydra").
|
||||
service: String,
|
||||
#[command(subcommand)]
|
||||
action: Option<SecretsAction>,
|
||||
},
|
||||
|
||||
/// Interactive shell into a service.
|
||||
Shell {
|
||||
/// Service name (e.g. "postgres", "gitea").
|
||||
service: String,
|
||||
},
|
||||
|
||||
/// Connect to the cluster VPN (foreground; Ctrl-C to disconnect).
|
||||
Connect,
|
||||
|
||||
/// Disconnect from the cluster VPN.
|
||||
Disconnect,
|
||||
|
||||
/// VPN diagnostics and key management.
|
||||
Vpn {
|
||||
#[command(subcommand)]
|
||||
action: VpnAction,
|
||||
},
|
||||
|
||||
/// Self-update from latest mainline commit.
|
||||
Update,
|
||||
|
||||
@@ -156,6 +192,21 @@ pub enum Verb {
|
||||
Version,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum VpnAction {
|
||||
/// Show VPN tunnel status.
|
||||
Status,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum SecretsAction {
|
||||
/// Get a specific secret field value.
|
||||
Get {
|
||||
/// Field name within the service's KV path.
|
||||
key: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum AuthAction {
|
||||
/// Log in to both SSO and Gitea.
|
||||
@@ -861,6 +912,72 @@ mod tests {
|
||||
let result = Cli::try_parse_from(&["sunbeam", "workflow", "status"]);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deploy_no_target() {
|
||||
let cli = parse(&["sunbeam", "deploy"]);
|
||||
match cli.verb {
|
||||
Some(Verb::Deploy { target, all }) => {
|
||||
assert!(target.is_none());
|
||||
assert!(!all);
|
||||
}
|
||||
_ => panic!("expected Deploy"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deploy_with_target() {
|
||||
let cli = parse(&["sunbeam", "deploy", "hydra"]);
|
||||
match cli.verb {
|
||||
Some(Verb::Deploy { target, .. }) => assert_eq!(target.unwrap(), "hydra"),
|
||||
_ => panic!("expected Deploy"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deploy_all() {
|
||||
let cli = parse(&["sunbeam", "deploy", "--all"]);
|
||||
match cli.verb {
|
||||
Some(Verb::Deploy { all, .. }) => assert!(all),
|
||||
_ => panic!("expected Deploy"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_secrets_list() {
|
||||
let cli = parse(&["sunbeam", "secrets", "hydra"]);
|
||||
match cli.verb {
|
||||
Some(Verb::Secrets { service, action }) => {
|
||||
assert_eq!(service, "hydra");
|
||||
assert!(action.is_none());
|
||||
}
|
||||
_ => panic!("expected Secrets"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_secrets_get() {
|
||||
let cli = parse(&["sunbeam", "secrets", "hydra", "get", "system-secret"]);
|
||||
match cli.verb {
|
||||
Some(Verb::Secrets { service, action }) => {
|
||||
assert_eq!(service, "hydra");
|
||||
match action {
|
||||
Some(SecretsAction::Get { key }) => assert_eq!(key, "system-secret"),
|
||||
_ => panic!("expected Get"),
|
||||
}
|
||||
}
|
||||
_ => panic!("expected Secrets"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_shell() {
|
||||
let cli = parse(&["sunbeam", "shell", "postgres"]);
|
||||
match cli.verb {
|
||||
Some(Verb::Shell { service }) => assert_eq!(service, "postgres"),
|
||||
_ => panic!("expected Shell"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Main dispatch function — parse CLI args and route to subcommands.
|
||||
@@ -1376,6 +1493,35 @@ pub async fn dispatch() -> Result<()> {
|
||||
crate::workflows::cmd::dispatch(&ctx_name, action).await
|
||||
}
|
||||
|
||||
Some(Verb::Deploy { target, all }) => {
|
||||
if all || target.is_none() {
|
||||
let is_production = !crate::config::active_context().ssh_host.is_empty();
|
||||
let env_str = if is_production { "production" } else { "local" };
|
||||
let domain = cli.domain.clone();
|
||||
let email = cli.email.clone();
|
||||
crate::manifests::cmd_apply(env_str, &domain, &email, "").await
|
||||
} else {
|
||||
let target = target.unwrap();
|
||||
crate::service_cmds::cmd_deploy(&target, &cli.domain, &cli.email).await
|
||||
}
|
||||
}
|
||||
|
||||
Some(Verb::Secrets { service, action }) => {
|
||||
crate::service_cmds::cmd_secrets(&service, action).await
|
||||
}
|
||||
|
||||
Some(Verb::Shell { service }) => {
|
||||
crate::service_cmds::cmd_shell(&service).await
|
||||
}
|
||||
|
||||
Some(Verb::Connect) => crate::vpn_cmds::cmd_connect().await,
|
||||
|
||||
Some(Verb::Disconnect) => crate::vpn_cmds::cmd_disconnect().await,
|
||||
|
||||
Some(Verb::Vpn { action }) => match action {
|
||||
VpnAction::Status => crate::vpn_cmds::cmd_vpn_status().await,
|
||||
},
|
||||
|
||||
Some(Verb::Update) => crate::update::cmd_update().await,
|
||||
|
||||
Some(Verb::Version) => {
|
||||
|
||||
@@ -53,6 +53,18 @@ pub struct Context {
|
||||
/// ACME email for cert-manager.
|
||||
#[serde(default, rename = "acme-email")]
|
||||
pub acme_email: String,
|
||||
|
||||
/// VPN coordination server URL (Headscale). When set, `sunbeam connect`
|
||||
/// can establish a WireGuard tunnel through this server and the CLI
|
||||
/// will route k8s API traffic through it instead of falling back to
|
||||
/// SSH or kubeconfig.
|
||||
#[serde(default, rename = "vpn-url", skip_serializing_if = "String::is_empty")]
|
||||
pub vpn_url: String,
|
||||
|
||||
/// VPN pre-auth key for registering with the coordination server.
|
||||
/// Stored in plain text — keep this file readable only by the user.
|
||||
#[serde(default, rename = "vpn-auth-key", skip_serializing_if = "String::is_empty")]
|
||||
pub vpn_auth_key: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -167,6 +179,7 @@ pub fn load_config() -> SunbeamConfig {
|
||||
ssh_host: config.production_host.clone(),
|
||||
infra_dir: config.infra_directory.clone(),
|
||||
acme_email: config.acme_email.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
if config.current_context.is_empty() {
|
||||
@@ -369,6 +382,7 @@ mod tests {
|
||||
ssh_host: "sienna@server.sunbeam.pt".to_string(),
|
||||
infra_dir: "/home/infra".to_string(),
|
||||
acme_email: "ops@sunbeam.pt".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let json = serde_json::to_string(&config).unwrap();
|
||||
|
||||
@@ -15,10 +15,12 @@ mod openbao;
|
||||
mod output;
|
||||
mod pm;
|
||||
mod secrets;
|
||||
mod service_cmds;
|
||||
mod services;
|
||||
mod tools;
|
||||
mod update;
|
||||
mod users;
|
||||
mod vpn_cmds;
|
||||
mod workflows;
|
||||
|
||||
#[tokio::main]
|
||||
|
||||
146
src/vpn_cmds.rs
Normal file
146
src/vpn_cmds.rs
Normal file
@@ -0,0 +1,146 @@
|
||||
//! `sunbeam connect` / `sunbeam disconnect` / `sunbeam vpn ...`
|
||||
//!
|
||||
//! These commands wrap the `sunbeam-net` daemon and run it in the foreground
|
||||
//! of the CLI process. We don't currently background it as a separate
|
||||
//! daemon process — running in the foreground keeps the lifecycle simple
|
||||
//! and is the right shape for the typical workflow ("connect, do work,
|
||||
//! disconnect with ^C").
|
||||
|
||||
use crate::config::active_context;
|
||||
use crate::error::{Result, SunbeamError};
|
||||
use crate::output::{ok, step, warn};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Run `sunbeam connect` — start the VPN daemon and block until shutdown.
|
||||
pub async fn cmd_connect() -> Result<()> {
|
||||
let ctx = active_context();
|
||||
|
||||
if ctx.vpn_url.is_empty() {
|
||||
return Err(SunbeamError::Other(
|
||||
"no VPN configured for this context — set vpn-url and vpn-auth-key in config"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
if ctx.vpn_auth_key.is_empty() {
|
||||
return Err(SunbeamError::Other(
|
||||
"no VPN auth key for this context — set vpn-auth-key in config".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let state_dir = vpn_state_dir()?;
|
||||
std::fs::create_dir_all(&state_dir).map_err(|e| {
|
||||
SunbeamError::Other(format!(
|
||||
"create vpn state dir {}: {e}",
|
||||
state_dir.display()
|
||||
))
|
||||
})?;
|
||||
|
||||
// Build the netmap label as "<user>@<host>" so multiple workstations
|
||||
// for the same human are distinguishable in `headscale nodes list`.
|
||||
let user = whoami::username().unwrap_or_else(|_| "unknown".to_string());
|
||||
let host = hostname::get()
|
||||
.ok()
|
||||
.and_then(|h| h.into_string().ok())
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
let hostname = format!("{user}@{host}");
|
||||
|
||||
let config = sunbeam_net::VpnConfig {
|
||||
coordination_url: ctx.vpn_url.clone(),
|
||||
auth_key: ctx.vpn_auth_key.clone(),
|
||||
state_dir: state_dir.clone(),
|
||||
// Bind the local k8s proxy on a fixed port the rest of the CLI can
|
||||
// discover via context (or via IPC, eventually).
|
||||
proxy_bind: "127.0.0.1:16443".parse().expect("static addr"),
|
||||
// Default cluster API target — TODO: derive from netmap once we
|
||||
// know which peer hosts the k8s API.
|
||||
cluster_api_addr: "100.64.0.1".parse().expect("static addr"),
|
||||
cluster_api_port: 6443,
|
||||
control_socket: state_dir.join("daemon.sock"),
|
||||
hostname,
|
||||
server_public_key: None,
|
||||
};
|
||||
|
||||
step(&format!("Connecting to {}", ctx.vpn_url));
|
||||
let handle = sunbeam_net::VpnDaemon::start(config)
|
||||
.await
|
||||
.map_err(|e| SunbeamError::Other(format!("daemon start: {e}")))?;
|
||||
|
||||
// Block until the daemon reaches Running, then sit on it until SIGINT.
|
||||
let mut ready = false;
|
||||
for _ in 0..60 {
|
||||
match handle.current_status() {
|
||||
sunbeam_net::DaemonStatus::Running { addresses, peer_count, .. } => {
|
||||
let addrs: Vec<String> = addresses.iter().map(|a| a.to_string()).collect();
|
||||
ok(&format!(
|
||||
"Connected ({}) — {} peers visible",
|
||||
addrs.join(", "),
|
||||
peer_count
|
||||
));
|
||||
ready = true;
|
||||
break;
|
||||
}
|
||||
sunbeam_net::DaemonStatus::Reconnecting { attempt } => {
|
||||
warn(&format!("Reconnecting (attempt {attempt})..."));
|
||||
}
|
||||
sunbeam_net::DaemonStatus::Error { ref message } => {
|
||||
return Err(SunbeamError::Other(format!("VPN error: {message}")));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
||||
}
|
||||
if !ready {
|
||||
warn("VPN daemon did not reach Running state within 30s — continuing anyway");
|
||||
}
|
||||
|
||||
println!("Press Ctrl-C to disconnect.");
|
||||
tokio::signal::ctrl_c()
|
||||
.await
|
||||
.map_err(|e| SunbeamError::Other(format!("install signal handler: {e}")))?;
|
||||
step("Disconnecting...");
|
||||
handle
|
||||
.shutdown()
|
||||
.await
|
||||
.map_err(|e| SunbeamError::Other(format!("daemon shutdown: {e}")))?;
|
||||
ok("Disconnected.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run `sunbeam disconnect` — signal a running daemon via its IPC socket.
|
||||
pub async fn cmd_disconnect() -> Result<()> {
|
||||
let state_dir = vpn_state_dir()?;
|
||||
let socket = state_dir.join("daemon.sock");
|
||||
if !socket.exists() {
|
||||
return Err(SunbeamError::Other(
|
||||
"no running VPN daemon (control socket missing)".into(),
|
||||
));
|
||||
}
|
||||
// The daemon's IPC server lives in sunbeam_net::daemon::ipc, but it's
|
||||
// not currently exported as a client. Until that lands, the canonical
|
||||
// way to disconnect is to ^C the foreground `sunbeam connect` process.
|
||||
Err(SunbeamError::Other(
|
||||
"background daemon control not yet implemented — Ctrl-C the running `sunbeam connect`"
|
||||
.into(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Run `sunbeam vpn status` — query a running daemon's status via IPC.
|
||||
pub async fn cmd_vpn_status() -> Result<()> {
|
||||
let state_dir = vpn_state_dir()?;
|
||||
let socket = state_dir.join("daemon.sock");
|
||||
if !socket.exists() {
|
||||
println!("VPN: not running");
|
||||
return Ok(());
|
||||
}
|
||||
println!("VPN: running (control socket at {})", socket.display());
|
||||
// TODO: actually query the IPC socket once the IPC client API is
|
||||
// exposed from sunbeam-net.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve the on-disk directory for VPN state (keys, control socket).
|
||||
fn vpn_state_dir() -> Result<PathBuf> {
|
||||
let home = std::env::var("HOME")
|
||||
.map_err(|_| SunbeamError::Other("HOME not set".into()))?;
|
||||
Ok(PathBuf::from(home).join(".sunbeam").join("vpn"))
|
||||
}
|
||||
@@ -162,6 +162,7 @@ mod tests {
|
||||
ssh_host: String::new(),
|
||||
infra_dir: "/home/user/infra".to_string(),
|
||||
acme_email: "admin@local.dev".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
let sc = StepContext::from_config(&ctx, "local");
|
||||
assert_eq!(sc.domain, "local.dev");
|
||||
@@ -180,6 +181,7 @@ mod tests {
|
||||
ssh_host: "sienna@62.210.145.138".to_string(),
|
||||
infra_dir: "/srv/infra".to_string(),
|
||||
acme_email: "ops@sunbeam.pt".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
let sc = StepContext::from_config(&ctx, "production");
|
||||
assert!(sc.is_production);
|
||||
|
||||
Reference in New Issue
Block a user