From b8f46807451b10481292adff0591026711156441 Mon Sep 17 00:00:00 2001 From: Viorel Munteanu Date: Thu, 20 Aug 2026 16:35:38 +0000 Subject: [PATCH] Extract OCI image config; use it for -r's default command read_oci_image_config() (src/oci_image.cpp) reads the image config blob referenced by the manifest and extracts User, ExposedPorts (as OciExposedPort{port, OciPortProtocol}, parsed from keys like "3000/tcp"), Env, Volumes, and the effective default command (Entrypoint ++ Cmd). Refactored the oci-layout/index.json/manifest loading read_oci_layers() already did into a shared read_oci_manifest() helper, since this is the first time a second "loud" (spdlog::error on failure) caller needs the identical validation. -r/--run now uses the image's own default command when none is given on the command line, falling back to /bin/sh only if the image sets neither Entrypoint nor Cmd. User/ExposedPorts/Env/Volumes are captured but not applied anywhere yet -- that lines up with volumes/networking still being deferred. Co-Authored-By: Claude Sonnet 5 --- CLAUDE.md | 5 ++ src/main.cpp | 21 +++-- src/oci_image.cpp | 190 ++++++++++++++++++++++++++++++++++++++-------- src/oci_image.h | 21 +++++ 4 files changed, 197 insertions(+), 40 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4e22ae9..9d2358c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,6 +23,11 @@ Source layout (all under `src/`): derives an image name/tag 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_oci_image_config()` reads the image config blob referenced by the manifest + and extracts `User`, `ExposedPorts`, `Env`, `Volumes`, and the effective default + command (`Entrypoint ++ Cmd`); `-r/--run` uses its command when none is given on + the command line. Only the default command is actually consumed today — the rest + is captured for when volumes/networking 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 diff --git a/src/main.cpp b/src/main.cpp index 584ae46..cd9857b 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -73,9 +73,10 @@ void print_usage(const char* prog) { "options:\n" " -m, --mount validate and mount an OCI Image Layout tar\n" " -r, --run mount, run bwrap in the foreground (default\n" - " command: /bin/sh; pass -- [args...]\n" - " to override), then unmount and clean up when\n" - " it exits\n" + " command: the image's own Entrypoint/Cmd if set,\n" + " else /bin/sh; pass -- [args...] to\n" + " override), then unmount and clean up when it\n" + " exits\n" " -u, --umount unmount a previously mounted image layer (the\n" " ID printed by --mount/--run, or from\n" " `containers-storage layers`)\n" @@ -240,14 +241,21 @@ int list_images_command(const std::filesystem::path& dir) { return 0; } -int run_container(const std::filesystem::path& image_tar, const std::vector& command, - bool use_nsenter) { +int run_container(const std::filesystem::path& image_tar, + const std::vector& requested_command, bool use_nsenter) { auto mounted = mount_image(image_tar); if (!mounted) { return 1; } fmt::print("mounted image at: {} (layer {})\n", mounted->merged_path, mounted->top_layer_id); + std::vector command = requested_command; + if (command.empty()) { + auto config = read_oci_image_config(image_tar); + command = (config && !config->command.empty()) ? config->command + : std::vector{"/bin/sh"}; + } + int exit_code = run_bwrap(mounted->merged_path, command, use_nsenter); if (exit_code < 0) { spdlog::error("failed to run bwrap"); @@ -361,9 +369,6 @@ int main(int argc, char* argv[]) { } if (mode == Mode::kRun) { std::vector command(argv + optind, argv + argc); - if (command.empty()) { - command = {"/bin/sh"}; - } // 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' diff --git a/src/oci_image.cpp b/src/oci_image.cpp index 7078279..ffbf7b3 100644 --- a/src/oci_image.cpp +++ b/src/oci_image.cpp @@ -23,6 +23,7 @@ #include #include +#include #include #include #include @@ -185,36 +186,14 @@ std::optional read_image_ref(const std::filesystem::path& tar_path) return ref; } -} // namespace +struct OciManifest { + std::string digest; + json data; +}; -std::string oci_digest_hex(std::string_view digest) { - constexpr std::string_view prefix = "sha256:"; - if (digest.substr(0, prefix.size()) == prefix) { - digest.remove_prefix(prefix.size()); - } - return std::string(digest); -} - -bool extract_blob_to_file(const std::filesystem::path& tar_path, std::string_view digest_hex, - const std::filesystem::path& out_file) { - std::string entry_name = fmt::format("blobs/sha256/{}", digest_hex); - - std::ofstream out(out_file, std::ios::binary | std::ios::trunc); - if (!out) { - spdlog::error("failed to open {} for writing", out_file.string()); - return false; - } - - bool found = for_each_chunk_in_entry(tar_path, entry_name, - [&](const char* data, size_t size) { out.write(data, static_cast(size)); }); - if (!found) { - spdlog::error("blob {} not found in {}", entry_name, tar_path.string()); - return false; - } - return true; -} - -std::optional> read_oci_layers(const std::filesystem::path& tar_path) { +// Validates oci-layout, parses index.json, and returns the manifest blob's digest + +// parsed JSON (the first index.json entry with the OCI image manifest media type). +std::optional read_oci_manifest(const std::filesystem::path& tar_path) { auto layout = read_entry_to_string(tar_path, "oci-layout"); if (!layout) { spdlog::error("not an OCI image tar: missing oci-layout"); @@ -268,26 +247,173 @@ std::optional> read_oci_layers(const std::filesystem::path return std::nullopt; } + return OciManifest{manifest_digest, std::move(manifest)}; +} + +// Parses an ExposedPorts key like "3000/tcp" or "53/udp". Returns nullopt (logged at +// debug level) for a non-numeric port or an unrecognized protocol, rather than +// guessing. +std::optional parse_exposed_port(const std::string& key) { + size_t slash = key.find('/'); + if (slash == std::string::npos) { + spdlog::debug("skipping malformed exposed port {}", key); + return std::nullopt; + } + + std::string port_str = key.substr(0, slash); + std::string proto_str = key.substr(slash + 1); + + if (port_str.empty() || + !std::all_of(port_str.begin(), port_str.end(), [](unsigned char c) { return std::isdigit(c); })) { + spdlog::debug("skipping exposed port with non-numeric port: {}", key); + return std::nullopt; + } + + OciPortProtocol protocol; + if (proto_str == "tcp") { + protocol = OciPortProtocol::kTcp; + } else if (proto_str == "udp") { + protocol = OciPortProtocol::kUdp; + } else { + spdlog::debug("skipping exposed port with unrecognized protocol: {}", key); + return std::nullopt; + } + + return OciExposedPort{std::stoi(port_str), protocol}; +} + +} // namespace + +std::string oci_digest_hex(std::string_view digest) { + constexpr std::string_view prefix = "sha256:"; + if (digest.substr(0, prefix.size()) == prefix) { + digest.remove_prefix(prefix.size()); + } + return std::string(digest); +} + +bool extract_blob_to_file(const std::filesystem::path& tar_path, std::string_view digest_hex, + const std::filesystem::path& out_file) { + std::string entry_name = fmt::format("blobs/sha256/{}", digest_hex); + + std::ofstream out(out_file, std::ios::binary | std::ios::trunc); + if (!out) { + spdlog::error("failed to open {} for writing", out_file.string()); + return false; + } + + bool found = for_each_chunk_in_entry(tar_path, entry_name, + [&](const char* data, size_t size) { out.write(data, static_cast(size)); }); + if (!found) { + spdlog::error("blob {} not found in {}", entry_name, tar_path.string()); + return false; + } + return true; +} + +std::optional> read_oci_layers(const std::filesystem::path& tar_path) { + auto manifest = read_oci_manifest(tar_path); + if (!manifest) { + return std::nullopt; + } + std::vector layers; - for (const auto& l : manifest.value("layers", json::array())) { + for (const auto& l : manifest->data.value("layers", json::array())) { OciLayer layer; layer.digest = l.value("digest", ""); layer.media_type = l.value("mediaType", ""); if (layer.digest.empty()) { - spdlog::error("image manifest {} has a layer with no digest", manifest_digest); + spdlog::error("image manifest {} has a layer with no digest", manifest->digest); return std::nullopt; } layers.push_back(std::move(layer)); } if (layers.empty()) { - spdlog::error("image manifest {} has no layers", manifest_digest); + spdlog::error("image manifest {} has no layers", manifest->digest); return std::nullopt; } return layers; } +std::optional read_oci_image_config(const std::filesystem::path& tar_path) { + auto manifest = read_oci_manifest(tar_path); + if (!manifest) { + return std::nullopt; + } + + std::string config_digest = manifest->data.value("config", json::object()).value("digest", ""); + if (config_digest.empty()) { + spdlog::error("image manifest {} has no config blob", manifest->digest); + return std::nullopt; + } + + std::string config_entry = fmt::format("blobs/sha256/{}", oci_digest_hex(config_digest)); + auto config_content = read_entry_to_string(tar_path, config_entry); + if (!config_content) { + spdlog::error("missing image config blob {}", config_digest); + return std::nullopt; + } + + json config_json; + try { + config_json = json::parse(*config_content); + } catch (const json::parse_error& e) { + spdlog::error("image config {} is not valid JSON: {}", config_digest, e.what()); + return std::nullopt; + } + + OciImageConfig result; + try { + json runtime_config = config_json.value("config", json::object()); + + result.user = runtime_config.value("User", ""); + + json exposed_ports = runtime_config.value("ExposedPorts", json::object()); + if (exposed_ports.is_object()) { + for (const auto& [key, unused] : exposed_ports.items()) { + if (auto parsed = parse_exposed_port(key)) { + result.exposed_ports.push_back(*parsed); + } + } + } + + json env = runtime_config.value("Env", json::array()); + if (env.is_array()) { + for (const auto& e : env) { + result.env.push_back(e.get()); + } + } + + json volumes = runtime_config.value("Volumes", json::object()); + if (volumes.is_object()) { + for (const auto& [key, unused] : volumes.items()) { + result.volumes.push_back(key); + } + } + + json entrypoint = runtime_config.value("Entrypoint", json::array()); + if (entrypoint.is_array()) { + for (const auto& e : entrypoint) { + result.command.push_back(e.get()); + } + } + + json cmd = runtime_config.value("Cmd", json::array()); + if (cmd.is_array()) { + for (const auto& c : cmd) { + result.command.push_back(c.get()); + } + } + } catch (const json::exception& e) { + spdlog::error("image config {} has an unexpected field type: {}", config_digest, e.what()); + return std::nullopt; + } + + return result; +} + std::optional> list_oci_images(const std::filesystem::path& dir) { std::error_code ec; if (!std::filesystem::is_directory(dir, ec)) { diff --git a/src/oci_image.h b/src/oci_image.h index 899717d..9fb2178 100644 --- a/src/oci_image.h +++ b/src/oci_image.h @@ -57,3 +57,24 @@ struct OciImageRef { // Files that aren't valid OCI archives are silently skipped. Returns nullopt if `dir` // isn't a readable directory; an empty vector is a valid result (nothing matched). std::optional> list_oci_images(const std::filesystem::path& dir); + +enum class OciPortProtocol { kTcp, kUdp }; + +struct OciExposedPort { + int port; + OciPortProtocol protocol; +}; + +struct OciImageConfig { + std::string user; // config.User, e.g. "git:git"; empty if unset + std::vector exposed_ports; // config.ExposedPorts keys, parsed "/" + std::vector env; // config.Env, e.g. "PATH=..." + std::vector volumes; // config.Volumes keys, e.g. "/var/lib/mysql" + std::vector command; // config.Entrypoint ++ config.Cmd +}; + +// Reads the image config blob referenced by the manifest (found via index.json, same +// validation read_oci_layers() already does) and extracts User, ExposedPorts, Env, +// Volumes, and the effective default command (Entrypoint ++ Cmd). Logs a specific +// error and returns nullopt if anything required is missing or malformed. +std::optional read_oci_image_config(const std::filesystem::path& tar_path);