Split main.cpp into cli_args, commands, and self_test

main.cpp had grown to ~910 lines holding CLI parsing, every command
implementation, and the -t/--test handler all in one file. Split it:

- cli_args.{h,cpp}: getopt_long parsing and all post-loop validation
  (parse_args()), producing a ParsedArgs the rest of the program consumes.
- commands.{h,cpp}: every command implementation plus dispatch_command(),
  an exhaustive switch over Mode with no default -- so -Wswitch (this
  project's warning_level=3) now catches a future Mode value added without
  a matching dispatch case, instead of silently falling through. Confirmed
  by temporarily adding an unhandled enumerator and observing the warning.
- self_test.{h,cpp}: -t/--test's own file, ahead of real tests landing here.

Also fixes Mode::mount, which previously had no explicit dispatch check at
all -- it ran only because it was whatever fell off the end of main()'s
if/else chain when nothing else matched. It's now mount_command(), a real
case in dispatch_command() like every other mode.

main.cpp itself shrinks to ~30 lines: load config, apply its log level,
parse_args(), dispatch_command().

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
This commit is contained in:
2026-08-24 14:07:57 +00:00
parent 00cb04ff41
commit 0f43a02bd7
9 changed files with 1194 additions and 943 deletions
+116 -68
View File
@@ -15,42 +15,83 @@ 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. status); this file stays the dense, file-by-file reference. Still early-stage.
Source layout (all under `src/`): Source layout (all under `src/`):
- `main.cpp` — CLI entry point, dependency checks, orchestration (`mount_image()`, - `main.cpp` the CLI entry point only, and deliberately tiny (~30 lines):
`run_container()`, `cleanup_image()`, `unmount_image()`, `list_images_command()`, loads the config file, applies its `global.log-level` (`apply_log_level()`,
`inspect_image_command()`, `create_volume_command()`, `list_volumes_command()`, `cli_args.h`) before CLI parsing so an explicit `--log-level` can still
`delete_volume_command()`, `list_processes_command()`, `clean_processes_command()`). override it afterward, calls `parse_args()` (`cli_args.h`) and returns its
`list_processes_command()` implements `--list-processes` (long-option only): exit code immediately if it gives one (covers `-h`/`-V` and every parse
calls `list_sessions()` (`pid_file.{h,cpp}`, see below) and prints one error), otherwise calls `dispatch_command()` (`commands.h`) and returns its
tab-aligned `pid`, `container name`, `running`/`exited` row per entry (same result. All of the actual option-parsing and command logic that used to live
two-column tab-alignment scheme as `list_images_command()`/ 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
enum of every CLI action) and `ParsedArgs` (everything `parse_args()`
extracts from `argv`) live in the header since `commands.h`'s
`dispatch_command()` consumes them; `print_usage()`/`print_version()`, the
`options` namespace of getopt long-option codes, and the `long_options`
array itself are `.cpp`-local. `parse_args(argc, argv, out)` runs the
`getopt_long` loop plus all of the post-loop validation that used to live at
the top of `main()`: mode/`--volume` interaction (`-v` alone vs. combined
with `-r`, see below), `--group` requires `--user`, no leftover positional
args outside `-r`/`-e`, and (moved here from what used to be inline in the
`Mode::exec` dispatch arm) `-e/--exec <pid>`'s own pid parsing/validation
(`ParsedArgs::exec_pid`, a positive integer or a hard error) and its
trailing-command requirement (`ParsedArgs::command`, required non-empty).
Returns an exit code `main()` should return immediately (`0` for `-h`/`-V`,
`1` for any parse error) when set; `nullopt` means `out` is ready for
`dispatch_command()`. `-D/--daemonize` (has a short form; `'D'` was free) is
a plain boolean flag (`ParsedArgs::daemonize_flag`, set in its own `case
'D':`, same pattern as `-n/--no-nsenter`). `--hostname <name>`/`--env
VAR=VALUE`/`--env-file <file>` (all long-option only, `--env`/`--env-file`
both repeatable) are collected here into `ParsedArgs::hostname_flag`/
`env_specs``--env` pushes `{false, optarg}`, `--env-file` pushes `{true,
optarg}` into the *same* ordered `std::vector<EnvSpec>` (not two separate
lists), preserving their exact relative command-line order across both
flags, since `resolve_env_specs()` (`env_spec.{h,cpp}`, see below) needs
that order to let a later one override an earlier one for the same variable
name — actually resolving them happens later, in `commands.cpp`'s
`run_container()`. `-v/--volume` is dual-purpose: used alone it's a
standalone `Mode::volume` request; combined with `-r/--run` it's repeatable
and requests a volume mount instead (resolved later by
`resolve_volume_mount()`, `volume_mount.{h,cpp}`, see below). Since `-v`
must be repeatable with `-r` but each occurrence still takes two
space-separated tokens, the getopt loop doesn't let `'v'` set `Mode`
directly: it accumulates `(spec, path)` pairs into `ParsedArgs::volume_specs`
(consuming the second token manually, with a guard against swallowing the
next flag if there isn't one), and only *after* the loop decides whether
that means one standalone `Mode::volume` call or, together with `-r`,
passes `volume_specs` through unresolved for `dispatch_command()`/
`run_container()` to handle.
- `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
(args.mode)` with **one explicit `case` per `Mode` enumerator and no
`default:`**, so `-Wswitch` (this project builds at `warning_level=3`)
forces a compile warning/error if a future `Mode` value is ever added
without a matching dispatch case, instead of silently falling through to
the wrong command — confirmed by testing (temporarily adding an unhandled
enumerator triggered exactly the expected `-Wswitch` warning). `Mode::mount`
has its own explicit case (`mount_command()`) for the same reason: it used
to be handled only by *falling off the end* of a long `if`/`else` chain in
`main()` with no explicit check at all — the very kind of implicit,
easy-to-silently-break behavior this dispatcher redesign exists to close
off, especially with more `Mode` values (docker-compose support) expected
soon. `list_processes_command()` implements `--list-processes` (long-option
only): calls `list_sessions()` (`pid_file.{h,cpp}`, see below) and prints
one tab-aligned `pid`, `container name`, `running`/`exited` row per entry
(same two-column tab-alignment scheme as `list_images_command()`/
`list_volumes_command()`, extended to a third column), no header row, silent `list_volumes_command()`, extended to a third column), no header row, silent
success on an empty list. `clean_processes_command()` implements success on an empty list. `clean_processes_command()` implements
`--clean-processes` (also long-option only): calls `clean_stale_sessions()` `--clean-processes` (also long-option only): calls `clean_stale_sessions()`
(`pid_file.{h,cpp}`) and prints one `removed stale pid file for '<name>' (pid (`pid_file.{h,cpp}`) and prints one `removed stale pid file for '<name>' (pid
<pid>)` line per file actually removed — nothing is printed for sessions still <pid>)` line per file actually removed — nothing is printed for sessions still
running, and an empty result (nothing stale) is silent success, same running, and an empty result (nothing stale) is silent success, same
convention as the rest of this file's list/delete commands. `-e/--exec <pid>` convention as the rest of this file's list/delete commands. `Mode::exec`'s
(has a short form, unlike the rest of the process-tracking flags) dispatches dispatch case is a one-line call to `exec_in_session(*args.exec_pid,
straight to `exec_in_session()` (`exec_session.{h,cpp}`, see below): `pid` args.command)` (`exec_session.{h,cpp}`, see below) — the pid/command
lands in `mode_arg` (parsed as a positive integer, erroring out otherwise) the parsing and validation now happens in `cli_args.cpp`'s `parse_args()`
same way `-r`'s image path does, and the trailing command instead (see above).
(`argv[optind:]`, required — errors out if empty) is collected the same way
`-r`'s own command is, sharing that mode's exemption from the "no leftover
positional args" check.
`-D/--daemonize` (has a short form; `'D'` was free) is a plain boolean flag
(`daemonize_flag`, set in its own `case 'D':`, same pattern as
`-n/--no-nsenter`) threaded through to `run_container()`, which computes
`container_name` *before* `mount_image()` now (moved up from right before
the `run_bwrap()` call — it only ever depended on `image_tar`, a parameter
available from the start, so this is a pure reordering) and, if daemonizing,
calls `daemonize(container_name)` (`daemonize.{h,cpp}`, see below)
immediately after: a returned value means this is the original (parent)
process (or a hard daemonize failure) — print it and `return` right away;
`nullopt` means this is the now-detached child, which falls through into the
rest of `run_container()`'s existing body completely unchanged, including
the unmount/cleanup that already runs after `run_bwrap()` returns (no
separate watcher/reaper — the daemonized child *is* what runs the whole
session, start to finish).
`inspect_image_command()` implements `-i/--inspect `inspect_image_command()` implements `-i/--inspect
<image.tar>`: prints every `OciImageConfig` field (user/group, exposed ports, env, <image.tar>`: prints every `OciImageConfig` field (user/group, exposed ports, env,
volumes, default command) without mounting or running the image — extend it volumes, default command) without mounting or running the image — extend it
@@ -67,38 +108,45 @@ Source layout (all under `src/`):
(also `std::filesystem::remove_all()`s the host directory — errors out before (also `std::filesystem::remove_all()`s the host directory — errors out before
touching the config if that fails, warns instead of failing if the directory was touching the config if that fails, warns instead of failing if the directory was
already gone) — see `config_file.{h,cpp}` below for what a "volume" means here (a already gone) — see `config_file.{h,cpp}` below for what a "volume" means here (a
distinct concept from `OciImageConfig::volumes`). `-v/--volume` is dual-purpose: distinct concept from `OciImageConfig::volumes`). `dispatch_command()`'s
used alone it's `create_volume_command()`; combined with `-r/--run` it instead `Mode::volume`/`Mode::delete_volume`/`Mode::delete_volume_full` cases call these.
requests a volume mount (repeatable) and is resolved by `resolve_volume_mount()` `run_container()` (the `Mode::run` dispatch case) resolves each `-v` spec
(see `volume_mount.{h,cpp}` below) instead. Since `-v` must be repeatable with (erroring out, `ok = false`, same as a failed `--user` resolution — `bwrap`
`-r` but each occurrence still takes two space-separated tokens, `main()`'s is skipped but unmount/cleanup still runs) into a `ResolvedVolumeMount`,
getopt loop no longer lets `'v'` set `Mode` itself: it accumulates rejecting a duplicate or non-absolute container path first, and passes the
`(spec, path)` pairs into `volume_specs` (consuming the second token manually, resolved list to `run_bwrap()`. `--hostname <name>` is likewise threaded
with a guard against swallowing the next flag if there isn't one), and only straight through `run_container()` into `run_bwrap()`/`build_bwrap_args()`
*after* the loop decides whether that means one standalone `Mode::kVolume` call (`bwrap.{h,cpp}`) — see there for how/when it actually takes effect.
or, together with `-r`, threads `volume_specs` through to `run_container()`. `run_container()` also derives a `container_name` for the session-tracking
`run_container()` resolves each spec (erroring out, `ok = false`, same as a pid file (see `pid_file.{h,cpp}` below): `read_image_ref()`
failed `--user` resolution — `bwrap` is skipped but unmount/cleanup still runs) (`oci_image.{h,cpp}`) applied to the single image tar being run, formatted
into a `ResolvedVolumeMount`, rejecting a duplicate or non-absolute container as `name:tag`, falling back to the tar's own filename stem if
path first, and passes the resolved list to `run_bwrap()`. `--hostname <name>` `read_image_ref()` can't determine one — passed through to `run_bwrap()`
(long-option only, no short form) is likewise threaded straight through alongside everything else. `--env`/`--env-file`'s already-ordered
`run_container()` into `run_bwrap()`/`build_bwrap_args()` (`bwrap.{h,cpp}`) — `env_specs` (see `cli_args.{h,cpp}` above) are resolved here, once, via
see there for how/when it actually takes effect. `run_container()` also derives `resolve_env_specs()` (`env_spec.{h,cpp}`, see below) — same `ok =
a `container_name` for the session-tracking pid file (see `pid_file.{h,cpp}` false`-on-failure pattern as volume/user resolution — and the resolved list
below): `read_image_ref()` (`oci_image.{h,cpp}`) applied to the single image is passed to `run_bwrap()` as `extra_env`. `-D/--daemonize`'s
tar being run, formatted as `name:tag`, falling back to the tar's own filename `daemonize_flag` is also consumed here: `run_container()` computes
stem if `read_image_ref()` can't determine one — passed through to `container_name` *before* `mount_image()` (needed so `daemonize()` below can
`run_bwrap()` alongside everything else. `--env VAR=VALUE`/`--env-file <file>` use the real container name for the log file from its very first line, not
(both long-option only, both repeatable) accumulate into a single ordered just after a later rename) and, if daemonizing, calls
`std::vector<EnvSpec>``--env` pushes `{false, optarg}`, `--env-file` pushes `daemonize(container_name)` (`daemonize.{h,cpp}`, see below) immediately
`{true, optarg}` — preserving their exact relative command-line order across after: a returned value means this is the original (parent) process (or a
*both* flags (not two separate lists), since `resolve_env_specs()` hard daemonize failure) — print it and `return` right away; `nullopt` means
(`env_spec.{h,cpp}`, see below) needs that order to let a later one override this is the now-detached child, which falls through into the rest of
an earlier one for the same variable name. `run_container()` calls it once `run_container()`'s existing body completely unchanged, including the
(same `ok = false`-on-failure pattern as volume/user resolution) and passes unmount/cleanup that already runs after `run_bwrap()` returns (no separate
the resolved list to `run_bwrap()` as `extra_env`. watcher/reaper — the daemonized child *is* what runs the whole session,
start to finish).
- `self_test.{h,cpp}``run_self_tests()` implements `-t/--test`, this
project's own built-in self-test mode (distinct from the Meson-driven
fixture smoke test under `tests/`, described in "Build & test commands"
below). Currently just reports `detect_bwrap_unshare_args()`'s output
(`bwrap.{h,cpp}`); deliberately its own small file since real tests are
expected here soon.
- `env_spec.{h,cpp}``resolve_env_specs()` turns an ordered list of - `env_spec.{h,cpp}``resolve_env_specs()` turns an ordered list of
`EnvSpec {is_file, value}` (see `main.cpp` above) into a flat, ordered list of `EnvSpec {is_file, value}` (see `cli_args.{h,cpp}` above) into a flat, ordered list of
`(key, value)` pairs. A literal (`--env`) is split at its *first* `=` (the `(key, value)` pairs. A literal (`--env`) is split at its *first* `=` (the
value may itself contain `=`; the key must be non-empty). A file (`--env-file`) value may itself contain `=`; the key must be non-empty). A file (`--env-file`)
is read line by line: blank/whitespace-only lines and lines whose first is read line by line: blank/whitespace-only lines and lines whose first
@@ -120,14 +168,14 @@ Source layout (all under `src/`):
annotations (`io.containerd.image.name` preferred, else annotations (`io.containerd.image.name` preferred, else
`org.opencontainers.image.ref.name`), falling back to the archive's filename and `org.opencontainers.image.ref.name`), falling back to the archive's filename and
`"latest"` respectively. `read_image_ref()` is public (not just an internal `"latest"` respectively. `read_image_ref()` is public (not just an internal
helper of `list_oci_images()`) precisely so `run_container()` (`main.cpp`) can helper of `list_oci_images()`) precisely so `run_container()` (`commands.cpp`) can
reuse the exact same logic to name a *single* image tar's session pid file (see reuse the exact same logic to name a *single* image tar's session pid file (see
`pid_file.{h,cpp}` below) instead of duplicating it. `pid_file.{h,cpp}` below) instead of duplicating it.
`read_oci_image_config()` reads the image config blob referenced by the manifest `read_oci_image_config()` reads the image config blob referenced by the manifest
and extracts `User` (split on `:` into `OciImageConfig::user`/`group`), and extracts `User` (split on `:` into `OciImageConfig::user`/`group`),
`ExposedPorts`, `Env`, `Volumes`, and the effective default command `ExposedPorts`, `Env`, `Volumes`, and the effective default command
(`Entrypoint ++ Cmd`). `user`/`group` and the default command are consumed by (`Entrypoint ++ Cmd`). `user`/`group` and the default command are consumed by
`-r/--run`, and every field is displayed by `-i/--inspect` (see `main.cpp` above) `-r/--run`, and every field is displayed by `-i/--inspect` (see `commands.{h,cpp}` above)
`ExposedPorts`/`Env`/`Volumes` are otherwise still just captured for when `ExposedPorts`/`Env`/`Volumes` are otherwise still just captured for when
networking/volumes are implemented. networking/volumes are implemented.
- `containers_storage.{h,cpp}` — wraps the `containers-storage` CLI (`import-layer`, - `containers_storage.{h,cpp}` — wraps the `containers-storage` CLI (`import-layer`,
@@ -235,7 +283,7 @@ Source layout (all under `src/`):
`"/root"` for uid 0 or `"/"` otherwise when there's no matching row. `"/root"` for uid 0 or `"/"` otherwise when there's no matching row.
`build_sandbox_env()` (`bwrap.cpp`) sets the sandboxed process's `HOME` from `build_sandbox_env()` (`bwrap.cpp`) sets the sandboxed process's `HOME` from
this — `"/root"` only when no user override applies at all (no `--user`, no this — `"/root"` only when no user override applies at all (no `--user`, no
image-declared `config.User`). `run_container()` (`main.cpp`) calls `resolve_user_and_group()` image-declared `config.User`). `run_container()` (`commands.cpp`) calls `resolve_user_and_group()`
with either the explicit `--user`/`--group` flags, or, when `--user` wasn't given, with either the explicit `--user`/`--group` flags, or, when `--user` wasn't given,
the image's own declared `config.User` (`OciImageConfig::user`/`group`) — so a the image's own declared `config.User` (`OciImageConfig::user`/`group`) — so a
container defaults to running as whatever user the image itself declares, not container defaults to running as whatever user the image itself declares, not
@@ -288,7 +336,7 @@ Source layout (all under `src/`):
failure path here (can't create the directory/file, can't lock, can't remove) failure path here (can't create the directory/file, can't lock, can't remove)
is a `spdlog::warn`, never fatal — session tracking is best-effort and must is a `spdlog::warn`, never fatal — session tracking is best-effort and must
never block or fail `-r/--run` itself. `list_sessions()` implements never block or fail `-r/--run` itself. `list_sessions()` implements
`--list-processes` (`main.cpp`'s `list_processes_command()`): scans the same `--list-processes` (`commands.cpp`'s `list_processes_command()`): scans the same
`run/` directory and reports one `SessionInfo {pid, container_name, running}` `run/` directory and reports one `SessionInfo {pid, container_name, running}`
per readable pid file. `pid` is read from the file's own contents, not parsed per readable pid file. `pid` is read from the file's own contents, not parsed
from the filename (ambiguous for names that themselves contain `-`); from the filename (ambiguous for names that themselves contain `-`);
@@ -336,7 +384,7 @@ Source layout (all under `src/`):
`--daemonize` was asked to capture would defeat the point of the flag. The `--daemonize` was asked to capture would defeat the point of the flag. The
child reports `"LOG <path>\n"` over the pipe immediately (so the parent can child reports `"LOG <path>\n"` over the pipe immediately (so the parent can
show a useful location even on failure) and returns `nullopt` to its caller show a useful location even on failure) and returns `nullopt` to its caller
(`run_container()`, `main.cpp`), which then falls through into the rest of (`run_container()`, `commands.cpp`), which then falls through into the rest of
that function's existing body completely unchanged — **the daemonized child that function's existing body completely unchanged — **the daemonized child
is what runs the whole rest of `run_container()`, including the unmount/ is what runs the whole rest of `run_container()`, including the unmount/
cleanup that already existed after `run_bwrap()` returns; no separate cleanup that already existed after `run_bwrap()` returns; no separate
@@ -410,7 +458,7 @@ Source layout (all under `src/`):
overwrites it afterward — same precedence pattern already used for `SPDLOG_LEVEL`. overwrites it afterward — same precedence pattern already used for `SPDLOG_LEVEL`.
`write_config_file()` writes the whole file back out (via libyaml's `write_config_file()` writes the whole file back out (via libyaml's
document-building/emitter API, symmetric to the read side) — used by document-building/emitter API, symmetric to the read side) — used by
`-v/--volume` (`create_volume_command()`, `main.cpp`) to persist a new `-v/--volume` (`create_volume_command()`, `commands.cpp`) to persist a new
`VolumeEntry {name, directory}` into the `volumes` section, preserving `global` `VolumeEntry {name, directory}` into the `volumes` section, preserving `global`
untouched. **`VolumeEntry`/the `volumes` section is a distinct concept from untouched. **`VolumeEntry`/the `volumes` section is a distinct concept from
`OciImageConfig::volumes`**: this is a user-defined `name -> host directory` `OciImageConfig::volumes`**: this is a user-defined `name -> host directory`
@@ -506,7 +554,7 @@ Build directory is `buildDir/` (already configured).
the enum's own name (e.g. `Mode::run`, `OciPortProtocol::tcp`), so plain snake_case the enum's own name (e.g. `Mode::run`, `OciPortProtocol::tcp`), so plain snake_case
enumerators are enough on their own. Free-standing constants also use plain snake_case; enumerators are enough on their own. Free-standing constants also use plain snake_case;
when several are conceptually related, group them under a named `namespace` instead of when several are conceptually related, group them under a named `namespace` instead of
relying on a shared prefix to imply the grouping (e.g. `main.cpp`'s `getopt_long` long-option relying on a shared prefix to imply the grouping (e.g. `cli_args.cpp`'s `getopt_long` long-option
codes live in `namespace options { constexpr int log_level = ...; }`, and `bwrap.cpp`'s codes live in `namespace options { constexpr int log_level = ...; }`, and `bwrap.cpp`'s
priv-drop-helper path/binary-name pair live in `namespace priv_drop { ... }`) — nest the named priv-drop-helper path/binary-name pair live in `namespace priv_drop { ... }`) — nest the named
namespace inside the file's existing anonymous namespace where one is already present, so namespace inside the file's existing anonymous namespace where one is already present, so
+2 -1
View File
@@ -17,7 +17,8 @@ conf_data.set10('ENABLE_TESTS', get_option('enable_tests'))
configure_file(output : 'config.h', configuration : conf_data) configure_file(output : 'config.h', configuration : conf_data)
slocker_lite = executable('slocker-lite', slocker_lite = executable('slocker-lite',
['src/main.cpp', 'src/process.cpp', 'src/oci_image.cpp', 'src/containers_storage.cpp', ['src/main.cpp', 'src/cli_args.cpp', 'src/commands.cpp', 'src/self_test.cpp',
'src/process.cpp', 'src/oci_image.cpp', 'src/containers_storage.cpp',
'src/bwrap.cpp', 'src/user_spec.cpp', 'src/config_file.cpp', 'src/volume_mount.cpp', 'src/bwrap.cpp', 'src/user_spec.cpp', 'src/config_file.cpp', 'src/volume_mount.cpp',
'src/pid_file.cpp', 'src/exec_session.cpp', 'src/env_spec.cpp', 'src/daemonize.cpp'], 'src/pid_file.cpp', 'src/exec_session.cpp', 'src/env_spec.cpp', 'src/daemonize.cpp'],
include_directories : include_directories('.'), include_directories : include_directories('.'),
+386
View File
@@ -0,0 +1,386 @@
// Copyright (C) 2026 Viorel Munteanu
//
// This program is free software; you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation; either version 2 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along
// with this program; if not, write to the Free Software Foundation, Inc.,
// 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
#include "cli_args.h"
#include <array>
#include <cstdlib>
#include <getopt.h>
#include <fmt/core.h>
#include <spdlog/spdlog.h>
#include "config.h"
namespace {
// --log-level/--user/--group/--list-volumes/--delete-volume[-full]/--hostname/
// --list-processes/--clean-processes/--env/--env-file have no short form
// (--log-level's was freed up so -l could become --list-images; -u is already
// --umount; the rest have no natural free letter left, or don't need one), so
// they need long-option vals outside the printable-char range short options use.
namespace options {
constexpr int log_level = 256;
constexpr int user = 257;
constexpr int group = 258;
constexpr int list_volumes = 259;
constexpr int delete_volume = 260;
constexpr int delete_volume_full = 261;
constexpr int hostname = 262;
constexpr int list_processes = 263;
constexpr int clean_processes = 264;
constexpr int env = 265;
constexpr int env_file = 266;
} // namespace options
constexpr std::array<struct option, 25> long_options = {{
{"help", no_argument, nullptr, 'h'},
{"version", no_argument, nullptr, 'V'},
{"test", no_argument, nullptr, 't'},
{"log-level", required_argument, nullptr, options::log_level},
{"mount", required_argument, nullptr, 'm'},
{"umount", required_argument, nullptr, 'u'},
{"run", required_argument, nullptr, 'r'},
{"cleanup", required_argument, nullptr, 'c'},
{"no-nsenter", no_argument, nullptr, 'n'},
{"daemonize", no_argument, nullptr, 'D'},
{"list-images", required_argument, nullptr, 'l'},
{"user", required_argument, nullptr, options::user},
{"group", required_argument, nullptr, options::group},
{"volume", required_argument, nullptr, 'v'},
{"list-volumes", no_argument, nullptr, options::list_volumes},
{"delete-volume", required_argument, nullptr, options::delete_volume},
{"delete-volume-full", required_argument, nullptr, options::delete_volume_full},
{"inspect", required_argument, nullptr, 'i'},
{"exec", required_argument, nullptr, 'e'},
{"hostname", required_argument, nullptr, options::hostname},
{"list-processes", no_argument, nullptr, options::list_processes},
{"clean-processes", no_argument, nullptr, options::clean_processes},
{"env", required_argument, nullptr, options::env},
{"env-file", required_argument, nullptr, options::env_file},
{nullptr, 0, nullptr, 0},
}};
void print_usage(const char* prog) {
fmt::print(
"usage: {0} -m|--mount <image.tar>\n"
" {0} -r|--run <image.tar> [-v <name-or-dir> <container-path>]... [-- <command> [args...]]\n"
" {0} -u|--umount <layer-id>\n"
" {0} -c|--cleanup <layer-id>\n"
" {0} -l|--list-images <directory>\n"
" {0} -i|--inspect <image.tar>\n"
" {0} -e|--exec <pid> [-- <command> [args...]]\n"
" {0} -v|--volume <name> <directory>\n"
" {0} --list-volumes\n"
" {0} --delete-volume <name>\n"
" {0} --delete-volume-full <name>\n"
" {0} --list-processes\n"
" {0} --clean-processes\n"
" {0} -t|--test\n"
" {0} -h|--help\n"
" {0} -V|--version\n"
"\n"
"options:\n"
" -m, --mount <image.tar> validate and mount an OCI Image Layout tar\n"
" -r, --run <image.tar> mount, run bwrap in the foreground (default\n"
" command: the image's own Entrypoint/Cmd if set,\n"
" else /bin/sh; pass -- <command> [args...] to\n"
" override), then unmount and clean up when it\n"
" exits\n"
" -u, --umount <layer-id> unmount a previously mounted image layer (the\n"
" ID printed by --mount/--run, or from\n"
" `containers-storage layers`)\n"
" -c, --cleanup <layer-id> delete a layer and its ancestor chain from\n"
" local storage (unmount it first with --umount)\n"
" -n, --no-nsenter with --run, bind the mount directly instead of\n"
" nsenter-ing into fuse-overlayfs's namespace\n"
" (this is automatic when running as root, where\n"
" the mount is already directly visible; pass\n"
" this to force it off otherwise)\n"
" -D, --daemonize with --run, fork into the background: detaches\n"
" from the controlling terminal (setsid()) and\n"
" ignores SIGHUP, redirecting stdin from /dev/null\n"
" and stdout/stderr to a log file under\n"
" $XDG_STATE_HOME/slocker-lite/logs/. Prints the\n"
" session's pid and log path, then returns\n"
" immediately -- the same pid --list-processes/\n"
" -e/--exec use\n"
" --user <user> with --run, run the command as this user (name\n"
" or numeric uid) instead of the image's own\n"
" declared user (or root, if it declares none),\n"
" resolved against the image's own /etc/passwd;\n"
" only takes effect when --run executes as root\n"
" (no user namespace involved)\n"
" --group <group> with --user, use this group (name or numeric\n"
" gid) instead of the user's own primary group\n"
" --hostname <name> with --run, set the sandbox's hostname (only\n"
" takes effect if the running kernel supports\n"
" --unshare-uts; otherwise ignored with a\n"
" warning)\n"
" --env VAR=VALUE with --run, set an environment variable in the\n"
" sandbox (overrides the default PATH/HOME/PWD/\n"
" TERM if given the same name). May be repeated;\n"
" combined with --env-file in command-line order,\n"
" each later one winning over an earlier one for\n"
" the same name\n"
" --env-file <file> with --run, load environment variables from\n"
" <file> (one VAR=VALUE per line; blank lines and\n"
" #-comments are skipped). May be repeated\n"
" -l, --list-images <dir> list OCI Image Layout tars (*.tar, *.tar.*) found\n"
" directly in <dir>, with their name:tag\n"
" -i, --inspect <image.tar> print an image's declared user, exposed ports,\n"
" env, volumes, and default command, without\n"
" mounting or running it\n"
" -e, --exec <pid> join a running --run session (pid must be one\n"
" shown by --list-processes as \"running\") and run\n"
" a command inside its container; pass\n"
" -- <command> [args...] to specify it\n"
" -v, --volume <name> <dir> create a named volume mapped to a host directory\n"
" (created if missing), recorded in the config\n"
" file's volumes section. With --run, instead\n"
" mount a volume into the sandbox: <name> is a\n"
" named volume or, if it contains '/', a host\n"
" directory (created if missing); <dir> is the\n"
" absolute path inside the container to mount it\n"
" at. May be repeated with --run. If the host\n"
" directory is empty and the image already has\n"
" content there, it's copied in first\n"
" --list-volumes list all named volumes (see -v/--volume) with\n"
" their host directory\n"
" --delete-volume <name>\n"
" remove a named volume from the config (the host\n"
" directory is left untouched)\n"
" --delete-volume-full <name>\n"
" like --delete-volume, but also recursively\n"
" deletes the volume's host directory\n"
" --list-processes list running --run sessions found by their pid\n"
" files under $XDG_STATE_HOME/slocker-lite/run/,\n"
" with their pid, container name, and status\n"
" (running or exited)\n"
" --clean-processes remove stale pid files (see --list-processes)\n"
" left behind by sessions that are no longer\n"
" running\n"
" -t, --test run the test suite\n"
" --log-level <level> set log verbosity (trace, debug, info, warn,\n"
" error, critical, off)\n"
" -h, --help print this help and exit\n"
" -V, --version print version information and exit\n",
prog);
}
void print_version() {
fmt::print(
"{} {}\n"
"Licensed under GNU GPL version 2 or later <https://gnu.org/licenses/gpl-2.0.html>\n"
"This is free software: you are free to change and redistribute it.\n"
"There is NO WARRANTY, to the extent permitted by law.\n",
PACKAGE, VERSION);
}
} // namespace
bool apply_log_level(std::string_view name) {
constexpr std::array<std::string_view, 7> valid_levels = {
"trace", "debug", "info", "warn", "error", "critical", "off"};
for (auto level : valid_levels) {
if (level == name) {
spdlog::set_level(spdlog::level::from_str(std::string(name)));
return true;
}
}
spdlog::error("invalid log level: {}", name);
return false;
}
std::optional<int> parse_args(int argc, char* argv[], ParsedArgs& out) {
opterr = 0;
int opt;
while ((opt = getopt_long(argc, argv, ":hVtm:u:r:c:nl:v:i:e:D", long_options.data(), nullptr)) != -1) {
switch (opt) {
case 'h':
print_usage(argv[0]);
return 0;
case 'V':
print_version();
return 0;
case 't':
case 'm':
case 'u':
case 'r':
case 'c':
case 'l':
case 'i':
case 'e':
case options::list_volumes:
case options::delete_volume:
case options::delete_volume_full:
case options::list_processes:
case options::clean_processes: {
Mode requested;
switch (opt) {
case 't':
requested = Mode::test;
break;
case 'm':
requested = Mode::mount;
break;
case 'u':
requested = Mode::unmount;
break;
case 'r':
requested = Mode::run;
break;
case 'c':
requested = Mode::cleanup;
break;
case 'l':
requested = Mode::list_images;
break;
case 'i':
requested = Mode::inspect;
break;
case 'e':
requested = Mode::exec;
break;
case options::list_volumes:
requested = Mode::list_volumes;
break;
case options::delete_volume:
requested = Mode::delete_volume;
break;
case options::delete_volume_full:
requested = Mode::delete_volume_full;
break;
case options::list_processes:
requested = Mode::list_processes;
break;
default:
requested = Mode::clean_processes;
break;
}
if (out.mode != Mode::none && out.mode != requested) {
spdlog::error("multiple actions specified");
print_usage(argv[0]);
return 1;
}
out.mode = requested;
if (optarg) {
out.mode_arg = optarg;
}
break;
}
case 'v': {
// -v/--volume takes two tokens: optarg (the name or host path) plus
// the immediately following argv entry (the directory, or, with -r,
// the container path). Consumed manually (rather than via getopt's
// own required_argument) so -v can repeat with -r -- see below for
// how the two uses are told apart.
if (optind >= argc || (argv[optind][0] == '-' && argv[optind][1] != '\0')) {
spdlog::error("--volume requires a name/path and a directory or container path");
print_usage(argv[0]);
return 1;
}
out.volume_specs.emplace_back(optarg, argv[optind]);
++optind;
break;
}
case 'n':
out.disable_nsenter = true;
break;
case 'D':
out.daemonize_flag = true;
break;
case options::log_level:
if (!apply_log_level(optarg)) {
return 1;
}
break;
case options::user:
out.user_flag = optarg;
break;
case options::group:
out.group_flag = optarg;
break;
case options::hostname:
out.hostname_flag = optarg;
break;
case options::env:
out.env_specs.push_back({false, optarg});
break;
case options::env_file:
out.env_specs.push_back({true, optarg});
break;
case ':':
spdlog::error("option requires an argument: -{}", static_cast<char>(optopt));
print_usage(argv[0]);
return 1;
case '?':
default:
spdlog::error("unrecognized option");
print_usage(argv[0]);
return 1;
}
}
if (!out.volume_specs.empty() && out.mode != Mode::run) {
if (out.mode != Mode::none) {
spdlog::error("--volume can only be used standalone or together with --run");
print_usage(argv[0]);
return 1;
}
if (out.volume_specs.size() > 1) {
spdlog::error("--volume can only be used once outside of --run");
print_usage(argv[0]);
return 1;
}
out.mode = Mode::volume;
}
if (out.mode == Mode::none) {
print_usage(argv[0]);
return 1;
}
if (out.mode != Mode::run && out.mode != Mode::exec && optind != argc) {
print_usage(argv[0]);
return 1;
}
if (out.group_flag && !out.user_flag) {
spdlog::error("--group requires --user");
print_usage(argv[0]);
return 1;
}
if (out.mode == Mode::run || out.mode == Mode::exec) {
out.command.assign(argv + optind, argv + argc);
}
if (out.mode == Mode::exec) {
if (out.command.empty()) {
spdlog::error("--exec requires a command to run");
print_usage(argv[0]);
return 1;
}
char* end = nullptr;
long parsed = std::strtol(out.mode_arg.c_str(), &end, 10);
if (out.mode_arg.empty() || !end || *end != '\0' || parsed <= 0) {
spdlog::error("invalid pid: {}", out.mode_arg);
return 1;
}
out.exec_pid = static_cast<pid_t>(parsed);
}
return std::nullopt;
}
+80
View File
@@ -0,0 +1,80 @@
// Copyright (C) 2026 Viorel Munteanu
//
// This program is free software; you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation; either version 2 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along
// with this program; if not, write to the Free Software Foundation, Inc.,
// 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
#pragma once
#include <optional>
#include <string>
#include <string_view>
#include <utility>
#include <vector>
#include <sys/types.h>
#include "env_spec.h"
enum class Mode {
none,
mount,
unmount,
test,
run,
cleanup,
list_images,
volume,
list_volumes,
delete_volume,
delete_volume_full,
inspect,
list_processes,
clean_processes,
exec
};
// Everything parse_args() extracts from argv, ready to hand to
// dispatch_command() (commands.h).
struct ParsedArgs {
Mode mode = Mode::none;
std::string mode_arg;
bool disable_nsenter = false;
bool daemonize_flag = false;
std::optional<std::string> user_flag;
std::optional<std::string> group_flag;
std::optional<std::string> hostname_flag;
std::vector<std::pair<std::string, std::string>> volume_specs;
std::vector<EnvSpec> env_specs;
// Trailing argv (after getopt_long stops), populated only for
// Mode::run/Mode::exec -- the command to run, or to run under -e/--exec.
std::vector<std::string> command;
// Parsed and range-validated from mode_arg when mode == Mode::exec.
std::optional<pid_t> exec_pid;
};
// Validates and applies a log-level name (trace/debug/info/warn/error/critical/off)
// via spdlog::set_level(). Returns false (logging an error) if `name` isn't
// recognized. Exposed (not parse_args()-internal) because main() also applies it
// directly for the config file's global.log-level, before parse_args() runs, so
// an explicit --log-level on the command line can still override it afterward.
bool apply_log_level(std::string_view name);
// Parses argv via getopt_long() into `out`, including all of the post-loop
// validation (mode/--volume interaction, --group requires --user, leftover
// positional args outside --run/--exec, --exec's own command/pid validation).
// Returns an exit code main() should return immediately (0 for -h/--help or
// -V/--version, 1 for any parse error) -- print_usage()/print_version() are
// already called internally in those cases. nullopt means parsing succeeded
// and `out` is ready for dispatch_command() (commands.h).
std::optional<int> parse_args(int argc, char* argv[], ParsedArgs& out);
+526
View File
@@ -0,0 +1,526 @@
// Copyright (C) 2026 Viorel Munteanu
//
// This program is free software; you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation; either version 2 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along
// with this program; if not, write to the Free Software Foundation, Inc.,
// 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
#include "commands.h"
#include <algorithm>
#include <array>
#include <cstdlib>
#include <functional>
#include <optional>
#include <string>
#include <string_view>
#include <unistd.h>
#include <vector>
#include <fmt/core.h>
#include <fmt/ranges.h>
#include <spdlog/spdlog.h>
#include "bwrap.h"
#include "containers_storage.h"
#include "daemonize.h"
#include "env_spec.h"
#include "exec_session.h"
#include "oci_image.h"
#include "pid_file.h"
#include "process.h"
#include "self_test.h"
#include "user_spec.h"
#include "volume_mount.h"
namespace {
constexpr std::array<std::string_view, 2> required_tools = {"containers-storage", "bwrap"};
// Shared by list_images_command()/list_volumes_command()/list_processes_command():
// pad each entry with tabs (not spaces) so columns line up on an 8-column tab
// stop past the longest entry in that column, however long that is.
constexpr size_t tab_width = 8;
bool check_required_dependencies() {
bool all_found = true;
for (auto name : required_tools) {
if (!find_in_path(name)) {
spdlog::error("required dependency not found in PATH: {}", name);
all_found = false;
}
}
return all_found;
}
struct MountedImage {
std::string merged_path;
std::string top_layer_id;
};
std::optional<MountedImage> mount_image(const std::filesystem::path& image_tar) {
if (!check_required_dependencies()) {
return std::nullopt;
}
auto mount_program = find_in_path("fuse-overlayfs");
if (!mount_program) {
spdlog::error("fuse-overlayfs not found in PATH");
return std::nullopt;
}
g_mount_program = mount_program->string();
auto layers = read_oci_layers(image_tar);
if (!layers) {
return std::nullopt;
}
std::string parent_id;
for (const auto& layer : *layers) {
std::filesystem::path tmp_file =
std::filesystem::temp_directory_path() /
fmt::format("slocker-lite-{}-{}", getpid(), oci_digest_hex(layer.digest));
if (!extract_blob_to_file(image_tar, oci_digest_hex(layer.digest), tmp_file)) {
return std::nullopt;
}
auto layer_id = import_layer(tmp_file, parent_id);
std::filesystem::remove(tmp_file);
if (!layer_id) {
spdlog::error("failed to import layer {}", layer.digest);
return std::nullopt;
}
parent_id = *layer_id;
}
auto merged = mount_layer(parent_id);
if (!merged) {
spdlog::error("failed to mount assembled image");
return std::nullopt;
}
return MountedImage{*merged, parent_id};
}
int mount_command(const std::filesystem::path& image_tar) {
auto mounted = mount_image(image_tar);
if (!mounted) {
return 1;
}
fmt::print("mounted image at: {} (layer {})\n", mounted->merged_path, mounted->top_layer_id);
return 0;
}
int unmount_image(const std::string& layer_id) {
if (!check_required_dependencies()) {
return 1;
}
if (!unmount_layer(layer_id)) {
spdlog::error("failed to unmount layer {}", layer_id);
return 1;
}
fmt::print("unmounted layer {}\n", layer_id);
return 0;
}
int cleanup_image(const std::string& layer_id) {
if (!check_required_dependencies()) {
return 1;
}
if (!cleanup_layer_chain(layer_id)) {
spdlog::error("failed to clean up layer {}", layer_id);
return 1;
}
fmt::print("cleaned up layer {}\n", layer_id);
return 0;
}
int list_images_command(const std::filesystem::path& dir) {
auto images = list_oci_images(dir);
if (!images) {
return 1;
}
std::vector<std::string> refs;
refs.reserve(images->size());
size_t max_len = 0;
for (const auto& ref : *images) {
refs.push_back(fmt::format("{}:{}", ref.name, ref.tag));
max_len = std::max(max_len, refs.back().size());
}
size_t target_tabs = max_len / tab_width + 1;
for (size_t i = 0; i < images->size(); ++i) {
size_t tabs_used = refs[i].size() / tab_width;
size_t tabs_needed = target_tabs > tabs_used ? target_tabs - tabs_used : 1;
fmt::print("{}{}{}\n", refs[i], std::string(tabs_needed, '\t'),
(*images)[i].path.filename().string());
}
return 0;
}
// Prints every OciImageConfig field for a human to read, without mounting or
// running the image. Extend this whenever OciImageConfig gains a new field.
int inspect_image_command(const std::filesystem::path& image_tar) {
auto config = read_oci_image_config(image_tar);
if (!config) {
return 1;
}
fmt::print("Image: {}\n", image_tar.string());
if (config->user.empty()) {
fmt::print("User: (not set)\n");
} else if (config->group.empty()) {
fmt::print("User: {}\n", config->user);
} else {
fmt::print("User: {}:{}\n", config->user, config->group);
}
fmt::print("Command: {}\n",
config->command.empty() ? "(not set)" : fmt::to_string(fmt::join(config->command, " ")));
if (config->exposed_ports.empty()) {
fmt::print("Exposed ports: (none)\n");
} else {
fmt::print("Exposed ports:\n");
for (const auto& port : config->exposed_ports) {
fmt::print(" {}/{}\n", port.port, port.protocol == OciPortProtocol::tcp ? "tcp" : "udp");
}
}
if (config->env.empty()) {
fmt::print("Env: (none)\n");
} else {
fmt::print("Env:\n");
for (const auto& e : config->env) {
fmt::print(" {}\n", e);
}
}
if (config->volumes.empty()) {
fmt::print("Volumes: (none)\n");
} else {
fmt::print("Volumes:\n");
for (const auto& v : config->volumes) {
fmt::print(" {}\n", v);
}
}
return 0;
}
int create_volume_command(const std::string& name, const std::string& directory,
const std::filesystem::path& config_path, AppConfig& config) {
if (!is_valid_volume_name(name)) {
spdlog::error("volume name '{}' must not contain '/'", name);
return 1;
}
std::filesystem::path resolved = std::filesystem::absolute(directory).lexically_normal();
for (const auto& volume : config.volumes) {
if (volume.name == name) {
spdlog::error("a volume named '{}' already exists (directory: {})", name, volume.directory);
return 1;
}
if (volume.directory == resolved.string()) {
spdlog::error("directory {} is already used by volume '{}'", resolved.string(), volume.name);
return 1;
}
}
std::error_code ec;
bool created = std::filesystem::create_directories(resolved, ec);
if (ec) {
spdlog::error("failed to create directory {}: {}", resolved.string(), ec.message());
return 1;
}
if (!created) {
spdlog::warn("directory {} already exists", resolved.string());
std::error_code empty_ec;
bool empty = std::filesystem::is_empty(resolved, empty_ec);
if (!empty_ec && !empty) {
spdlog::warn("directory {} is not empty", resolved.string());
}
}
config.volumes.push_back({name, resolved.string()});
if (!write_config_file(config_path, config)) {
return 1;
}
fmt::print("created volume '{}' -> {}\n", name, resolved.string());
return 0;
}
int list_volumes_command(const AppConfig& config) {
size_t max_len = 0;
for (const auto& volume : config.volumes) {
max_len = std::max(max_len, volume.name.size());
}
size_t target_tabs = max_len / tab_width + 1;
for (const auto& volume : config.volumes) {
size_t tabs_used = volume.name.size() / tab_width;
size_t tabs_needed = target_tabs > tabs_used ? target_tabs - tabs_used : 1;
fmt::print("{}{}{}\n", volume.name, std::string(tabs_needed, '\t'), volume.directory);
}
return 0;
}
int list_processes_command() {
auto sessions = list_sessions();
std::vector<std::string> pids;
std::vector<std::string> names;
pids.reserve(sessions.size());
names.reserve(sessions.size());
size_t max_pid_len = 0;
size_t max_name_len = 0;
for (const auto& session : sessions) {
pids.push_back(fmt::format("{}", session.pid));
names.push_back(session.container_name);
max_pid_len = std::max(max_pid_len, pids.back().size());
max_name_len = std::max(max_name_len, names.back().size());
}
// Applied independently to each of the two variable-width columns.
size_t pid_target_tabs = max_pid_len / tab_width + 1;
size_t name_target_tabs = max_name_len / tab_width + 1;
for (size_t i = 0; i < sessions.size(); ++i) {
size_t pid_tabs_used = pids[i].size() / tab_width;
size_t pid_tabs_needed = pid_target_tabs > pid_tabs_used ? pid_target_tabs - pid_tabs_used : 1;
size_t name_tabs_used = names[i].size() / tab_width;
size_t name_tabs_needed = name_target_tabs > name_tabs_used ? name_target_tabs - name_tabs_used : 1;
fmt::print("{}{}{}{}{}\n", pids[i], std::string(pid_tabs_needed, '\t'), names[i],
std::string(name_tabs_needed, '\t'), sessions[i].running ? "running" : "exited");
}
return 0;
}
int clean_processes_command() {
for (const auto& session : clean_stale_sessions()) {
fmt::print("removed stale pid file for '{}' (pid {})\n", session.container_name, session.pid);
}
return 0;
}
int delete_volume_command(const std::string& name, const std::filesystem::path& config_path,
AppConfig& config, bool delete_directory) {
auto it = std::find_if(config.volumes.begin(), config.volumes.end(),
[&](const VolumeEntry& volume) { return volume.name == name; });
if (it == config.volumes.end()) {
spdlog::error("no volume named '{}' exists", name);
return 1;
}
if (delete_directory) {
if (!std::filesystem::exists(it->directory)) {
spdlog::warn("directory {} does not exist, nothing to delete", it->directory);
}
std::error_code ec;
std::filesystem::remove_all(it->directory, ec);
if (ec) {
spdlog::error("failed to delete directory {}: {}", it->directory, ec.message());
return 1;
}
}
config.volumes.erase(it);
if (!write_config_file(config_path, config)) {
return 1;
}
fmt::print("deleted volume '{}'\n", name);
return 0;
}
int run_container(const std::filesystem::path& image_tar,
const std::vector<std::string>& requested_command, bool use_nsenter,
const std::optional<std::string>& user, const std::optional<std::string>& group,
const std::optional<std::string>& hostname,
const std::vector<std::pair<std::string, std::string>>& volume_specs,
const std::vector<EnvSpec>& env_specs, bool daemonize_flag, const AppConfig& app_config) {
// Only depends on image_tar, so this can run before mount_image() -- moved
// up here (rather than right before the run_bwrap() call, as before) so
// daemonize() below can use the real container name for the log file from
// its very first line, not just after a later rename.
auto image_ref = read_image_ref(image_tar);
std::string container_name =
image_ref ? fmt::format("{}:{}", image_ref->name, image_ref->tag) : image_tar.stem().string();
if (daemonize_flag) {
auto result = daemonize(container_name);
if (result) {
// Parent (or a hard daemonize failure before ever forking) -- report
// and return immediately. The child falls through below instead.
if (result->pid) {
fmt::print("started in background: pid {}, log: {}\n", *result->pid, result->log_path);
return 0;
}
spdlog::error("failed to start in background{}", result->log_path.empty()
? ""
: fmt::format(" (see log: {})", result->log_path));
return 1;
}
}
auto mounted = mount_image(image_tar);
if (!mounted) {
return 1;
}
fmt::print("mounted image at: {} (layer {})\n", mounted->merged_path, mounted->top_layer_id);
auto config = read_oci_image_config(image_tar);
bool ok = true;
std::vector<ResolvedVolumeMount> volume_mounts;
for (const auto& [spec, container_path] : volume_specs) {
if (container_path.empty() || container_path.front() != '/') {
spdlog::error("volume container path '{}' must be absolute", container_path);
ok = false;
continue;
}
bool duplicate = std::any_of(
volume_mounts.begin(), volume_mounts.end(),
[&](const ResolvedVolumeMount& mount) { return mount.container_path == container_path; });
if (duplicate) {
spdlog::error("volume container path '{}' is mounted more than once", container_path);
ok = false;
continue;
}
// use_nsenter also governs whether resolve_volume_mount() needs nsenter to see
// the image's own content when populating an empty volume -- the same
// rootless-mount visibility constraint run_bwrap() itself works around.
auto resolved =
resolve_volume_mount(spec, container_path, app_config, mounted->merged_path, use_nsenter);
if (!resolved) {
ok = false;
continue;
}
volume_mounts.push_back(std::move(*resolved));
}
auto resolved_env = resolve_env_specs(env_specs);
if (!resolved_env) {
ok = false;
}
// Falls back to the image's own declared user (config.User) when --user wasn't
// given on the command line, rather than always defaulting to root.
std::optional<std::string> effective_user = user;
std::optional<std::string> effective_group = group;
if (!effective_user && config && !config->user.empty()) {
effective_user = config->user;
effective_group = config->group.empty() ? std::nullopt : std::optional<std::string>(config->group);
}
std::optional<ResolvedUser> resolved_user;
if (effective_user) {
resolved_user = resolve_user_and_group(*effective_user, effective_group, mounted->merged_path);
if (!resolved_user) {
ok = false;
}
}
std::vector<std::string> command = requested_command;
if (command.empty()) {
command = (config && !config->command.empty()) ? config->command
: std::vector<std::string>{"/bin/sh"};
}
std::function<void(pid_t)> on_bwrap_pid_known;
if (daemonize_flag) {
on_bwrap_pid_known = [&](pid_t pid) { report_daemon_started(container_name, pid); };
}
int exit_code = -1;
if (ok) {
exit_code = run_bwrap(mounted->merged_path, command, use_nsenter, volume_mounts, resolved_user, hostname,
container_name, *resolved_env, on_bwrap_pid_known);
if (exit_code < 0) {
spdlog::error("failed to run bwrap");
}
}
if (!unmount_layer(mounted->top_layer_id)) {
spdlog::error("failed to unmount layer {}", mounted->top_layer_id);
}
if (!cleanup_layer_chain(mounted->top_layer_id)) {
spdlog::error("failed to clean up layer {}", mounted->top_layer_id);
}
return (!ok || exit_code < 0) ? 1 : exit_code;
}
} // namespace
int dispatch_command(const ParsedArgs& args, const std::filesystem::path& config_path, AppConfig& config) {
// Exhaustive over every Mode enumerator, deliberately with no default:
// -Wswitch (this project builds at warning_level=3) then forces a
// warning/error if a future Mode value is ever added without a matching
// case here, instead of silently falling through to the wrong command.
switch (args.mode) {
case Mode::none:
// Unreachable: parse_args() already rejects Mode::none before
// dispatch_command() is ever called.
spdlog::error("internal error: no mode selected");
return 1;
case Mode::mount:
return mount_command(args.mode_arg);
case Mode::unmount:
return unmount_image(args.mode_arg);
case Mode::cleanup:
return cleanup_image(args.mode_arg);
case Mode::list_images:
return list_images_command(args.mode_arg);
case Mode::inspect:
return inspect_image_command(args.mode_arg);
case Mode::volume:
return create_volume_command(args.volume_specs.front().first, args.volume_specs.front().second,
config_path, config);
case Mode::list_volumes:
return list_volumes_command(config);
case Mode::delete_volume:
return delete_volume_command(args.mode_arg, config_path, config, false);
case Mode::delete_volume_full:
return delete_volume_command(args.mode_arg, config_path, config, true);
case Mode::list_processes:
return list_processes_command();
case Mode::clean_processes:
return clean_processes_command();
case Mode::test:
return run_self_tests();
case Mode::exec:
return exec_in_session(*args.exec_pid, args.command);
case Mode::run: {
// As root, containers-storage mount doesn't need to reexec into a private
// user namespace to gain privilege, so the mount is already directly
// visible; nsenter into it then fails ("reassociate to namespace 'ns/user'
// failed: Invalid argument") since we're already in that same namespace.
bool use_nsenter = !args.disable_nsenter && geteuid() != 0;
if (geteuid() == 0 && !args.disable_nsenter) {
spdlog::debug("running as root; skipping nsenter (the mount is already directly visible)");
}
return run_container(args.mode_arg, args.command, use_nsenter, args.user_flag, args.group_flag,
args.hostname_flag, args.volume_specs, args.env_specs, args.daemonize_flag,
config);
}
}
return 1; // unreachable
}
+27
View File
@@ -0,0 +1,27 @@
// Copyright (C) 2026 Viorel Munteanu
//
// This program is free software; you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation; either version 2 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along
// with this program; if not, write to the Free Software Foundation, Inc.,
// 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
#pragma once
#include <filesystem>
#include "cli_args.h"
#include "config_file.h"
// Runs whichever command args.mode selects (an exhaustive switch over Mode --
// see commands.cpp -- so a Mode value ever added without a matching case
// triggers a -Wswitch warning/error rather than silently falling through).
int dispatch_command(const ParsedArgs& args, const std::filesystem::path& config_path, AppConfig& config);
+6 -874
View File
@@ -14,655 +14,14 @@
// with this program; if not, write to the Free Software Foundation, Inc., // with this program; if not, write to the Free Software Foundation, Inc.,
// 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. // 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
#include <algorithm>
#include <array>
#include <cstdlib>
#include <filesystem> #include <filesystem>
#include <functional>
#include <getopt.h>
#include <optional>
#include <string>
#include <string_view>
#include <unistd.h>
#include <vector>
#include <fmt/core.h>
#include <fmt/ranges.h>
#include <spdlog/cfg/env.h> #include <spdlog/cfg/env.h>
#include <spdlog/spdlog.h> #include <spdlog/spdlog.h>
#include "bwrap.h" #include "cli_args.h"
#include "config.h" #include "commands.h"
#include "config_file.h" #include "config_file.h"
#include "containers_storage.h"
#include "daemonize.h"
#include "env_spec.h"
#include "exec_session.h"
#include "oci_image.h"
#include "pid_file.h"
#include "process.h"
#include "user_spec.h"
#include "volume_mount.h"
namespace {
constexpr std::array<std::string_view, 2> required_tools = {"containers-storage", "bwrap"};
// Shared by list_images_command()/list_volumes_command()/list_processes_command():
// pad each entry with tabs (not spaces) so columns line up on an 8-column tab
// stop past the longest entry in that column, however long that is.
constexpr size_t tab_width = 8;
enum class Mode {
none,
mount,
unmount,
test,
run,
cleanup,
list_images,
volume,
list_volumes,
delete_volume,
delete_volume_full,
inspect,
list_processes,
clean_processes,
exec
};
// --log-level/--user/--group/--list-volumes/--delete-volume[-full]/--hostname/
// --list-processes/--clean-processes/--env/--env-file have no short form
// (--log-level's was freed up so -l could become --list-images; -u is already
// --umount; the rest have no natural free letter left, or don't need one), so
// they need long-option vals outside the printable-char range short options use.
namespace options {
constexpr int log_level = 256;
constexpr int user = 257;
constexpr int group = 258;
constexpr int list_volumes = 259;
constexpr int delete_volume = 260;
constexpr int delete_volume_full = 261;
constexpr int hostname = 262;
constexpr int list_processes = 263;
constexpr int clean_processes = 264;
constexpr int env = 265;
constexpr int env_file = 266;
} // namespace options
constexpr std::array<struct option, 25> long_options = {{
{"help", no_argument, nullptr, 'h'},
{"version", no_argument, nullptr, 'V'},
{"test", no_argument, nullptr, 't'},
{"log-level", required_argument, nullptr, options::log_level},
{"mount", required_argument, nullptr, 'm'},
{"umount", required_argument, nullptr, 'u'},
{"run", required_argument, nullptr, 'r'},
{"cleanup", required_argument, nullptr, 'c'},
{"no-nsenter", no_argument, nullptr, 'n'},
{"daemonize", no_argument, nullptr, 'D'},
{"list-images", required_argument, nullptr, 'l'},
{"user", required_argument, nullptr, options::user},
{"group", required_argument, nullptr, options::group},
{"volume", required_argument, nullptr, 'v'},
{"list-volumes", no_argument, nullptr, options::list_volumes},
{"delete-volume", required_argument, nullptr, options::delete_volume},
{"delete-volume-full", required_argument, nullptr, options::delete_volume_full},
{"inspect", required_argument, nullptr, 'i'},
{"exec", required_argument, nullptr, 'e'},
{"hostname", required_argument, nullptr, options::hostname},
{"list-processes", no_argument, nullptr, options::list_processes},
{"clean-processes", no_argument, nullptr, options::clean_processes},
{"env", required_argument, nullptr, options::env},
{"env-file", required_argument, nullptr, options::env_file},
{nullptr, 0, nullptr, 0},
}};
void print_usage(const char* prog) {
fmt::print(
"usage: {0} -m|--mount <image.tar>\n"
" {0} -r|--run <image.tar> [-v <name-or-dir> <container-path>]... [-- <command> [args...]]\n"
" {0} -u|--umount <layer-id>\n"
" {0} -c|--cleanup <layer-id>\n"
" {0} -l|--list-images <directory>\n"
" {0} -i|--inspect <image.tar>\n"
" {0} -e|--exec <pid> [-- <command> [args...]]\n"
" {0} -v|--volume <name> <directory>\n"
" {0} --list-volumes\n"
" {0} --delete-volume <name>\n"
" {0} --delete-volume-full <name>\n"
" {0} --list-processes\n"
" {0} --clean-processes\n"
" {0} -t|--test\n"
" {0} -h|--help\n"
" {0} -V|--version\n"
"\n"
"options:\n"
" -m, --mount <image.tar> validate and mount an OCI Image Layout tar\n"
" -r, --run <image.tar> mount, run bwrap in the foreground (default\n"
" command: the image's own Entrypoint/Cmd if set,\n"
" else /bin/sh; pass -- <command> [args...] to\n"
" override), then unmount and clean up when it\n"
" exits\n"
" -u, --umount <layer-id> unmount a previously mounted image layer (the\n"
" ID printed by --mount/--run, or from\n"
" `containers-storage layers`)\n"
" -c, --cleanup <layer-id> delete a layer and its ancestor chain from\n"
" local storage (unmount it first with --umount)\n"
" -n, --no-nsenter with --run, bind the mount directly instead of\n"
" nsenter-ing into fuse-overlayfs's namespace\n"
" (this is automatic when running as root, where\n"
" the mount is already directly visible; pass\n"
" this to force it off otherwise)\n"
" -D, --daemonize with --run, fork into the background: detaches\n"
" from the controlling terminal (setsid()) and\n"
" ignores SIGHUP, redirecting stdin from /dev/null\n"
" and stdout/stderr to a log file under\n"
" $XDG_STATE_HOME/slocker-lite/logs/. Prints the\n"
" session's pid and log path, then returns\n"
" immediately -- the same pid --list-processes/\n"
" -e/--exec use\n"
" --user <user> with --run, run the command as this user (name\n"
" or numeric uid) instead of the image's own\n"
" declared user (or root, if it declares none),\n"
" resolved against the image's own /etc/passwd;\n"
" only takes effect when --run executes as root\n"
" (no user namespace involved)\n"
" --group <group> with --user, use this group (name or numeric\n"
" gid) instead of the user's own primary group\n"
" --hostname <name> with --run, set the sandbox's hostname (only\n"
" takes effect if the running kernel supports\n"
" --unshare-uts; otherwise ignored with a\n"
" warning)\n"
" --env VAR=VALUE with --run, set an environment variable in the\n"
" sandbox (overrides the default PATH/HOME/PWD/\n"
" TERM if given the same name). May be repeated;\n"
" combined with --env-file in command-line order,\n"
" each later one winning over an earlier one for\n"
" the same name\n"
" --env-file <file> with --run, load environment variables from\n"
" <file> (one VAR=VALUE per line; blank lines and\n"
" #-comments are skipped). May be repeated\n"
" -l, --list-images <dir> list OCI Image Layout tars (*.tar, *.tar.*) found\n"
" directly in <dir>, with their name:tag\n"
" -i, --inspect <image.tar> print an image's declared user, exposed ports,\n"
" env, volumes, and default command, without\n"
" mounting or running it\n"
" -e, --exec <pid> join a running --run session (pid must be one\n"
" shown by --list-processes as \"running\") and run\n"
" a command inside its container; pass\n"
" -- <command> [args...] to specify it\n"
" -v, --volume <name> <dir> create a named volume mapped to a host directory\n"
" (created if missing), recorded in the config\n"
" file's volumes section. With --run, instead\n"
" mount a volume into the sandbox: <name> is a\n"
" named volume or, if it contains '/', a host\n"
" directory (created if missing); <dir> is the\n"
" absolute path inside the container to mount it\n"
" at. May be repeated with --run. If the host\n"
" directory is empty and the image already has\n"
" content there, it's copied in first\n"
" --list-volumes list all named volumes (see -v/--volume) with\n"
" their host directory\n"
" --delete-volume <name>\n"
" remove a named volume from the config (the host\n"
" directory is left untouched)\n"
" --delete-volume-full <name>\n"
" like --delete-volume, but also recursively\n"
" deletes the volume's host directory\n"
" --list-processes list running --run sessions found by their pid\n"
" files under $XDG_STATE_HOME/slocker-lite/run/,\n"
" with their pid, container name, and status\n"
" (running or exited)\n"
" --clean-processes remove stale pid files (see --list-processes)\n"
" left behind by sessions that are no longer\n"
" running\n"
" -t, --test run the test suite\n"
" --log-level <level> set log verbosity (trace, debug, info, warn,\n"
" error, critical, off)\n"
" -h, --help print this help and exit\n"
" -V, --version print version information and exit\n",
prog);
}
void print_version() {
fmt::print(
"{} {}\n"
"Licensed under GNU GPL version 2 or later <https://gnu.org/licenses/gpl-2.0.html>\n"
"This is free software: you are free to change and redistribute it.\n"
"There is NO WARRANTY, to the extent permitted by law.\n",
PACKAGE, VERSION);
}
bool apply_log_level(std::string_view name) {
constexpr std::array<std::string_view, 7> valid_levels = {
"trace", "debug", "info", "warn", "error", "critical", "off"};
for (auto level : valid_levels) {
if (level == name) {
spdlog::set_level(spdlog::level::from_str(std::string(name)));
return true;
}
}
spdlog::error("invalid log level: {}", name);
return false;
}
int run_tests() {
for (const auto& arg : detect_bwrap_unshare_args()) {
fmt::print("{}\n", arg);
}
return 0;
}
} // namespace
bool check_required_dependencies() {
bool all_found = true;
for (auto name : required_tools) {
if (!find_in_path(name)) {
spdlog::error("required dependency not found in PATH: {}", name);
all_found = false;
}
}
return all_found;
}
int unmount_image(const std::string& layer_id) {
if (!check_required_dependencies()) {
return 1;
}
if (!unmount_layer(layer_id)) {
spdlog::error("failed to unmount layer {}", layer_id);
return 1;
}
fmt::print("unmounted layer {}\n", layer_id);
return 0;
}
struct MountedImage {
std::string merged_path;
std::string top_layer_id;
};
std::optional<MountedImage> mount_image(const std::filesystem::path& image_tar) {
if (!check_required_dependencies()) {
return std::nullopt;
}
auto mount_program = find_in_path("fuse-overlayfs");
if (!mount_program) {
spdlog::error("fuse-overlayfs not found in PATH");
return std::nullopt;
}
g_mount_program = mount_program->string();
auto layers = read_oci_layers(image_tar);
if (!layers) {
return std::nullopt;
}
std::string parent_id;
for (const auto& layer : *layers) {
std::filesystem::path tmp_file =
std::filesystem::temp_directory_path() /
fmt::format("slocker-lite-{}-{}", getpid(), oci_digest_hex(layer.digest));
if (!extract_blob_to_file(image_tar, oci_digest_hex(layer.digest), tmp_file)) {
return std::nullopt;
}
auto layer_id = import_layer(tmp_file, parent_id);
std::filesystem::remove(tmp_file);
if (!layer_id) {
spdlog::error("failed to import layer {}", layer.digest);
return std::nullopt;
}
parent_id = *layer_id;
}
auto merged = mount_layer(parent_id);
if (!merged) {
spdlog::error("failed to mount assembled image");
return std::nullopt;
}
return MountedImage{*merged, parent_id};
}
int cleanup_image(const std::string& layer_id) {
if (!check_required_dependencies()) {
return 1;
}
if (!cleanup_layer_chain(layer_id)) {
spdlog::error("failed to clean up layer {}", layer_id);
return 1;
}
fmt::print("cleaned up layer {}\n", layer_id);
return 0;
}
int list_images_command(const std::filesystem::path& dir) {
auto images = list_oci_images(dir);
if (!images) {
return 1;
}
std::vector<std::string> refs;
refs.reserve(images->size());
size_t max_len = 0;
for (const auto& ref : *images) {
refs.push_back(fmt::format("{}:{}", ref.name, ref.tag));
max_len = std::max(max_len, refs.back().size());
}
size_t target_tabs = max_len / tab_width + 1;
for (size_t i = 0; i < images->size(); ++i) {
size_t tabs_used = refs[i].size() / tab_width;
size_t tabs_needed = target_tabs > tabs_used ? target_tabs - tabs_used : 1;
fmt::print("{}{}{}\n", refs[i], std::string(tabs_needed, '\t'),
(*images)[i].path.filename().string());
}
return 0;
}
// Prints every OciImageConfig field for a human to read, without mounting or
// running the image. Extend this whenever OciImageConfig gains a new field.
int inspect_image_command(const std::filesystem::path& image_tar) {
auto config = read_oci_image_config(image_tar);
if (!config) {
return 1;
}
fmt::print("Image: {}\n", image_tar.string());
if (config->user.empty()) {
fmt::print("User: (not set)\n");
} else if (config->group.empty()) {
fmt::print("User: {}\n", config->user);
} else {
fmt::print("User: {}:{}\n", config->user, config->group);
}
fmt::print("Command: {}\n",
config->command.empty() ? "(not set)" : fmt::to_string(fmt::join(config->command, " ")));
if (config->exposed_ports.empty()) {
fmt::print("Exposed ports: (none)\n");
} else {
fmt::print("Exposed ports:\n");
for (const auto& port : config->exposed_ports) {
fmt::print(" {}/{}\n", port.port, port.protocol == OciPortProtocol::tcp ? "tcp" : "udp");
}
}
if (config->env.empty()) {
fmt::print("Env: (none)\n");
} else {
fmt::print("Env:\n");
for (const auto& e : config->env) {
fmt::print(" {}\n", e);
}
}
if (config->volumes.empty()) {
fmt::print("Volumes: (none)\n");
} else {
fmt::print("Volumes:\n");
for (const auto& v : config->volumes) {
fmt::print(" {}\n", v);
}
}
return 0;
}
int create_volume_command(const std::string& name, const std::string& directory,
const std::filesystem::path& config_path, AppConfig& config) {
if (!is_valid_volume_name(name)) {
spdlog::error("volume name '{}' must not contain '/'", name);
return 1;
}
std::filesystem::path resolved = std::filesystem::absolute(directory).lexically_normal();
for (const auto& volume : config.volumes) {
if (volume.name == name) {
spdlog::error("a volume named '{}' already exists (directory: {})", name, volume.directory);
return 1;
}
if (volume.directory == resolved.string()) {
spdlog::error("directory {} is already used by volume '{}'", resolved.string(), volume.name);
return 1;
}
}
std::error_code ec;
bool created = std::filesystem::create_directories(resolved, ec);
if (ec) {
spdlog::error("failed to create directory {}: {}", resolved.string(), ec.message());
return 1;
}
if (!created) {
spdlog::warn("directory {} already exists", resolved.string());
std::error_code empty_ec;
bool empty = std::filesystem::is_empty(resolved, empty_ec);
if (!empty_ec && !empty) {
spdlog::warn("directory {} is not empty", resolved.string());
}
}
config.volumes.push_back({name, resolved.string()});
if (!write_config_file(config_path, config)) {
return 1;
}
fmt::print("created volume '{}' -> {}\n", name, resolved.string());
return 0;
}
int list_volumes_command(const AppConfig& config) {
size_t max_len = 0;
for (const auto& volume : config.volumes) {
max_len = std::max(max_len, volume.name.size());
}
size_t target_tabs = max_len / tab_width + 1;
for (const auto& volume : config.volumes) {
size_t tabs_used = volume.name.size() / tab_width;
size_t tabs_needed = target_tabs > tabs_used ? target_tabs - tabs_used : 1;
fmt::print("{}{}{}\n", volume.name, std::string(tabs_needed, '\t'), volume.directory);
}
return 0;
}
int list_processes_command() {
auto sessions = list_sessions();
std::vector<std::string> pids;
std::vector<std::string> names;
pids.reserve(sessions.size());
names.reserve(sessions.size());
size_t max_pid_len = 0;
size_t max_name_len = 0;
for (const auto& session : sessions) {
pids.push_back(fmt::format("{}", session.pid));
names.push_back(session.container_name);
max_pid_len = std::max(max_pid_len, pids.back().size());
max_name_len = std::max(max_name_len, names.back().size());
}
// Applied independently to each of the two variable-width columns.
size_t pid_target_tabs = max_pid_len / tab_width + 1;
size_t name_target_tabs = max_name_len / tab_width + 1;
for (size_t i = 0; i < sessions.size(); ++i) {
size_t pid_tabs_used = pids[i].size() / tab_width;
size_t pid_tabs_needed = pid_target_tabs > pid_tabs_used ? pid_target_tabs - pid_tabs_used : 1;
size_t name_tabs_used = names[i].size() / tab_width;
size_t name_tabs_needed = name_target_tabs > name_tabs_used ? name_target_tabs - name_tabs_used : 1;
fmt::print("{}{}{}{}{}\n", pids[i], std::string(pid_tabs_needed, '\t'), names[i],
std::string(name_tabs_needed, '\t'), sessions[i].running ? "running" : "exited");
}
return 0;
}
int clean_processes_command() {
for (const auto& session : clean_stale_sessions()) {
fmt::print("removed stale pid file for '{}' (pid {})\n", session.container_name, session.pid);
}
return 0;
}
int delete_volume_command(const std::string& name, const std::filesystem::path& config_path,
AppConfig& config, bool delete_directory) {
auto it = std::find_if(config.volumes.begin(), config.volumes.end(),
[&](const VolumeEntry& volume) { return volume.name == name; });
if (it == config.volumes.end()) {
spdlog::error("no volume named '{}' exists", name);
return 1;
}
if (delete_directory) {
if (!std::filesystem::exists(it->directory)) {
spdlog::warn("directory {} does not exist, nothing to delete", it->directory);
}
std::error_code ec;
std::filesystem::remove_all(it->directory, ec);
if (ec) {
spdlog::error("failed to delete directory {}: {}", it->directory, ec.message());
return 1;
}
}
config.volumes.erase(it);
if (!write_config_file(config_path, config)) {
return 1;
}
fmt::print("deleted volume '{}'\n", name);
return 0;
}
int run_container(const std::filesystem::path& image_tar,
const std::vector<std::string>& requested_command, bool use_nsenter,
const std::optional<std::string>& user, const std::optional<std::string>& group,
const std::optional<std::string>& hostname,
const std::vector<std::pair<std::string, std::string>>& volume_specs,
const std::vector<EnvSpec>& env_specs, bool daemonize_flag, const AppConfig& app_config) {
// Only depends on image_tar, so this can run before mount_image() -- moved
// up here (rather than right before the run_bwrap() call, as before) so
// daemonize() below can use the real container name for the log file from
// its very first line, not just after a later rename.
auto image_ref = read_image_ref(image_tar);
std::string container_name =
image_ref ? fmt::format("{}:{}", image_ref->name, image_ref->tag) : image_tar.stem().string();
if (daemonize_flag) {
auto result = daemonize(container_name);
if (result) {
// Parent (or a hard daemonize failure before ever forking) -- report
// and return immediately. The child falls through below instead.
if (result->pid) {
fmt::print("started in background: pid {}, log: {}\n", *result->pid, result->log_path);
return 0;
}
spdlog::error("failed to start in background{}", result->log_path.empty()
? ""
: fmt::format(" (see log: {})", result->log_path));
return 1;
}
}
auto mounted = mount_image(image_tar);
if (!mounted) {
return 1;
}
fmt::print("mounted image at: {} (layer {})\n", mounted->merged_path, mounted->top_layer_id);
auto config = read_oci_image_config(image_tar);
bool ok = true;
std::vector<ResolvedVolumeMount> volume_mounts;
for (const auto& [spec, container_path] : volume_specs) {
if (container_path.empty() || container_path.front() != '/') {
spdlog::error("volume container path '{}' must be absolute", container_path);
ok = false;
continue;
}
bool duplicate = std::any_of(
volume_mounts.begin(), volume_mounts.end(),
[&](const ResolvedVolumeMount& mount) { return mount.container_path == container_path; });
if (duplicate) {
spdlog::error("volume container path '{}' is mounted more than once", container_path);
ok = false;
continue;
}
// use_nsenter also governs whether resolve_volume_mount() needs nsenter to see
// the image's own content when populating an empty volume -- the same
// rootless-mount visibility constraint run_bwrap() itself works around.
auto resolved =
resolve_volume_mount(spec, container_path, app_config, mounted->merged_path, use_nsenter);
if (!resolved) {
ok = false;
continue;
}
volume_mounts.push_back(std::move(*resolved));
}
auto resolved_env = resolve_env_specs(env_specs);
if (!resolved_env) {
ok = false;
}
// Falls back to the image's own declared user (config.User) when --user wasn't
// given on the command line, rather than always defaulting to root.
std::optional<std::string> effective_user = user;
std::optional<std::string> effective_group = group;
if (!effective_user && config && !config->user.empty()) {
effective_user = config->user;
effective_group = config->group.empty() ? std::nullopt : std::optional<std::string>(config->group);
}
std::optional<ResolvedUser> resolved_user;
if (effective_user) {
resolved_user = resolve_user_and_group(*effective_user, effective_group, mounted->merged_path);
if (!resolved_user) {
ok = false;
}
}
std::vector<std::string> command = requested_command;
if (command.empty()) {
command = (config && !config->command.empty()) ? config->command
: std::vector<std::string>{"/bin/sh"};
}
std::function<void(pid_t)> on_bwrap_pid_known;
if (daemonize_flag) {
on_bwrap_pid_known = [&](pid_t pid) { report_daemon_started(container_name, pid); };
}
int exit_code = -1;
if (ok) {
exit_code = run_bwrap(mounted->merged_path, command, use_nsenter, volume_mounts, resolved_user, hostname,
container_name, *resolved_env, on_bwrap_pid_known);
if (exit_code < 0) {
spdlog::error("failed to run bwrap");
}
}
if (!unmount_layer(mounted->top_layer_id)) {
spdlog::error("failed to unmount layer {}", mounted->top_layer_id);
}
if (!cleanup_layer_chain(mounted->top_layer_id)) {
spdlog::error("failed to clean up layer {}", mounted->top_layer_id);
}
return (!ok || exit_code < 0) ? 1 : exit_code;
}
int main(int argc, char* argv[]) { int main(int argc, char* argv[]) {
spdlog::cfg::load_env_levels(); spdlog::cfg::load_env_levels();
@@ -676,237 +35,10 @@ int main(int argc, char* argv[]) {
apply_log_level(*config->log_level); apply_log_level(*config->log_level);
} }
Mode mode = Mode::none; ParsedArgs args;
std::string mode_arg; if (auto exit_code = parse_args(argc, argv, args)) {
bool disable_nsenter = false; return *exit_code;
bool daemonize_flag = false;
std::optional<std::string> user_flag;
std::optional<std::string> group_flag;
std::optional<std::string> hostname_flag;
std::vector<std::pair<std::string, std::string>> volume_specs;
std::vector<EnvSpec> env_specs;
opterr = 0;
int opt;
while ((opt = getopt_long(argc, argv, ":hVtm:u:r:c:nl:v:i:e:D", long_options.data(), nullptr)) != -1) {
switch (opt) {
case 'h':
print_usage(argv[0]);
return 0;
case 'V':
print_version();
return 0;
case 't':
case 'm':
case 'u':
case 'r':
case 'c':
case 'l':
case 'i':
case 'e':
case options::list_volumes:
case options::delete_volume:
case options::delete_volume_full:
case options::list_processes:
case options::clean_processes: {
Mode requested;
switch (opt) {
case 't':
requested = Mode::test;
break;
case 'm':
requested = Mode::mount;
break;
case 'u':
requested = Mode::unmount;
break;
case 'r':
requested = Mode::run;
break;
case 'c':
requested = Mode::cleanup;
break;
case 'l':
requested = Mode::list_images;
break;
case 'i':
requested = Mode::inspect;
break;
case 'e':
requested = Mode::exec;
break;
case options::list_volumes:
requested = Mode::list_volumes;
break;
case options::delete_volume:
requested = Mode::delete_volume;
break;
case options::delete_volume_full:
requested = Mode::delete_volume_full;
break;
case options::list_processes:
requested = Mode::list_processes;
break;
default:
requested = Mode::clean_processes;
break;
}
if (mode != Mode::none && mode != requested) {
spdlog::error("multiple actions specified");
print_usage(argv[0]);
return 1;
}
mode = requested;
if (optarg) {
mode_arg = optarg;
}
break;
}
case 'v': {
// -v/--volume takes two tokens: optarg (the name or host path) plus
// the immediately following argv entry (the directory, or, with -r,
// the container path). Consumed manually (rather than via getopt's
// own required_argument) so -v can repeat with -r -- see main()'s
// post-loop handling for how the two uses are told apart.
if (optind >= argc || (argv[optind][0] == '-' && argv[optind][1] != '\0')) {
spdlog::error("--volume requires a name/path and a directory or container path");
print_usage(argv[0]);
return 1;
}
volume_specs.emplace_back(optarg, argv[optind]);
++optind;
break;
}
case 'n':
disable_nsenter = true;
break;
case 'D':
daemonize_flag = true;
break;
case options::log_level:
if (!apply_log_level(optarg)) {
return 1;
}
break;
case options::user:
user_flag = optarg;
break;
case options::group:
group_flag = optarg;
break;
case options::hostname:
hostname_flag = optarg;
break;
case options::env:
env_specs.push_back({false, optarg});
break;
case options::env_file:
env_specs.push_back({true, optarg});
break;
case ':':
spdlog::error("option requires an argument: -{}", static_cast<char>(optopt));
print_usage(argv[0]);
return 1;
case '?':
default:
spdlog::error("unrecognized option");
print_usage(argv[0]);
return 1;
}
} }
if (!volume_specs.empty() && mode != Mode::run) { return dispatch_command(args, config_path, *config);
if (mode != Mode::none) {
spdlog::error("--volume can only be used standalone or together with --run");
print_usage(argv[0]);
return 1;
}
if (volume_specs.size() > 1) {
spdlog::error("--volume can only be used once outside of --run");
print_usage(argv[0]);
return 1;
}
mode = Mode::volume;
}
if (mode == Mode::none) {
print_usage(argv[0]);
return 1;
}
if (mode != Mode::run && mode != Mode::exec && optind != argc) {
print_usage(argv[0]);
return 1;
}
if (group_flag && !user_flag) {
spdlog::error("--group requires --user");
print_usage(argv[0]);
return 1;
}
if (mode == Mode::test) {
return run_tests();
}
if (mode == Mode::unmount) {
return unmount_image(mode_arg);
}
if (mode == Mode::cleanup) {
return cleanup_image(mode_arg);
}
if (mode == Mode::list_images) {
return list_images_command(mode_arg);
}
if (mode == Mode::inspect) {
return inspect_image_command(mode_arg);
}
if (mode == Mode::volume) {
return create_volume_command(volume_specs.front().first, volume_specs.front().second, config_path,
*config);
}
if (mode == Mode::list_volumes) {
return list_volumes_command(*config);
}
if (mode == Mode::delete_volume || mode == Mode::delete_volume_full) {
return delete_volume_command(mode_arg, config_path, *config, mode == Mode::delete_volume_full);
}
if (mode == Mode::list_processes) {
return list_processes_command();
}
if (mode == Mode::clean_processes) {
return clean_processes_command();
}
if (mode == Mode::exec) {
std::vector<std::string> command(argv + optind, argv + argc);
if (command.empty()) {
spdlog::error("--exec requires a command to run");
print_usage(argv[0]);
return 1;
}
char* end = nullptr;
long parsed = std::strtol(mode_arg.c_str(), &end, 10);
if (mode_arg.empty() || !end || *end != '\0' || parsed <= 0) {
spdlog::error("invalid pid: {}", mode_arg);
return 1;
}
return exec_in_session(static_cast<pid_t>(parsed), command);
}
if (mode == Mode::run) {
std::vector<std::string> command(argv + optind, argv + argc);
// As root, containers-storage mount doesn't need to reexec into a private
// user namespace to gain privilege, so the mount is already directly
// visible; nsenter into it then fails ("reassociate to namespace 'ns/user'
// failed: Invalid argument") since we're already in that same namespace.
bool use_nsenter = !disable_nsenter && geteuid() != 0;
if (geteuid() == 0 && !disable_nsenter) {
spdlog::debug("running as root; skipping nsenter (the mount is already directly visible)");
}
return run_container(mode_arg, command, use_nsenter, user_flag, group_flag, hostname_flag, volume_specs,
env_specs, daemonize_flag, *config);
}
auto mounted = mount_image(mode_arg);
if (!mounted) {
return 1;
}
fmt::print("mounted image at: {} (layer {})\n", mounted->merged_path, mounted->top_layer_id);
return 0;
} }
+28
View File
@@ -0,0 +1,28 @@
// Copyright (C) 2026 Viorel Munteanu
//
// This program is free software; you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation; either version 2 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along
// with this program; if not, write to the Free Software Foundation, Inc.,
// 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
#include "self_test.h"
#include <fmt/core.h>
#include "bwrap.h"
int run_self_tests() {
for (const auto& arg : detect_bwrap_unshare_args()) {
fmt::print("{}\n", arg);
}
return 0;
}
+23
View File
@@ -0,0 +1,23 @@
// Copyright (C) 2026 Viorel Munteanu
//
// This program is free software; you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation; either version 2 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along
// with this program; if not, write to the Free Software Foundation, Inc.,
// 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
#pragma once
// Implements -t/--test, this project's own built-in self-test mode (distinct
// from the Meson-driven fixture smoke test under tests/). Currently just
// reports which bwrap --unshare-xxx flags the running kernel supports (see
// detect_bwrap_unshare_args(), bwrap.h) -- a placeholder for real tests.
int run_self_tests();