use std::path::PathBuf;
use boatramp_core::daemon_config::{DaemonConfig, KernelRef};
use clap::Subcommand;
use serde::Deserialize;
use crate::client;
use crate::config::ProjectConfig;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error(transparent)]
Client(#[from] crate::client::ClientError),
#[error("control-plane request: {0}")]
Http(#[from] reqwest::Error),
#[error(transparent)]
Json(#[from] serde_json::Error),
#[error(transparent)]
Io(#[from] std::io::Error),
#[error("unknown dynamic config key {0:?}; run `boatramp config list`")]
UnknownKey(String),
#[error(
"{0} is a restart-class setting: edit `[{1}]` in boatramp.cfg on each node and restart \
(it is not runtime-changeable — that is deliberate)"
)]
RestartClass(String, &'static str),
#[error("invalid value for {key}: {msg}")]
BadValue { key: String, msg: String },
#[error("server rejected the config: {0}")]
Rejected(String),
#[error(transparent)]
Migrate(#[from] crate::apply::Error),
#[error(transparent)]
DaemonConfig(#[from] crate::config::ConfigError),
}
type Result<T> = std::result::Result<T, Error>;
#[derive(Debug, clap::Args)]
pub struct ConfigArgs {
#[arg(long, env = "BOATRAMP_SERVER", global = true)]
server: Option<String>,
#[command(subcommand)]
command: ConfigCommand,
}
#[derive(Debug, Subcommand)]
enum ConfigCommand {
Get {
key: Option<String>,
},
Set {
key: String,
value: String,
},
Rollback,
Apply {
#[arg(short, long)]
file: PathBuf,
},
List,
Describe {
key: String,
},
Migrate {
file: PathBuf,
#[arg(long)]
write: bool,
},
Dump {
#[arg(long, default_value = "boatramp.cfg")]
file: PathBuf,
#[arg(long)]
explain: bool,
},
}
#[derive(Deserialize)]
struct ConfigResponse {
generation: Option<String>,
config: DaemonConfig,
}
#[derive(Debug, serde::Serialize)]
pub struct DynKey {
pub key: String,
pub note: String,
}
#[derive(Debug, serde::Serialize)]
#[serde(tag = "result", rename_all = "snake_case")]
pub enum ConfigOutput {
Get {
generation: Option<String>,
config: Box<DaemonConfig>,
},
GetKey { key: String, value: String },
Set {
key: String,
value: String,
generation: String,
},
RolledBack { generation: Option<String> },
Applied { file: String, generation: String },
List { keys: Vec<DynKey> },
Describe {
key: String,
class: &'static str,
section: Option<&'static str>,
note: Option<&'static str>,
},
Migrated {
written_to: Option<String>,
manifest: String,
},
Dump {
#[serde(skip)]
text: String,
config: serde_json::Value,
},
}
impl crate::output::Rendered for ConfigOutput {
fn render_human(&self, w: &mut dyn std::io::Write) -> std::io::Result<()> {
match self {
Self::Get { generation, config } => {
writeln!(
w,
"generation: {}",
generation.as_deref().unwrap_or("(file baseline)")
)?;
writeln!(
w,
"{}",
serde_json::to_string_pretty(config).map_err(std::io::Error::other)?
)
}
Self::GetKey { value, .. } => writeln!(w, "{value}"),
Self::Set {
key,
value,
generation,
} => writeln!(w, "set {key} = {value} (generation {generation})"),
Self::RolledBack { generation } => match generation {
None => writeln!(w, "no prior generation to roll back to"),
Some(g) => writeln!(w, "rolled back to generation {g}"),
},
Self::Applied { file, generation } => {
writeln!(w, "applied {file} (generation {generation})")
}
Self::List { keys } => {
writeln!(
w,
"Dynamic keys (change fleet-wide with `config set`, no restart):"
)?;
for k in keys {
writeln!(w, " {:<32} {}", k.key, k.note)?;
}
writeln!(w)?;
writeln!(
w,
"Trust anchors + posture + listener settings are restart-class"
)?;
writeln!(
w,
"(edit boatramp.cfg + restart). See `config describe <key>`."
)
}
Self::Describe {
key,
class,
section,
note,
} => match *class {
"restart" => writeln!(
w,
"{key}: restart-class — edit `[{}]` in boatramp.cfg + restart.",
section.as_deref().unwrap_or("")
),
"dynamic" => {
writeln!(
w,
"{key}: dynamic — `config set` converges it fleet-wide without a restart."
)?;
writeln!(w, " {}", note.as_deref().unwrap_or(""))
}
_ => writeln!(w, "{key}: unknown; run `config list`."),
},
Self::Migrated {
written_to,
manifest,
} => {
if written_to.is_none() {
write!(w, "{manifest}")
} else {
Ok(())
}
}
Self::Dump { text, .. } => writeln!(w, "{text}"),
}
}
}
pub async fn run(args: ConfigArgs, config: &ProjectConfig) -> Result<ConfigOutput> {
match &args.command {
ConfigCommand::List => {
return Ok(ConfigOutput::List {
keys: dynamic_keys(),
});
}
ConfigCommand::Describe { key } => {
return Ok(describe(key));
}
ConfigCommand::Migrate { file, write } => {
return migrate_manifest(file, *write);
}
ConfigCommand::Dump { file, explain } => {
let resolved = crate::config::ServerConfig::load(file, None)?;
let text = resolved.dump_redacted()?;
if *explain {
tracing::info!(
"resolved: built-in defaults < {} < BOATRAMP_* env (secret fields redacted)",
file.display()
);
}
let config = serde_json::from_str(&text)?;
return Ok(ConfigOutput::Dump { text, config });
}
_ => {}
}
let server = client::resolve_server(args.server, config)?;
let http = client::http_client(client::token(config).as_deref());
match args.command {
ConfigCommand::Get { key } => get(&http, &server, key).await,
ConfigCommand::Set { key, value } => set(&http, &server, &key, &value).await,
ConfigCommand::Rollback => rollback(&http, &server).await,
ConfigCommand::Apply { file } => apply(&http, &server, &file).await,
ConfigCommand::List
| ConfigCommand::Describe { .. }
| ConfigCommand::Migrate { .. }
| ConfigCommand::Dump { .. } => {
unreachable!("handled above")
}
}
}
fn migrate_manifest(file: &std::path::Path, write: bool) -> Result<ConfigOutput> {
let text = std::fs::read_to_string(file)?;
let manifest = crate::apply::ApplyManifest::parse(&text)?;
let upgraded = crate::apply_migrate::render_manifest(&manifest)?;
let written_to = if write {
std::fs::write(file, &upgraded)?;
tracing::info!("upgraded {} to the current (v0.6.0) schema", file.display());
Some(file.display().to_string())
} else {
None
};
Ok(ConfigOutput::Migrated {
written_to,
manifest: upgraded,
})
}
async fn fetch(http: &crate::client::ApiClient, server: &str) -> Result<ConfigResponse> {
Ok(http
.get(format!("{server}/api/daemon/config"))
.send()
.await?
.error_for_status()?
.json()
.await?)
}
async fn put(http: &crate::client::ApiClient, server: &str, cfg: &DaemonConfig) -> Result<String> {
let resp = http
.put(format!("{server}/api/daemon/config"))
.json(cfg)
.send()
.await?;
if resp.status() == reqwest::StatusCode::BAD_REQUEST {
return Err(Error::Rejected(resp.text().await?.trim().to_string()));
}
let v: serde_json::Value = resp.error_for_status()?.json().await?;
Ok(v["generation"].as_str().unwrap_or("").to_string())
}
async fn get(
http: &crate::client::ApiClient,
server: &str,
key: Option<String>,
) -> Result<ConfigOutput> {
let r = fetch(http, server).await?;
Ok(match key {
None => ConfigOutput::Get {
generation: r.generation,
config: Box::new(r.config),
},
Some(k) => {
let value = read_key(&r.config, &k)?;
ConfigOutput::GetKey { key: k, value }
}
})
}
async fn set(
http: &crate::client::ApiClient,
server: &str,
key: &str,
value: &str,
) -> Result<ConfigOutput> {
let mut r = fetch(http, server).await?;
write_key(&mut r.config, key, value)?;
let generation = put(http, server, &r.config).await?;
Ok(ConfigOutput::Set {
key: key.to_string(),
value: value.to_string(),
generation,
})
}
async fn rollback(http: &crate::client::ApiClient, server: &str) -> Result<ConfigOutput> {
let resp = http
.post(format!("{server}/api/daemon/config/rollback"))
.send()
.await?;
if resp.status() == reqwest::StatusCode::CONFLICT {
return Ok(ConfigOutput::RolledBack { generation: None });
}
let v: serde_json::Value = resp.error_for_status()?.json().await?;
Ok(ConfigOutput::RolledBack {
generation: Some(v["generation"].as_str().unwrap_or("?").to_string()),
})
}
async fn apply(
http: &crate::client::ApiClient,
server: &str,
file: &std::path::Path,
) -> Result<ConfigOutput> {
let bytes = std::fs::read(file)?;
let cfg: DaemonConfig = serde_json::from_slice(&bytes)?;
let generation = put(http, server, &cfg).await?;
Ok(ConfigOutput::Applied {
file: file.display().to_string(),
generation,
})
}
fn is_clear(value: &str) -> bool {
matches!(value, "null" | "unset" | "")
}
fn parse_bool(key: &str, value: &str) -> Result<bool> {
value.parse().map_err(|_| Error::BadValue {
key: key.to_string(),
msg: format!("expected true/false, got {value:?}"),
})
}
fn parse_u64(key: &str, value: &str) -> Result<u64> {
value.parse().map_err(|_| Error::BadValue {
key: key.to_string(),
msg: format!("expected an integer, got {value:?}"),
})
}
fn restart_class_section(key: &str) -> Option<&'static str> {
match key {
"addr" | "data_dir" | "http_redirect_addr" => Some("serve"),
k if k.starts_with("auth_root") || k == "signer" || k == "bootstrap_secret" => {
Some("serve")
}
k if k.starts_with("tls") || k.starts_with("acme") => Some("serve"),
k if k.starts_with("security.") || k == "security" => Some("security"),
k if k.starts_with("cluster.") || k == "cluster" => Some("cluster"),
_ => None,
}
}
fn write_key(cfg: &mut DaemonConfig, key: &str, value: &str) -> Result<()> {
if let Some(section) = restart_class_section(key) {
return Err(Error::RestartClass(key.to_string(), section));
}
let clear = is_clear(value);
match key {
"default_site" => cfg.default_site = (!clear).then(|| value.to_string()),
"protect_previews" => cfg.protect_previews = clear_or(clear, parse_bool(key, value)?),
"cluster_rate_limit" => cfg.cluster_rate_limit = clear_or(clear, parse_bool(key, value)?),
"max_upload_bytes" => cfg.max_upload_bytes = clear_or(clear, parse_u64(key, value)?),
"upload_idle_timeout_secs" => {
cfg.upload_idle_timeout_secs = clear_or(clear, parse_u64(key, value)?);
}
"max_concurrent_uploads" => {
cfg.max_concurrent_uploads = clear_or(clear, parse_u64(key, value)?);
}
"compute.vcpus" => cfg.compute.vcpus = clear_or(clear, parse_u64(key, value)? as u32),
"compute.mem_mib" => cfg.compute.mem_mib = clear_or(clear, parse_u64(key, value)? as u32),
"console.enabled" => {
cfg.console.enabled = if clear {
None
} else {
Some(parse_bool(key, value)?)
}
}
"console.host" => cfg.console.host = (!clear).then(|| value.to_string()),
"console.path" => cfg.console.path = (!clear).then(|| value.to_string()),
"mcp.enabled" => {
cfg.mcp.enabled = if clear {
None
} else {
Some(parse_bool(key, value)?)
}
}
"compute.default_kernel" => {
cfg.compute.default_kernel = if clear {
None
} else {
Some(
serde_json::from_str::<KernelRef>(value).map_err(|e| Error::BadValue {
key: key.to_string(),
msg: format!("expected a KernelRef JSON object: {e}"),
})?,
)
}
}
"posture.oidc_require_audience" => {
cfg.posture.oidc_require_audience = clear_or(clear, parse_bool(key, value)?);
}
"posture.ratelimit_fail_open" => {
cfg.posture.ratelimit_fail_open = clear_or(clear, parse_bool(key, value)?);
}
"posture.allow_shared_kernel_compute" => {
cfg.posture.allow_shared_kernel_compute = clear_or(clear, parse_bool(key, value)?);
}
_ => return Err(Error::UnknownKey(key.to_string())),
}
Ok(())
}
fn clear_or<T>(clear: bool, v: T) -> Option<T> {
(!clear).then_some(v)
}
fn read_key(cfg: &DaemonConfig, key: &str) -> Result<String> {
let json = serde_json::to_value(cfg)?;
let mut node = &json;
for part in key.split('.') {
node = node.get(part).unwrap_or(&serde_json::Value::Null);
}
Ok(if node.is_null() {
"(unset — file baseline)".to_string()
} else {
node.to_string()
})
}
fn dynamic_keys() -> Vec<DynKey> {
DYNAMIC_KEYS
.iter()
.map(|(key, note)| DynKey {
key: (*key).to_string(),
note: (*note).to_string(),
})
.collect()
}
fn describe(key: &str) -> ConfigOutput {
if let Some(section) = restart_class_section(key) {
return ConfigOutput::Describe {
key: key.to_string(),
class: "restart",
section: Some(section),
note: None,
};
}
match DYNAMIC_KEYS.iter().find(|(k, _)| *k == key) {
Some((_, class)) => ConfigOutput::Describe {
key: key.to_string(),
class: "dynamic",
section: None,
note: Some(*class),
},
None => ConfigOutput::Describe {
key: key.to_string(),
class: "unknown",
section: None,
note: None,
},
}
}
const DYNAMIC_KEYS: &[(&str, &str)] = &[
("default_site", "catch-all site for an unmatched Host"),
("protect_previews", "require a token to view previews"),
("max_upload_bytes", "upload cap (≤ the posture ceiling)"),
("upload_idle_timeout_secs", "abort a stalled upload"),
("max_concurrent_uploads", "cap simultaneous uploads"),
("cluster_rate_limit", "rate-limit via the shared KV"),
("compute.vcpus", "advertised schedulable vCPUs"),
("compute.mem_mib", "advertised schedulable memory (MiB)"),
(
"compute.default_kernel",
"fleet default microVM kernel (KernelRef JSON)",
),
("console.enabled", "serve the embedded web console"),
(
"console.host",
"console host pattern (* / exact / *.suffix)",
),
(
"console.path",
"console URL path prefix (default /_console)",
),
(
"posture.oidc_require_audience",
"tighten-only: require an OIDC audience",
),
(
"posture.ratelimit_fail_open",
"tighten-only: fail closed (set false)",
),
(
"posture.allow_shared_kernel_compute",
"tighten-only: forbid shared-kernel (set false)",
),
];
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn set_scalars_and_clear() {
let mut cfg = DaemonConfig::default();
write_key(&mut cfg, "default_site", "blog").unwrap();
assert_eq!(cfg.default_site.as_deref(), Some("blog"));
write_key(&mut cfg, "max_upload_bytes", "1048576").unwrap();
assert_eq!(cfg.max_upload_bytes, Some(1048576));
write_key(&mut cfg, "compute.vcpus", "8").unwrap();
assert_eq!(cfg.compute.vcpus, Some(8));
write_key(&mut cfg, "default_site", "unset").unwrap();
assert!(cfg.default_site.is_none());
}
#[test]
fn set_console_keys() {
let mut cfg = DaemonConfig::default();
write_key(&mut cfg, "console.enabled", "true").unwrap();
assert_eq!(cfg.console.enabled, Some(true));
write_key(&mut cfg, "console.host", "console.example.com").unwrap();
assert_eq!(cfg.console.host.as_deref(), Some("console.example.com"));
write_key(&mut cfg, "console.path", "/admin").unwrap();
assert_eq!(cfg.console.path.as_deref(), Some("/admin"));
write_key(&mut cfg, "console.enabled", "unset").unwrap();
assert!(cfg.console.enabled.is_none());
}
#[test]
fn restart_class_and_unknown_are_rejected() {
let mut cfg = DaemonConfig::default();
assert!(matches!(
write_key(&mut cfg, "auth_root_private_key", "x"),
Err(Error::RestartClass(_, "serve"))
));
assert!(matches!(
write_key(
&mut cfg,
"security.allow_unauthenticated_public_bind",
"true"
),
Err(Error::RestartClass(_, "security"))
));
assert!(matches!(
write_key(&mut cfg, "nope", "1"),
Err(Error::UnknownKey(_))
));
assert!(matches!(
write_key(&mut cfg, "protect_previews", "maybe"),
Err(Error::BadValue { .. })
));
}
#[test]
fn read_key_reports_unset() {
let cfg = DaemonConfig::default();
assert!(read_key(&cfg, "default_site").unwrap().contains("unset"));
}
}