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>
| } | ||
| Ok(component.to_string()) | ||
| }) | ||
| .collect::<std::result::Result<Vec<_>, _>>()?; |
There was a problem hiding this comment.
I think it makes sense to only support flat keys, i.e. without arrays (e.g. foo.bar[0].baz).
We don't have many things that use arrays, but now have interceptors and middlewares that are arrays. So editing these would be a manual config edit still.
There was a problem hiding this comment.
I think that's okay for now? I can just document that for now I guess
There was a problem hiding this comment.
Yeah, that makes sense.
I was thinking for more complex config edits, it would be nice to be able to do it in the editor, e.g. config edit or config set --edit, or similar.Not for this PR, just food for thought.
There was a problem hiding this comment.
I kept array element addressing out of scope and documented that arrays must be replaced as complete values in config set --help, the man page, and the gateway configuration docs.
I also changed assignments to use Cargo-style TOML syntax rather than adding an OpenShell-specific path language. An interactive config edit command sounds like a useful follow-up for more complex edits, but I’m leaving it out of this PR.
|
|
||
| #[derive(Debug, Subcommand)] | ||
| enum ConfigCommand { | ||
| /// Update fields in the gateway TOML configuration. |
There was a problem hiding this comment.
Worth mentioning the config is updated in place? I think the name set is quite clear already, but maybe worth making sure?
I'm wondering if there is anything that could go wrong, e.g. someone overrides something that is hard to recover? Looking at other tools, it doesn't seem a concern they deal with.
There was a problem hiding this comment.
Updated config set --help to say that the selected gateway TOML file is validated and atomically replaced. I used “atomically replaced” instead of “updated in place” because the implementation writes a temporary file and renames it over the destination.
|
|
||
| #[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>
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