diff --git a/CLAUDE.md b/CLAUDE.md index efd1e7f..9124511 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,12 +16,15 @@ 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()`). - `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. + `run_container()`, `cleanup_image()`, `unmount_image()`, `list_images_command()`, + `create_volume_command()`). `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 `: see `config_file.{h,cpp}` below for what a + "volume" means here (a distinct concept from `OciImageConfig::volumes`). - `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, @@ -96,17 +99,27 @@ Source layout (all under `src/`): run would skip `run_container()`'s unmount/cleanup entirely, leaving the layer imported and/or mounted. - `config_file.{h,cpp}` — `load_config_file()` reads and parses (via libyaml's - document API, ``) the `global` section 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`). Only `global.log-level` - is supported today — 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 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`. + document API, ``) 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) — the two aren't connected yet, though a + future `-r/--run` volume-mounting feature would presumably look volumes up here by + name. 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 diff --git a/README.md b/README.md index 6c8ff4d..613553b 100644 --- a/README.md +++ b/README.md @@ -17,9 +17,11 @@ running kernel actually supports, instead of requiring the full set. ## Status Early-stage. Mounting, running, and dropping privileges to a specific user/group all -work. Volumes and image-declared networking (`Volumes`/`ExposedPorts`/`Env` from the -image config) are parsed but not yet applied, and there's no background/daemonized -run mode yet. +work. Named volumes (`-v/--volume`) can be created and are persisted in the config +file, but aren't consumed by `-r/--run` yet. Image-declared networking +(`ExposedPorts`/`Env` from the image config, and the image's own separately-declared +`Volumes`) are parsed but not yet applied, and there's no background/daemonized run +mode yet. ## Requirements @@ -55,6 +57,7 @@ slocker-lite -r|--run [-- [args...]] slocker-lite -u|--umount slocker-lite -c|--cleanup slocker-lite -l|--list-images +slocker-lite -v|--volume slocker-lite -t|--test slocker-lite -h|--help slocker-lite -V|--version @@ -70,6 +73,7 @@ slocker-lite -V|--version | `--user ` | With `--run`, run the command as this user (name or numeric uid) instead of the image's own declared user (or root, if it declares none). Resolved against the image's own `/etc/passwd`. Only takes effect when `--run` executes as root. | | `--group ` | With `--user`, use this group (name or numeric gid) instead of the user's primary group. | | `-l, --list-images ` | List OCI Image Layout tars (`*.tar`, `*.tar.*`) found directly in ``, with their `name:tag`. | +| `-v, --volume ` | Create a named volume mapped to a host directory (created if missing), recorded in the config file's `volumes` section. Fails if the name or directory is already used by an existing volume. | | `-t, --test` | Print which `bwrap --unshare-xxx` namespaces the running kernel supports. | | `--log-level ` | Set log verbosity (`trace`, `debug`, `info`, `warn`, `error`, `critical`, `off`). | | `-h, --help` | Print usage and exit. | @@ -92,6 +96,9 @@ sudo ./buildDir/slocker-lite -r myimage.tar --user git # List every OCI image tar in a directory ./buildDir/slocker-lite -l ./images + +# Create a named volume backed by a host directory +./buildDir/slocker-lite -v mydata ~/slocker-volumes/mydata ``` ## Configuration @@ -99,17 +106,21 @@ sudo ./buildDir/slocker-lite -r myimage.tar --user git Persistent settings can be kept in a local YAML config file at `$XDG_CONFIG_HOME/slocker-lite/config.yaml` (falling back to `$HOME/.config/slocker-lite/config.yaml` if `XDG_CONFIG_HOME` isn't set). The file is -organized into sections; only `global` exists today: +organized into sections: ```yaml global: log-level: debug +volumes: + mydata: /home/user/slocker-volumes/mydata ``` -Only options that make sense as a standing preference are supported here — right now -just `log-level` (one-shot commands like `--mount`/`--run`/`--user` don't belong in a -config file). A missing config file is fine (nothing is overridden); an explicit -`--log-level` on the command line always overrides the config file. +`global.log-level` is the only standing preference supported today (one-shot +commands like `--mount`/`--run`/`--user` don't belong in a config file). An explicit +`--log-level` on the command line always overrides the config file. The `volumes` +section is managed by `-v/--volume` (see above) rather than hand-edited — it's not +consumed by `-r/--run` yet. A missing config file is fine either way (nothing is +overridden, and one gets created the first time `-v/--volume` is used). ## How it works diff --git a/src/config_file.cpp b/src/config_file.cpp index 1df3d3c..44a132f 100644 --- a/src/config_file.cpp +++ b/src/config_file.cpp @@ -44,6 +44,17 @@ const yaml_node_t* find_in_mapping(yaml_document_t& document, const yaml_node_t& return nullptr; } +int add_scalar(yaml_document_t& document, std::string_view value) { + return yaml_document_add_scalar(&document, reinterpret_cast(const_cast(YAML_STR_TAG)), + reinterpret_cast(value.data()), + static_cast(value.size()), YAML_PLAIN_SCALAR_STYLE); +} + +int add_mapping(yaml_document_t& document) { + return yaml_document_add_mapping(&document, reinterpret_cast(const_cast(YAML_MAP_TAG)), + YAML_BLOCK_MAPPING_STYLE); +} + } // namespace std::filesystem::path config_file_path() { @@ -88,8 +99,82 @@ std::optional load_config_file(const std::filesystem::path& path) { } } } + if (const yaml_node_t* volumes = find_in_mapping(document, *root, "volumes")) { + if (volumes->type == YAML_MAPPING_NODE) { + for (auto* pair = volumes->data.mapping.pairs.start; + pair < volumes->data.mapping.pairs.top; ++pair) { + yaml_node_t* key_node = yaml_document_get_node(&document, pair->key); + yaml_node_t* value_node = yaml_document_get_node(&document, pair->value); + if (key_node && key_node->type == YAML_SCALAR_NODE && value_node && + value_node->type == YAML_SCALAR_NODE) { + config.volumes.push_back( + {std::string(scalar_value(*key_node)), std::string(scalar_value(*value_node))}); + } + } + } + } } yaml_document_delete(&document); return config; } + +bool write_config_file(const std::filesystem::path& path, const AppConfig& config) { + std::error_code ec; + std::filesystem::create_directories(path.parent_path(), ec); + if (ec) { + spdlog::error("failed to create directory {}: {}", path.parent_path().string(), ec.message()); + return false; + } + + yaml_document_t document; + yaml_document_initialize(&document, nullptr, nullptr, nullptr, 1, 1); + + int root = add_mapping(document); + + if (config.log_level) { + int global = add_mapping(document); + yaml_document_append_mapping_pair(&document, global, add_scalar(document, "log-level"), + add_scalar(document, *config.log_level)); + yaml_document_append_mapping_pair(&document, root, add_scalar(document, "global"), global); + } + + if (!config.volumes.empty()) { + int volumes = add_mapping(document); + for (const auto& volume : config.volumes) { + yaml_document_append_mapping_pair(&document, volumes, add_scalar(document, volume.name), + add_scalar(document, volume.directory)); + } + yaml_document_append_mapping_pair(&document, root, add_scalar(document, "volumes"), volumes); + } + + FILE* file = std::fopen(path.c_str(), "w"); + if (!file) { + spdlog::error("failed to open {} for writing", path.string()); + yaml_document_delete(&document); + return false; + } + + yaml_emitter_t emitter; + yaml_emitter_initialize(&emitter); + yaml_emitter_set_output_file(&emitter, file); + + bool ok = yaml_emitter_open(&emitter) != 0; + if (!ok) { + // yaml_emitter_dump() below is what normally consumes/destroys `document` -- + // since open failed and dump never runs, it must be deleted explicitly here. + yaml_document_delete(&document); + } else { + ok = yaml_emitter_dump(&emitter, &document) != 0; + ok = yaml_emitter_close(&emitter) != 0 && ok; + } + + yaml_emitter_delete(&emitter); + std::fclose(file); + + if (!ok) { + spdlog::error("failed to write config file {}", path.string()); + return false; + } + return true; +} diff --git a/src/config_file.h b/src/config_file.h index 729db22..46444f2 100644 --- a/src/config_file.h +++ b/src/config_file.h @@ -19,21 +19,38 @@ #include #include #include +#include + +// A user-defined named volume (-v/--volume): maps `name` to a host `directory`. +// Unrelated to OciImageConfig::volumes, which are mount points an *image* declares +// it wants -- this is a separate, user-driven concept, tracked here so it can be +// referenced by name later once -r/--run starts consuming volumes. +struct VolumeEntry { + std::string name; + std::string directory; // stored absolute + lexically-normalized +}; // Fields that make sense to persist across invocations (one-shot flags like -// -m/-r/--user don't belong here). Only the "global" section's log-level is -// supported today; add more optional fields as more long options gain config-file -// support. +// -m/-r/--user don't belong here). Only the "global" section's log-level and the +// "volumes" section are supported today; add more optional fields as more long +// options gain config-file support. struct AppConfig { std::optional log_level; // global.log-level + std::vector volumes; // volumes section: name -> directory }; // $XDG_CONFIG_HOME/slocker-lite/config.yaml, or $HOME/.config/slocker-lite/config.yaml // if XDG_CONFIG_HOME is unset/empty. std::filesystem::path config_file_path(); -// Loads and parses `path`'s "global" section. A missing file is not an error -- -// returns a default-constructed AppConfig (nothing set). Unknown sections/keys are -// ignored, so the format stays forward-compatible. Malformed YAML syntax logs a -// specific error and returns nullopt. +// Loads and parses `path`'s "global" and "volumes" sections. A missing file is not +// an error -- returns a default-constructed AppConfig (nothing set). Unknown +// sections/keys (and malformed individual volume entries) are ignored, so the +// format stays forward-compatible. Malformed YAML syntax logs a specific error and +// returns nullopt. std::optional load_config_file(const std::filesystem::path& path); + +// Writes `config` back to `path` as YAML (global + volumes sections), creating +// `path`'s parent directory if needed. Rewrites the whole file. Logs a specific +// error and returns false on failure. +bool write_config_file(const std::filesystem::path& path, const AppConfig& config); diff --git a/src/main.cpp b/src/main.cpp index 3a63186..49c5da1 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -41,7 +41,7 @@ namespace { constexpr std::array kRequiredTools = {"containers-storage", "bwrap"}; -enum class Mode { kNone, kMount, kUnmount, kTest, kRun, kCleanup, kListImages }; +enum class Mode { kNone, kMount, kUnmount, kTest, kRun, kCleanup, kListImages, kVolume }; // --log-level/--user/--group have no short form (--log-level's was freed up so -l // could become --list-images; -u is already --umount), so they need long-option @@ -50,7 +50,7 @@ constexpr int kLogLevelOpt = 256; constexpr int kUserOpt = 257; constexpr int kGroupOpt = 258; -constexpr std::array kLongOptions = {{ +constexpr std::array kLongOptions = {{ {"help", no_argument, nullptr, 'h'}, {"version", no_argument, nullptr, 'V'}, {"test", no_argument, nullptr, 't'}, @@ -63,6 +63,7 @@ constexpr std::array kLongOptions = {{ {"list-images", required_argument, nullptr, 'l'}, {"user", required_argument, nullptr, kUserOpt}, {"group", required_argument, nullptr, kGroupOpt}, + {"volume", required_argument, nullptr, 'v'}, {nullptr, 0, nullptr, 0}, }}; @@ -73,6 +74,7 @@ void print_usage(const char* prog) { " {0} -u|--umount \n" " {0} -c|--cleanup \n" " {0} -l|--list-images \n" + " {0} -v|--volume \n" " {0} -t|--test\n" " {0} -h|--help\n" " {0} -V|--version\n" @@ -104,6 +106,9 @@ void print_usage(const char* prog) { " gid) instead of the user's own primary group\n" " -l, --list-images list OCI Image Layout tars (*.tar, *.tar.*) found\n" " directly in , with their name:tag\n" + " -v, --volume create a named volume mapped to a host directory\n" + " (created if missing), recorded in the config\n" + " file's volumes section\n" " -t, --test run the test suite\n" " --log-level set log verbosity (trace, debug, info, warn,\n" " error, critical, off)\n" @@ -256,6 +261,45 @@ int list_images_command(const std::filesystem::path& dir) { return 0; } +int create_volume_command(const std::string& name, const std::string& directory, + const std::filesystem::path& config_path, AppConfig& config) { + 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 run_container(const std::filesystem::path& image_tar, const std::vector& requested_command, bool use_nsenter, const std::optional& user, const std::optional& group) { @@ -312,7 +356,8 @@ int run_container(const std::filesystem::path& image_tar, int main(int argc, char* argv[]) { spdlog::cfg::load_env_levels(); - auto config = load_config_file(config_file_path()); + std::filesystem::path config_path = config_file_path(); + auto config = load_config_file(config_path); if (!config) { return 1; } @@ -328,7 +373,7 @@ int main(int argc, char* argv[]) { opterr = 0; int opt; - while ((opt = getopt_long(argc, argv, ":hVtm:u:r:c:nl:", kLongOptions.data(), nullptr)) != -1) { + while ((opt = getopt_long(argc, argv, ":hVtm:u:r:c:nl:v:", kLongOptions.data(), nullptr)) != -1) { switch (opt) { case 'h': print_usage(argv[0]); @@ -341,7 +386,8 @@ int main(int argc, char* argv[]) { case 'u': case 'r': case 'c': - case 'l': { + case 'l': + case 'v': { Mode requested; switch (opt) { case 't': @@ -359,9 +405,12 @@ int main(int argc, char* argv[]) { case 'c': requested = Mode::kCleanup; break; - default: + case 'l': requested = Mode::kListImages; break; + default: + requested = Mode::kVolume; + break; } if (mode != Mode::kNone && mode != requested) { spdlog::error("multiple actions specified"); @@ -404,7 +453,13 @@ int main(int argc, char* argv[]) { print_usage(argv[0]); return 1; } - if (mode != Mode::kRun && optind != argc) { + if (mode == Mode::kVolume) { + if (optind + 1 != argc) { + spdlog::error("--volume requires a name and a directory"); + print_usage(argv[0]); + return 1; + } + } else if (mode != Mode::kRun && optind != argc) { print_usage(argv[0]); return 1; } @@ -426,6 +481,9 @@ int main(int argc, char* argv[]) { if (mode == Mode::kListImages) { return list_images_command(mode_arg); } + if (mode == Mode::kVolume) { + return create_volume_command(mode_arg, argv[optind], config_path, *config); + } if (mode == Mode::kRun) { std::vector command(argv + optind, argv + argc); // As root, containers-storage mount doesn't need to reexec into a private