diff --git a/mkosi/__init__.py b/mkosi/__init__.py index 590123ea3d..5c5e056d21 100644 --- a/mkosi/__init__.py +++ b/mkosi/__init__.py @@ -3260,6 +3260,104 @@ def make_disk( return make_image(context, msg=msg, skip=skip, split=split, tabs=tabs, root=context.root, definitions=definitions) +def make_oci(context: Context, root_layer: Path, dst: Path) -> None: + ca_store = dst / "blobs" / "sha256" + with umask(~0o755): + ca_store.mkdir(parents=True) + + layer_diff_digest = hash_file(root_layer) + maybe_compress( + context, + context.config.compress_output, + context.staging / "rootfs.layer", + # Pass explicit destination to suppress adding an extension + context.staging / "rootfs.layer", + ) + layer_digest = hash_file(root_layer) + root_layer.rename(ca_store / layer_digest) + + creation_time = ( + datetime.datetime.fromtimestamp(context.config.source_date_epoch, tz=datetime.timezone.utc) + if context.config.source_date_epoch + else datetime.datetime.now(tz=datetime.timezone.utc) + ).isoformat() + + oci_config = { + "created": creation_time, + "architecture": context.config.architecture.to_oci(), + # Name of the operating system which the image is built to run on as defined by + # https://github.com/opencontainers/image-spec/blob/v1.0.2/config.md#properties. + "os": "linux", + "rootfs": { + "type": "layers", + "diff_ids": [f"sha256:{layer_diff_digest}"], + }, + "config": { + "Cmd": [ + "/sbin/init", + *context.config.kernel_command_line, + *context.config.kernel_command_line_extra, + ], + }, + "history": [ + { + "created": creation_time, + "comment": "Created by mkosi", + }, + ], + } + oci_config_blob = json.dumps(oci_config) + oci_config_digest = hashlib.sha256(oci_config_blob.encode()).hexdigest() + with umask(~0o644): + (ca_store / oci_config_digest).write_text(oci_config_blob) + + layer_suffix = context.config.compress_output.oci_media_type_suffix() + oci_manifest = { + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "config": { + "mediaType": "application/vnd.oci.image.config.v1+json", + "digest": f"sha256:{oci_config_digest}", + "size": (ca_store / oci_config_digest).stat().st_size, + }, + "layers": [ + { + "mediaType": f"application/vnd.oci.image.layer.v1.tar{layer_suffix}", + "digest": f"sha256:{layer_digest}", + "size": (ca_store / layer_digest).stat().st_size, + } + ], + "annotations": { + "io.systemd.mkosi.version": __version__, + **({ + "org.opencontainers.image.version": context.config.image_version, + } if context.config.image_version else {}), + } + } + oci_manifest_blob = json.dumps(oci_manifest) + oci_manifest_digest = hashlib.sha256(oci_manifest_blob.encode()).hexdigest() + with umask(~0o644): + (ca_store / oci_manifest_digest).write_text(oci_manifest_blob) + + (dst / "index.json").write_text( + json.dumps( + { + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.index.v1+json", + "manifests": [ + { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": f"sha256:{oci_manifest_digest}", + "size": (ca_store / oci_manifest_digest).stat().st_size, + } + ], + } + ) + ) + + (dst / "oci-layout").write_text(json.dumps({"imageLayoutVersion": "1.0.0"})) + + def make_esp(context: Context, uki: Path) -> list[Partition]: if not (arch := context.config.architecture.to_efi()): die(f"Architecture {context.config.architecture} does not support UEFI") @@ -3561,6 +3659,17 @@ def build_image(context: Context) -> None: tools=context.config.tools(), sandbox=context.sandbox, ) + elif context.config.output_format == OutputFormat.oci: + make_tar( + context.root, context.staging / "rootfs.layer", + tools=context.config.tools(), + sandbox=context.sandbox, + ) + make_oci( + context, + context.staging / "rootfs.layer", + context.staging / context.config.output_with_format, + ) elif context.config.output_format == OutputFormat.cpio: make_cpio( context.root, context.staging / context.config.output_with_format, diff --git a/mkosi/config.py b/mkosi/config.py index 8e1bda5d25..bf39d5f6be 100644 --- a/mkosi/config.py +++ b/mkosi/config.py @@ -173,6 +173,7 @@ class OutputFormat(StrEnum): sysext = enum.auto() tar = enum.auto() uki = enum.auto() + oci = enum.auto() def extension(self) -> str: return { @@ -205,6 +206,7 @@ class Compression(StrEnum): xz = enum.auto() bz2 = enum.auto() gz = enum.auto() + gzip = "gz" lz4 = enum.auto() lzma = enum.auto() @@ -216,6 +218,18 @@ def extension(self) -> str: Compression.zstd: ".zst" }.get(self, f".{self}") + def oci_media_type_suffix(self) -> str: + suffix = { + Compression.none: "", + Compression.gz: "+gzip", + Compression.zstd: "+zstd", + }.get(self) + + if not suffix: + die(f"Compression {self} not supported for OCI layers") + + return suffix + class DocFormat(StrEnum): auto = enum.auto() @@ -379,6 +393,28 @@ def to_qemu(self) -> str: return a + def to_oci(self) -> str: + a = { + Architecture.arm : "arm", + Architecture.arm64 : "arm64", + Architecture.loongarch64 : "loong64", + Architecture.mips64_le : "mips64le", + Architecture.mips_le : "mipsle", + Architecture.ppc : "ppc", + Architecture.ppc64 : "ppc64", + Architecture.ppc64_le : "ppc64le", + Architecture.riscv32 : "riscv", + Architecture.riscv64 : "riscv64", + Architecture.s390x : "s390x", + Architecture.x86 : "386", + Architecture.x86_64 : "amd64", + }.get(self) + + if not a: + die(f"Architecture {self} not supported by OCI") + + return a + def default_serial_tty(self) -> str: return { Architecture.arm : "ttyAMA0", @@ -634,6 +670,8 @@ def config_default_compression(namespace: argparse.Namespace) -> Compression: return Compression.xz else: return Compression.zstd + elif namespace.output_format == OutputFormat.oci: + return Compression.gzip else: return Compression.none diff --git a/mkosi/resources/mkosi.md b/mkosi/resources/mkosi.md index 2c63b74284..1adc889480 100644 --- a/mkosi/resources/mkosi.md +++ b/mkosi/resources/mkosi.md @@ -667,7 +667,8 @@ boolean argument: either `1`, `yes`, or `true` to enable, or `0`, `no`, archive is generated), `disk` (a block device OS image with a GPT partition table), `uki` (a unified kernel image with the OS image in the `.initrd` PE section), `esp` (`uki` but wrapped in a disk image - with only an ESP partition), `sysext`, `confext`, `portable` or `none` + with only an ESP partition), `oci` (a directory compatible with the + OCI image specification), `sysext`, `confext`, `portable` or `none` (the OS image is solely intended as a build image to produce another artifact). @@ -705,11 +706,12 @@ boolean argument: either `1`, `yes`, or `true` to enable, or `0`, `no`, : Configure compression for the resulting image or archive. The argument can be either a boolean or a compression algorithm (`xz`, `zstd`). `zstd` compression is used by default, except CentOS and derivatives up to version - 8, which default to `xz`. Note that when applied to block device image types, + 8, which default to `xz`, and OCI images, which default to `gzip`. + Note that when applied to block device image types, compression means the image cannot be started directly but needs to be decompressed first. This also means that the `shell`, `boot`, `qemu` verbs are not available when this option is used. Implied for `tar`, `cpio`, `uki`, - and `esp`. + `esp`, and `oci`. `CompressLevel=`, `--compress-level=` diff --git a/tests/test_boot.py b/tests/test_boot.py index 7bd85abafd..ddc5d6ce78 100644 --- a/tests/test_boot.py +++ b/tests/test_boot.py @@ -57,7 +57,7 @@ def test_format(config: Image.Config, format: OutputFormat) -> None: if image.config.distribution == Distribution.rhel_ubi: return - if format in (OutputFormat.tar, OutputFormat.none) or format.is_extension_image(): + if format in (OutputFormat.tar, OutputFormat.oci, OutputFormat.none) or format.is_extension_image(): return if format == OutputFormat.directory and not find_virtiofsd():