Skip to content

feat(gateway): add config set overrides - #2350

Open
matthewgrossman wants to merge 4 commits into
mainfrom
codex/config-set-gateway
Open

feat(gateway): add config set overrides#2350
matthewgrossman wants to merge 4 commits into
mainfrom
codex/config-set-gateway

Conversation

@matthewgrossman

Copy link
Copy Markdown
Contributor

Summary

Add a generic, atomic openshell-gateway config set command 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

  • add repeatable dotted KEY=VALUE assignments for gateway TOML updates
  • preserve comments, validate the resulting gateway schema, and replace the config atomically
  • add mise run gateway:docker -- --set KEY=VALUE passthrough after generated config creation
  • document the command and local development workflow
  • keep multi-file config merging out of scope

Testing

  • mise run pre-commit passes
  • Unit tests added/updated
  • E2E tests added/updated (not applicable; no sandbox infrastructure behavior changed)

Checklist

  • Follows Conventional Commits
  • Commits are signed off (DCO)

Signed-off-by: Matthew Grossman <mgrossman@nvidia.com>
@copy-pr-bot

copy-pr-bot Bot commented Jul 17, 2026

Copy link
Copy Markdown

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.

@github-actions

Copy link
Copy Markdown

Signed-off-by: Matthew Grossman <mgrossman@nvidia.com>
@matthewgrossman
matthewgrossman marked this pull request as ready for review July 17, 2026 23:49
@matthewgrossman
matthewgrossman requested a review from pimlock July 20, 2026 16:47
Comment thread crates/openshell-server/src/config_command.rs Outdated
Comment thread crates/openshell-server/src/config_command.rs Outdated

#[derive(Debug, Args)]
pub struct ConfigArgs {
#[command(subcommand)]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to add some properties here, e.g. the help_template, after_help, etc. to keep the help output consistent?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 .).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. The parsed component vector is now named Assignment::path.

}
}

fn parse_assignment_value(raw: &str) -> Item {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 = true is rejected as an invalid value fragment.
  • openshell.gateway.log_level=42\nother is 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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<()> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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<()> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@matthewgrossman matthewgrossman Jul 23, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmm revisiting this before trying to resolve, I think I agree here and will make a quick fix

@matthewgrossman matthewgrossman Jul 23, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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<()> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +98 to +110
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)));
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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<()> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe a function on Assignment?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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}"))?;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 pimlock left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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}"))?;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants