Add -l/--list-images to discover OCI archives in a directory
list_oci_images() (src/oci_image.cpp) scans a directory non-recursively for *.tar/*.tar.* files and, for each one that's a valid OCI Image Layout archive, derives a name:tag from its index.json manifest annotations -- io.containerd.image.name if present (a full reference), else org.opencontainers.image.ref.name (conventionally just a bare tag for skopeo/podman-produced archives). Falls back to the archive's filename (.tar and any compression suffix stripped) for the name and "latest" for the tag. Files that aren't OCI archives are skipped quietly, since a directory scan is expected to hit unrelated tars. -l/--list-images <directory> wires this into the CLI, printing "name:tag<TAB>filename" per image. This is also why --log-level lost its short form last session: -l needed to be free for this. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+34
-8
@@ -38,18 +38,23 @@ namespace {
|
||||
|
||||
constexpr std::array<std::string_view, 2> kRequiredTools = {"containers-storage", "bwrap"};
|
||||
|
||||
enum class Mode { kNone, kMount, kUnmount, kTest, kRun, kCleanup };
|
||||
enum class Mode { kNone, kMount, kUnmount, kTest, kRun, kCleanup, kListImages };
|
||||
|
||||
constexpr std::array<struct option, 10> kLongOptions = {{
|
||||
// --log-level has no short form (freed up so -l could become --list-images), so it
|
||||
// needs a long-option val outside the printable-char range short options use.
|
||||
constexpr int kLogLevelOpt = 256;
|
||||
|
||||
constexpr std::array<struct option, 11> kLongOptions = {{
|
||||
{"help", no_argument, nullptr, 'h'},
|
||||
{"version", no_argument, nullptr, 'V'},
|
||||
{"test", no_argument, nullptr, 't'},
|
||||
{"log-level", required_argument, nullptr, 'l'},
|
||||
{"log-level", required_argument, nullptr, kLogLevelOpt},
|
||||
{"mount", required_argument, nullptr, 'm'},
|
||||
{"umount", required_argument, nullptr, 'u'},
|
||||
{"run", required_argument, nullptr, 'r'},
|
||||
{"cleanup", required_argument, nullptr, 'c'},
|
||||
{"no-nsenter", no_argument, nullptr, 'n'},
|
||||
{"list-images", required_argument, nullptr, 'l'},
|
||||
{nullptr, 0, nullptr, 0},
|
||||
}};
|
||||
|
||||
@@ -59,6 +64,7 @@ void print_usage(const char* prog) {
|
||||
" {0} -r|--run <image.tar> [-- <command> [args...]]\n"
|
||||
" {0} -u|--umount <layer-id>\n"
|
||||
" {0} -c|--cleanup <layer-id>\n"
|
||||
" {0} -l|--list-images <directory>\n"
|
||||
" {0} -t|--test\n"
|
||||
" {0} -h|--help\n"
|
||||
" {0} -V|--version\n"
|
||||
@@ -79,8 +85,10 @@ void print_usage(const char* prog) {
|
||||
" (this is automatic when running as root, where\n"
|
||||
" the mount is already directly visible; pass\n"
|
||||
" this to force it off otherwise)\n"
|
||||
" -l, --list-images <dir> list OCI Image Layout tars (*.tar, *.tar.*) found\n"
|
||||
" directly in <dir>, with their name:tag\n"
|
||||
" -t, --test run the test suite\n"
|
||||
" -l, --log-level <level> set log verbosity (trace, debug, info, warn,\n"
|
||||
" --log-level <level> set log verbosity (trace, debug, info, warn,\n"
|
||||
" error, critical, off)\n"
|
||||
" -h, --help print this help and exit\n"
|
||||
" -V, --version print version information and exit\n",
|
||||
@@ -203,6 +211,17 @@ int cleanup_image(const std::string& layer_id) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int list_images_command(const std::filesystem::path& dir) {
|
||||
auto images = list_oci_images(dir);
|
||||
if (!images) {
|
||||
return 1;
|
||||
}
|
||||
for (const auto& ref : *images) {
|
||||
fmt::print("{}:{}\t{}\n", ref.name, ref.tag, ref.path.filename().string());
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int run_container(const std::filesystem::path& image_tar, const std::vector<std::string>& command,
|
||||
bool use_nsenter) {
|
||||
auto mounted = mount_image(image_tar);
|
||||
@@ -235,7 +254,7 @@ int main(int argc, char* argv[]) {
|
||||
|
||||
opterr = 0;
|
||||
int opt;
|
||||
while ((opt = getopt_long(argc, argv, ":hVtl:m:u:r:c:n", kLongOptions.data(), nullptr)) != -1) {
|
||||
while ((opt = getopt_long(argc, argv, ":hVtm:u:r:c:nl:", kLongOptions.data(), nullptr)) != -1) {
|
||||
switch (opt) {
|
||||
case 'h':
|
||||
print_usage(argv[0]);
|
||||
@@ -247,7 +266,8 @@ int main(int argc, char* argv[]) {
|
||||
case 'm':
|
||||
case 'u':
|
||||
case 'r':
|
||||
case 'c': {
|
||||
case 'c':
|
||||
case 'l': {
|
||||
Mode requested;
|
||||
switch (opt) {
|
||||
case 't':
|
||||
@@ -262,9 +282,12 @@ int main(int argc, char* argv[]) {
|
||||
case 'r':
|
||||
requested = Mode::kRun;
|
||||
break;
|
||||
default:
|
||||
case 'c':
|
||||
requested = Mode::kCleanup;
|
||||
break;
|
||||
default:
|
||||
requested = Mode::kListImages;
|
||||
break;
|
||||
}
|
||||
if (mode != Mode::kNone && mode != requested) {
|
||||
spdlog::error("multiple actions specified");
|
||||
@@ -280,7 +303,7 @@ int main(int argc, char* argv[]) {
|
||||
case 'n':
|
||||
disable_nsenter = true;
|
||||
break;
|
||||
case 'l':
|
||||
case kLogLevelOpt:
|
||||
if (!apply_log_level(optarg)) {
|
||||
return 1;
|
||||
}
|
||||
@@ -315,6 +338,9 @@ int main(int argc, char* argv[]) {
|
||||
if (mode == Mode::kCleanup) {
|
||||
return cleanup_image(mode_arg);
|
||||
}
|
||||
if (mode == Mode::kListImages) {
|
||||
return list_images_command(mode_arg);
|
||||
}
|
||||
if (mode == Mode::kRun) {
|
||||
std::vector<std::string> command(argv + optind, argv + argc);
|
||||
if (command.empty()) {
|
||||
|
||||
@@ -22,8 +22,10 @@
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <spdlog/spdlog.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <fstream>
|
||||
#include <functional>
|
||||
#include <tuple>
|
||||
|
||||
namespace {
|
||||
|
||||
@@ -91,6 +93,98 @@ std::optional<std::string> read_entry_to_string(const std::filesystem::path& tar
|
||||
return content;
|
||||
}
|
||||
|
||||
bool matches_tar_glob(const std::filesystem::path& path) {
|
||||
const std::string name = path.filename().string();
|
||||
constexpr std::string_view suffix = ".tar";
|
||||
if (name.size() >= suffix.size() &&
|
||||
name.compare(name.size() - suffix.size(), suffix.size(), suffix) == 0) {
|
||||
return true;
|
||||
}
|
||||
return name.find(".tar.") != std::string::npos;
|
||||
}
|
||||
|
||||
// Filename with everything from the last ".tar" onward stripped, so "foo.tar.gz"
|
||||
// becomes "foo", not "foo.tar".
|
||||
std::string archive_basename(const std::filesystem::path& path) {
|
||||
const std::string name = path.filename().string();
|
||||
size_t tar_pos = name.rfind(".tar");
|
||||
return tar_pos == std::string::npos ? name : name.substr(0, tar_pos);
|
||||
}
|
||||
|
||||
struct ParsedRef {
|
||||
std::string name;
|
||||
std::string tag;
|
||||
};
|
||||
|
||||
// Splits a reference like "[registry/]repo[:tag]" at the last ':' that comes after
|
||||
// the last '/' (so a registry's own "host:port" isn't mistaken for a tag). A bare
|
||||
// word with no '/' at all (e.g. "musl-current") is treated as just a tag, matching
|
||||
// the observed skopeo/podman convention for org.opencontainers.image.ref.name.
|
||||
ParsedRef parse_image_ref(const std::string& ref) {
|
||||
size_t last_slash = ref.rfind('/');
|
||||
size_t last_colon = ref.rfind(':');
|
||||
bool has_tag = last_colon != std::string::npos &&
|
||||
(last_slash == std::string::npos || last_colon > last_slash);
|
||||
if (has_tag) {
|
||||
return {ref.substr(0, last_colon), ref.substr(last_colon + 1)};
|
||||
}
|
||||
if (last_slash != std::string::npos) {
|
||||
return {ref, ""};
|
||||
}
|
||||
return {"", ref};
|
||||
}
|
||||
|
||||
std::optional<OciImageRef> read_image_ref(const std::filesystem::path& tar_path) {
|
||||
auto layout = read_entry_to_string(tar_path, "oci-layout");
|
||||
if (!layout) {
|
||||
spdlog::debug("{}: not an OCI image tar (missing oci-layout)", tar_path.string());
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
auto index_content = read_entry_to_string(tar_path, "index.json");
|
||||
if (!index_content) {
|
||||
spdlog::debug("{}: not an OCI image tar (missing index.json)", tar_path.string());
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
json index;
|
||||
try {
|
||||
index = json::parse(*index_content);
|
||||
} catch (const json::parse_error& e) {
|
||||
spdlog::debug("{}: index.json is not valid JSON: {}", tar_path.string(), e.what());
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
json manifest_entry;
|
||||
bool found = false;
|
||||
for (const auto& m : index.value("manifests", json::array())) {
|
||||
if (m.value("mediaType", "") == "application/vnd.oci.image.manifest.v1+json") {
|
||||
manifest_entry = m;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
spdlog::debug("{}: index.json has no OCI image manifest entry", tar_path.string());
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
OciImageRef ref;
|
||||
ref.path = tar_path;
|
||||
|
||||
auto annotations = manifest_entry.value("annotations", json::object());
|
||||
std::string annotation_ref = annotations.value("io.containerd.image.name", "");
|
||||
if (annotation_ref.empty()) {
|
||||
annotation_ref = annotations.value("org.opencontainers.image.ref.name", "");
|
||||
}
|
||||
|
||||
ParsedRef parsed = annotation_ref.empty() ? ParsedRef{} : parse_image_ref(annotation_ref);
|
||||
ref.name = parsed.name.empty() ? archive_basename(tar_path) : parsed.name;
|
||||
ref.tag = parsed.tag.empty() ? "latest" : parsed.tag;
|
||||
|
||||
return ref;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string oci_digest_hex(std::string_view digest) {
|
||||
@@ -193,3 +287,30 @@ std::optional<std::vector<OciLayer>> read_oci_layers(const std::filesystem::path
|
||||
|
||||
return layers;
|
||||
}
|
||||
|
||||
std::optional<std::vector<OciImageRef>> list_oci_images(const std::filesystem::path& dir) {
|
||||
std::error_code ec;
|
||||
if (!std::filesystem::is_directory(dir, ec)) {
|
||||
spdlog::error("not a directory: {}", dir.string());
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::vector<OciImageRef> images;
|
||||
for (const auto& entry : std::filesystem::directory_iterator(dir, ec)) {
|
||||
if (!entry.is_regular_file() || !matches_tar_glob(entry.path())) {
|
||||
continue;
|
||||
}
|
||||
if (auto ref = read_image_ref(entry.path())) {
|
||||
images.push_back(std::move(*ref));
|
||||
}
|
||||
}
|
||||
if (ec) {
|
||||
spdlog::error("failed to read directory {}: {}", dir.string(), ec.message());
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::sort(images.begin(), images.end(), [](const OciImageRef& a, const OciImageRef& b) {
|
||||
return std::tie(a.name, a.tag) < std::tie(b.name, b.tag);
|
||||
});
|
||||
return images;
|
||||
}
|
||||
|
||||
@@ -42,3 +42,18 @@ bool extract_blob_to_file(const std::filesystem::path& tar_path,
|
||||
// Strips the "sha256:" algorithm prefix from a digest string, e.g.
|
||||
// "sha256:abcd" -> "abcd". Returns the input unchanged if there is no such prefix.
|
||||
std::string oci_digest_hex(std::string_view digest);
|
||||
|
||||
struct OciImageRef {
|
||||
std::string name;
|
||||
std::string tag;
|
||||
std::filesystem::path path; // the archive file this was found in
|
||||
};
|
||||
|
||||
// Scans `dir` (non-recursively) for files matching *.tar or *.tar.*, and for each one
|
||||
// that's a valid OCI Image Layout archive, determines an image name/tag from its
|
||||
// index.json manifest annotations (io.containerd.image.name or
|
||||
// org.opencontainers.image.ref.name), falling back to the archive's filename (with
|
||||
// .tar and any compression suffix stripped) for the name and "latest" for the tag.
|
||||
// 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);
|
||||
|
||||
Reference in New Issue
Block a user