feat(gateway): add config set overrides - #2350
Conversation
Signed-off-by: Matthew Grossman <mgrossman@nvidia.com>
|
Auto-sync is disabled for draft pull requests in this repository. Workflows must be run manually. Contributors can view more details about this message here. |
|
🌿 Preview your docs: https://nvidia-preview-pr-2350.docs.buildwithfern.com/openshell |
Signed-off-by: Matthew Grossman <mgrossman@nvidia.com>
|
|
||
| #[derive(Debug, Args)] | ||
| pub struct ConfigArgs { | ||
| #[command(subcommand)] |
There was a problem hiding this comment.
Do we need to add some properties here, e.g. the help_template, after_help, etc. to keep the help output consistent?
There was a problem hiding this comment.
I checked the existing CLI conventions. The custom help templates are used by the user-facing openshell CLI, while openshell-gateway, including generate-certs, currently uses Clap’s default formatting. I kept the default formatting for consistency within this binary and added the relevant atomic-replacement and array behavior to the generated long help.
|
|
||
| #[derive(Clone, Debug)] | ||
| struct Assignment { | ||
| key: Vec<String>, |
There was a problem hiding this comment.
Probably a nit, but in the toml context, I would argue that key is the string representation root.table.table2.setting, whereas the path is the Vec representation (split by .).
There was a problem hiding this comment.
Done. The parsed component vector is now named Assignment::path.
| } | ||
| } | ||
|
|
||
| fn parse_assignment_value(raw: &str) -> Item { |
There was a problem hiding this comment.
Could we add focused tests for RHS parsing and use toml_edit’s value parser directly here?
Proposed parser shape:
fn parse_assignment_value(raw: &str) -> Result<Item, String> {
if let Ok(item) = raw.parse::<Item>() {
return Ok(item);
}
if raw.contains(['\n', '\r']) {
return Err("invalid assignment value: expected a single TOML value or an unquoted string".into());
}
Ok(value(raw))
}Proposed test cases:
openshell.gateway.log_level="""debug\ntrace"""is accepted as a multiline basic string.openshell.gateway.log_level='''debug\\n\ntrace'''is accepted as a multiline literal string.openshell.gateway.log_level=42\nother = trueis rejected as an invalid value fragment.openshell.gateway.log_level=42\notheris rejected as a multiline unquoted value.
That would pin the intended boundary between “single TOML value”, “single-line bare string fallback”, and invalid multiline input, while avoiding the current fake-document parse.
There was a problem hiding this comment.
Addressed by removing the fake-document RHS parser and parsing the complete argument as exactly one TOML assignment. This reuses toml_edit for both the key and value grammar and deliberately removes the custom unquoted-string fallback, matching Cargo-style configuration overrides.
Added coverage for multiline basic and literal strings, multiple assignments, unquoted strings, and table-header fragments. Trailing assignments are now rejected instead of being silently discarded.
| .ok_or_else(|| miette::miette!("gateway config key '{key}' must be a TOML table")) | ||
| } | ||
|
|
||
| fn write_atomically(path: &Path, contents: &[u8]) -> Result<()> { |
There was a problem hiding this comment.
This follows the same basic tempfile + persist pattern we already use elsewhere, but we now have a couple of local atomic file writers. Could we either reuse/extract a small shared helper, or add tests here for the metadata we care about? In particular, the current implementation preserves mode bits but not uid/gid on Unix, which can matter for managed config files.
There was a problem hiding this comment.
Good catch. The latest commit does not address the uid/gid case: the writer currently preserves mode bits, but the replacement file is owned by the user running the command.
I think this thread should remain open while I add a Unix mode-preservation test and decide whether to preserve uid/gid or explicitly limit the supported guarantee to caller-owned configuration files.
| format!("invalid assignment '{input}': expected a dotted KEY=VALUE argument") | ||
| })?; | ||
|
|
||
| let key = raw_key |
There was a problem hiding this comment.
Could we use toml_edit::Key::parse(raw_key) for the assignment path instead of splitting on . manually? The current parser only supports bare key components, and it will mis-split valid TOML quoted keys such as:
openshell.drivers."containerd.io".socket_path=/run/containerd/containerd.sock
With raw_key.split('.'), that becomes "containerd and io" as separate components, so there is no way to set driver tables or future config keys whose TOML key contains a dot. toml_edit::Key::parse already understands TOML dotted-key syntax and would keep this aligned with the value parsing side.
There was a problem hiding this comment.
Addressed by parsing the complete KEY=VALUE argument as TOML instead of splitting the key manually. This uses toml_edit’s TOML key parser internally and supports quoted components such as openshell.drivers."containerd.io".socket_path.
Added both parser-level and end-to-end coverage for a quoted key containing a dot.
| Ok(()) | ||
| } | ||
|
|
||
| fn set(path: &Path, settings: &SetArgs) -> Result<()> { |
There was a problem hiding this comment.
nit: In the context of Go, I've often found that splitting something like this into function working on different types (Path -> io.Writer / io.Reader -> Config object) make things simpler to test without needing to go through the file system.
There was a problem hiding this comment.
Assignment parsing is now isolated in Assignment::from_str and can be tested without filesystem access. I left the read-transform-validate-write flow together for now because the existing tests exercise important filesystem behavior such as atomic replacement and leaving the original untouched after validation failure.
There was a problem hiding this comment.
hmm revisiting this before trying to resolve, I think I agree here and will make a quick fix
There was a problem hiding this comment.
I separated config reading, in-memory transformation, validation, and atomic writing. The transformation is now testable without filesystem access, and set is just the "orchestration" function to tie all those testable bits together
| Ok(()) | ||
| } | ||
|
|
||
| fn set(path: &Path, settings: &SetArgs) -> Result<()> { |
There was a problem hiding this comment.
Question: Can set be a method (possibly apply?) on the SetArgs type? At the very least, does it make sense to reorder the arguments to make it clear that path is the output?
There was a problem hiding this comment.
Renamed this to update_file to make the read-modify-write behavior explicit. I kept it as a free func so SetArgs remains a CLI input type, and retained path-first ordering because the path is both the source and destination.
| let mut document = if original.trim().is_empty() { | ||
| DocumentMut::new() | ||
| } else { | ||
| original | ||
| .parse::<DocumentMut>() | ||
| .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))); | ||
| } |
There was a problem hiding this comment.
This seems like it should be common config handling (i.e. config.load()). How does OpenShell currently handle a config file that doesn't exist? Should this not generate a default config, or is this only done on parsing?
| .unwrap_or_else(|| value(raw)) | ||
| } | ||
|
|
||
| fn apply_assignment(document: &mut DocumentMut, assignment: &Assignment) -> Result<()> { |
There was a problem hiding this comment.
Addressed by removing the standalone value parser. Assignment::from_str now parses the complete TOML assignment and produces both the logical path and value.
| } | ||
|
|
||
| let rendered = document.to_string(); | ||
| config_file::parse(&rendered, path).map_err(|err| miette::miette!("{err}"))?; |
There was a problem hiding this comment.
Could we add coverage for setting a field inside a nested required table on a missing config file, and either support or document the expected behavior? For example:
openshell-gateway config set openshell.gateway.tls.cert_path=/tmp/cert.pem
starts from an empty document when the config file does not exist, then creates [openshell.gateway.tls] with only cert_path. Since TlsConfig also requires key_path, final schema validation should fail before writing. That may be the right behavior, but it would be good to pin it with a test and clarify that creating nested config objects from scratch may require setting all required sibling fields in the same invocation.
There was a problem hiding this comment.
Agreed. The current behavior is safe—schema validation fails before writing—but the missing-file case is not yet explicitly tested or documented.
This was not addressed in the latest commit, so I’m leaving the thread open. I plan to add a failure test proving that setting only tls.cert_path does not create the file, a success test setting both required TLS paths together, and a short documentation note.
Signed-off-by: Matthew Grossman <mgrossman@nvidia.com>
Signed-off-by: Matthew Grossman <mgrossman@nvidia.com>
pimlock
left a comment
There was a problem hiding this comment.
Two findings from validating the current config-set behavior.
| 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}"))?; |
There was a problem hiding this comment.
P2: Reuse startup's driver-specific configuration validation before replacing the file. ConfigFile stores [openshell.drivers.<name>] as raw toml::Value, so this call validates only the generic gateway schema. I reproduced openshell.drivers.docker.image_pull_polciy="Never": config set exits successfully and writes the typo, then a Docker-selected gateway fails during startup with invalid [openshell.drivers.docker] table: unknown field image_pull_polciy.
Could we extract and reuse the pure file-to-driver validation performed at startup here? It should merge inherited fields and deserialize applicable compiled-in driver tables without initializing drivers. For single-driver builds, I think we should validate compiled drivers, reject selection or modification of known unavailable drivers, and preserve untouched dormant tables. A validation failure should leave the original file unchanged.
| } | ||
| parent | ||
| .get_mut(key) | ||
| .and_then(Item::as_table_mut) |
There was a problem hiding this comment.
P2: Support traversal through existing inline tables. With the valid configuration auth = { allow_unauthenticated_users = true }, setting openshell.gateway.auth.allow_unauthenticated_users=false fails with gateway config key 'auth' must be a TOML table because traversal only accepts Item::Table.
Since the command accepts TOML dotted keys and the documentation includes inline tables among supported TOML values, could we mutate InlineTable directly or convert it to a normal table before descending? A regression test should cover updating a nested field beneath an existing inline table.
Summary
Add a generic, atomic
openshell-gateway config setcommand and expose repeatable configuration overrides through the local Docker gateway task.Related Issue
N/A — extracted from the config-management work in #2137 following the engineering discussion.
Changes
KEY=VALUEassignments for gateway TOML updatesmise run gateway:docker -- --set KEY=VALUEpassthrough after generated config creationTesting
mise run pre-commitpassesChecklist