Bundle manifest¶
convert produces a bundle, not a bare disk file: a directory holding
the disk and a contemper.json manifest beside it.
my-appliance-v3.aarch64/
├── contemper.json
└── disk.qcow2
The directory is named <repository>-<tag>.<arch>, using the machine
architecture names aarch64 and x86_64.
The manifest is there because the disk alone can't answer the questions
deploying needs answered: which images it came from, which target was
resolved, what volumes the image declared, what ports it exposed. It's
plain JSON in a plain directory, so cat is a perfectly good inspection
tool.
Fields¶
| Field | Meaning |
|---|---|
formatVersion |
manifest format version, currently 1 |
contemperVersion |
the contemper version that built the bundle |
createdAt |
build time, UTC |
source.ref, source.digest |
the source reference as given, and the digest of the image used |
source.repo |
the source image's repository name, no tag; deploy --to local-qemu's default instance name |
support.ref, support.digest |
the support image, if one was merged |
support.origin |
where support.ref came from: "target" (the resolved target's own default) or "flag" (--support on the command line) |
support.variants |
each of the support image's branches' resolved variant: branch, variant, and (unless it was a no-op) ref/digest for the image that won |
volumeHelper.ref, volumeHelper.digest, volumeHelper.variants |
the automatically merged volume-formatting support image, in the same shape as support (without origin), if the source image declares volumes and --no-volume-helper wasn't given |
target |
the canonical target name, for example qemu-qcow2, never the alias |
boot |
how the image boots: "uki" (contemper assembled a UKI) or "bootloader" (the image's own bootloader is on the ESP, see Bootloader images). Manifests written before this field existed lack it; read them as "uki" |
secureBoot |
true when the image asked for UEFI Secure Boot with io.contemper.secure-boot (bootloader images only); omitted otherwise. deploy --to local-qemu then boots Secure Boot firmware, see Secure Boot. Manifests written before this field existed lack it; read them as false |
arch |
the image architecture (arm64, amd64) |
disk.file, disk.format, disk.sizeBytes, disk.sha256 |
the disk file and its checksum |
volumes |
one object per volume declared with VOLUME: name, path, size (bytes; omitted if unsized), fs (always "ext4") |
hints.exposedPorts, hints.healthcheck |
from EXPOSE and HEALTHCHECK; inputs for deployment, not used for the disk |
reproducible |
false when the source was a local archive or layout |
support, volumeHelper, volumes and the entries under hints are
left out when empty; hints itself is always present.
Why support and volumeHelper are separate
A bundle can carry two independent support-image merges: the one the
user asked for with --support, and the one contemper adds on its
own because the image declares volumes (see the volumes
guide). Keeping them as two top-level fields,
each shaped exactly like the other, means support always reflects
only what the user asked for, volumeHelper always reflects only
what contemper added, and a reader that only cares about one never
has to filter a merged list by some added "role" field.
Example¶
{
"formatVersion": 1,
"contemperVersion": "v0.1.0",
"createdAt": "2026-09-26T19:28:49Z",
"source": {
"ref": "oci-archive:_out/example.tar",
"digest": "sha256:d3cc1ee83a78e8aa7e91d9c552e26ad53021f436e1b0e35aba1bb1b15b80f585",
"repo": "example"
},
"volumeHelper": {
"ref": "ghcr.io/contemper-project/volumes-support:v1",
"digest": "sha256:1111111111111111111111111111111111111111111111111111111111111",
"variants": [
{
"branch": "init-system",
"variant": "openrc",
"ref": "ghcr.io/contemper-project/volumes-support-init-system-openrc:v1",
"digest": "sha256:2222222222222222222222222222222222222222222222222222222222222"
}
]
},
"target": "qemu-qcow2",
"boot": "uki",
"arch": "arm64",
"disk": {
"file": "disk.qcow2",
"format": "qcow2",
"sizeBytes": 112656384,
"sha256": "639ee78c1ce9ea132ecd93590405121a7f2f337438d49fa029482c8e542c6a9d"
},
"volumes": [
{ "name": "data", "path": "/data", "size": 10737418240, "fs": "ext4" }
],
"hints": {},
"reproducible": false
}
A bundle built with a support image records where its reference came from:
"support": {
"ref": "ghcr.io/contemper-project/incus-support:v1",
"digest": "sha256:9f2c...",
"origin": "target"
},
"origin": "flag" records the same thing when --support on the command
line replaced the target's default instead. digest is what the
reference resolved to when the bundle was built, so a floating tag such
as :v1 stays traceable.
The manifest doubles as the deployment metadata format and the provenance record: the digests linking a disk to its inputs are recorded because deploying needs them anyway.
Converting into an existing bundle directory¶
convert writes the bundle into a subdirectory of --out named after
the image (<repository>-<tag>.<arch>). It assembles the whole bundle,
disk and contemper.json, in a temporary directory beside that
subdirectory and moves it into place only once everything succeeded. A
failed conversion leaves no partial bundle behind, and an existing bundle
of the same name stays as it was.
When the conversion succeeds, a previous bundle of the same name is
replaced as a whole, so files the new run doesn't produce (for example a
disk.raw from an earlier --keep-raw run) are gone afterwards. If that
subdirectory exists, is not empty and has no contemper.json, convert
refuses to touch it and fails before doing any work.
Group file¶
A convert run given an --arch list or all (see
multi-architecture) also writes a group file
beside its bundles, so a script can find the one for a given
architecture. It is named <name>.multiarch.json, where <name> is the
bundles' shared <repository>-<tag>, and a later run replaces it.
{
"formatVersion": 1,
"source": {
"ref": "oci-archive:_out/example.tar",
"repo": "example"
},
"bundles": [
{ "arch": "amd64", "path": "example-dev.x86_64" },
{ "arch": "arm64", "path": "example-dev.aarch64" }
]
}
| Field | Meaning |
|---|---|
formatVersion |
group file format version, currently 1 |
source.ref, source.repo |
the source reference as given, and the source image's repository name, as in each bundle's manifest; there is no source.digest because every architecture resolves to its own image (each bundle's manifest records its digest) |
bundles[].arch |
the image architecture (amd64, arm64), at most once each |
bundles[].path |
the bundle directory, relative to the group file's own directory; never absolute and never leaving that directory |
The file lists at least one bundle, in amd64, arm64 order. Single-architecture runs write no group file.
contemper deploy --to local-qemu reads the file when given its path
instead of a bundle directory, and boots the entry for the host's
architecture (or the one --arch names), after checking that the
bundle's own manifest has the same arch. A script must do the same:
resolve each path relative to the group file's directory, then check the
bundle manifest's arch.
On \"no new artifact format\"
contemper reads only normal OCI images. A bundle is an output, and a directory holding a disk and a JSON file barely qualifies as a format. Tar or OCI-artifact packaging of bundles are possible later additions if distribution needs them.