verify

Prove a rebuilt target carries what migrate promised, against the attestation that run wrote. The comparison is local. Optional --sign sends only the report digest and OS pair to /v1/attest/sign, which records no usage event.

Does not bill. Only usage kind migration is billable. After the verdict is fixed, verify may POST kind=verify to claim a seat (402-gated, never metered). A VM already seated by migrate claims nothing new. Offline (no credentials) still produces the parity report.

The report names categories it did not check (unverified: soak, db_upgrade, response_equivalence when those lanes were not supplied, probe for a container attestation with no probe record, and entrypoint: ... for a target read as an image whose configured entrypoint was not compared). Omitted only when every optional lane ran. A report that lists only what it checked is indistinguishable from one that checked everything (#475 VR-4).

When the attestation records a container feasibility check as assumed by the operator (migrate --assume-undetermined), the report names it: an assumed by the operator block in the text, and container_checks_assumed_by_operator in the JSON (inside the signed facts). This says migrate was run on the operator's word for that check; it does not say a Containerfile was emitted, since an assumed run can still stop at the entrypoint or the base image. It does not change the verdict or the exit code, and a report for an attestation with no assumed check is unchanged.

vrtmv verify --attestation out/vrtmv-attestation.json --root /mnt/rebuild
vrtmv verify --attestation out/vrtmv-attestation.json --image rebuild.qcow2 --sign --json
vrtmv verify --attestation out/vrtmv-attestation.json --ssh user@host --sudo
vrtmv verify --attestation out/vrtmv-attestation.json --root /mnt/host-b --baseline host-a-verify.json
FlagPurpose
--attestation <path>The vrtmv-attestation.json migrate wrote.
--baseline <path>A previous host's verify JSON (verify --out). Suppresses gaps already on that class profile so this host reports only its own deltas. Not a migrate attestation.
--root / --image / --sshThe rebuilt target (see Sources).
--ssh-port, --ssh-key, --jump, --sudoSSH collection options.
--signAsk the API to Ed25519-sign the verdict (/v1/attest/sign). Needs credentials. Signing failure is a note, not a hard error.
-o, --out <path>Write the parity report JSON here.
--jsonEmit the parity report as JSON instead of a text summary.
--require <class>Exit 0 only if every named class is equivalent (packages, configs, aspects, firewall, services, accounts, selinux).
--target-kind <machine|container>What the target is. It decides how the source's enabled units are read: on a machine each promised unit must be enabled; in an image the entrypoint's program must be present and executable, and the units the image does not start are named beside the verdict. See Container images and enabled units.
--data-dir <path>=<dir>The directory mounted at a volume-class data path, to compare with the manifest. Repeatable. See Data paths.
--data-volume <path>=<volume>The named podman volume mounted at a volume-class data path, read from a helper container. Repeatable. Needs --data-helper-image. See A named podman volume.
--data-helper-image <image>The image the helper container runs. For --data-volume it must be in podman's local storage. For --data-claim the cluster pulls it, and the default is registry.access.redhat.com/ubi10/ubi@sha256:27e14f4987d7abe56664d7e1b1dddcd0226d4ca593da5726f0f65697a17d0fee.
--data-claim <path>=<claim>The Kubernetes claim mounted at a volume-class data path, read from a Job in the cluster. Repeatable. Needs --data-namespace. See A Kubernetes claim.
--data-namespace <ns>The namespace of the claims, where the Job is created.
--data-kube-context <name>The kubeconfig context to use. Omit for the current one.
--data-kubectlReach the cluster with kubectl instead of oc.
--data-manifest <file>The vrtmv-data-attestation.json that vrtmv data plan wrote for the source: the file hashes --data-dir directories and --data-volume volumes are compared with.
--probe-record <file>The record vrtmv probe record wrote on the source. See Probes.
--probes <file>The probes file the record was taken with. Needs --probe-record.
--probe-container <name>A running container to run the probes in. The entrypoint its image is configured with is compared with the attested one. Needs --probes.
--probe-runtime <podman|docker>The runtime for --probe-container. Default podman.

Exits non-zero unless the verdict is equivalent, so CI can gate on parity. See Parity Attestation.

Container images and enabled units

On a machine, verify requires every unit the attestation promises to be enabled, read from the enablement links under /etc/systemd/system. An image has no init and nothing in it reads those links, so for an image verify does not look at them. It checks the statement migrate --format container recorded in container_target.units (Container output):

  • The entrypoint's program must be in the image: a regular file with an execute bit, found by following links inside the image. This is one expected item of the services class. If the program is missing, has no execute bit, is not a file, or could not be read, the item is missing (services entrypoint:<program>), the verdict is partial and the exit is 1.
  • The units the image does not start are printed next to the verdict whenever there are any, as a NOT STARTED line, and are in the JSON under services_reading.container.units_not_started and units_not_started_never_promised. The count on that line is of every unit the source had enabled other than the entrypoint's: the ones the plan would have enabled on a machine, and the ones it would not have enabled there either. They do not change the verdict or the exit code. When migrate read the asserted unit's ExecStart on the source and found another program, or could not read it, the line under the entrypoint says so.
  • The unit the operator asserted for the entrypoint (migrate --entrypoint-unit) is shown as their assertion. verify does not check it.

The report always says which reading was used: a units read as line in the text and services_reading in the JSON, inside the signed facts. chosen_by is --target-kind when you gave the flag and attestation when you did not.

Without --target-kind, the attestation decides. One whose migrate run emitted only a Containerfile is read as an image's. One whose run also emitted a format for a machine (--format ansible,container) keeps the enablement promise for that machine and is read as a machine's; the report's note says to pass --target-kind container when the target is the image. --target-kind container is refused for an attestation with no units statement (written by 2.2.0 or earlier, or by a run that emitted no Containerfile). --target-kind machine on an attestation of a container-only run checks no unit, since none is promised, and the note says so. --target-kind cannot be combined with --borrow-from.

A report over an attestation with no units statement, read without the flag, has no services_reading and is unchanged.

Not checked from the files: whether the image is configured to start the attested entrypoint. verify reads the image's files, which do not record it, and a target read as an image lists this under unverified as entrypoint: .... With --probe-container the image's configuration is read from the runtime and compared: see The configured entrypoint.

Data paths

When the attestation comes from migrate --format container on a source with data, it records the class you declared for each data path under container_data. verify names every one of those paths in a data block, in one of three states: verified, differs, or not_checked with the reason. Data never changes the verdict. It can change the exit code.

A volume is outside the image, so verify compares one only when you name the directory that is mounted there, and it needs the file hashes of the source from data plan:

vrtmv data plan --root /mnt/vm --copy /srv/site --dest-dir /data/vol -o plan
SOURCE_ROOT=/mnt/vm bash plan/vrtmv-data-copy.sh
vrtmv verify --attestation out/vrtmv-attestation.json --root /mnt/rebuild \
  --data-dir /srv/site=/data/vol/srv/site --data-manifest plan/vrtmv-data-attestation.json
StateWhenExit code
verifiedEvery file the manifest lists under the path is in the directory with the same sha256. The report records the directory and the time it was read. Files in the directory that the manifest does not list are counted in unlisted_files and said in the reason; they do not change the state.unchanged
verified, a volume that was empty on the sourceThe attestation counted no file under the path (files: 0), the manifest of the path lists none, and the directory holds no regular file. All three are needed. A path the attestation records as not on the source (not_on_source) also has files: 0 and is never this case: it is not_checked and the exit is 1.unchanged
differsA file is missing or has other bytes, or the directory holds no regular file and the source had files. That last case needs no manifest: the attestation's own file count is enough. The reason says "empty" only for a directory with nothing in it; one that holds only links or directories is reported as holding no regular file, with the number of directories, links and special files found.non-zero
not_checked, a directory was namedThe directory could not be read, no manifest was given, the manifest does not cover the path, the manifest lists no file under it and the attestation did not count zero there or the directory holds a file, the manifest lists fewer files under the path than the attestation counted there, or a file in it could not be read. A directory that could not be read is never reported as empty.non-zero
not_checked, a database that was namedThe data plan exports and imports the database and has no file hashes for it, so its files are not compared, whatever the directory holds. An empty directory where the source had files differs. Leave --data-dir off for a database: it is then listed as not checked and the exit is unchanged.non-zero
not_checked, no directory namedA volume with no --data-dir, a disposable path, a path the source walk did not read, a directory with no class.unchanged

Every not_checked path is also listed under unverified, by name, with its reason. So are the two things --assume-undetermined local-state can stand for: a source walk that did not run, and a local-state check that could not answer.

A data gap turns a passing exit code (0, 3 or 5) into 1. It does not change 1 or 4. The data block is inside the signed facts, so content_sha256 covers each path's state, the directory, the time and the manifest's sha256. Because the time is covered, two runs over the same directory a second apart have different digests.

With a manifest the data block also has plan_destination: what the manifest records about who else could write to the directories of the data plan, when the plan was made. It has one record for the directory the plan compared with (compared_with, from --verify-dest) and one for the directory its copy script fills (filled, from --dest-dir), each with a path, a state and a reason, and a state over both. It changes no path's state and not the exit.

StateA directoryOver both
accepted_as_sharedThe plan was made with --allow-shared-dest and went on for this directory: something was let through for the one it compared with, or its script fills this one where it would refuse.Either directory is.
not_sharedOnly the directory compared with. The plan looked at every directory above it and only root and the account running the plan could write to one. Who could write to the directory itself is not judged, and the reason says so.Every directory the manifest names is.
not_knownNothing was recorded: a plan of 2.2.0 or earlier, which did not look, or a --dest-dir directory with no flag, which the copy script looks at after the manifest is written.Anything else, and a manifest that names no directory.

not_known is not not_shared. verify does not itself look at who can write to a directory given with --data-dir.

What verified does not say. It is about the files the manifest lists: a file in the directory that the manifest does not list is counted (unlisted_files) and not compared with anything, and owners, modes and links are not compared (the copy script checks those when it runs). A file under a more specific declaration belongs to that declaration and is not counted as unlisted. It is about the moment the directory was read, and a volume is written to afterwards. And one thing only ties the manifest to the source the attestation was taken from: a manifest that lists fewer files under a path than the attestation counted there is not accepted, since it was taken from another root or after files were removed. A manifest from another source with as many files or more is not caught. The report records the manifest's sha256 so that a reader can check which one was used. A link inside the directory is not followed; a link where the source had a file or a directory counts as a difference. data plan --verify-dest gives the same answer.

--data-dir is refused for a path the attestation does not declare as a volume. An attestation written by 2.2.0 or earlier has no container_data: its report has no data block, says under unverified that data is not recorded, and --data-dir is refused. --data-dir and --data-manifest cannot be combined with --borrow-from, and a report that has a data block is not borrowed by another host. A named podman volume is not a directory on this machine: give it with --data-volume, below. A Kubernetes claim is given with --data-claim, below that. A volume that was empty on the source is verified when the directory offered for it holds no regular file and the manifest covers the path. Without the manifest it is not_checked with a non-zero exit, because the attestation counts only the files no package owns. Once the workload has written a file there it is not_checked with a non-zero exit as well: the manifest has nothing to compare the file with.

A named podman volume

A named volume is kept by podman and is not a directory you can hand to verify. --data-volume <path>=<volume> has verify read it from a helper container:

vrtmv verify --attestation vrtmv-attestation.json --root /mnt/rebuilt \
  --data-volume /srv/site=site --data-helper-image localhost/myapp:1 \
  --data-manifest plan/vrtmv-data-attestation.json

For each volume, verify asks podman where the volume's directory is, starts one container from the helper image with that directory mounted read-only and no network, and has it list the volume with find and hash every regular file with sha256sum. It compares what the container printed with the manifest, by the same rules and with the same three states as --data-dir. The container is removed when it ends. A reason for a volume calls it a volume (The volume is empty), where the same reason for --data-dir says directory. The helper image must already be in podman's local storage, with /bin/sh, GNU find and sha256sum: verify pulls nothing and chooses no image. The image built from the migration's Containerfile serves.

The statement is second-hand and the report says by whom. Each such path carries volume, read_at (when its container was started) and observed_by:

"volume": "site",
"read_at": "2026-10-08T15:02:11Z",
"observed_by": {
  "by": "podman helper container",
  "engine": "podman 4.9.3",
  "volume": "site",
  "mountpoint": "/var/lib/containers/storage/volumes/site/_data",
  "container_name": "vrtmv-data-verify-3f0c9a1b72de",
  "container_id": "9b1c…",
  "image": "localhost/myapp:1",
  "image_id": "5d2e…"
}

verified for a volume therefore means: the find and sha256sum of that image, in that container, at that time, reported every file the manifest lists with the manifest's hash. verify did not read the files itself. The image is recorded by the id podman has for it, so a later reader can tell which tools were trusted.

A volume that was not read is not_checked and fails the exit. It is never reported as verified and never as empty. That covers a volume podman does not have, a name podman would complete to another volume's (it answers for a volume whose name starts with the one given when exactly one does, and verify takes only the exact name), a helper container that did not start or did not run to its end, output that stops before its last line, a find in the container that failed part-way, and a helper image with no GNU find or no sha256sum. An empty volume is one the helper listed completely and found nothing in; where the source had files that is differs. A file the helper listed and could not hash is counted as unreadable, as with a directory.

verify never passes the volume's name to podman run -v. Doing so creates a volume that does not exist. On podman 3.4.4 it also uses up, read-only or not, the one copy of the image's files a volume gets at its mount path the first time it is mounted; on 4.9.3 a read-only mount does not. verify mounts the volume's directory instead, which does neither on either version, and for that reason reads only a volume of podman's local driver with no mount options; any other is not_checked with that reason. The helper container is not started with --rm, because podman 4 then deletes the file its id is read from; verify removes it by name when it has ended.

--data-volume is refused for a path the attestation does not declare as a volume, for a path also given with --data-dir, and when the helper image is not in local storage or podman cannot be run; those end the run before any container is started. It cannot be combined with --borrow-from.

A file in the volume that the manifest does not list is counted (unlisted_files) and not compared, as with a directory, and the rule for a volume that was empty on the source is the same. So is the rule for a database: its files are not compared, a volume named for it is not_checked with a non-zero exit, and the reason says to leave --data-volume off for it.

A file name in the volume that is not UTF-8 cannot be named by a manifest, which is JSON and writes such a name with U+FFFD in place of the bytes that are not (one U+FFFD can stand for more than one byte, so the respelling does not give the length of the name). The reason then ends with how many such names the volume holds. One the workload wrote is counted in unlisted_files. One the manifest lists is there and cannot be told from another with the same respelling, so it is counted as unreadable and the path is not_checked with a non-zero exit; it is not reported as missing. --data-dir does not say this yet: there such a file is reported as missing.

Not covered: owners and modes in the volume (as with a directory), a volume on another machine or behind podman --remote whose directory the local podman cannot mount, and a time limit on the helper. The whole listing is held in memory, so a volume with millions of files costs accordingly.

A Kubernetes claim

A claim is in a cluster and is not a directory you can hand to verify. --data-claim <path>=<claim> has verify read it from a Job:

vrtmv verify --attestation out/vrtmv-attestation.json --root /mnt/built \
  --data-claim /srv/site=site-data --data-namespace staging \
  --data-manifest plan/vrtmv-data-attestation.json

For each claim verify creates a Job in the namespace whose pod mounts the claim read-only, lists the data directory that deploy's init container fills, and prints the sha256 of each file. verify compares what the pod printed with the manifest, as it does for a directory, and deletes the Job. A ReadWriteOnce claim is attached to one node, so when a pod has the claim mounted the Job's pod is put on that pod's node; the workload keeps running.

The statement is second-hand, as for a podman volume, and the report says who looked. Each path carries claim, read_at (when the Job was created) and observed_by: the cluster's API server as your kubeconfig names it, the server and client versions, the namespace, the claim and the volume it is bound to, the Job, the pod and its uid, the node, and the image as you named it and as the node resolved it. The hashes are the ones that image's sha256sum printed.

Three states, as for a directory. These are not_checked with the reason and a non-zero exit, and none of them is reported as an empty volume: a claim the cluster does not have, a claim that is not bound, a Job that could not be created, a pod that failed or had not finished after five minutes, output that stops before its last line, a helper image with no GNU find, and a claim with no data directory, which is one no init container of vrtmv filled.

A reason is written by verify and holds none of the cluster's text. The data block is inside what verify signs, so the reason for a claim that was not read is one of a fixed set of sentences, with the claim's name and the namespace as you gave them. What kubectl printed, the state of the Job's container and the last warning the cluster recorded against the pod are printed on standard error, one line each, starting verify: --data-claim <path>=<claim>:. Keep standard error if you want them; the report says they were printed there. A claim's phase is named when it is Pending or Lost, and any other is "not Bound". observed_by is not covered by this: the API server's address, the versions, and the names of the volume, pod and node are the cluster's own, and they are in the report because they say who looked.

verify uses your cluster login and nothing of the bucket: the Job is given no credential and no URL. --data-claim is refused for a path the attestation does not declare as a volume and for a path also given with --data-dir or --data-volume, and it ends the run before any Job is created when the cluster does not answer. It cannot be combined with --borrow-from. The rules for an unlisted file, for a volume that was empty on the source and for a database are those of a directory. A database claim that nothing was imported into holds an empty data directory, which differs from a source that had files.

Limits. The pod's output is read through the cluster's log of the container, which the node rotates at a size the cluster sets (10 MiB by default), so a claim with more files than fit is not_checked. The Job's pod runs as root to read files of any owner. Owners and modes in the claim are not compared.

Image-class paths

A path declared image is in the image that verify reads, so it is compared on every run and takes no flag. verify looks up each entry migrate listed under the path: a file by its sha256, a link by its text, each entry by its numeric owner, and a file or a directory by its mode. No manifest is involved.

StateWhenExit code
verifiedEvery listed entry is in the image as recorded.unchanged
differsAn entry is not in the image, or its bytes, link text, owner, mode or kind is not the recorded one. The reason has the counts and names the first five. An image built without the archive is this, with every entry missing.non-zero
not_checkedAn entry was there and could not be read, the target was read as an SSH collection, or the attestation declares the path and has no list for it: no image key, or no row for the path in image.paths because migrate refused to list it. It is never reported as verified.non-zero
not_checked, nothing listedThe path has a row in image.paths and migrate found no file, directory or link under it, so there is nothing to compare.unchanged

A file in the image that is not on the list does not change the state. That includes every file a package owns. A directory above a listed entry that is a symbolic link in the image is a difference, and what is behind the link is not read as the entry. The report's image_note says what verified means for these paths; note describes volumes.

A released 2.2.0 verify reads an attestation that has image-class data and ignores the list: it gives the same verdict, exit code and content_sha256 whether the image holds the data, holds a changed file, or holds none of it. Use this release's verify for such an attestation.

Probes

A probe is a command you supply whose answer depends on the source's data. vrtmv probe record runs it on the source and keeps the answer. verify --probe-record runs it again in a container and compares the two.

vrtmv verify --attestation out/vrtmv-attestation.json --root /mnt/rebuilt \
  --probe-record probe-record.json --probes probes.json --probe-container web

You start the container, from the rebuilt image and with its volumes mounted, and name it with --probe-container. verify runs podman exec <name> <command> for each probe and does nothing else to it: it does not start, stop or remove a container. It asks the runtime whether the container is running and records the container id and the image id the runtime gives. It does not check that this is the image --root or --image was read from.

The report gets a probes block, with each probe in one of four states. The comparison is byte for byte on standard output.

StateWhenExit code
matchThe command exited 0 and printed the same bytes on the source and in the container.unchanged
differsIt ran in both and the container's exit status is not 0, or the bytes are not the same.1
not_comparedThe source has an answer and the container run gave none: no container was named, it is paused, not running or does not exist, no probes file was given or it does not define the probe, the file defines the probe with another command or timeout_secs than the source ran, the command is not in the container, it timed out.1
not_recordedThe source has no answer (see probe record), or the probes file defines a probe the record does not have. Nothing is run in the container.unchanged

Probes never change the verdict or a class verdict. As with data, "1" means a passing exit (0, 3 or 5) becomes 1. A probe that is not_compared or not_recorded is also listed under unverified by name, with the reason. When the attestation is for a container and no --probe-record is given, unverified lists probe.

For match and differs the block has both answers: exit status, byte count, sha256 of the output and the time, for the source and for the container. The statement is that this probe gave this answer on the source and this answer in the container at those two times. It says nothing about a later moment. The block is inside the signed facts, and the times are part of it, so two runs of the same probes give different content_sha256 values.

The report does not hold the command or its output. The record holds a sha256 of each probe's command and timeout_secs, and a probe is run in the container only when the probes file defines it with the same one; otherwise it is not_compared and the reason names the probe. So a match or a differs is between two runs of one definition. The report itself does not carry that sha256; it carries the record file's. Nothing ties the record file to the attestation.

The reason given for a not_recorded probe is written by this verify from a code in the record. For each probe the report takes from the record its id, and for a recorded probe the time it ran, its exit status, the byte count and the sha256 of what it printed. A record where one of those, or the sha256 of a probe's definition, is not in the form probe record writes is refused before anything runs. The definition's sha256 is compared and not copied. So an id in the report is whatever the record says, within that form: up to 64 letters, digits, -, _ or .. --probe-record cannot be combined with --borrow-from, and this build and later ones do not borrow a report with a probes block for another host. 2.2.0 and earlier do not know the block: given such a report as --borrow-from they borrow it as they would without it, and a probe that failed the exit for the representative does not fail theirs. A host target (--ssh) has no container to run a probe in, and replaying against a host is not built.

The configured entrypoint

When the attestation records an entrypoint (container_target.entrypoint, from migrate --format container) and you name a container with --probe-container, verify also asks the runtime two things and puts the answers in the probes block under entrypoint:

  • image: the Entrypoint and Cmd in the configuration of the image the runtime says the container was created from (<runtime> inspect --type image, by the image id in the block).
  • container: the program and arguments the container was started with (<runtime> inspect --type container, Path and Args).

Each is compared with the attested entrypoint split into words at white space, which is how migrate writes the ENTRYPOINT line.

SideStateWhenExit code
imagesameThe image's Entrypoint is the attested words, in order, and it has no Cmd.unchanged
imagediffersAnything else the runtime could tell: another word, a word more or fewer, the shell form (/bin/sh -c ...), a Cmd beside the right Entrypoint (a runtime appends it), or the attested words as a Cmd with no Entrypoint.1
containersameThe container was started with the attested words and no other.unchanged
containerdiffersIt was started with another command: run --entrypoint, or words after the image name.unchanged
eithernot_comparedThe container is paused, not running or does not exist, or the runtime gave no answer verify reads. Listed under unverified with the reason.unchanged

An image that differs turns a passing exit (0, 3 or 5) into 1, as a probe that differs does. It never changes the verdict or a class verdict. A container started with another command does not change the exit: it is how you start a second process beside the entrypoint's for a probe to ask, and the report says it so that a match is not read as the image's own start. When a side is not same, what the runtime printed for it goes to standard error. The report is signed and holds the state and a count of words, not the runtime's words.

What this does not say. It is about the image the runtime names for that container, and nothing ties that image to the files --root or --image read. It does not say the entrypoint runs or keeps running. Without --probe-container nothing is compared and the unverified entry stays. A container that is not running is not compared, though the runtime could still describe its image.

An image built with migrate --preserve is never equivalent. When everything translated matches and every preserved file is in the image byte-identical, the verdict is preserved-as-is and the exit code is 5. That is not a pass, and no flag maps 5 to 0. It says the preserved files are the source's bytes. It does not say they run.