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:
@@ -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
@@ -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
@@ -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)) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user