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
|
derives an image name/tag from its `index.json` manifest annotations
|
||||||
(`io.containerd.image.name` preferred, else `org.opencontainers.image.ref.name`),
|
(`io.containerd.image.name` preferred, else `org.opencontainers.image.ref.name`),
|
||||||
falling back to the archive's filename and `"latest"` respectively.
|
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`,
|
- `containers_storage.{h,cpp}` — wraps the `containers-storage` CLI (`import-layer`,
|
||||||
`mount`, `unmount`, `layer --json`, `delete-layer`), forcing `fuse-overlayfs` as the
|
`mount`, `unmount`, `layer --json`, `delete-layer`), forcing `fuse-overlayfs` as the
|
||||||
overlay `mount_program`. `cleanup_layer_chain()` walks a layer's parent chain
|
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"
|
"options:\n"
|
||||||
" -m, --mount <image.tar> validate and mount an OCI Image Layout tar\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"
|
" -r, --run <image.tar> mount, run bwrap in the foreground (default\n"
|
||||||
" command: /bin/sh; pass -- <command> [args...]\n"
|
" command: the image's own Entrypoint/Cmd if set,\n"
|
||||||
" to override), then unmount and clean up when\n"
|
" else /bin/sh; pass -- <command> [args...] to\n"
|
||||||
" it exits\n"
|
" override), then unmount and clean up when it\n"
|
||||||
|
" exits\n"
|
||||||
" -u, --umount <layer-id> unmount a previously mounted image layer (the\n"
|
" -u, --umount <layer-id> unmount a previously mounted image layer (the\n"
|
||||||
" ID printed by --mount/--run, or from\n"
|
" ID printed by --mount/--run, or from\n"
|
||||||
" `containers-storage layers`)\n"
|
" `containers-storage layers`)\n"
|
||||||
@@ -240,14 +241,21 @@ int list_images_command(const std::filesystem::path& dir) {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
int run_container(const std::filesystem::path& image_tar, const std::vector<std::string>& command,
|
int run_container(const std::filesystem::path& image_tar,
|
||||||
bool use_nsenter) {
|
const std::vector<std::string>& requested_command, bool use_nsenter) {
|
||||||
auto mounted = mount_image(image_tar);
|
auto mounted = mount_image(image_tar);
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
fmt::print("mounted image at: {} (layer {})\n", mounted->merged_path, mounted->top_layer_id);
|
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);
|
int exit_code = run_bwrap(mounted->merged_path, command, use_nsenter);
|
||||||
if (exit_code < 0) {
|
if (exit_code < 0) {
|
||||||
spdlog::error("failed to run bwrap");
|
spdlog::error("failed to run bwrap");
|
||||||
@@ -361,9 +369,6 @@ int main(int argc, char* argv[]) {
|
|||||||
}
|
}
|
||||||
if (mode == Mode::kRun) {
|
if (mode == Mode::kRun) {
|
||||||
std::vector<std::string> command(argv + optind, argv + argc);
|
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
|
// As root, containers-storage mount doesn't need to reexec into a private
|
||||||
// user namespace to gain privilege, so the mount is already directly
|
// user namespace to gain privilege, so the mount is already directly
|
||||||
// visible; nsenter into it then fails ("reassociate to namespace 'ns/user'
|
// visible; nsenter into it then fails ("reassociate to namespace 'ns/user'
|
||||||
|
|||||||
+158
-32
@@ -23,6 +23,7 @@
|
|||||||
#include <spdlog/spdlog.h>
|
#include <spdlog/spdlog.h>
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
#include <cctype>
|
||||||
#include <fstream>
|
#include <fstream>
|
||||||
#include <functional>
|
#include <functional>
|
||||||
#include <tuple>
|
#include <tuple>
|
||||||
@@ -185,36 +186,14 @@ std::optional<OciImageRef> read_image_ref(const std::filesystem::path& tar_path)
|
|||||||
return ref;
|
return ref;
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace
|
struct OciManifest {
|
||||||
|
std::string digest;
|
||||||
|
json data;
|
||||||
|
};
|
||||||
|
|
||||||
std::string oci_digest_hex(std::string_view digest) {
|
// Validates oci-layout, parses index.json, and returns the manifest blob's digest +
|
||||||
constexpr std::string_view prefix = "sha256:";
|
// parsed JSON (the first index.json entry with the OCI image manifest media type).
|
||||||
if (digest.substr(0, prefix.size()) == prefix) {
|
std::optional<OciManifest> read_oci_manifest(const std::filesystem::path& tar_path) {
|
||||||
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 layout = read_entry_to_string(tar_path, "oci-layout");
|
auto layout = read_entry_to_string(tar_path, "oci-layout");
|
||||||
if (!layout) {
|
if (!layout) {
|
||||||
spdlog::error("not an OCI image tar: missing oci-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 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;
|
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;
|
OciLayer layer;
|
||||||
layer.digest = l.value("digest", "");
|
layer.digest = l.value("digest", "");
|
||||||
layer.media_type = l.value("mediaType", "");
|
layer.media_type = l.value("mediaType", "");
|
||||||
if (layer.digest.empty()) {
|
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;
|
return std::nullopt;
|
||||||
}
|
}
|
||||||
layers.push_back(std::move(layer));
|
layers.push_back(std::move(layer));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (layers.empty()) {
|
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 std::nullopt;
|
||||||
}
|
}
|
||||||
|
|
||||||
return layers;
|
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::optional<std::vector<OciImageRef>> list_oci_images(const std::filesystem::path& dir) {
|
||||||
std::error_code ec;
|
std::error_code ec;
|
||||||
if (!std::filesystem::is_directory(dir, 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`
|
// 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).
|
// 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);
|
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