00cb04ff41
Ahead of docker-compose multi-container support, audited the three pieces of mutable global state (g_mount_program, g_foreground_child_pid, g_report_fd/g_log_path) for concurrency safety. Decided each container's session will run in its own forked OS process, the same model -D/--daemonize already uses, rather than one process managing multiple containers without forking. Under that model none of the globals need to change, since "one OS process" already equals "one running container" -- recorded as a load-bearing constraint for the compose orchestrator. No code changes; documentation only. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
530 lines
39 KiB
Markdown
530 lines
39 KiB
Markdown
# CLAUDE.md
|
|
|
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
|
|
## Project state
|
|
|
|
`slocker-lite` (C++20, built with Meson) mounts an OCI Image Layout tar (`oci-layout` +
|
|
`index.json` + `blobs/sha256/*`, as produced by `skopeo`/`podman save --format
|
|
oci-archive`/modern `docker save`) using `containers-storage` and `fuse-overlayfs`, then
|
|
(via `-r/--run`) runs a sandboxed command against it with `bwrap`. The real deployment
|
|
target is Android with a stock kernel, where `podman`/`docker` don't run (missing
|
|
namespace support) and there's no kernel overlayfs (hence `fuse-overlayfs`); `bwrap` is
|
|
invoked in "degraded mode" using only whichever `--unshare-xxx` namespaces the running
|
|
kernel actually supports. See `README.md` for the human-facing overview (build/usage/
|
|
status); this file stays the dense, file-by-file reference. Still early-stage.
|
|
|
|
Source layout (all under `src/`):
|
|
- `main.cpp` — CLI entry point, dependency checks, orchestration (`mount_image()`,
|
|
`run_container()`, `cleanup_image()`, `unmount_image()`, `list_images_command()`,
|
|
`inspect_image_command()`, `create_volume_command()`, `list_volumes_command()`,
|
|
`delete_volume_command()`, `list_processes_command()`, `clean_processes_command()`).
|
|
`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
|
|
success on an empty list. `clean_processes_command()` implements
|
|
`--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>)` line per file actually removed — nothing is printed for sessions still
|
|
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>`
|
|
(has a short form, unlike the rest of the process-tracking flags) dispatches
|
|
straight to `exec_in_session()` (`exec_session.{h,cpp}`, see below): `pid`
|
|
lands in `mode_arg` (parsed as a positive integer, erroring out otherwise) the
|
|
same way `-r`'s image path does, and the trailing command
|
|
(`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
|
|
<image.tar>`: prints every `OciImageConfig` field (user/group, exposed ports, env,
|
|
volumes, default command) without mounting or running the image — extend it
|
|
whenever `OciImageConfig` gains a new field (see `oci_image.{h,cpp}` below).
|
|
`run_container()` unconditionally calls `read_oci_image_config()` and reuses the
|
|
result for two independent defaults: the command to run (`Entrypoint ++ Cmd`) when
|
|
none is given on the command line, and, when `--user` wasn't given, the sandboxed
|
|
process's user/group (`config.User`, split into `OciImageConfig::user`/`group`) —
|
|
an explicit `--user`/`--group` on the command line always takes precedence.
|
|
`create_volume_command()` implements `-v/--volume <name> <directory>`;
|
|
`list_volumes_command()` implements `--list-volumes` (same tab-alignment scheme as
|
|
`list_images_command()`, reused as-is); `delete_volume_command()` implements both
|
|
`--delete-volume <name>` (config entry only) and `--delete-volume-full <name>`
|
|
(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
|
|
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:
|
|
used alone it's `create_volume_command()`; combined with `-r/--run` it instead
|
|
requests a volume mount (repeatable) and is resolved by `resolve_volume_mount()`
|
|
(see `volume_mount.{h,cpp}` below) instead. Since `-v` must be repeatable with
|
|
`-r` but each occurrence still takes two space-separated tokens, `main()`'s
|
|
getopt loop no longer lets `'v'` set `Mode` itself: it accumulates
|
|
`(spec, path)` pairs into `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::kVolume` call
|
|
or, together with `-r`, threads `volume_specs` through to `run_container()`.
|
|
`run_container()` resolves each spec (erroring out, `ok = false`, same as a
|
|
failed `--user` resolution — `bwrap` is skipped but unmount/cleanup still runs)
|
|
into a `ResolvedVolumeMount`, rejecting a duplicate or non-absolute container
|
|
path first, and passes the resolved list to `run_bwrap()`. `--hostname <name>`
|
|
(long-option only, no short form) is likewise threaded straight through
|
|
`run_container()` into `run_bwrap()`/`build_bwrap_args()` (`bwrap.{h,cpp}`) —
|
|
see there for how/when it actually takes effect. `run_container()` also derives
|
|
a `container_name` for the session-tracking pid file (see `pid_file.{h,cpp}`
|
|
below): `read_image_ref()` (`oci_image.{h,cpp}`) applied to the single image
|
|
tar being run, formatted as `name:tag`, falling back to the tar's own filename
|
|
stem if `read_image_ref()` can't determine one — passed through to
|
|
`run_bwrap()` alongside everything else. `--env VAR=VALUE`/`--env-file <file>`
|
|
(both long-option only, both repeatable) accumulate into a single ordered
|
|
`std::vector<EnvSpec>` — `--env` pushes `{false, optarg}`, `--env-file` pushes
|
|
`{true, optarg}` — preserving their exact relative command-line order across
|
|
*both* flags (not two separate lists), 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. `run_container()` calls it once
|
|
(same `ok = false`-on-failure pattern as volume/user resolution) and passes
|
|
the resolved list to `run_bwrap()` as `extra_env`.
|
|
- `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
|
|
`(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`)
|
|
is read line by line: blank/whitespace-only lines and lines whose first
|
|
non-whitespace character is `#` are skipped (comments), with a trailing `\r`
|
|
stripped first for CRLF files; every other line is parsed the same way as a
|
|
literal. Logs a specific error and returns `nullopt` on the first hard failure
|
|
(malformed line, empty key, or an unreadable file) — deliberately stops at the
|
|
first line, not "skip and warn", since an env file with a typo should fail
|
|
loudly rather than silently omit a variable a container might depend on.
|
|
`build_sandbox_env()` (`bwrap.cpp`, see below) appends the resolved list after
|
|
its own built-in `PATH`/`HOME`/`PWD`/`TERM` — no deduplication needed there,
|
|
since `run_process_foreground()`'s own `setenv(..., 1)` loop already lets the
|
|
later occurrence in iteration order win for a repeated key, so an explicit
|
|
`--env PATH=...` still overrides the default.
|
|
- `oci_image.{h,cpp}` — validates/parses the OCI Image Layout tar (libarchive +
|
|
nlohmann_json) and extracts layer blobs. `list_oci_images()` scans a directory
|
|
(non-recursively) for `*.tar`/`*.tar.*` files and, for each valid OCI archive,
|
|
derives an image name/tag via `read_image_ref()` from its `index.json` manifest
|
|
annotations (`io.containerd.image.name` preferred, else
|
|
`org.opencontainers.image.ref.name`), falling back to the archive's filename and
|
|
`"latest"` respectively. `read_image_ref()` is public (not just an internal
|
|
helper of `list_oci_images()`) precisely so `run_container()` (`main.cpp`) can
|
|
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.
|
|
`read_oci_image_config()` reads the image config blob referenced by the manifest
|
|
and extracts `User` (split on `:` into `OciImageConfig::user`/`group`),
|
|
`ExposedPorts`, `Env`, `Volumes`, and the effective default command
|
|
(`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)
|
|
— `ExposedPorts`/`Env`/`Volumes` are otherwise still just captured for when
|
|
networking/volumes are implemented.
|
|
- `containers_storage.{h,cpp}` — wraps the `containers-storage` CLI (`import-layer`,
|
|
`mount`, `unmount`, `layer --json`, `delete-layer`), forcing `fuse-overlayfs` as the
|
|
overlay `mount_program`. `cleanup_layer_chain()` walks a layer's parent chain
|
|
(children before parents) deleting each one.
|
|
- `bwrap.{h,cpp}` — `detect_bwrap_unshare_args()` probes the kernel (via a forked
|
|
`unshare(2)` per namespace type) for which `--unshare-xxx` flags `bwrap` can actually
|
|
use; `build_bwrap_args()`/`run_bwrap()` assemble and run the sandboxed command.
|
|
`build_bwrap_args()` also takes a `std::vector<ResolvedVolumeMount>` (see
|
|
`volume_mount.h` below) and appends one writable `--bind <host_directory>
|
|
<container_path>` per entry. `wrap_for_root_namespace()` is `run_bwrap()`'s own
|
|
nsenter-wrapping logic pulled out into a reusable, exported function — it's also
|
|
what `volume_mount.cpp`'s copy-into-an-empty-volume step uses to reach the
|
|
image's content when running rootless (see below); `run_bwrap()` itself now just
|
|
calls it once on the assembled `bwrap` argv.
|
|
`build_bwrap_args()` deliberately drops `--unshare-net` from what's actually passed
|
|
to `bwrap` even when the kernel supports it — without any network setup (e.g.
|
|
`slirp4netns`), unsharing it just leaves the sandbox with no network at all. Re-add
|
|
once network isolation is implemented; `detect_bwrap_unshare_args()` itself still
|
|
probes/reports it (e.g. via `-t/--test`), since that's kernel capability, not policy.
|
|
`build_bwrap_args()`/`run_bwrap()` also take an optional `hostname` (from
|
|
`--hostname`, long-option only): passed through as bwrap's own `--hostname` only
|
|
when `--unshare-uts` is actually among the flags `bwrap` is being given (bwrap
|
|
itself refuses `--hostname` without it) — otherwise logs a warning and leaves the
|
|
sandbox's hostname alone, since a stock Android kernel in degraded mode may not
|
|
support a UTS namespace at all.
|
|
Never requests `--unshare-user` when running as root: root doesn't need a fresh
|
|
user namespace for privilege, and bwrap's own single-mapping uid/gid setup for one
|
|
triggers the kernel's unprivileged-userns setgroups() restriction, which showed up
|
|
as every other supplementary group collapsing to the overflow gid ("nobody") in
|
|
`id`, and `su` inside the sandbox failing with "can't set groups: Operation not
|
|
permitted". Because of that, bwrap's own `--uid`/`--gid` (which require
|
|
`--unshare-user`) aren't usable when running as root either — `--user`/`--group`
|
|
work around this: when set, `build_bwrap_args()`/`run_bwrap()` bind-mount the
|
|
separate `slocker-lite-priv-drop` helper (see below) into the sandbox at a fixed
|
|
hidden path and route the real command through it as
|
|
`<uid>:<gid> -- <command...>`. This only actually works without a user namespace
|
|
(i.e. running as root) — under `--unshare-user`, the sandbox's uid map has only
|
|
one valid entry, so the helper's own `setuid()` fails cleanly there instead of
|
|
silently doing nothing. `run_bwrap()` fails fast (returns -1) if the helper can't
|
|
be found next to this binary when `--user` was requested, rather than silently
|
|
running the command as root. `run_bwrap()` also takes a `container_name` and
|
|
tracks the running session with it: it passes a lambda as
|
|
`run_process_foreground()`'s new `on_start` callback (see `process.{h,cpp}`
|
|
below) that calls `create_session_lock(container_name, pid)` (`pid_file.{h,cpp}`,
|
|
see below) the instant the real `bwrap` pid is known, then calls
|
|
`release_session_lock()` once `run_process_foreground()` returns (covering
|
|
every exit path — normal, nonzero, or a forwarded-signal exit — since that call
|
|
always blocks until the child has actually exited).
|
|
`build_bwrap_args()` no longer passes `--clearenv`/`--setenv` to `bwrap` itself;
|
|
instead, `build_sandbox_env()` builds the sandboxed command's exact environment
|
|
(`PATH`, `HOME`, `PWD` — hardcoded to `"/"`, matching `--chdir`'s own value; note
|
|
per bwrap's own man page `--clearenv` never actually unset `PWD` in the first
|
|
place, so this isn't a straight port of a prior `--setenv` — and `TERM`, only
|
|
if the host process has one, followed by `extra_env` — the resolved
|
|
`--env`/`--env-file` list from `resolve_env_specs()` (`env_spec.h`), appended
|
|
last so it can override the built-in defaults for the same key) and
|
|
`run_bwrap()` passes it straight to `run_process_foreground()`'s own `env`
|
|
override (see `process.{h,cpp}` below). This works because `bwrap` (and
|
|
`nsenter`, when interposed via `wrap_for_root_namespace()`) doesn't alter its
|
|
own inherited environment unless told to, and neither does
|
|
`slocker-lite-priv-drop` (just `setgroups()`/`setgid()`/`setuid()`/`execvp()`,
|
|
no env manipulation) — so controlling it once, at the outermost exec, is
|
|
sufficient for it to reach the final sandboxed command unchanged. **Because
|
|
that outermost exec now uses this same explicitly-built environment**,
|
|
`build_bwrap_args()`/`wrap_for_root_namespace()` resolve `bwrap`'s and
|
|
`nsenter`'s own argv[0] to an absolute path via `find_in_path()` (called from
|
|
this process's own, unmodified environment, before `fork()`) instead of
|
|
leaving them as bare names — confirmed by direct testing: `--env PATH=...`
|
|
used to break `execvp()`'s ability to even *locate* `bwrap`/`nsenter`
|
|
(bare-name lookup happens in the child, using the already-overridden PATH),
|
|
not just what the sandboxed command itself sees. With the fix, only the
|
|
*sandboxed command's own* lookup is affected by a `--env PATH=...` override
|
|
(as expected — same as overriding `PATH` in any real shell before running a
|
|
bare command name), and `bwrap`/`nsenter` are always found regardless.
|
|
`run_bwrap()` also takes an optional `on_bwrap_pid_known` callback, invoked
|
|
alongside (not instead of) the session-lock-creation lambda, at the exact
|
|
same `on_start` timing — `-D/--daemonize` (`daemonize.{h,cpp}`, see below)
|
|
hooks in here via `report_daemon_started()` to learn the real pid at the
|
|
same instant everything else that needs it does, rather than needing its own
|
|
separate pid-discovery mechanism.
|
|
- `priv_drop_helper.cpp` → the separate `slocker-lite-priv-drop` binary (its own
|
|
`executable()` target in `meson.build`, **built with `-static`**). Deliberately
|
|
has zero dependencies on the rest of this project (no fmt/spdlog/etc.) and is
|
|
fully statically linked: it gets bind-mounted *into the container image's own
|
|
filesystem*, which won't have `slocker-lite`'s own shared library dependencies —
|
|
a dynamically linked binary bind-mounted that way fails outright ("error while
|
|
loading shared libraries"), which is exactly what happened before this was split
|
|
out (the original approach bind-mounted `slocker-lite`'s own — dynamically linked
|
|
— binary via `/proc/self/exe` and reexeced it; kept only as a lesson, not as
|
|
working code). Usage: `slocker-lite-priv-drop <uid>:<gid> -- <command> [args...]`;
|
|
does `setgroups(0,…)` → `setgid()` → `setuid()` → `execvp()`, in that order
|
|
(dropping the group needs `CAP_SETGID`, which is lost once `setuid()` drops root).
|
|
`find_priv_drop_helper()` (`src/bwrap.cpp`) locates it next to `slocker-lite`'s own
|
|
binary (via `/proc/self/exe`'s directory), which holds both when run straight from
|
|
`buildDir/` and after a real `meson install`.
|
|
- `user_spec.{h,cpp}` — `resolve_user_and_group()` resolves a user/group spec (each
|
|
a name or numeric id) against the *mounted image's own* `/etc/passwd`/`/etc/group`
|
|
(not the host's), since names like `git` only mean anything inside that image's own
|
|
user database. A numeric user with no group and no matching `/etc/passwd` entry
|
|
defaults gid to the same numeric value as the uid. `ResolvedUser` (`bwrap.h`) also
|
|
carries `home`, looked up by the final resolved uid's `/etc/passwd` entry (field 5)
|
|
regardless of whether `user` was given as a name or a number; falls back to
|
|
`"/root"` for uid 0 or `"/"` otherwise when there's no matching row.
|
|
`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
|
|
image-declared `config.User`). `run_container()` (`main.cpp`) calls `resolve_user_and_group()`
|
|
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
|
|
container defaults to running as whatever user the image itself declares, not
|
|
root, unless the image declares none.
|
|
- `process.{h,cpp}` — argv-based subprocess helpers (fork/execvp, no shell):
|
|
`run_process()` captures stdout (used for `containers-storage` calls),
|
|
`run_process_foreground()` inherits all of stdio (used for the interactive `bwrap`
|
|
run). Also `find_in_path()`, a shared `$PATH` lookup. `run_process_foreground()`
|
|
installs a SIGINT/SIGTERM handler around its `waitpid()` that forwards the signal
|
|
to the running child and keeps waiting instead of letting the default disposition
|
|
kill `slocker-lite` itself — without this, Ctrl-C (or `kill`) during `-r`'s `bwrap`
|
|
run would skip `run_container()`'s unmount/cleanup entirely, leaving the layer
|
|
imported and/or mounted. `run_process_foreground()` also takes an optional
|
|
`on_start` callback, invoked with the child's real pid right after `fork()`
|
|
succeeds (before the signal handlers go up and it blocks in `waitpid()`) — the
|
|
only point where that pid is knowable, and still accurate even when `argv`
|
|
itself execs into something else first (e.g. `nsenter` handing off to the final
|
|
command via its own in-place `execvp()` — a pid never changes across `exec()`).
|
|
`run_bwrap()` (`bwrap.cpp`) is the one caller that uses it, for session pid-file
|
|
tracking (see `pid_file.{h,cpp}` below). `run_process_foreground()` also takes
|
|
an optional `env` (list of key/value pairs): when set, the forked child
|
|
replaces its entire environment via `clearenv()`/`setenv()` (plain POSIX, not
|
|
the GNU-only `execvpe()` — the target platform includes musl) before `execvp()`,
|
|
instead of inheriting this process's own. `nullopt` (the default) leaves the
|
|
child's environment untouched. `run_bwrap()` is again the one caller that uses
|
|
this, via `build_sandbox_env()` (`bwrap.cpp`) — see there.
|
|
- `pid_file.{h,cpp}` — tracks one running `-r/--run` session (a live `bwrap`
|
|
process) as a locked pid file, so an outside process (or a later
|
|
`slocker-lite` invocation) can tell whether it's still running.
|
|
`session_pid_file_path()` resolves
|
|
`$XDG_STATE_HOME/slocker-lite/run/<container_name>-<pid>` (falling back to
|
|
`$HOME/.local/state/...` when `XDG_STATE_HOME` is unset/empty — same
|
|
resolution pattern as `config_file_path()` below, for state instead of
|
|
config), sanitizing `container_name` first (anything outside `[A-Za-z0-9._-]`
|
|
→ `_`, since an image name/tag can contain `/` or `:`). `session_log_file_path()`
|
|
is a sibling resolving to `$XDG_STATE_HOME/slocker-lite/logs/<container_name>-<pid>.log`
|
|
instead — same sanitization, same `$XDG_STATE_HOME`/`$HOME` fallback, just a
|
|
different subdirectory and a `.log` extension — used by `daemonize.{h,cpp}`
|
|
(see below) for `-D/--daemonize`'s log file. `create_session_lock()`
|
|
creates the file (`O_CREAT|O_WRONLY|O_TRUNC|O_CLOEXEC`, mode 0644 — `O_CLOEXEC`
|
|
matters: this fd must never leak into the sandboxed command's own fd table),
|
|
writes the pid as text, and takes an exclusive, non-blocking `flock()` on it —
|
|
held only by that fd, so its lifetime tracks `slocker-lite`'s own process
|
|
lifetime (released automatically on any exit, including a crash), which lines
|
|
up with `bwrap` itself being invoked with `--die-with-parent`. Any external
|
|
tool can check liveness the same way: attempt the same exclusive non-blocking
|
|
`flock()` on the file — success means nothing holds it anymore (stale, safe to
|
|
remove), `EWOULDBLOCK` means a live process still does. `release_session_lock()`
|
|
closes the fd (releasing the flock immediately) and removes the file. Every
|
|
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
|
|
never block or fail `-r/--run` itself. `list_sessions()` implements
|
|
`--list-processes` (`main.cpp`'s `list_processes_command()`): scans the same
|
|
`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
|
|
from the filename (ambiguous for names that themselves contain `-`);
|
|
`container_name` is then recovered by stripping that exact `-<pid>` suffix
|
|
back off the filename. `running` reuses the same liveness check any external
|
|
tool would do — a non-blocking exclusive `flock()` that succeeds means the
|
|
file is actually stale, so `running` is false in that case; the lock is always
|
|
released again immediately either way, never left held by the check itself. A
|
|
file that can't be opened or doesn't parse as a pid (e.g. removed mid-scan) is
|
|
silently skipped, not reported as an error — scanning a live directory is
|
|
inherently racy. Both `list_sessions()` and `clean_stale_sessions()` (the
|
|
latter implements `--clean-processes`) share a private `open_session_file()`
|
|
helper for the open/read-pid/recover-name step. `clean_stale_sessions()`
|
|
doesn't just remove whatever a separate `list_sessions()` call reported as not
|
|
running — it re-takes the same non-blocking `flock()` used to test liveness
|
|
and holds it across the `remove()` call itself, per file, so the stale check
|
|
and the removal stay atomic against a new session starting in the gap between
|
|
a check and a later removal. Only files it actually removes are reported back
|
|
(as `SessionInfo`s with `running=false`); still-locked (running) files are
|
|
left untouched and not reported.
|
|
- `daemonize.{h,cpp}` — implements `-D/--daemonize`'s fork/detach mechanics.
|
|
`daemonize(container_name)` sets up a `pipe2(..., O_CLOEXEC)` pair (so it
|
|
never leaks into `bwrap`/the sandboxed command, same reasoning as the pid
|
|
file's own `O_CLOEXEC`) and `fork()`s. The **child** calls `setsid()` —
|
|
deliberately here, not via re-adding bwrap's own `--new-session` (removed
|
|
earlier, see the `build_bwrap_args()` comment): `--new-session` only calls
|
|
`setsid()` for the deeply-nested sandboxed command *inside* bwrap's own
|
|
namespace setup, leaving the outer `bwrap`/`nsenter`/`slocker-lite` processes
|
|
still attached to the *original* session and still receiving its signals
|
|
(e.g. a `SIGHUP` when the controlling terminal closes) — not real
|
|
daemonization. Calling `setsid()` in our own forked child, *before* it execs
|
|
into `nsenter`/`bwrap`, detaches the entire chain at once, since `exec()`
|
|
never changes session membership — confirmed by direct testing
|
|
(`ps -o pid,sid,pgid,tty`): the daemon child becomes its own session leader
|
|
with no controlling tty, and `bwrap` (a later descendant) shares that same
|
|
session, also with no tty. The child also `sigaction()`s `SIGHUP` to
|
|
`SIG_IGN` (survives the later `exec()` into `nsenter`/`bwrap`, unlike a real
|
|
handler, which `exec()` resets to default — confirmed by sending `SIGHUP`
|
|
directly to a running daemonized `bwrap` pid and it staying alive), then
|
|
redirects stdin to `/dev/null` and stdout/stderr to a log file at
|
|
`session_log_file_path(container_name, getpid())` (`pid_file.h`) — named
|
|
after its *own* pid since the real session pid (`bwrap`'s) isn't known yet.
|
|
If the log directory/file can't be set up at all, that's a **hard** failure
|
|
here (`_exit(1)`), not best-effort — silently losing the very output
|
|
`--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
|
|
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
|
|
that function's existing body completely unchanged — **the daemonized child
|
|
is what runs the whole rest of `run_container()`, including the unmount/
|
|
cleanup that already existed after `run_bwrap()` returns; no separate
|
|
watcher/reaper process exists**. The **parent** blocks reading the pipe until
|
|
EOF, returning the accumulated `"LOG "`/`"PID "` lines as a `DaemonizeResult`
|
|
— the caller then prints it and exits immediately without running any
|
|
session logic itself. `report_daemon_started(container_name, pid)` (called
|
|
from `run_bwrap()`'s new `on_bwrap_pid_known` callback — see `bwrap.{h,cpp}`
|
|
below — the instant the real `bwrap` pid is known) renames the pid-named log
|
|
file to `<container_name>-<pid>.log`, re-reports the *updated* `"LOG "` line
|
|
(a real bug caught by testing: the parent's first `"LOG "` line names the
|
|
pre-rename, daemon-pid-named path — without a second one, the parent would
|
|
print a stale filename that doesn't match where the file actually ends up),
|
|
then `"PID <pid>\n"` and closes its own end of the pipe — must happen here,
|
|
explicitly, rather than waiting for the pipe to close naturally at the end of
|
|
the (potentially very long) daemon's lifetime, or the parent would block for
|
|
as long as the session runs instead of returning promptly. The pipe's write
|
|
fd and the current log path are tracked as private file-scope state in
|
|
`daemonize.cpp` (matching `process.cpp`'s own `g_foreground_child_pid`
|
|
pattern for "there's only ever one of these per process" runtime state),
|
|
since `report_daemon_started()` is called later, from a different function,
|
|
not threaded explicitly through every call in between.
|
|
- `exec_session.{h,cpp}` — implements `-e/--exec <pid>`: joins an already-running
|
|
`-r/--run` session's namespaces via `nsenter` and runs a command inside it in
|
|
the foreground. `exec_in_session()` first confirms `pid` is a tracked, running
|
|
session via `list_sessions()` (`pid_file.h`) — same liveness check
|
|
`--list-processes`/`--clean-processes` already use, no new logic needed there.
|
|
**Key discovery, confirmed by direct testing, not assumed**: `pid` (the one
|
|
`run_process_foreground()` captured and pid-file-tracked when `-r` launched
|
|
`bwrap`) is bwrap's own *outer* process — it sets up the mount and user
|
|
namespaces itself, then `clone()`s the actual sandboxed command into fresh
|
|
pid/uts/ipc/cgroup namespaces, and `clone()`'s namespace-creation flags only
|
|
ever affect the newly created child, never the caller. So the outer process
|
|
itself never actually enters those namespaces — comparing
|
|
`/proc/<outer_pid>/ns/{pid,uts,ipc,cgroup}` against this process's own showed
|
|
them identical, while only `mnt`/`user` differed. `resolve_namespace_pid()`
|
|
reads `/proc/<pid>/task/<pid>/children` (the direct-children list `procfs`
|
|
exposes) to find that real inner process and joins *its* namespaces instead —
|
|
falls back to `pid` itself (best-effort, not fatal) if that file can't be
|
|
read. For each of `{mnt→--mount, uts→--uts, ipc→--ipc, pid→--pid,
|
|
cgroup→--cgroup, user→--user}` (`net` deliberately excluded — this project
|
|
never isolates networking, see `bwrap.cpp` below), `readlink()`s both
|
|
`/proc/<ns_pid>/ns/<type>` and `/proc/self/ns/<type>` and only passes
|
|
nsenter's corresponding `--type=/proc/<ns_pid>/ns/<type>` flag when they
|
|
differ — an identical-namespace re-entry attempt can fail outright
|
|
(`setns()`'s own `EINVAL` restriction on re-entering a namespace you're
|
|
already in), so skipping is deliberate, not just an optimization. `mnt` is the
|
|
one type where a *read* failure (permission denied, or the process vanished)
|
|
is treated as fatal, since without it "joining the container" is meaningless;
|
|
every other type just degrades to a skip. Always appends
|
|
`--preserve-credentials`: without it, `nsenter --user` also tries to
|
|
`setuid()`/`setgid()`/`setgroups()` to the target's identity within the new
|
|
user namespace, which fails outright (`setgroups failed: Operation not
|
|
permitted`) against the `setgroups`-denied unprivileged user namespace bwrap
|
|
creates whenever `-r/--run` isn't root — confirmed by hitting this exact
|
|
failure during manual testing before adding the flag. Runs the final
|
|
`nsenter ... -- <command>` via the existing `run_process_foreground()`
|
|
(`process.h`) — same inherited stdio and SIGINT/SIGTERM forwarding as every
|
|
other foreground external command, no new process-running logic needed.
|
|
- `config_file.{h,cpp}` — `load_config_file()` reads and parses (via libyaml's
|
|
document API, `<yaml.h>`) the `global` and `volumes` 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`). `global.log-level` is the
|
|
only supported `global` key — other long options are one-shot flags, not settings,
|
|
so they don't belong in a persistent config file. A missing file returns a
|
|
default-constructed (empty) `AppConfig`, not an error; unknown sections/keys (and
|
|
malformed individual volume entries) are ignored for forward-compatibility;
|
|
malformed YAML syntax is a hard error. `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()`, `main.cpp`) to persist a new
|
|
`VolumeEntry {name, directory}` into the `volumes` section, preserving `global`
|
|
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). `AppConfig`/`config.volumes` is looked
|
|
up by name in `resolve_volume_mount()` (`volume_mount.{h,cpp}`, see below), which
|
|
is how `-r/--run`'s own `-v` usage finds a named volume's host directory.
|
|
- `volume_mount.{h,cpp}` — `is_valid_volume_name()` (no `/`, checked by both
|
|
`create_volume_command()` and to tell a `-v` spec's name/path apart) and
|
|
`resolve_volume_mount()`, called once per `-v` occurrence from `run_container()`
|
|
when running with `-r`. A spec with no `/` is looked up in `config.volumes` by
|
|
name (error if unknown); one with `/` is treated as a host directory path and
|
|
`create_directories()`'d if missing. If the resulting host directory is empty and
|
|
the image already has non-empty content at the given container path, that content
|
|
is copied in first. Both the existence check and the copy run as a single
|
|
`sh -c '[ -d ... ] && cp -a ...'` invocation wrapped through
|
|
`wrap_for_root_namespace()` (`bwrap.h`) — **not** a plain `std::filesystem` check
|
|
— because a rootless `containers-storage mount`'s content isn't visible to this
|
|
process at all without `nsenter`, the same constraint `run_bwrap()` itself works
|
|
around (see the root-vs-rootless paragraph below). `cp -a
|
|
--preserve=mode,ownership,timestamps,links[,xattr]` does the copy; whether
|
|
`,xattr` is included is decided by a direct `setxattr()`/`removexattr()` probe on
|
|
the host directory (no new library dependency — Linux POSIX ACLs are themselves
|
|
stored as xattrs, so this one probe stands in for both, logging a single
|
|
`spdlog::warn` if unsupported). A nonzero `cp` exit is only ever a warning, never
|
|
fatal — often just an ownership-preservation shortfall when not running as root.
|
|
|
|
Errors are logged via `spdlog::error`; every external command is also traced at debug
|
|
level in `run_process()`/`run_process_foreground()` (`src/process.cpp`) — visible via
|
|
`SPDLOG_LEVEL=debug`, since spdlog's default level is `info` — and a failed external
|
|
command additionally logs a `spdlog::warn`, which is visible by default (no env var
|
|
needed). The final "mounted image at: ..." success line is direct stdout program
|
|
output, not a log.
|
|
|
|
Because `containers-storage mount` runs rootless, it reexecs itself into a private
|
|
user+mount namespace to gain the privilege it needs for the overlay mount — which
|
|
leaves the result invisible to a plain shell or child process outside that namespace.
|
|
Confirmed `containers-storage unshare` does **not** rejoin an already-running mount's
|
|
namespace; only `nsenter` targeting the live `fuse-overlayfs` daemon's PID does.
|
|
`-r/--run` handles this automatically by locating that PID and running `bwrap` via
|
|
`nsenter` into its namespaces (`wrap_for_root_namespace()`, `src/bwrap.h`) — reused
|
|
as-is by `volume_mount.cpp`'s copy-into-an-empty-volume step, since that also needs
|
|
to read image content that's otherwise invisible outside the same namespace.
|
|
**Running as root sidesteps all of this**: no privilege
|
|
reexec is needed, so the mount is already directly visible in the current namespace,
|
|
and `nsenter --user=...` into it then fails ("reassociate to namespace 'ns/user'
|
|
failed: Invalid argument") since the caller is already in that same user namespace.
|
|
`-r/--run` detects `geteuid() == 0` and skips `nsenter` automatically in that case;
|
|
`-n/--no-nsenter` forces it off manually for any other situation where the mount turns
|
|
out to already be directly visible.
|
|
|
|
**Mutable global state and multi-container support:** an audit ahead of planned
|
|
docker-compose support (running multiple containers at once) found exactly three
|
|
pieces of mutable global/file-scope state in `src/`: `g_mount_program`
|
|
(`containers_storage.{h,cpp}`, the resolved `fuse-overlayfs` path — genuinely
|
|
process-wide, invariant across containers), `g_foreground_child_pid`
|
|
(`process.cpp`, plus `run_process_foreground()`'s process-wide `SIGINT`/`SIGTERM`
|
|
handler installation — tracks one foreground child at a time), and
|
|
`g_report_fd`/`g_log_path` (`daemonize.cpp`, one in-flight `-D/--daemonize`
|
|
handshake's report-pipe fd and log path). **Decision, confirmed by the user:**
|
|
multi-container/compose support will run each container's session in its own
|
|
forked OS process — the same model `-D/--daemonize` already uses — rather than
|
|
one process managing multiple containers concurrently without forking. Under
|
|
that model, "one OS process" and "one running container" stay the same thing
|
|
they already are today, so **none of these globals need to become per-container
|
|
state** — each forked child only ever tracks/signals one foreground child and
|
|
handles one daemonize handshake, exactly as today. This is a load-bearing
|
|
constraint for however the compose orchestrator ends up implemented: it must
|
|
fork (not thread, not run an in-process event loop over N containers) one child
|
|
per service, each child reusing `run_container()`'s existing single-container
|
|
code path unchanged.
|
|
|
|
## Build & test commands
|
|
|
|
Build directory is `buildDir/` (already configured).
|
|
|
|
- Configure (only needed if `buildDir/` is missing or deleted): `meson setup buildDir`
|
|
- Build: `meson compile -C buildDir` (or `ninja -C buildDir`) — also builds
|
|
`buildDir/slocker-lite-priv-drop`, the statically-linked helper `-r --user` needs
|
|
(see `priv_drop_helper.cpp` in "Project state")
|
|
- Run the executable: `./buildDir/slocker-lite -m <image.tar>` (see `--help` for the
|
|
full flag list: `-m/--mount`, `-r/--run`, `-u/--umount`, `-c/--cleanup`,
|
|
`-l/--list-images`, `-i/--inspect`, `-e/--exec`, `-n/--no-nsenter`, `-D/--daemonize`,
|
|
`--user`, `--group`, `--hostname`, `--env`, `--env-file`, `-v/--volume`, `--list-volumes`, `--delete-volume`,
|
|
`--delete-volume-full`, `--list-processes`, `--clean-processes`, `-t/--test`, `--log-level`,
|
|
`-h/--help`, `-V/--version`)
|
|
- Run tests: `meson test -C buildDir`
|
|
|
|
## Code style
|
|
|
|
- Null-pointer checks: prefer `if (!ptr)` / `if (ptr)` over `if (ptr == nullptr)` / `if (ptr != nullptr)`.
|
|
- Constants: no `k` Hungarian-notation prefix. `enum class` values are already qualified by
|
|
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;
|
|
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
|
|
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
|
|
namespace inside the file's existing anonymous namespace where one is already present, so
|
|
internal linkage is unchanged. A `kXxx`-named identifier that turns out not to actually be
|
|
`const` (mutable global/static state) instead follows this codebase's existing `g_` prefix
|
|
convention (e.g. `containers_storage.cpp`'s `g_mount_program`, matching `process.cpp`'s
|
|
`g_foreground_child_pid` and `daemonize.cpp`'s `g_report_fd`/`g_log_path`).
|
|
|
|
## Licensing
|
|
|
|
- Every `.c`/`.cpp`/`.h` file under `src/` must start with the GPLv2-or-later copyright
|
|
header (see any existing file under `src/` for the exact text).
|
|
- After adding a new source file under `src/`, run `./add-license.sh` from the repo
|
|
root to prepend the header (it reads `copyright-header` and inserts it via `sed`,
|
|
skipping files that already have it, so it's safe to re-run at any time).
|
|
|
|
## Build configuration notes
|
|
|
|
- `meson.build` sets `warning_level=3` and `cpp_std=c++20` — keep new code warning-clean under `-Wall -Wextra -Wpedantic`-equivalent settings.
|
|
- The single Meson `test()` target runs `slocker-lite` against a fixture OCI image tar generated at build time by `tests/gen_fixture.py` (a `custom_target`) and checks its exit code (no test framework is wired in yet).
|