From 7de7838534abe8633409891e482ce4d5027b396c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20Sch=C3=BCller?= Date: Thu, 25 Apr 2024 16:04:05 +0200 Subject: [PATCH] stages/org.osbuild.skopeo: support for dir and oci-archive --- stages/org.osbuild.skopeo | 6 +- stages/org.osbuild.skopeo.meta.json | 44 ++++++++++++++ stages/test/test_skopeo.py | 91 +++++++++++++++++++++++++++++ 3 files changed, 139 insertions(+), 2 deletions(-) diff --git a/stages/org.osbuild.skopeo b/stages/org.osbuild.skopeo index f50935d5..30bb1bb2 100755 --- a/stages/org.osbuild.skopeo +++ b/stages/org.osbuild.skopeo @@ -1,5 +1,6 @@ #!/usr/bin/python3 import os +import pathlib import subprocess import sys @@ -19,9 +20,10 @@ def main(inputs, output, options): storage_root = destination.get("storage-path", "/var/lib/containers/storage") storage_driver = destination.get("storage-driver", "overlay") dest = f"containers-storage:[{storage_driver}@{output}{storage_root}+/run/containers/storage]{image_name}" - elif dest_type == "oci": + elif dest_type in ("oci", "oci-archive", "dir"): path = destination["path"] - dest = f"oci:{output}{path}" + dest = f"{dest_type}:{output}{path}" + pathlib.Path(f"{output}{path}").parent.mkdir(parents=True, exist_ok=True) else: raise ValueError(f"Unknown destination type '{dest_type}'") diff --git a/stages/org.osbuild.skopeo.meta.json b/stages/org.osbuild.skopeo.meta.json index 777ebe0a..2b6c0b1a 100644 --- a/stages/org.osbuild.skopeo.meta.json +++ b/stages/org.osbuild.skopeo.meta.json @@ -47,6 +47,44 @@ "type": "string" } } + }, + "destination-oci-archive": { + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "path" + ], + "properties": { + "type": { + "enum": [ + "oci-archive" + ] + }, + "path": { + "description": "Location of a tar archive compliant with 'Open Container Image Layout Specification'", + "type": "string" + }, + } + }, + "destination-dir": { + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "path" + ], + "properties": { + "type": { + "enum": [ + "dir" + ] + }, + "path": { + "description": "Location of a directory storing the manifest, layer tarballs and signatures as individual files. This is a non-standardized format, primarily useful for debugging or noninvasive container inspection.", + "type": "string" + } + } } }, "inputs": { @@ -80,6 +118,12 @@ }, { "$ref": "#/definitions/destination-oci" + }, + { + "$ref": "#/definitions/destination-oci-archive" + }, + { + "$ref": "#/definitions/destination-dir" } ] } diff --git a/stages/test/test_skopeo.py b/stages/test/test_skopeo.py index 2b591e72..47356a2e 100644 --- a/stages/test/test_skopeo.py +++ b/stages/test/test_skopeo.py @@ -1,8 +1,12 @@ #!/usr/bin/python3 +import json +import os +import subprocess import pytest from osbuild import testutil +from osbuild.testutil import has_executable, make_container STAGE_NAME = "org.osbuild.skopeo" @@ -13,8 +17,14 @@ STAGE_NAME = "org.osbuild.skopeo" ({"destination": {}}, "is not valid under any of the given schemas"), ({"destination": {"type": "foo"}}, "is not valid under any of the given schemas"), ({"destination": {"type": "oci"}}, "is not valid under any of the given schemas"), + ({"destination": {"type": "dir"}}, "is not valid under any of the given schemas"), + ({"destination": {"type": "os-archive"}}, "is not valid under any of the given schemas"), + ({"destination": {"type": "dir", "path": "/foo"}, "remove-signatures": "YesPlease"}, + "'YesPlease' is not of type 'boolean'"), # good ({"destination": {"type": "oci", "path": "/foo"}}, ""), + ({"destination": {"type": "oci-archive", "path": "/foo"}}, ""), + ({"destination": {"type": "dir", "path": "/foo"}}, ""), # this one might not be expected but it's valid because we don't require any # *inputs* and it'll be a no-op in the stage @@ -33,3 +43,84 @@ def test_schema_validation_skopeo(stage_schema, test_data, expected_err): else: assert res.valid is False testutil.assert_jsonschema_error_contains(res, expected_err, expected_num_errs=1) + + +@pytest.mark.skipif(os.getuid() != 0, reason="needs root") +@pytest.mark.skipif(not has_executable("podman"), reason="no podman executable") +@pytest.mark.parametrize("dest_type,local_output_path", [ + ("dir", "/tmp/test-output-skopeo-dir"), + ("oci-archive", "/tmp/test-output-skopeo.tar"), + ("oci", "/tmp/test-output-skopeo-dir"), +]) +def test_skopeo_copy(tmp_path, stage_module, dest_type, local_output_path): + with make_container(tmp_path, {"file1": "file1 from final layer"}) as cont_tag: + # export for the container-deploy stage + fake_container_dst = tmp_path / "fake-container" + subprocess.check_call([ + "podman", "save", + "--format=oci-archive", + f"--output={fake_container_dst}", + cont_tag, + ]) + + inputs = { + "images": { + # seems to be unused with fake_container_path? + "path": fake_container_dst, + "data": { + "archives": { + fake_container_dst: { + "format": "oci-archive", + "name": cont_tag, + }, + }, + }, + }, + } + + output_dir = tmp_path / "output" + options = { + "destination": { + "type": dest_type, + "path": local_output_path + } + } + + stage_module.main(inputs, output_dir, options) + + assert output_dir.exists() + + result = output_dir / local_output_path.lstrip("/") + assert result.exists() + + if dest_type == "dir": + assert (result / "version").exists() + + manifest_file = (result / "manifest.json") + elif dest_type == "oci-archive": + # TBD extract TAR and check content + assert result.exists() + elif dest_type == "oci": + assert (result / "oci-layout").exists() + + index_file = (result / "index.json") + assert index_file.exists() + + data = json.loads(index_file.read_bytes()) + + assert data.get("manifests") is not None, "'index.json' seems corrupt - no 'manifests' section found" + assert data["manifests"][0].get("digest") is not None, \ + "'manifest.json' seems corrupt - no 'manifests[0].digest' section found" + data_digest = data["manifests"][0]["digest"].split(':') + + manifest_file = result / "blobs" / data_digest[0] / data_digest[1] + + if dest_type in ["dir", "oci"]: + assert manifest_file.exists() + + data = json.loads(manifest_file.read_bytes()) + + assert data.get("config") is not None, "'manifest.json' seems corrupt - no 'config' section found" + + assert data["config"].get("digest") is not None, \ + "'manifest.json' seems corrupt - no 'config.digest' section found"