From 56c14dac85a780076e7857520140d7863bb02075 Mon Sep 17 00:00:00 2001 From: Matthew Grossman Date: Fri, 17 Jul 2026 16:12:38 -0700 Subject: [PATCH 1/4] feat(gateway): add config set overrides Signed-off-by: Matthew Grossman --- Cargo.lock | 1 + Cargo.toml | 1 + architecture/gateway.md | 6 + crates/openshell-server/Cargo.toml | 1 + crates/openshell-server/src/cli.rs | 67 +++- crates/openshell-server/src/config_command.rs | 316 ++++++++++++++++++ crates/openshell-server/src/config_file.rs | 6 +- crates/openshell-server/src/lib.rs | 1 + deploy/man/openshell-gateway.8.md | 15 + docs/reference/gateway-config.mdx | 31 ++ tasks/scripts/gateway-docker.sh | 56 ++++ 11 files changed, 499 insertions(+), 2 deletions(-) create mode 100644 crates/openshell-server/src/config_command.rs diff --git a/Cargo.lock b/Cargo.lock index b412467610..a6364f025f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4220,6 +4220,7 @@ dependencies = [ "tokio-stream", "tokio-tungstenite 0.26.2", "toml", + "toml_edit", "tonic", "tower 0.5.3", "tower-http 0.6.8", diff --git a/Cargo.toml b/Cargo.toml index 7081fe97e2..3be8771afb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -73,6 +73,7 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" serde_yml = "0.0.12" toml = "0.8" +toml_edit = "0.22" apollo-parser = "0.8.5" tower-mcp-types = "0.12.0" regex = "1" diff --git a/architecture/gateway.md b/architecture/gateway.md index cca6599281..387ea5879b 100644 --- a/architecture/gateway.md +++ b/architecture/gateway.md @@ -557,6 +557,12 @@ Driver implementation settings live in the TOML driver tables. See `docs/reference/gateway-config.mdx` for worked per-driver examples and RFC 0003 for the full schema. +`openshell-gateway config set` updates a single resolved TOML file from +repeatable dotted `KEY=VALUE` assignments. It preserves comments, validates +the complete gateway schema, and replaces the file atomically. Local +development tasks can apply overrides to their generated configuration through +this command without changing gateway startup precedence. + `database_url` is env-only and rejected when present in the file (`OPENSHELL_DB_URL` / `--db-url`). diff --git a/crates/openshell-server/Cargo.toml b/crates/openshell-server/Cargo.toml index 4c4f289ed6..3143ec7b6f 100644 --- a/crates/openshell-server/Cargo.toml +++ b/crates/openshell-server/Cargo.toml @@ -80,6 +80,7 @@ pin-project-lite = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } toml = { workspace = true } +toml_edit = { workspace = true } tokio-stream = { workspace = true } sqlx = { workspace = true } reqwest = { workspace = true } diff --git a/crates/openshell-server/src/cli.rs b/crates/openshell-server/src/cli.rs index 269b9f40e9..6095059aaf 100644 --- a/crates/openshell-server/src/cli.rs +++ b/crates/openshell-server/src/cli.rs @@ -39,6 +39,8 @@ struct Cli { enum Commands { /// Generate mTLS PKI and write Kubernetes Secrets (Helm pre-install hook). GenerateCerts(certgen::CertgenArgs), + /// Update the gateway TOML configuration. + Config(crate::config_command::ConfigArgs), } #[derive(clap::Args, Debug)] @@ -49,7 +51,7 @@ struct RunArgs { /// When set, gateway-wide settings and per-driver tables are read from /// the file. Gateway command-line flags and `OPENSHELL_*` environment /// variables continue to take precedence over gateway file values. - #[arg(long, env = "OPENSHELL_GATEWAY_CONFIG")] + #[arg(long, env = "OPENSHELL_GATEWAY_CONFIG", global = true)] config: Option, /// IP address to bind the server, health, and metrics listeners to. @@ -228,6 +230,7 @@ pub async fn run_cli() -> Result<()> { match cli.command { Some(Commands::GenerateCerts(args)) => certgen::run(args).await, + Some(Commands::Config(args)) => crate::config_command::run(args, cli.run.config), None => Box::pin(run_from_args(cli.run, matches)).await, } } @@ -1086,6 +1089,68 @@ mod tests { )); } + #[test] + fn config_set_uses_explicit_config_path() { + let _lock = ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let _guard = EnvVarGuard::remove("OPENSHELL_GATEWAY_CONFIG"); + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("custom/gateway.toml"); + let path_string = path.to_string_lossy().into_owned(); + + let cli = Cli::try_parse_from([ + "openshell-gateway", + "config", + "set", + "--config", + &path_string, + "openshell.gateway.compute_drivers=[\"podman\"]", + "openshell.gateway.bind_address=0.0.0.0:17670", + ]) + .expect("config set should parse without runtime arguments"); + let Cli { command, run } = cli; + let Some(super::Commands::Config(args)) = command else { + panic!("expected config subcommand"); + }; + + crate::config_command::run(args, run.config).unwrap(); + + let loaded = crate::config_file::load(&path).unwrap(); + assert_eq!( + loaded.openshell.gateway.compute_drivers, + Some(vec!["podman".to_string()]) + ); + assert_eq!( + loaded.openshell.gateway.bind_address, + Some("0.0.0.0:17670".parse().unwrap()) + ); + } + + #[test] + fn config_set_requires_at_least_one_assignment() { + let error = Cli::try_parse_from(["openshell-gateway", "config", "set"]) + .expect_err("config set without an assignment should fail"); + + assert_eq!( + error.kind(), + clap::error::ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn config_set_rejects_non_assignment_arguments() { + let error = Cli::try_parse_from([ + "openshell-gateway", + "config", + "set", + "openshell.gateway.log_level", + ]) + .expect_err("config set arguments must use KEY=VALUE syntax"); + + assert_eq!(error.kind(), clap::error::ErrorKind::ValueValidation); + } + #[test] fn bare_invocation_with_no_db_url_parses_for_runtime_defaults() { // db_url is Option at the clap level so subcommand parsing diff --git a/crates/openshell-server/src/config_command.rs b/crates/openshell-server/src/config_command.rs new file mode 100644 index 0000000000..4d019d4b93 --- /dev/null +++ b/crates/openshell-server/src/config_command.rs @@ -0,0 +1,316 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! CLI handlers for updating the gateway TOML configuration. + +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::str::FromStr; + +use clap::{Args, Subcommand}; +use miette::{IntoDiagnostic, Result, WrapErr}; +use tempfile::NamedTempFile; +use toml_edit::{DocumentMut, Item, Table, value}; + +use crate::{config_file, defaults}; + +#[derive(Debug, Args)] +pub struct ConfigArgs { + #[command(subcommand)] + command: ConfigCommand, +} + +#[derive(Debug, Subcommand)] +enum ConfigCommand { + /// Update fields in the gateway TOML configuration. + Set(SetArgs), +} + +#[derive(Debug, Args)] +struct SetArgs { + /// Dotted TOML key and value to set. May be repeated. + #[arg(required = true, value_name = "KEY=VALUE")] + assignments: Vec, +} + +#[derive(Clone, Debug)] +struct Assignment { + key: Vec, + value: Item, +} + +impl FromStr for Assignment { + type Err = String; + + fn from_str(input: &str) -> std::result::Result { + let (raw_key, raw_value) = input.split_once('=').ok_or_else(|| { + format!("invalid assignment '{input}': expected a dotted KEY=VALUE argument") + })?; + + let key = raw_key + .split('.') + .map(|component| { + if component.is_empty() + || !component + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-')) + { + return Err(format!( + "invalid config key '{raw_key}': use dot-separated TOML bare keys" + )); + } + Ok(component.to_string()) + }) + .collect::, _>>()?; + + Ok(Self { + key, + value: parse_assignment_value(raw_value.trim()), + }) + } +} + +pub fn run(args: ConfigArgs, explicit_path: Option) -> Result<()> { + match args.command { + ConfigCommand::Set(settings) => { + let path = explicit_path.map_or_else(defaults::default_gateway_config_path, Ok)?; + set(&path, &settings)?; + println!("updated gateway configuration: {}", path.display()); + println!("Restart the gateway service for changes to take effect."); + } + } + Ok(()) +} + +fn set(path: &Path, settings: &SetArgs) -> Result<()> { + let original = match fs::read_to_string(path) { + Ok(contents) => contents, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => String::new(), + Err(err) => { + return Err(err) + .into_diagnostic() + .wrap_err_with(|| format!("failed to read gateway config '{}'", path.display())); + } + }; + + let mut document = if original.trim().is_empty() { + DocumentMut::new() + } else { + original + .parse::() + .into_diagnostic() + .wrap_err_with(|| format!("failed to parse gateway config '{}'", path.display()))? + }; + + let openshell = ensure_table(document.as_table_mut(), "openshell")?; + if !openshell.contains_key("version") { + openshell.insert("version", value(i64::from(config_file::SCHEMA_VERSION))); + } + + for assignment in &settings.assignments { + apply_assignment(&mut document, assignment)?; + } + + let rendered = document.to_string(); + config_file::parse(&rendered, path).map_err(|err| miette::miette!("{err}"))?; + write_atomically(path, rendered.as_bytes()) +} + +fn parse_assignment_value(raw: &str) -> Item { + let source = format!("value = {raw}"); + source + .parse::() + .ok() + .and_then(|mut document| document.as_table_mut().remove("value")) + .unwrap_or_else(|| value(raw)) +} + +fn apply_assignment(document: &mut DocumentMut, assignment: &Assignment) -> Result<()> { + let (key, parents) = assignment + .key + .split_last() + .ok_or_else(|| miette::miette!("config assignment key must not be empty"))?; + let mut table = document.as_table_mut(); + for parent in parents { + table = ensure_table(table, parent)?; + } + table.insert(key, assignment.value.clone()); + Ok(()) +} + +fn ensure_table<'a>(parent: &'a mut Table, key: &str) -> Result<&'a mut Table> { + if !parent.contains_key(key) { + parent.insert(key, Item::Table(Table::new())); + } + parent + .get_mut(key) + .and_then(Item::as_table_mut) + .ok_or_else(|| miette::miette!("gateway config key '{key}' must be a TOML table")) +} + +fn write_atomically(path: &Path, contents: &[u8]) -> Result<()> { + let parent = path.parent().ok_or_else(|| { + miette::miette!( + "gateway config path '{}' has no parent directory", + path.display() + ) + })?; + let parent = if parent.as_os_str().is_empty() { + Path::new(".") + } else { + parent + }; + fs::create_dir_all(parent) + .into_diagnostic() + .wrap_err_with(|| format!("failed to create config directory '{}'", parent.display()))?; + + let permissions = fs::metadata(path) + .ok() + .map(|metadata| metadata.permissions()); + let mut temp = NamedTempFile::new_in(parent) + .into_diagnostic() + .wrap_err_with(|| format!("failed to create temporary file in '{}'", parent.display()))?; + temp.write_all(contents) + .into_diagnostic() + .wrap_err("failed to write gateway configuration")?; + temp.as_file() + .sync_all() + .into_diagnostic() + .wrap_err("failed to sync gateway configuration")?; + if let Some(permissions) = permissions { + temp.as_file() + .set_permissions(permissions) + .into_diagnostic() + .wrap_err("failed to preserve gateway config permissions")?; + } + temp.persist(path) + .map_err(|err| err.error) + .into_diagnostic() + .wrap_err_with(|| format!("failed to replace gateway config '{}'", path.display()))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn settings(assignments: &[&str]) -> SetArgs { + SetArgs { + assignments: assignments + .iter() + .map(|assignment| assignment.parse().unwrap()) + .collect(), + } + } + + #[test] + fn set_creates_config_with_typed_values() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("openshell/gateway.toml"); + + set( + &path, + &settings(&[ + "openshell.gateway.compute_drivers=[\"podman\"]", + "openshell.gateway.bind_address=0.0.0.0:17670", + "openshell.gateway.log_level=debug", + "openshell.gateway.grpc_rate_limit_requests=42", + "openshell.gateway.enable_loopback_service_http=false", + "openshell.drivers.vm.vcpus=4", + ]), + ) + .unwrap(); + + let loaded = config_file::load(&path).unwrap(); + assert_eq!(loaded.openshell.version, Some(config_file::SCHEMA_VERSION)); + assert_eq!( + loaded.openshell.gateway.compute_drivers, + Some(vec!["podman".to_string()]) + ); + assert_eq!(loaded.openshell.gateway.log_level.as_deref(), Some("debug")); + assert_eq!(loaded.openshell.gateway.grpc_rate_limit_requests, Some(42)); + assert_eq!( + loaded.openshell.gateway.enable_loopback_service_http, + Some(false) + ); + assert_eq!( + loaded.openshell.drivers["vm"] + .get("vcpus") + .and_then(toml::Value::as_integer), + Some(4) + ); + } + + #[test] + fn set_preserves_comments_and_unrelated_settings() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("gateway.toml"); + fs::write( + &path, + "# keep this comment\n[openshell]\nversion = 1\n\n[openshell.gateway]\nlog_level = \"debug\"\ncompute_drivers = [\"docker\"]\n", + ) + .unwrap(); + + set( + &path, + &settings(&["openshell.gateway.compute_drivers=[\"podman\"]"]), + ) + .unwrap(); + + let updated = fs::read_to_string(&path).unwrap(); + assert!(updated.contains("# keep this comment")); + assert!(updated.contains("log_level = \"debug\"")); + } + + #[test] + fn later_assignment_to_the_same_key_wins() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("gateway.toml"); + + set( + &path, + &settings(&[ + "openshell.gateway.log_level=info", + "openshell.gateway.log_level=debug", + ]), + ) + .unwrap(); + + let loaded = config_file::load(&path).unwrap(); + assert_eq!(loaded.openshell.gateway.log_level.as_deref(), Some("debug")); + } + + #[test] + fn assignment_requires_key_value_syntax_and_bare_dotted_keys() { + let missing_value = "openshell.gateway.log_level" + .parse::() + .unwrap_err(); + assert!(missing_value.contains("KEY=VALUE")); + + let invalid_key = "openshell.gateway.bad key=value" + .parse::() + .unwrap_err(); + assert!(invalid_key.contains("dot-separated TOML bare keys")); + } + + #[test] + fn validation_failure_does_not_replace_the_config() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("gateway.toml"); + let original = "[openshell]\nversion = 1\n\n[openshell.gateway]\nlog_level = \"info\"\n"; + fs::write(&path, original).unwrap(); + + let error = set( + &path, + &settings(&[ + "openshell.gateway.log_level=debug", + "openshell.gateway.unknown_setting=value", + ]), + ) + .unwrap_err(); + + assert!(error.to_string().contains("unknown field")); + assert_eq!(fs::read_to_string(&path).unwrap(), original); + } +} diff --git a/crates/openshell-server/src/config_file.rs b/crates/openshell-server/src/config_file.rs index c4e0cbc959..8c7557f1d7 100644 --- a/crates/openshell-server/src/config_file.rs +++ b/crates/openshell-server/src/config_file.rs @@ -244,10 +244,14 @@ pub fn load(path: &Path) -> Result { path: path.to_path_buf(), source, })?; + parse(&contents, path) +} + +pub(crate) fn parse(contents: &str, path: &Path) -> Result { if contents.trim().is_empty() { return Ok(ConfigFile::default()); } - let file: ConfigFile = toml::from_str(&contents).map_err(|source| ConfigFileError::Parse { + let file: ConfigFile = toml::from_str(contents).map_err(|source| ConfigFileError::Parse { path: path.to_path_buf(), source, })?; diff --git a/crates/openshell-server/src/lib.rs b/crates/openshell-server/src/lib.rs index dc00bc4561..4b8ef0da5e 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -27,6 +27,7 @@ mod auth; pub mod certgen; pub mod cli; mod compute; +mod config_command; pub mod config_file; mod defaults; mod grpc; diff --git a/deploy/man/openshell-gateway.8.md b/deploy/man/openshell-gateway.8.md index 2d584c4ba1..13064b09df 100644 --- a/deploy/man/openshell-gateway.8.md +++ b/deploy/man/openshell-gateway.8.md @@ -14,6 +14,8 @@ openshell-gateway - OpenShell gateway server daemon **openshell-gateway** \[*OPTIONS*\] +**openshell-gateway config set** \[**--config** *PATH*\] *KEY=VALUE*... + # DESCRIPTION **openshell-gateway** is the control-plane server for OpenShell. It @@ -32,6 +34,10 @@ TLS. # OPTIONS +**--config** *PATH* +: Read the gateway TOML configuration from *PATH*. Config subcommands + update this file. Environment: **OPENSHELL_GATEWAY_CONFIG**. + **--bind-address** *IP* : IP address to bind all listeners to. Default: **127.0.0.1**. Environment: **OPENSHELL_BIND_ADDRESS**. @@ -111,6 +117,15 @@ Compute driver settings such as sandbox image, callback endpoint, image pull policy, network name, VM state directory, and guest TLS material are configured in the TOML file passed with **--config**. +# CONFIGURATION COMMANDS + +**config set** *KEY=VALUE*... +: Update one or more dotted keys in the gateway TOML file. The command + preserves comments, validates the resulting schema, and replaces the + file atomically. Later assignments to the same key win. Use + **--config** or **OPENSHELL_GATEWAY_CONFIG** to select a non-default + file. + # SYSTEMD INTEGRATION The package installs a systemd user unit at diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index 3dde7643d5..c2e4d46336 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -18,6 +18,37 @@ Gateway CLI flag > gateway OPENSHELL_* env var > TOML file > built-in defa `database_url` is env-only. The loader rejects it when it appears in the file. When `OPENSHELL_DB_URL` is unset, the gateway stores its SQLite database under `$XDG_STATE_HOME/openshell/gateway/openshell.db`. +## Update Configuration from the Command Line + +Use `openshell-gateway config set` with one or more dotted `KEY=VALUE` +assignments: + +```shell +openshell-gateway config set \ + 'openshell.gateway.compute_drivers=["podman"]' \ + openshell.gateway.bind_address=0.0.0.0:17670 +``` + +The command updates `$XDG_CONFIG_HOME/openshell/gateway.toml`, creating the +file and parent directory when needed. Pass `--config ` or set +`OPENSHELL_GATEWAY_CONFIG` to update another file. It preserves comments, +validates the resulting gateway schema, and replaces the file atomically. +Assignments accept TOML strings, integers, booleans, arrays, and tables; +unquoted values that are not valid TOML become strings. Later assignments to +the same key win. + +For the local Docker development gateway, pass assignments through the Mise +task. The task applies them after generating its normal configuration: + +```shell +mise run gateway:docker -- \ + --set openshell.drivers.docker.network_name=openshell-dev \ + --set openshell.drivers.docker.image_pull_policy=Never +``` + +Gateway CLI flags and `OPENSHELL_*` environment variables still take +precedence over values written to the file. + ## Package-Managed Locations Package-managed gateways do not require a TOML file. Create one at the package's optional config location when you need to override built-in defaults. Set `OPENSHELL_GATEWAY_CONFIG` in the launch environment to use a different file. diff --git a/tasks/scripts/gateway-docker.sh b/tasks/scripts/gateway-docker.sh index 0c71489697..0aca3fcd30 100644 --- a/tasks/scripts/gateway-docker.sh +++ b/tasks/scripts/gateway-docker.sh @@ -16,6 +16,7 @@ # OPENSHELL_DOCKER_GATEWAY_NAME=my-docker-gateway mise run gateway:docker # OPENSHELL_SANDBOX_NAMESPACE=my-ns mise run gateway:docker # OPENSHELL_SANDBOX_IMAGE=ghcr.io/... mise run gateway:docker +# mise run gateway:docker -- --set openshell.drivers.docker.network_name=my-network # # After the gateway is running, point the CLI at it with either: # openshell --gateway docker-dev @@ -32,6 +33,54 @@ SANDBOX_IMAGE="${OPENSHELL_SANDBOX_IMAGE:-ghcr.io/nvidia/openshell-community/san SANDBOX_IMAGE_PULL_POLICY="${OPENSHELL_SANDBOX_IMAGE_PULL_POLICY:-IfNotPresent}" LOG_LEVEL="${OPENSHELL_LOG_LEVEL:-info}" GATEWAY_BIN="${ROOT}/target/debug/openshell-gateway" +CONFIG_OVERRIDES=() + +usage() { + cat <<'EOF' +Usage: mise run gateway:docker -- [--set KEY=VALUE]... + +Start a local OpenShell gateway backed by Docker. + +Options: + --set KEY=VALUE Override a dotted key in the generated gateway TOML. + Repeat to set multiple values; later values win. + -h, --help Show this help. + +Example: + mise run gateway:docker -- \ + --set openshell.drivers.docker.network_name=openshell-dev \ + --set openshell.drivers.docker.image_pull_policy=Never +EOF +} + +while (( $# > 0 )); do + case "$1" in + --set) + if (( $# < 2 )); then + echo "ERROR: --set requires a KEY=VALUE argument" >&2 + exit 2 + fi + CONFIG_OVERRIDES+=("$2") + shift 2 + ;; + --set=*) + CONFIG_OVERRIDES+=("${1#--set=}") + shift + ;; + -h|--help) + usage + exit 0 + ;; + --) + shift + ;; + *) + echo "ERROR: unknown argument '$1'" >&2 + usage >&2 + exit 2 + ;; + esac +done normalize_arch() { case "$1" in @@ -191,6 +240,13 @@ grpc_endpoint = "${GRPC_ENDPOINT}" supervisor_bin = "${SUPERVISOR_BIN}" EOF +if (( ${#CONFIG_OVERRIDES[@]} > 0 )); then + echo "Applying gateway configuration overrides..." + "${GATEWAY_BIN}" config set \ + --config "${CONFIG_PATH}" \ + "${CONFIG_OVERRIDES[@]}" >/dev/null +fi + GATEWAY_ENDPOINT="http://127.0.0.1:${PORT}" register_gateway_metadata "${GATEWAY_NAME}" "${GATEWAY_ENDPOINT}" "${PORT}" From 9bd284e1ad26a1da6dae956981a8bd124d82c7bb Mon Sep 17 00:00:00 2001 From: Matthew Grossman Date: Fri, 17 Jul 2026 16:44:10 -0700 Subject: [PATCH 2/4] fix(gateway): preserve config update metadata Signed-off-by: Matthew Grossman --- crates/openshell-server/src/config_command.rs | 98 +++++++++++++++++-- 1 file changed, 92 insertions(+), 6 deletions(-) diff --git a/crates/openshell-server/src/config_command.rs b/crates/openshell-server/src/config_command.rs index 4d019d4b93..328e762d32 100644 --- a/crates/openshell-server/src/config_command.rs +++ b/crates/openshell-server/src/config_command.rs @@ -84,7 +84,8 @@ pub fn run(args: ConfigArgs, explicit_path: Option) -> Result<()> { } fn set(path: &Path, settings: &SetArgs) -> Result<()> { - let original = match fs::read_to_string(path) { + let write_path = resolve_write_path(path)?; + let original = match fs::read_to_string(&write_path) { Ok(contents) => contents, Err(err) if err.kind() == std::io::ErrorKind::NotFound => String::new(), Err(err) => { @@ -114,7 +115,25 @@ fn set(path: &Path, settings: &SetArgs) -> Result<()> { let rendered = document.to_string(); config_file::parse(&rendered, path).map_err(|err| miette::miette!("{err}"))?; - write_atomically(path, rendered.as_bytes()) + write_atomically(&write_path, rendered.as_bytes()) +} + +fn resolve_write_path(path: &Path) -> Result { + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_symlink() => { + fs::canonicalize(path).into_diagnostic().wrap_err_with(|| { + format!( + "failed to resolve gateway config symlink '{}'", + path.display() + ) + }) + } + Ok(_) => Ok(path.to_path_buf()), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(path.to_path_buf()), + Err(err) => Err(err) + .into_diagnostic() + .wrap_err_with(|| format!("failed to inspect gateway config '{}'", path.display())), + } } fn parse_assignment_value(raw: &str) -> Item { @@ -135,7 +154,15 @@ fn apply_assignment(document: &mut DocumentMut, assignment: &Assignment) -> Resu for parent in parents { table = ensure_table(table, parent)?; } - table.insert(key, assignment.value.clone()); + let existing_decor = table + .get(key) + .and_then(Item::as_value) + .map(|existing| existing.decor().clone()); + let mut replacement = assignment.value.clone(); + if let (Some(decor), Some(value)) = (existing_decor, replacement.as_value_mut()) { + *value.decor_mut() = decor; + } + table.insert(key, replacement); Ok(()) } @@ -248,19 +275,78 @@ mod tests { let path = temp.path().join("gateway.toml"); fs::write( &path, - "# keep this comment\n[openshell]\nversion = 1\n\n[openshell.gateway]\nlog_level = \"debug\"\ncompute_drivers = [\"docker\"]\n", + "# keep this comment\n[openshell]\nversion = 1\n\n[openshell.gateway]\nlog_level = \"info\" # keep this inline comment\ncompute_drivers = [\"docker\"]\n", ) .unwrap(); set( &path, - &settings(&["openshell.gateway.compute_drivers=[\"podman\"]"]), + &settings(&[ + "openshell.gateway.log_level=debug", + "openshell.gateway.compute_drivers=[\"podman\"]", + ]), ) .unwrap(); let updated = fs::read_to_string(&path).unwrap(); assert!(updated.contains("# keep this comment")); - assert!(updated.contains("log_level = \"debug\"")); + assert!(updated.contains("log_level = \"debug\" # keep this inline comment")); + } + + #[cfg(unix)] + #[test] + fn set_updates_a_symlink_target_without_replacing_the_symlink() { + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().unwrap(); + let target = temp.path().join("managed-gateway.toml"); + let path = temp.path().join("gateway.toml"); + fs::write( + &target, + "[openshell]\nversion = 1\n\n[openshell.gateway]\nlog_level = \"info\"\n", + ) + .unwrap(); + symlink("managed-gateway.toml", &path).unwrap(); + + set(&path, &settings(&["openshell.gateway.log_level=debug"])).unwrap(); + + assert!( + fs::symlink_metadata(&path) + .unwrap() + .file_type() + .is_symlink() + ); + let loaded = config_file::load(&target).unwrap(); + assert_eq!(loaded.openshell.gateway.log_level.as_deref(), Some("debug")); + assert_eq!( + fs::read_to_string(&path).unwrap(), + fs::read_to_string(&target).unwrap() + ); + } + + #[cfg(unix)] + #[test] + fn set_rejects_a_dangling_config_symlink() { + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().unwrap(); + let target = temp.path().join("missing-gateway.toml"); + let path = temp.path().join("gateway.toml"); + symlink("missing-gateway.toml", &path).unwrap(); + + let error = set(&path, &settings(&["openshell.gateway.log_level=debug"])).unwrap_err(); + + assert!( + format!("{error:?}").contains("failed to resolve gateway config symlink"), + "unexpected error: {error:?}" + ); + assert!( + fs::symlink_metadata(&path) + .unwrap() + .file_type() + .is_symlink() + ); + assert!(!target.exists()); } #[test] From 3e3088f2c52448988c8adb5d52e9eb8aab7b0f73 Mon Sep 17 00:00:00 2001 From: Matthew Grossman Date: Thu, 23 Jul 2026 12:43:45 -0700 Subject: [PATCH 3/4] use toml syntax instead of custom syntax Signed-off-by: Matthew Grossman --- architecture/gateway.md | 9 +- crates/openshell-server/src/cli.rs | 20 ++- crates/openshell-server/src/config_command.rs | 140 ++++++++++++------ deploy/man/openshell-gateway.8.md | 12 +- docs/reference/gateway-config.mdx | 16 +- tasks/scripts/gateway-docker.sh | 11 +- 6 files changed, 141 insertions(+), 67 deletions(-) diff --git a/architecture/gateway.md b/architecture/gateway.md index 387ea5879b..7e2147dd84 100644 --- a/architecture/gateway.md +++ b/architecture/gateway.md @@ -558,10 +558,11 @@ Driver implementation settings live in the TOML driver tables. See 0003 for the full schema. `openshell-gateway config set` updates a single resolved TOML file from -repeatable dotted `KEY=VALUE` assignments. It preserves comments, validates -the complete gateway schema, and replaces the file atomically. Local -development tasks can apply overrides to their generated configuration through -this command without changing gateway startup precedence. +repeatable TOML dotted `KEY=VALUE` assignments. Keys and values use TOML +syntax, matching Cargo's command-line configuration override convention. It +preserves comments, validates the complete gateway schema, and replaces the +file atomically. Local development tasks can apply overrides to their generated +configuration through this command without changing gateway startup precedence. `database_url` is env-only and rejected when present in the file (`OPENSHELL_DB_URL` / `--db-url`). diff --git a/crates/openshell-server/src/cli.rs b/crates/openshell-server/src/cli.rs index 6095059aaf..c366f4f18b 100644 --- a/crates/openshell-server/src/cli.rs +++ b/crates/openshell-server/src/cli.rs @@ -1106,7 +1106,7 @@ mod tests { "--config", &path_string, "openshell.gateway.compute_drivers=[\"podman\"]", - "openshell.gateway.bind_address=0.0.0.0:17670", + "openshell.gateway.bind_address=\"0.0.0.0:17670\"", ]) .expect("config set should parse without runtime arguments"); let Cli { command, run } = cli; @@ -1151,6 +1151,24 @@ mod tests { assert_eq!(error.kind(), clap::error::ErrorKind::ValueValidation); } + #[test] + fn config_set_help_documents_whole_array_replacement() { + let mut command = command(); + let config = command + .find_subcommand_mut("config") + .expect("config subcommand"); + let set = config + .find_subcommand_mut("set") + .expect("config set subcommand"); + let mut help = Vec::new(); + set.write_long_help(&mut help).unwrap(); + let help = String::from_utf8(help).unwrap(); + + assert!(help.contains("validates the result and atomically replaces the file")); + assert!(help.contains("Array elements cannot be addressed individually")); + assert!(help.contains("assign the complete array instead")); + } + #[test] fn bare_invocation_with_no_db_url_parses_for_runtime_defaults() { // db_url is Option at the clap level so subcommand parsing diff --git a/crates/openshell-server/src/config_command.rs b/crates/openshell-server/src/config_command.rs index 328e762d32..e63b873bea 100644 --- a/crates/openshell-server/src/config_command.rs +++ b/crates/openshell-server/src/config_command.rs @@ -23,20 +23,24 @@ pub struct ConfigArgs { #[derive(Debug, Subcommand)] enum ConfigCommand { - /// Update fields in the gateway TOML configuration. + /// Update the selected gateway TOML file. + /// + /// The command validates the result and atomically replaces the file. Set(SetArgs), } #[derive(Debug, Args)] struct SetArgs { - /// Dotted TOML key and value to set. May be repeated. + /// TOML dotted key and value to set. May be repeated. + /// String values must be quoted according to TOML syntax. + /// Array elements cannot be addressed individually; assign the complete array instead. #[arg(required = true, value_name = "KEY=VALUE")] assignments: Vec, } #[derive(Clone, Debug)] struct Assignment { - key: Vec, + path: Vec, value: Item, } @@ -44,33 +48,44 @@ impl FromStr for Assignment { type Err = String; fn from_str(input: &str) -> std::result::Result { - let (raw_key, raw_value) = input.split_once('=').ok_or_else(|| { - format!("invalid assignment '{input}': expected a dotted KEY=VALUE argument") + let document = input.parse::().map_err(|err| { + format!("invalid assignment: expected one TOML KEY=VALUE assignment: {err}") })?; - - let key = raw_key - .split('.') - .map(|component| { - if component.is_empty() - || !component - .chars() - .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-')) - { - return Err(format!( - "invalid config key '{raw_key}': use dot-separated TOML bare keys" - )); - } - Ok(component.to_string()) - }) - .collect::, _>>()?; + let values = document.as_table().get_values(); + let [(keys, value)] = values.as_slice() else { + return Err( + "invalid assignment: expected exactly one TOML KEY=VALUE assignment".to_string(), + ); + }; + if !has_only_dotted_parents(document.as_table(), keys) { + return Err( + "invalid assignment: expected a TOML dotted key, not a table header".to_string(), + ); + } + let mut value = (*value).clone(); + value.decor_mut().clear(); Ok(Self { - key, - value: parse_assignment_value(raw_value.trim()), + path: keys.iter().map(|key| key.get().to_string()).collect(), + value: Item::Value(value), }) } } +fn has_only_dotted_parents(table: &Table, keys: &[&toml_edit::Key]) -> bool { + let mut current = table; + for key in keys.iter().take(keys.len().saturating_sub(1)) { + let Some(Item::Table(next)) = current.get(key.get()) else { + return false; + }; + if !next.is_dotted() { + return false; + } + current = next; + } + true +} + pub fn run(args: ConfigArgs, explicit_path: Option) -> Result<()> { match args.command { ConfigCommand::Set(settings) => { @@ -136,18 +151,9 @@ fn resolve_write_path(path: &Path) -> Result { } } -fn parse_assignment_value(raw: &str) -> Item { - let source = format!("value = {raw}"); - source - .parse::() - .ok() - .and_then(|mut document| document.as_table_mut().remove("value")) - .unwrap_or_else(|| value(raw)) -} - fn apply_assignment(document: &mut DocumentMut, assignment: &Assignment) -> Result<()> { let (key, parents) = assignment - .key + .path .split_last() .ok_or_else(|| miette::miette!("config assignment key must not be empty"))?; let mut table = document.as_table_mut(); @@ -240,11 +246,12 @@ mod tests { &path, &settings(&[ "openshell.gateway.compute_drivers=[\"podman\"]", - "openshell.gateway.bind_address=0.0.0.0:17670", - "openshell.gateway.log_level=debug", + "openshell.gateway.bind_address=\"0.0.0.0:17670\"", + "openshell.gateway.log_level=\"debug\"", "openshell.gateway.grpc_rate_limit_requests=42", "openshell.gateway.enable_loopback_service_http=false", "openshell.drivers.vm.vcpus=4", + "openshell.drivers.\"containerd.io\".socket_path=\"/run/containerd.sock\"", ]), ) .unwrap(); @@ -267,6 +274,12 @@ mod tests { .and_then(toml::Value::as_integer), Some(4) ); + assert_eq!( + loaded.openshell.drivers["containerd.io"] + .get("socket_path") + .and_then(toml::Value::as_str), + Some("/run/containerd.sock") + ); } #[test] @@ -282,7 +295,7 @@ mod tests { set( &path, &settings(&[ - "openshell.gateway.log_level=debug", + "openshell.gateway.log_level=\"debug\"", "openshell.gateway.compute_drivers=[\"podman\"]", ]), ) @@ -308,7 +321,7 @@ mod tests { .unwrap(); symlink("managed-gateway.toml", &path).unwrap(); - set(&path, &settings(&["openshell.gateway.log_level=debug"])).unwrap(); + set(&path, &settings(&["openshell.gateway.log_level=\"debug\""])).unwrap(); assert!( fs::symlink_metadata(&path) @@ -334,7 +347,7 @@ mod tests { let path = temp.path().join("gateway.toml"); symlink("missing-gateway.toml", &path).unwrap(); - let error = set(&path, &settings(&["openshell.gateway.log_level=debug"])).unwrap_err(); + let error = set(&path, &settings(&["openshell.gateway.log_level=\"debug\""])).unwrap_err(); assert!( format!("{error:?}").contains("failed to resolve gateway config symlink"), @@ -357,8 +370,8 @@ mod tests { set( &path, &settings(&[ - "openshell.gateway.log_level=info", - "openshell.gateway.log_level=debug", + "openshell.gateway.log_level=\"info\"", + "openshell.gateway.log_level=\"debug\"", ]), ) .unwrap(); @@ -368,16 +381,53 @@ mod tests { } #[test] - fn assignment_requires_key_value_syntax_and_bare_dotted_keys() { + fn assignment_requires_exactly_one_toml_key_value() { let missing_value = "openshell.gateway.log_level" .parse::() .unwrap_err(); assert!(missing_value.contains("KEY=VALUE")); - let invalid_key = "openshell.gateway.bad key=value" + let unquoted_string = "openshell.gateway.log_level=debug" + .parse::() + .unwrap_err(); + assert!(unquoted_string.contains("TOML KEY=VALUE")); + + let multiple = "openshell.gateway.log_level=\"debug\"\nopenshell.gateway.disable_tls=true" .parse::() .unwrap_err(); - assert!(invalid_key.contains("dot-separated TOML bare keys")); + assert!(multiple.contains("exactly one")); + + let table_header = "[openshell.gateway]\nlog_level=\"debug\"" + .parse::() + .unwrap_err(); + assert!(table_header.contains("exactly one")); + } + + #[test] + fn assignment_uses_toml_key_and_value_syntax() { + let assignment = + "openshell.drivers.\"containerd.io\".socket_path = \"/run/containerd.sock\"" + .parse::() + .unwrap(); + + assert_eq!( + assignment.path, + ["openshell", "drivers", "containerd.io", "socket_path"] + ); + assert_eq!(assignment.value.as_str(), Some("/run/containerd.sock")); + } + + #[test] + fn assignment_accepts_multiline_toml_strings() { + let basic = "openshell.gateway.log_level=\"\"\"debug\ntrace\"\"\"" + .parse::() + .unwrap(); + assert_eq!(basic.value.as_str(), Some("debug\ntrace")); + + let literal = "openshell.gateway.log_level='''debug\\n\ntrace'''" + .parse::() + .unwrap(); + assert_eq!(literal.value.as_str(), Some("debug\\n\ntrace")); } #[test] @@ -390,8 +440,8 @@ mod tests { let error = set( &path, &settings(&[ - "openshell.gateway.log_level=debug", - "openshell.gateway.unknown_setting=value", + "openshell.gateway.log_level=\"debug\"", + "openshell.gateway.unknown_setting=\"value\"", ]), ) .unwrap_err(); diff --git a/deploy/man/openshell-gateway.8.md b/deploy/man/openshell-gateway.8.md index 13064b09df..0214b6f480 100644 --- a/deploy/man/openshell-gateway.8.md +++ b/deploy/man/openshell-gateway.8.md @@ -120,11 +120,13 @@ configured in the TOML file passed with **--config**. # CONFIGURATION COMMANDS **config set** *KEY=VALUE*... -: Update one or more dotted keys in the gateway TOML file. The command - preserves comments, validates the resulting schema, and replaces the - file atomically. Later assignments to the same key win. Use - **--config** or **OPENSHELL_GATEWAY_CONFIG** to select a non-default - file. +: Update one or more TOML dotted keys in the gateway TOML file. Each + assignment must use TOML syntax, including quotes around string values. + The command preserves comments, validates the resulting schema, and + replaces the file atomically. Later assignments to the same key win. + Use **--config** or **OPENSHELL_GATEWAY_CONFIG** to select a non-default + file. Array elements cannot be addressed individually; assign the complete + array value instead. # SYSTEMD INTEGRATION diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index c2e4d46336..344056b71e 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -20,30 +20,32 @@ Gateway CLI flag > gateway OPENSHELL_* env var > TOML file > built-in defa ## Update Configuration from the Command Line -Use `openshell-gateway config set` with one or more dotted `KEY=VALUE` +Use `openshell-gateway config set` with one or more TOML dotted `KEY=VALUE` assignments: ```shell openshell-gateway config set \ 'openshell.gateway.compute_drivers=["podman"]' \ - openshell.gateway.bind_address=0.0.0.0:17670 + 'openshell.gateway.bind_address="0.0.0.0:17670"' ``` The command updates `$XDG_CONFIG_HOME/openshell/gateway.toml`, creating the file and parent directory when needed. Pass `--config ` or set `OPENSHELL_GATEWAY_CONFIG` to update another file. It preserves comments, validates the resulting gateway schema, and replaces the file atomically. -Assignments accept TOML strings, integers, booleans, arrays, and tables; -unquoted values that are not valid TOML become strings. Later assignments to -the same key win. +Assignments use TOML syntax, including quotes around string values. They accept +TOML strings, integers, booleans, arrays, and inline tables. Quoted TOML keys +can address names that contain dots. Later assignments to the same key win. +Array elements cannot be addressed individually; assign the complete array +value instead. For the local Docker development gateway, pass assignments through the Mise task. The task applies them after generating its normal configuration: ```shell mise run gateway:docker -- \ - --set openshell.drivers.docker.network_name=openshell-dev \ - --set openshell.drivers.docker.image_pull_policy=Never + --set 'openshell.drivers.docker.network_name="openshell-dev"' \ + --set 'openshell.drivers.docker.image_pull_policy="Never"' ``` Gateway CLI flags and `OPENSHELL_*` environment variables still take diff --git a/tasks/scripts/gateway-docker.sh b/tasks/scripts/gateway-docker.sh index 0aca3fcd30..6d66d4292d 100644 --- a/tasks/scripts/gateway-docker.sh +++ b/tasks/scripts/gateway-docker.sh @@ -16,7 +16,7 @@ # OPENSHELL_DOCKER_GATEWAY_NAME=my-docker-gateway mise run gateway:docker # OPENSHELL_SANDBOX_NAMESPACE=my-ns mise run gateway:docker # OPENSHELL_SANDBOX_IMAGE=ghcr.io/... mise run gateway:docker -# mise run gateway:docker -- --set openshell.drivers.docker.network_name=my-network +# mise run gateway:docker -- --set 'openshell.drivers.docker.network_name="my-network"' # # After the gateway is running, point the CLI at it with either: # openshell --gateway docker-dev @@ -42,14 +42,15 @@ Usage: mise run gateway:docker -- [--set KEY=VALUE]... Start a local OpenShell gateway backed by Docker. Options: - --set KEY=VALUE Override a dotted key in the generated gateway TOML. - Repeat to set multiple values; later values win. + --set KEY=VALUE Override a TOML dotted key in the generated gateway TOML. + Values use TOML syntax. Repeat to set multiple values; + later values win. -h, --help Show this help. Example: mise run gateway:docker -- \ - --set openshell.drivers.docker.network_name=openshell-dev \ - --set openshell.drivers.docker.image_pull_policy=Never + --set 'openshell.drivers.docker.network_name="openshell-dev"' \ + --set 'openshell.drivers.docker.image_pull_policy="Never"' EOF } From e3f1d9f950095f7acead38fbb3b4d12056237dc8 Mon Sep 17 00:00:00 2001 From: Matthew Grossman Date: Thu, 23 Jul 2026 15:02:58 -0700 Subject: [PATCH 4/4] code review Signed-off-by: Matthew Grossman --- crates/openshell-server/src/config_command.rs | 78 +++++++++---------- 1 file changed, 38 insertions(+), 40 deletions(-) diff --git a/crates/openshell-server/src/config_command.rs b/crates/openshell-server/src/config_command.rs index e63b873bea..f325bda6c3 100644 --- a/crates/openshell-server/src/config_command.rs +++ b/crates/openshell-server/src/config_command.rs @@ -90,7 +90,7 @@ pub fn run(args: ConfigArgs, explicit_path: Option) -> Result<()> { match args.command { ConfigCommand::Set(settings) => { let path = explicit_path.map_or_else(defaults::default_gateway_config_path, Ok)?; - set(&path, &settings)?; + update_file(&path, &settings)?; println!("updated gateway configuration: {}", path.display()); println!("Restart the gateway service for changes to take effect."); } @@ -98,25 +98,33 @@ pub fn run(args: ConfigArgs, explicit_path: Option) -> Result<()> { Ok(()) } -fn set(path: &Path, settings: &SetArgs) -> Result<()> { +fn update_file(path: &Path, settings: &SetArgs) -> Result<()> { let write_path = resolve_write_path(path)?; - let original = match fs::read_to_string(&write_path) { - Ok(contents) => contents, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => String::new(), - Err(err) => { - return Err(err) - .into_diagnostic() - .wrap_err_with(|| format!("failed to read gateway config '{}'", path.display())); - } - }; + let original = read_config(&write_path) + .wrap_err_with(|| format!("failed to read gateway config '{}'", path.display()))?; + let document = update_document(&original, settings) + .wrap_err_with(|| format!("failed to update gateway config '{}'", path.display()))?; + let rendered = document.to_string(); + config_file::parse(&rendered, path).map_err(|err| miette::miette!("{err}"))?; + write_atomically(&write_path, rendered.as_bytes()) +} + +fn read_config(path: &Path) -> Result { + match fs::read_to_string(path) { + Ok(contents) => Ok(contents), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(String::new()), + Err(err) => Err(err).into_diagnostic(), + } +} +fn update_document(original: &str, settings: &SetArgs) -> Result { let mut document = if original.trim().is_empty() { DocumentMut::new() } else { original .parse::() .into_diagnostic() - .wrap_err_with(|| format!("failed to parse gateway config '{}'", path.display()))? + .wrap_err("failed to parse gateway configuration")? }; let openshell = ensure_table(document.as_table_mut(), "openshell")?; @@ -128,9 +136,7 @@ fn set(path: &Path, settings: &SetArgs) -> Result<()> { apply_assignment(&mut document, assignment)?; } - let rendered = document.to_string(); - config_file::parse(&rendered, path).map_err(|err| miette::miette!("{err}"))?; - write_atomically(&write_path, rendered.as_bytes()) + Ok(document) } fn resolve_write_path(path: &Path) -> Result { @@ -242,7 +248,7 @@ mod tests { let temp = tempfile::tempdir().unwrap(); let path = temp.path().join("openshell/gateway.toml"); - set( + update_file( &path, &settings(&[ "openshell.gateway.compute_drivers=[\"podman\"]", @@ -283,25 +289,18 @@ mod tests { } #[test] - fn set_preserves_comments_and_unrelated_settings() { - let temp = tempfile::tempdir().unwrap(); - let path = temp.path().join("gateway.toml"); - fs::write( - &path, - "# keep this comment\n[openshell]\nversion = 1\n\n[openshell.gateway]\nlog_level = \"info\" # keep this inline comment\ncompute_drivers = [\"docker\"]\n", - ) - .unwrap(); - - set( - &path, + fn update_document_preserves_comments_and_unrelated_settings() { + let original = "# keep this comment\n[openshell]\nversion = 1\n\n[openshell.gateway]\nlog_level = \"info\" # keep this inline comment\ncompute_drivers = [\"docker\"]\n"; + let updated = update_document( + original, &settings(&[ "openshell.gateway.log_level=\"debug\"", "openshell.gateway.compute_drivers=[\"podman\"]", ]), ) - .unwrap(); + .unwrap() + .to_string(); - let updated = fs::read_to_string(&path).unwrap(); assert!(updated.contains("# keep this comment")); assert!(updated.contains("log_level = \"debug\" # keep this inline comment")); } @@ -321,7 +320,7 @@ mod tests { .unwrap(); symlink("managed-gateway.toml", &path).unwrap(); - set(&path, &settings(&["openshell.gateway.log_level=\"debug\""])).unwrap(); + update_file(&path, &settings(&["openshell.gateway.log_level=\"debug\""])).unwrap(); assert!( fs::symlink_metadata(&path) @@ -347,7 +346,8 @@ mod tests { let path = temp.path().join("gateway.toml"); symlink("missing-gateway.toml", &path).unwrap(); - let error = set(&path, &settings(&["openshell.gateway.log_level=\"debug\""])).unwrap_err(); + let error = + update_file(&path, &settings(&["openshell.gateway.log_level=\"debug\""])).unwrap_err(); assert!( format!("{error:?}").contains("failed to resolve gateway config symlink"), @@ -363,20 +363,18 @@ mod tests { } #[test] - fn later_assignment_to_the_same_key_wins() { - let temp = tempfile::tempdir().unwrap(); - let path = temp.path().join("gateway.toml"); - - set( - &path, + fn update_document_applies_assignments_in_order() { + let updated = update_document( + "", &settings(&[ "openshell.gateway.log_level=\"info\"", "openshell.gateway.log_level=\"debug\"", ]), ) - .unwrap(); + .unwrap() + .to_string(); - let loaded = config_file::load(&path).unwrap(); + let loaded = config_file::parse(&updated, Path::new("gateway.toml")).unwrap(); assert_eq!(loaded.openshell.gateway.log_level.as_deref(), Some("debug")); } @@ -437,7 +435,7 @@ mod tests { let original = "[openshell]\nversion = 1\n\n[openshell.gateway]\nlog_level = \"info\"\n"; fs::write(&path, original).unwrap(); - let error = set( + let error = update_file( &path, &settings(&[ "openshell.gateway.log_level=\"debug\"",