Skip to content

feat(gateway): merge drop-in config fragments over the base file - #5489

Open
elezar wants to merge 3 commits into
mainfrom
feat/5446-gateway-config-dropins/elezar
Open

elezar wants to merge 3 commits into
mainfrom
feat/5446-gateway-config-dropins/elezar

Conversation

@elezar

@elezar elezar commented Nov 11, 2026 •

Copy link
Copy Markdown
Member

Summary

The gateway now merges *.toml drop-in configs from a directory over its base config file, so operators and test harnesses can customize a package-seeded gateway.toml without editing it. Test environments use the same mechanism instead of a merge script.

Related Issue

Closes #5446

Changes

  • Add load_layered in config_file.rs: load the base file and the drop-in configs, merge them in lexical order, and validate the merged result once. Tables merge key by key; scalars and arrays replace; a drop-in cannot unset a key.
  • Require every file to declare [openshell] version = 2, but let a drop-in set only some keys of a table, such as one key under [openshell.gateway.tls]. Unknown keys are rejected.
  • Attribute an unknown key, a wrong type or a rejected value to the file that sets it, using the key path from serde_path_to_error. An error that no single key explains, such as a required key no file sets, names the merged configuration and lists its files. Parse errors stay free of file contents.
  • Add --config-dir / OPENSHELL_GATEWAY_CONFIG_DIR. Without them, the gateway uses gateway.d/ next to the base file when it exists. An explicit directory must exist. A gateway.d/ with no drop-in configs and no base file counts as no configuration. --config and --config-dir compose rather than exclude each other, as in CRI-O and kubelet.
  • Follow a drop-in symlink only when it resolves to a regular file inside the drop-in directory, which covers Kubernetes ConfigMap mounts. The base file still rejects symlinks.
  • Make config preflight load the same merged result, list the files it merged, and fail on the same errors as startup. A legacy schema version still reports its version, and a malformed version still reports detected_version=unavailable.
  • Accept --config as an alias for config preflight --path to match the daemon flag. It is a separate commit.
  • Document the directory, order, merge and symlink rules in configuration.mdx, the RPM guide and the man page.
  • Install tmachine test overrides as gateway.d/90-test-overrides.toml, write a minimal base file when a package has no default, and remove merge-gateway-toml.py and its tests.

Not covered: the Helm ConfigMap and Secret mapping from the issue's open questions, and the e2e additional-CA overrides, which still edit the base file because e2e/rust/tests/additional_ca.rs rewrites it in place. Implicit gateway.d/ discovery applies beside any --config path, so a drop-in directory in a shared writable location is merged; it deserves a separate look.

Release Notes

Gateway configuration can be split across a base gateway.toml and *.toml drop-in configs in gateway.d/. Later files override earlier ones, and config preflight reports the merged result. A gateway.d/ directory next to an existing --config file is now merged, so remove files you do not want applied.

Testing

  • Checks appropriate to the affected code and behavior pass: cargo clippy -p openshell-server --all-targets and cargo fmt --check are clean and the openshell-server lib tests pass (2701 tests); the pre-commit lint hook passed on each commit.
  • Unit tests added/updated: override, ordering, array replace, partial nested tables, unknown keys, wrong types, missing required keys, secrets, version handling, cross-file rules, symlinks inside and outside the directory, the no-base case, the --config alias and layered preflight.
  • E2E tests added/updated (if applicable): the tmachine Ansible changes were not run, only parsed as YAML.

Checklist

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

@github-actions

Copy link
Copy Markdown

@github-actions

Copy link
Copy Markdown

Compatibility review (advisory)

New or updated breaking changes; see the full report for all findings.

1. cli — No explicit risk acceptance found

For openshell-gateway config preflight --path FILE containing [openshell] version = 'two', detected_version=unavailable becomes detected_version=2, even without fragments.

  • Impact: Automation matching the stable category=malformed detected_version=unavailable response no longer recognizes malformed schema declarations.
  • Action: Preserve existing preflight detected_version semantics on this no-break train by deriving diagnostics from the actual merged version value rather than assuming schema v2.
  • PR: feat(gateway): merge drop-in config fragments over the base file #5489
Review details
  • Execution: complete
  • Candidate: PR #5489 (31ffaa182e4e3e9658590e33a9893ea83ee8123b)
  • Baseline: 1580589082f693306abd5b63a2d9fc945b0e9caa (1580589082f693306abd5b63a2d9fc945b0e9caa)
  • Reviewed: rust-sdk, python-sdk, typescript-sdk, api-behavior, cli, configuration, policy, helm, state

Advisory review; publication gates are unchanged.

Branch check run

@elezar elezar added the area:gateway Gateway server and control-plane work label Nov 12, 2026
@elezar
elezar force-pushed the feat/5446-gateway-config-dropins/elezar branch from 69a79e1 to 6903a84 Compare November 12, 2026 15:57

`name` assigns an operator-facing identity to the gateway installation. Set it with `[openshell.gateway].name`, `--name`, or `OPENSHELL_GATEWAY_NAME`. It defaults to `openshell`; the Helm chart defaults it to the chart fullname so all replicas in one installation share a name. Chart fullnames are only unique within their Kubernetes namespace, so set `server.name` explicitly when one collector receives telemetry from multiple namespaces or clusters. This identity is independent of client-side gateway aliases, TLS names, and `gateway_jwt.gateway_id`.

## Drop-In Fragments

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.

Is Drop-In fragments how this is referred to in other ecosystems?

@TaylorMutch TaylorMutch 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 error-attribution bugs remain in the layered loader. Both affect the requirement in #5446 that errors name the file that caused them.

Error attribution can report an error already fixed by a fragment

attribute_to_layer returns the first invalid intermediate configuration without checking whether its error is the error in the final merged configuration:

if let Err(layer_error) = finish_layered(candidate) {
return match layer_error {
ConfigFileError::Parse { detail, .. } => ConfigFileError::Parse {
path: path.clone(),
detail,
},
other => other,
};

Reproduction:

# gateway.toml
[openshell]
version = 2
[openshell.gateway]
database_max_connections = "bad"
# gateway.d/10-fix.toml
[openshell.gateway]
database_max_connections = 10
# gateway.d/20-bad.toml
[openshell.gateway]
not_a_field = 1

The final merged configuration has a valid pool size and an unknown field from 20-bad.toml. Instead, startup reports gateway.toml: invalid type: a string, expected u32. Operators are directed to fix a value that has already been overridden, and the actual failure is hidden. Attribute the error in the final merged configuration rather than returning an unrelated prefix error.

Semantic failures lose the offending fragment's path

attribute_to_layer only attaches a path to Parse errors. InvalidValue and SecretInFile retain no source path, so preflight_layered falls back to the base file:

let path = match &source {
ConfigFileError::Io { path, .. }
| ConfigFileError::NonRegular { path, .. }
| ConfigFileError::Parse { path, .. } => path.clone(),
_ => base.or(dir).map(Path::to_path_buf).unwrap_or_default(),

With a valid base containing [openshell] version = 2 and this fragment:

# gateway.d/20-bad.toml
[openshell.gateway]
database_max_connections = 0

Preflight reports path='.../gateway.toml' category=malformed detected_version=2, although the invalid value comes from 20-bad.toml. Startup's semantic error also omits the fragment path. Before layering, the single selected file made the source unambiguous; with drop-ins, operators are sent to the wrong file. Preserve the originating path for semantic validation errors as well as parse errors.

Verification: both behaviors reproduced in an isolated probe using this commit's loader, merge, and attribution functions with a reduced schema. The full server tests could not run offline because hickory-proto v0.26.2 was not cached. The Ansible integration path was inspected but not executed.

@matthewgrossman matthewgrossman left a comment

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.

Reviewed the merged-validation design and the tmachine migration. The approach is sound; the notes below are mostly about error attribution and untested paths. Inline comments follow.

path: path.clone(),
detail,
},
other => other,

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.

Only Parse errors get re-pathed here; every other variant (SecretInFile, semantic validation errors) falls through to other => other with no file name. Before this PR an operator with one file got a path-specific error. With several fragments, they now see e.g. a secret rejection but can't tell which fragment holds it, yet configuration.mdx says "An error names the first file that causes it." The tests only assert attribution for Parse (layered_secret_in_fragment_is_rejected checks the variant, not the file). Either attribute the other variants too or soften the docs.

merged
.try_into()
.map_err(|error: toml::de::Error| ConfigFileError::Parse {
path: PathBuf::new(),

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.

If a merged-result Parse error is never attributed to a layer (the cumulative replay in attribute_to_layer passes for every prefix), it keeps this empty PathBuf::new(), and preflight_layered forwards that path into the stable failure message, so the operator sees a blank path. Consider falling back to the base or directory path here.

///
/// Hidden files and entries without a `.toml` extension are ignored so editor
/// backups and package-manager leftovers never take effect.
pub fn fragment_paths(dir: &Path) -> Result<Vec<PathBuf>, ConfigFileError> {

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.

Fragments are read through read_config_contents, so O_NOFOLLOW and the non-regular-file check now apply to every file in gateway.d. A symlinked fragment, which is common in /etc/*.d setups, will fail startup. That matches the base file, but the docs don't say so for fragments; worth one sentence in configuration.mdx.

};
anchor
.parent()
.map(|parent| parent.join("gateway.d"))

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.

Before this PR, a gateway.d directory next to an explicit --config file had no effect. Now any *.toml in it is merged silently, so a leftover or unrelated directory can change gateway behavior on upgrade. This is the stated design and preflight lists merged files, but it's worth a line in the release note.

- openshell_gateway_overrides.stat.exists
- openshell_gateway_merge_default | default('') | length > 0

- name: Write a minimal base for a package with no default configuration

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 task only runs when openshell_gateway_merge_default is set and the file is missing. I couldn't find where that variable is set from this diff. If an installer (deb, binaries) leaves it unset and no base file exists, the fragment lands with no base and the gateway fails with a missing version. The PR notes the Ansible changes were only parsed as YAML, so please run at least one packaged tmachine environment before merging.

- Hidden files and files without a `.toml` extension are ignored.
- A fragment can omit `[openshell] version`. The merged result must declare version 2.

The gateway validates the merged result once, including unknown fields and values that must come from the environment. An error names the first file that causes it. The precedence is: command-line flag, then `OPENSHELL_*` environment variable, then fragments, then the base file, then the built-in default.

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.

See the comment on attribute_to_layer: this sentence is only true for parse errors today.

Load `*.toml` drop-in configs from a directory and merge them over the
base config in lexical order, so operators and packages can customize a
gateway without editing the base file. Select the directory with
`--config-dir` or `OPENSHELL_GATEWAY_CONFIG_DIR`, or let the gateway use
`gateway.d` beside the base file. `--config` and `--config-dir` compose.

Tables merge key by key; scalars and arrays replace; a drop-in cannot
unset a key. Every file must declare `[openshell] version = 2`, but a
drop-in may set only some keys of a table. The merged result is
validated once, and an error names the file that sets the offending key.
Errors that no single key explains name the merged configuration and its
files. Unknown keys are rejected.

Follow a drop-in symlink only when it resolves inside the drop-in
directory, which covers Kubernetes ConfigMap mounts, and keep the base
file strict. Treat a `gateway.d` with no drop-in configs and no base
file as no configuration. `config preflight` loads the same merged
result as startup and lists the files it merged.

Signed-off-by: Evan Lezar <elezar@nvidia.com>
Install an environment's gateway overrides as `gateway.d/90-test-overrides.toml`
next to the gateway's own config instead of merging them into it with a
script. Write a minimal base file when a package has no default, and have
the override files declare `[openshell] version = 2`.

Signed-off-by: Evan Lezar <elezar@nvidia.com>
Match the daemon flag so the same name selects the base config file in
both places. Keep --path for compatibility.

Signed-off-by: Evan Lezar <elezar@nvidia.com>
@elezar
elezar force-pushed the feat/5446-gateway-config-dropins/elezar branch from 6903a84 to 22fb7a9 Compare November 13, 2026 13:43
@github-actions

Copy link
Copy Markdown

Compatibility review (advisory)

New or updated breaking changes; see the full report for all findings.

1. configuration — No explicit risk acceptance found

openshell-gateway --config FILE and config preflight --path FILE previously read only FILE but now automatically merge adjacent gateway.d/*.toml files.

  • Impact: Existing operators with previously ignored sibling files can receive different settings or startup/preflight failures without changing their invocation or selected configuration.
  • Action: For v0.1.9, preserve existing single-file discovery and require explicit --config-dir or OPENSHELL_GATEWAY_CONFIG_DIR opt-in for overlays.
  • PR: feat(gateway): merge drop-in config fragments over the base file #5489
Review details
  • Execution: complete
  • Candidate: PR #5489 (87061c2d25ddd45f13412fd2b9e3dbb255fc6d97)
  • Baseline: b4a6541d870f186ad5f5224be1f8b9ac9c872e20 (b4a6541d870f186ad5f5224be1f8b9ac9c872e20)
  • Reviewed: rust-sdk, python-sdk, typescript-sdk, api-behavior, cli, configuration, policy, helm, state

Advisory review; publication gates are unchanged.

Branch check run

This branch has not been deployed

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

Labels

area:gateway Gateway server and control-plane work

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(gateway): support drop-in configuration fragments (gateway.d/*.toml)

3 participants