Add -v/--volume to create a named volume mapped to a host directory

Creates the host directory if missing (warning if it already exists,
another if it's non-empty) and records name -> directory in the config
file's new "volumes" section. Fails if the name or directory is already
used by an existing volume.

This is a distinct concept from OciImageConfig::volumes (an image's own
declared mount points, still unconsumed) -- a user-defined volume, meant
to be referenced by name once -r/--run starts actually mounting volumes.

config_file.{h,cpp} gains write_config_file(), symmetric to the existing
load_config_file(), built on libyaml's document-building/emitter API.
Rewrites the whole file each time; global.log-level round-trips
untouched alongside the new volumes section.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
This commit is contained in:
2026-08-21 12:59:37 +00:00
parent be5a0b8202
commit aac656be31
5 changed files with 223 additions and 39 deletions
+85
View File
@@ -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<yaml_char_t*>(const_cast<char*>(YAML_STR_TAG)),
reinterpret_cast<const yaml_char_t*>(value.data()),
static_cast<int>(value.size()), YAML_PLAIN_SCALAR_STYLE);
}
int add_mapping(yaml_document_t& document) {
return yaml_document_add_mapping(&document, reinterpret_cast<yaml_char_t*>(const_cast<char*>(YAML_MAP_TAG)),
YAML_BLOCK_MAPPING_STYLE);
}
} // namespace
std::filesystem::path config_file_path() {
@@ -88,8 +99,82 @@ std::optional<AppConfig> 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;
}
+24 -7
View File
@@ -19,21 +19,38 @@
#include <filesystem>
#include <optional>
#include <string>
#include <vector>
// 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<std::string> log_level; // global.log-level
std::vector<VolumeEntry> 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<AppConfig> 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);
+65 -7
View File
@@ -41,7 +41,7 @@ namespace {
constexpr std::array<std::string_view, 2> 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<struct option, 13> kLongOptions = {{
constexpr std::array<struct option, 14> kLongOptions = {{
{"help", no_argument, nullptr, 'h'},
{"version", no_argument, nullptr, 'V'},
{"test", no_argument, nullptr, 't'},
@@ -63,6 +63,7 @@ constexpr std::array<struct option, 13> 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 <layer-id>\n"
" {0} -c|--cleanup <layer-id>\n"
" {0} -l|--list-images <directory>\n"
" {0} -v|--volume <name> <directory>\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 <dir> list OCI Image Layout tars (*.tar, *.tar.*) found\n"
" directly in <dir>, with their name:tag\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\n"
" -t, --test run the test suite\n"
" --log-level <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<std::string>& requested_command, bool use_nsenter,
const std::optional<std::string>& user, const std::optional<std::string>& 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<std::string> command(argv + optind, argv + argc);
// As root, containers-storage mount doesn't need to reexec into a private