Skip to content

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

Description

@elezar

User Story

I run the packaged OpenShell gateway and customize a few settings, such as driver options. I need those customizations to live apart from the package-seeded gateway.toml, so I don't have to reapply them after a package change or migration.

Problem Statement

The gateway reads exactly one TOML file, and TOML cannot be layered. The rpm seeds ~/.config/openshell/gateway.toml from a vendor default and migrates it on upgrade only while it is unedited. Any customization therefore means editing that file, which turns it into an operator-owned file the package no longer updates.

Impact / Why This Matters

  • Operators who edit the file stop receiving updated package defaults.
  • Operators who replace it lose the defaults and migrations altogether.
  • Automation that needs to change a few settings, such as the tmachine test harness, carries a merge script (tests/tmachine/ansible/roles/openshell_gateway/files/merge-gateway-toml.py) to do what the gateway cannot.

Proposed Design

  • The gateway loads its base file plus *.toml fragments from a gateway.d/ directory, in lexical order.
  • Tables merge key by key; scalars and arrays replace.
  • The merged result is validated once (version, unknown fields, secrets in the file), and errors name the fragment file.
  • Precedence becomes flag > env > fragments > base file > built-in default.
  • openshell-gateway config preflight loads the same merged result and lists the files it merged.
  • Operator fragments stay separate from package defaults, so a package can update its defaults without touching operator changes.

This implements open question 1 ("Directory-based config (conf.d pattern)") of RFC 0003 (proposed in #951, implemented in #1317). The RFC defers it until three decisions are settled, which this work must resolve: whether --config and --config-dir are exclusive or composable and which takes precedence; whether a later file's array replaces or appends; and that deny_unknown_fields validation applies to the merged result rather than each fragment.

Prior art to examine before settling the semantics (their exact behavior is to be verified, not assumed): cri-o's crio.conf.d drop-in directory, containerd's config imports, systemd-style *.d override directories, and the kubelet's config directory. Compare how each handles arrays, unsetting a key, ordering, and a vendor directory plus an admin directory.

Suggested UX

# ~/.config/openshell/gateway.d/10-podman.toml
[openshell.drivers.podman]
userns = "keep-id"

Acceptance Criteria

  • A fragment overrides a key the base file sets, and adds a table the base lacks.
  • Fragments apply in lexical order, and a later fragment wins.
  • Preflight and startup report the same merged result and fail on the same errors.
  • Errors name the file that caused them.
  • docs/how-it-works/gateways/configuration.mdx, deploy/rpm/CONFIGURATION.md and the man page describe the directory, its order and its merge rules.

Open Questions

  • Is the directory implicit (next to the config file) or selected by --config-dir / OPENSHELL_GATEWAY_CONFIG_DIR? Does an explicit --config disable it?
  • How does a fragment unset a key the base sets, since TOML has no null?
  • Do fragments need [openshell] version?
  • Should a vendor directory under /usr/share and an admin directory both be honored, with systemd-style override order?
  • How does it map to Helm ConfigMap and Secret mounts?

Alternatives Considered

  • Per-key OPENSHELL_* environment overrides: they exist for many gateway settings, but not for nested driver tables, arrays or most of the schema.
  • Repeated --config flags where the later file wins: this fits a systemd ExecStart, but needs the same merge and validation design and an edited or overridden unit.
  • containerd-style imports in the base file: this puts the list of fragments in the file the operator is trying not to edit.
  • Keep merging in scripts: fragile, and it hides the effective configuration.
  • Replace the file wholesale: this loses package defaults and migrations.

Activity

  1. TaylorMutch commented on Nov 10, 2026

    @TaylorMutch
    Collaborator

    My preference is for --config-dir and --config to be mutually exclusive: allow one or the other, and reject specifying both. That makes the choice explicit: --config-dir selects directory-based configuration using the conf.d pattern, while --config preserves the existing single-file behavior.

  2. elezar commented on Nov 10, 2026

    @elezar
    MemberAuthor

    My preference is for --config-dir and --config to be mutually exclusive: allow one or the other, and reject specifying both. That makes the choice explicit: --config-dir selects directory-based configuration using the conf.d pattern, while --config preserves the existing single-file behavior.

    If the goal is to allow users to override configs by using drop-in files how would this work? Would they be expected to modify the mechanism that they use to start the gateway? Would it not make more sense to allow --config to define the base config, and anything in --config-dir to override these settings based on a predefined ordering.

  3. elezar commented on Nov 10, 2026

    @elezar
    MemberAuthor

    Prior art check on how other tools handle a base config plus drop-ins. CRI-O, containerd and kubelet were checked against their docs and source; the systemd row is from general knowledge and not verified.

    Tool Mechanism Base + dir together? Order Merge semantics
    CRI-O crio.conf plus crio.conf.d/*.conf. --config and --config-dir (default /etc/crio/crio.conf.d). Yes. The base is read first and has the lowest precedence. Neither flag disables the other. Lexical by filename. The last file wins. Each drop-in is decoded onto the already-populated struct. Tables merge by key, scalars overwrite, arrays replace. The exception is storage_options, which is appended and de-duplicated. There is no way to unset a key.
    containerd imports = ["/etc/containerd/conf.d/*.toml"] inside config.toml. There is no directory flag. The base file lists the fragments. Imports are merged over the main file, globs supported, paths relative to the importing file, circular imports detected. Scalars overwrite when non-empty. Arrays append (de-duplicated). Maps merge by key, but each value is replaced whole. An imported file's version can't be higher than the main config's.
    kubelet --config plus --config-dir. Drop-ins use the .conf suffix and are YAML/JSON with apiVersion and kind. No default directory. Yes. Precedence is base, then drop-ins, then CLI flags. Lexical by full filename. Lists replace whole. Maps merge per field, except map[string][]string, where the whole list per key is replaced. Validation runs only on the final merged result. There is no unset.
    systemd (not verified) unit.d/*.conf in vendor (/usr/lib) and admin (/etc) directories. Yes. Lexical by filename across all directories. The same filename in /etc shadows /usr/lib. Settings override. An empty assignment resets a list. This is the only explicit unset mechanism in the set.

    What this suggests for the open questions

    • --config vs --config-dir. CRI-O and kubelet compose them: --config is the base and the directory layers on top. That fits the stated goal, since operators can override packaged defaults without changing how the gateway is started. Making them mutually exclusive would be the outlier.
    • Imports in the base file. containerd's imports puts the fragment list in the file operators are trying not to edit. CRI-O and kubelet avoid this with a flag or default directory.
    • Arrays. CRI-O and kubelet replace; containerd appends. This should be decided explicitly per field if there are arrays operators would want to extend. CRI-O special-cases storage_options.
    • Unsetting a key. No TOML-based tool here supports it. A drop-in can only set an empty or zero value. systemd's empty-assignment reset is the only explicit mechanism.
    • Validation. kubelet validates only the merged result, which matches the RFC's requirement that deny_unknown_fields applies to the merged config rather than each fragment.
    • Vendor and admin directories. Only systemd shadows across multiple directories. CRI-O and kubelet use a single directory.

    Not checked: whether CRI-O rejects unknown keys in an individual drop-in.

  4. TaylorMutch commented on Nov 10, 2026

    @TaylorMutch
    Collaborator

    Fair enough, just was thinking about how to keep things simple without complexity, but I'm okay with allowing them to be composed if it doesn't introduce much complexity.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:gatewayGateway server and control-plane workstate:acceptedA maintainer decided OpenShell should pursue this issue

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions