Split config.yaml into global+persistent files; add -c/--config-file

config.yaml now holds only the global section (log-level, unshare-*,
with-veth, with-ipv6); a new persistent.yaml holds volumes/networks.
load_config_file()/write_config_file() are replaced by
load_global_config()/load_persistent_config()/write_global_config()/
write_persistent_config(), each touching only their own file.

-c/--config-file <path> lets one invocation use an alternate file for the
global section only -- persistent.yaml is always the one fixed path,
regardless of -c, so an experiment can never affect real volumes/networks
(a -c file's own volumes/networks, if any, are simply never read either).
A -c path that doesn't exist is a hard error, unlike the default path's
existing missing-file leniency.

migrate_legacy_config_if_needed() moves volumes/networks out of an
old-format config.yaml into persistent.yaml on first run after upgrading,
always against the fixed default paths regardless of -c. A name collision
aborts the migration for that run (touching neither file) rather than
risking data loss.

Required reordering main() to parse CLI args before loading config (so
-c's value is known first) -- ParsedArgs::log_level_flag_given tracks
whether --log-level was already given so the config file's own log-level
doesn't clobber it despite the reversed call order.
This commit is contained in:
2026-09-05 09:32:36 +00:00
parent d2d631117d
commit cbec986e78
11 changed files with 738 additions and 208 deletions
+148 -56
View File
@@ -15,14 +15,30 @@ kernel actually supports. See `README.md` for the human-facing overview (build/u
status); this file stays the dense, file-by-file reference. Still early-stage.
Source layout (all under `src/`):
- `main.cpp` — the CLI entry point only, and deliberately tiny (~30 lines):
loads the config file, applies its `global.log-level` (`apply_log_level()`,
`cli_args.h`) before CLI parsing so an explicit `--log-level` can still
override it afterward, calls `parse_args()` (`cli_args.h`) and returns its
exit code immediately if it gives one (covers `-h`/`-V` and every parse
error), otherwise calls `dispatch_command()` (`commands.h`) and returns its
result. All of the actual option-parsing and command logic that used to live
here moved out into `cli_args.{h,cpp}`/`commands.{h,cpp}`/`self_test.{h,cpp}`
- `main.cpp` — the CLI entry point only, and deliberately tiny (~45 lines):
calls `parse_args()` (`cli_args.h`) first and returns its exit code
immediately if it gives one (covers `-h`/`-V` and every parse error);
otherwise calls `migrate_legacy_config_if_needed()` (`config_file.h`, see
below) unconditionally, resolves the effective *global* config path
(`args.config_file_flag` if `-c/--config-file` was given, else
`config_file_path()` — a `-c` path that doesn't exist is a hard error here,
unlike the default path's own missing-file leniency), loads that via
`load_global_config()` and the separate, always-fixed
`persistent_file_path()` via `load_persistent_config()`, and merges both
into one `AppConfig`. **Config loading had to move to *after* `parse_args()`**
(it used to run first, specifically so an explicit `--log-level` could
still override the config file's own value by being applied later) since
which file even supplies the global section now depends on `-c`, itself a
CLI flag — this only works without breaking that precedence because
`ParsedArgs::log_level_flag_given` (`cli_args.h`) tracks whether the CLI
already gave `--log-level` (which still applies immediately, in
`parse_args()`, unchanged); `main()` then only applies `config.log_level`
via `apply_log_level()` when that's false, preserving the exact same final
SPDLOG_LEVEL-env → config-file → `--log-level` precedence as before, just
with the config load itself now happening later. Finally calls
`dispatch_command()` (`commands.h`) and returns its result. All of the
actual option-parsing and command logic that used to live here moved out
into `cli_args.{h,cpp}`/`commands.{h,cpp}`/`self_test.{h,cpp}`
(see below) specifically to keep this file from re-growing into a dumping
ground as more commands (docker-compose support, etc.) get added.
- `cli_args.{h,cpp}` — command-line parsing only, nothing else. `Mode` (the
@@ -113,6 +129,22 @@ Source layout (all under `src/`):
`port_forward.h`, since resolving which network a spec refers to needs
runtime join state that doesn't exist yet at parse time), but has no
standalone use at all: rejected post-loop unless combined with `-r`.
`-c/--config-file <path>` reuses `'c'` (freed up when `--cleanup` dropped
its own short form) into `ParsedArgs::config_file_flag` — a plain
`required_argument` flag with no mode interaction at all, always available
regardless of what command is being run, same as `--hostname`/`--user`.
It only ever affects which file `main()` (`main.cpp`, see below) loads for
the *global* section (`config_file.h`'s `load_global_config()`); resolving
it, and deciding whether the resulting `AppConfig`'s own `log_level`
should still be applied, both had to move out of this file and into
`main()`, since they need to know about the loaded config, which
`cli_args.cpp` itself has no dependency on otherwise. What `parse_args()`
*does* still do, unchanged, is apply `--log-level` immediately in its own
case (`apply_log_level()`) — but now also sets a new
`ParsedArgs::log_level_flag_given` bool alongside it, purely so `main()`
can tell afterward whether the CLI already provided one before deciding
whether to also apply the config's own (see `main.cpp`'s own entry for why
this two-flag dance is needed at all).
- `commands.{h,cpp}` — every command's implementation, plus the dispatcher.
`dispatch_command(args, config_path, config)` (the only externally-linked
function; everything else in this file is `.cpp`-local) is a `switch
@@ -211,22 +243,29 @@ Source layout (all under `src/`):
expected, not a reason to leave a network the user explicitly asked to
delete sitting in the config.
`write_config_command()` implements `-w/--write-config`: unlike
`create_volume_command()`/`delete_volume_command()`'s use of
`write_config_file()` (which only ever persists `AppConfig` fields that are
already set), this fills in *every* field before writing — the six
`create_volume_command()`/`delete_volume_command()`'s own use of
`write_persistent_config()` (which only ever persists `AppConfig` fields
that are already set, into the separate `persistent.yaml`), this fills in
*every global* field before writing via `write_global_config()` — the six
`unshare-*` bools plus `with-veth`/`with-ipv6`, all via `.value_or(true)`,
and `log_level` from the actually
active `spdlog::get_level()` (not merely a default for when unset — this
also captures an explicit `--log-level` passed alongside `-w` on the same
command line, overriding whatever an existing config file's own
`log-level` already was, since `main()`/`parse_args()` already applied it
in that precedence order by the time this runs) — so a bare `-w` bootstraps
a complete, fully-populated config file for hand-editing, and `-w` combined
with other flags captures their effective values into it. `volumes` is left
exactly as loaded — an open-ended list with no "default" entry to
materialize. Prints the config file's full path (`write_config_file()`
already creates the parent directory and the file itself if missing, so no
separate existence check is needed here).
command line, overriding whatever the effective config's own `log-level`
already was, since `main()`/`parse_args()` already applied it in that
precedence order by the time this runs — see `main.cpp`'s own entry above
for the full precedence chain, including how `-c/--config-file` fits in)
— so a bare `-w` bootstraps a complete, fully-populated *global* config
file for hand-editing, and `-w` combined with other flags captures their
effective values into it. `volumes`/`networks` aren't part of `AppConfig`'s
global-only concern from this command's point of view at all anymore —
it never reads or writes `persistent.yaml`. `config_path` (the parameter
this function takes) is `main()`'s already-resolved effective global path
— the default `config.yaml`, or wherever `-c/--config-file` pointed
instead — so `-w` combined with `-c` bootstraps a global config file at
that custom location rather than the default. Prints the file's full path
(`write_global_config()` already creates the parent directory and the
file itself if missing, so no separate existence check is needed here).
`run_container()` (the `Mode::run` dispatch case) resolves each `-v` spec
(erroring out, `ok = false`, same as a failed `--user` resolution — `bwrap`
is skipped but unmount/cleanup still runs) into a `ResolvedVolumeMount`,
@@ -2065,28 +2104,53 @@ Source layout (all under `src/`):
`EINTR`-retry loop — matching this project's existing direct-POSIX style
(`process.cpp` already retries `waitpid()` the same way) — rather than
`<thread>`/`<chrono>` (unused anywhere else in this project).
- `config_file.{h,cpp}``load_config_file()` reads and parses (via libyaml's
document API, `<yaml.h>`) the `global`, `volumes`, and `networks` sections of
the local YAML config file located by `config_file_path()`
(`$XDG_CONFIG_HOME/slocker-lite/config.yaml`,
falling back to `$HOME/.config/slocker-lite/config.yaml`). Supported `global` keys:
`log-level`; six `unshare-<type>` keys (`unshare-user`/`unshare-ipc`/
`unshare-pid`/`unshare-net`/`unshare-uts`/`unshare-cgroup`, one per
`bwrap.cpp`'s own `namespace_probes` entry) controlling whether `-r/--run`
requests each of bwrap's `--unshare-xxx` flags; and two `with-<feature>` keys
(`with-veth`/`with-ipv6`, `AppConfig::with_veth`/`with_ipv6`) giving
`-n/--network`'s own creation-time `veth`/`ipv6` policy (`NetworkEntry`,
above) a persistent default, used whenever the corresponding
`--with-veth`/`--with-ipv6` CLI flag (`cli_args.{h,cpp}`, see above) isn't
given — every other long option is a one-shot flag, not a setting, so it
doesn't belong in a persistent config file. All eight of these boolean keys
share one small `.cpp`-local `BoolGlobalKey {key, field}` pairing table (two
arrays, `unshare_keys` and `network_default_keys`, both consumed by shared
`load_bool_keys()`/`write_bool_keys()` helpers rather than repeating the same
find-parse-or-warn / emit-if-set loop body per group) and accept
- `config_file.{h,cpp}`reads/writes (via libyaml's document API,
`<yaml.h>`) two separate local YAML files, not one: `config_file_path()`
(`$XDG_CONFIG_HOME/slocker-lite/config.yaml`, falling back to
`$HOME/.config/slocker-lite/config.yaml`) holds only the `global` section;
`persistent_file_path()` (same directory, `persistent.yaml`) holds
`volumes`/`networks` — both resolved through one shared `.cpp`-local
`config_dir()` so the two paths can never drift relative to each other.
**Split specifically so `-c/--config-file` (`cli_args.{h,cpp}`) can safely
redirect just the global section for one invocation**: `volumes`/`networks`
are real, provisioned host state (named volumes, live bridges/namespaces),
and letting `-c` touch them too would risk a mistake shadowing or
corrupting real persistent state. `load_global_config(path)` parses only
`path`'s `global` mapping (any `volumes`/`networks` physically present are
never read at all — this is what makes `-c` safe even against an
old-format file that still has them); `load_persistent_config(path)` is
the mirror image, reading only `volumes`/`networks` and ignoring `global`.
Both share one `.cpp`-local `parse_yaml_file(path)` (open + `yaml_parser_load()`;
a missing file yields an empty, root-less document rather than `nullopt`,
so "doesn't exist" and "exists but empty" are indistinguishable to callers
— both already read back as "nothing set"; `nullopt` only for a genuine
parse failure) instead of duplicating that scaffolding twice.
`write_global_config(path, config)`/`write_persistent_config(path, config)`
are the write-side mirror, sharing a `.cpp`-local `write_yaml_document(path,
document)` for the create-directory/open-file/emitter dance — carefully
preserving the exact document-ownership rule libyaml requires
(`yaml_emitter_dump()` consumes/deletes the document itself once
`yaml_emitter_open()` succeeds; a failure before that point means this
function must delete it explicitly instead, exactly as the original single
combined write function already had to).
Supported `global` keys: `log-level`; six `unshare-<type>` keys
(`unshare-user`/`unshare-ipc`/`unshare-pid`/`unshare-net`/`unshare-uts`/
`unshare-cgroup`, one per `bwrap.cpp`'s own `namespace_probes` entry)
controlling whether `-r/--run` requests each of bwrap's `--unshare-xxx`
flags; and two `with-<feature>` keys (`with-veth`/`with-ipv6`,
`AppConfig::with_veth`/`with_ipv6`) giving `-n/--network`'s own
creation-time `veth`/`ipv6` policy (`NetworkEntry`, above) a persistent
default, used whenever the corresponding `--with-veth`/`--with-ipv6` CLI
flag (`cli_args.{h,cpp}`, see above) isn't given — every other long option
is a one-shot flag, not a setting, so it doesn't belong in a persistent
config file. All eight of these boolean keys share one small `.cpp`-local
`BoolGlobalKey {key, field}` pairing table (two arrays, `unshare_keys` and
`network_default_keys`, both consumed by shared
`load_bool_keys()`/`write_bool_keys()` helpers rather than repeating the
same find-parse-or-warn / emit-if-set loop body per group) and accept
`"1"`/`"on"`/`"yes"`/`"true"` (enabled) or `"0"`/`"off"`/`"no"`/`"false"`
(disabled), case-insensitively — `parse_bool_flag()`, exported (not just this
file's own internal helper) specifically so `cli_args.cpp`'s own
(disabled), case-insensitively — `parse_bool_flag()`, exported (not just
this file's own internal helper) specifically so `cli_args.cpp`'s own
`--with-ipv6`/`--with-veth` value parsing accepts exactly the same forms as
the config file itself, rather than a second, drifting copy. An unset key
defaults to enabled, and an unrecognized value logs a `spdlog::warn` and is
@@ -2096,16 +2160,17 @@ Source layout (all under `src/`):
returns a default-constructed (empty) `AppConfig`, not an error; unknown
sections/keys (and malformed individual volume entries) are likewise ignored
for forward-compatibility.
`main()` applies `config->log_level` (via the existing `apply_log_level()`)
right after `spdlog::cfg::load_env_levels()` and before parsing CLI options,
so an explicit `--log-level` on the command line always overwrites it
afterward — same precedence pattern already used for `SPDLOG_LEVEL`.
`write_config_file()` writes the whole file back out (via libyaml's
document-building/emitter API, symmetric to the read side) — used by
`-v/--volume` (`create_volume_command()`, `commands.cpp`) to persist a new
`VolumeEntry {name, directory}` into the `volumes` section, preserving
`global` (including any set `unshare-*`/`with-veth`/`with-ipv6` keys,
re-serialized as canonical `"true"`/`"false"`) untouched. **`VolumeEntry`/the `volumes` section is a
`main()` (see above) applies the merged `AppConfig`'s own `log_level` (via
the existing `apply_log_level()`) once loaded, but only when
`!args.log_level_flag_given` — see `main.cpp`'s own entry for why config
loading had to move to *after* `parse_args()` (to know `-c`'s value first)
and what that meant for preserving log-level precedence.
`write_persistent_config()` writes `volumes`/`networks` back out — used by
`-v/--volume` (`create_volume_command()`, `commands.cpp`, which now calls
`persistent_file_path()` directly rather than taking a `config_path`
parameter it no longer needs) to persist a new `VolumeEntry {name,
directory}` into the `volumes` section, leaving `config.yaml` completely
untouched. **`VolumeEntry`/the `volumes` section is a
distinct concept from `OciImageConfig::volumes`**: this is a user-defined
`name -> host directory` mapping created via `-v/--volume`, not an image's
own declared mount points (still unconsumed, see `oci_image.{h,cpp}` above).
@@ -2128,15 +2193,42 @@ Source layout (all under `src/`):
on load, same forward-compatible policy as everything else here. `ipv6`
reuses `parse_bool_flag()`, defaulting to `true` (enabled) if absent or
unparseable; `subnet6` is only read/written when `ipv6` is true.
`write_config_file()` writes each network as its own nested mapping under
`networks`, `ipv6` re-serialized as canonical `"true"`/`"false"` like the
`unshare-*` keys. `veth` (default `true`) round-trips the same way as
`write_persistent_config()` writes each network as its own nested mapping
under `networks`, `ipv6` re-serialized as canonical `"true"`/`"false"` like
the `unshare-*` keys. `veth` (default `true`) round-trips the same way as
`ipv6` (`parse_bool_flag()`, written as canonical `"true"`/`"false"`,
always written regardless of value — unlike `subnet6`, there's no
companion field whose presence depends on it) — see `network_bridge.h`'s
`probe_veth_support()`/`should_use_veth()` above and `--with-veth`/
`global.with-veth` (`cli_args.{h,cpp}`/`config_file.{h,cpp}`) below for
what it controls.
what it controls. `create_network_command()`/`delete_network_command()`
(`commands.cpp`) likewise call `persistent_file_path()` directly now,
dropping the `config_path` parameter they used to take.
**`migrate_legacy_config_if_needed()`** — the one-time-per-upgrade bridge
from the old single-file format to the split above: reuses
`load_persistent_config()` *against `config_file_path()`* (the global
file's own path) to detect legacy `volumes`/`networks` still sitting
there — that loader only ever reads those two sections, so pointing it at
an old-format `config.yaml` naturally surfaces whatever's left, with no
separate detection logic needed. If found, merges them into
`persistent_file_path()` (a name collision against an already-existing
entry there aborts the *entire* migration for this run, touching neither
file, with a warning identifying the conflict — never silently drops or
overwrites data; expected to be exceedingly rare, since `persistent.yaml`
doesn't exist at all the first time this runs for a given install), then
rewrites `config.yaml` via `write_global_config()` — which, by
construction, never writes `volumes`/`networks` at all, completing the
strip. Logs an info-level summary of what moved. A cheap no-op (single
read, no writes) when `config.yaml` has no legacy data, which is the
common case on every run after the first. **Always operates on the fixed
default paths, regardless of `-c/--config-file`** — migration only ever
concerns the *default* `config.yaml`, never whatever a given invocation's
`-c` points at instead, so an alternate global-only file can never be
mistaken for — or have its own `volumes`/`networks`, if any, migrated
from — the real persistent config. Called unconditionally, early in
`main()`, before resolving which file supplies that invocation's own
global section (see `main.cpp`'s own entry above).
- `network_subnet.{h,cpp}` — pure CIDR arithmetic backing `-n/--network`'s
subnet allocation and `network_bridge.{h,cpp}`'s (see below) gateway-address
computation; no kernel/`ip`/`iptables` calls of its own. `is_valid_network_name()`
@@ -2284,7 +2376,7 @@ Build directory is `buildDir/` (already configured).
`--user`, `--group`, `--hostname`, `--env`, `--env-file`, `-v/--volume`, `--list-volumes`, `--delete-volume`,
`--delete-volume-full`, `-n/--network`, `--extern`, `--intern`, `--subnet`, `--with-ipv6`, `--subnet6`,
`--with-veth`, `--list-networks`, `--delete-network`, `-p/--port-forward`, `--no-dns`, `--list-processes`, `--clean-processes`,
`-w/--write-config`, `-t/--test [-- <catch-command-line-options>]`, `--log-level`,
`-c/--config-file`, `-w/--write-config`, `-t/--test [-- <catch-command-line-options>]`, `--log-level`,
`-h/--help`, `-V/--version`)
- Run tests: `meson test -C buildDir` (the `[unit]` + safe `[integration]` categories
only — see `self_test.{h,cpp}`'s own entry above and `README.md`'s "Testing" section