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 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 16:35:38 +00:00
parent d009c3777b
commit b8f4680745
4 changed files with 197 additions and 40 deletions
+5
View File
@@ -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
+13 -8
View File
@@ -73,9 +73,10 @@ void print_usage(const char* prog) {
"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: /bin/sh; pass -- <command> [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 -- <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"
@@ -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<std::string>& command,
bool use_nsenter) {
int run_container(const std::filesystem::path& image_tar,
const std::vector<std::string>& 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<std::string> command = requested_command;
if (command.empty()) {
auto config = read_oci_image_config(image_tar);
command = (config && !config->command.empty()) ? config->command
: std::vector<std::string>{"/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<std::string> 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'
+158 -32
View File
@@ -23,6 +23,7 @@
#include <spdlog/spdlog.h>
#include <algorithm>
#include <cctype>
#include <fstream>
#include <functional>
#include <tuple>
@@ -185,36 +186,14 @@ std::optional<OciImageRef> 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<std::streamsize>(size)); });
if (!found) {
spdlog::error("blob {} not found in {}", entry_name, tar_path.string());
return false;
}
return true;
}
std::optional<std::vector<OciLayer>> 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<OciManifest> 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<std::vector<OciLayer>> 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<OciExposedPort> 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<std::streamsize>(size)); });
if (!found) {
spdlog::error("blob {} not found in {}", entry_name, tar_path.string());
return false;
}
return true;
}
std::optional<std::vector<OciLayer>> read_oci_layers(const std::filesystem::path& tar_path) {
auto manifest = read_oci_manifest(tar_path);
if (!manifest) {
return std::nullopt;
}
std::vector<OciLayer> 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<OciImageConfig> 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<std::string>());
}
}
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<std::string>());
}
}
json cmd = runtime_config.value("Cmd", json::array());
if (cmd.is_array()) {
for (const auto& c : cmd) {
result.command.push_back(c.get<std::string>());
}
}
} 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<std::vector<OciImageRef>> list_oci_images(const std::filesystem::path& dir) {
std::error_code ec;
if (!std::filesystem::is_directory(dir, ec)) {
+21
View File
@@ -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<std::vector<OciImageRef>> 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<OciExposedPort> exposed_ports; // config.ExposedPorts keys, parsed "<port>/<proto>"
std::vector<std::string> env; // config.Env, e.g. "PATH=..."
std::vector<std::string> volumes; // config.Volumes keys, e.g. "/var/lib/mysql"
std::vector<std::string> 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<OciImageConfig> read_oci_image_config(const std::filesystem::path& tar_path);