Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
7 changes: 7 additions & 0 deletions architecture/gateway.md
Original file line number Diff line number Diff line change
Expand Up @@ -557,6 +557,13 @@ 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 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`).

Expand Down
1 change: 1 addition & 0 deletions crates/openshell-server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
85 changes: 84 additions & 1 deletion crates/openshell-server/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand All @@ -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<PathBuf>,

/// IP address to bind the server, health, and metrics listeners to.
Expand Down Expand Up @@ -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,
}
}
Expand Down Expand Up @@ -1086,6 +1089,86 @@ 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 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<String> at the clap level so subcommand parsing
Expand Down
Loading
Loading