Vrtmv
Vrtmv reverse-engineers Linux VMs from disk images and produces Ansible roles that rebuild an equivalent workload on a modern target OS — with a signed parity report as output.
Run vrtmv assess centos7.vmdk and get back a complete picture of what is inside. Run vrtmv migrate centos7.vmdk --target rocky9 and get back an Ansible role that rebuilds it on Rocky 9, plus a report attesting that the result is equivalent to the source.
Vrtmv reverse-engineers a workload from wherever it lives: a cold disk image, a running Linux VM, or a legacy bare-metal server. The source does not need to be live, reachable, or bootable — but if it is running, Vrtmv can collect from it directly over SSH, so physical machines and VMs that can't be imaged are in scope too.
What Vrtmv is for
Hypervisor exit and distribution end-of-life (CentOS Linux, EOL June 2024) have turned Linux migration into a board-level programme in regulated enterprises. The tools that move virtual machines competently still deliver the same undocumented, drifted, pre-EOL pet VMs onto the new platform — no infrastructure-as-code, no evidence, no modernisation.
Vrtmv is the codification and attestation layer those tools do not provide:
- Any source. Cold disk images (no SSH, nothing booted), running Linux VMs, and legacy bare-metal servers — the last two collected over SSH. A physical box that predates your virtualization estate is reverse-engineered the same way a VMDK is.
- Offline analysis. For cold images, inventory extraction runs entirely on the customer host with nothing booted.
- Cross-distro translation. CentOS 7 in, Rocky 9 out. Package names, config paths, init-system differences, service accounts, and MAC (SELinux/AppArmor) posture — translated from a curated, hosted translation API.
- Parity attestation. Every output is auditable. Each translation carries provenance and a graded confidence; the report is a signed, timestamped artefact suitable as audit evidence.
- Your workload data stays local. Image contents, configuration, and extracted inventory never leave the customer environment. The only thing sent off-host is package identifiers, looked up against the Vrtmv translation API.
How to read these docs
- Concepts explains the client–server architecture, the Translation Index, and what "attestation" means here.
- Guide walks from install and authentication through your first migration.
- Commands is the reference for every
vrtmvsubcommand. - The Vrtmv API documents the authenticated service the client talks to, including accounts, quota, and billing.
Vrtmv is a commercial product. The curated Translation Index is served, never shipped — it is the asset, kept current centrally and licensed per account. See Accounts, quota & billing.
Architecture
Vrtmv is a client–server system with three parts.
The client — vrtmv
A single binary that runs where the customer's disk images live. It probes a source, extracts an inventory locally, and performs every translation lookup over the network against the authenticated Vrtmv API. It never connects to the translation database directly, and it never sends workload data off-host — only package identifiers.
The client is written in Rust: it parses untrusted disk images, so memory safety is a security property, not a convenience.
The API — vrtmv-api
The only thing that connects to the Translation Index. It is the authentication and account boundary: licensing, metering, and quota all live here. Clients authenticate per account with a bearer token; each lookup is metered. See The Vrtmv API.
The Translation Index
A curated PostgreSQL database mapping packages, services, config paths, service accounts, and MAC policy across distributions, with provenance on every row. It is the product's moat, and it is served, not shipped — never embedded in the client. See The Translation Index.
The pipeline
A migration flows through five stages:
- Probe / source — detect the image format, or open a mounted root or SSH source.
- Inventory — read the distro (
os-release) and installed packages (dpkg / rpm) locally. - Resolve — map native package names to canonical slugs via the API.
- Translate — fetch the target-distro packages, config-path relocations, and conditionals for those canonicals.
- Render — evaluate conditionals against the local inventory and emit an Ansible role plus a JSON attestation.
Stages 1–2 are entirely local. Stages 3–4 send only identifiers to the API. Stage 5 is local again — which is what makes the attestation specific to this host.
Where your data goes
| Data | Leaves the host? |
|---|---|
Image contents, /etc, accounts, keys | No |
| Extracted inventory (package list) | No |
| Package identifiers (names, canonical slugs, release pair) | Yes — to the API, to look up translations |
| Depersonalized VM fingerprint (a one-way hash) | Yes — for metering only |
| Host hint (a one-way hash of the machine's hardware id, see below) | Yes — with usage events, to count the hosts behind one fingerprint |
The hardware id itself (DMI product_uuid, NIC MAC addresses) | No |
The host hint exists because clones of one image share a machine-id, so several real hosts can report under one fingerprint. With each usage event the engine sends a SHA-256 of the host's DMI product_uuid, or, when the machine has none, of the sorted MAC addresses of its physical network interfaces. A placeholder product_uuid (malformed, one repeated digit, or a known firmware default) counts as none. The hash is keyed with the VM's raw identity (the stamp /etc/vrtmv/instance-id, else its machine-id), which never leaves the host, so a hint means something only beside its own fingerprint and cannot be traced back to the hardware id. It is read only from a running host (the local root, or over --ssh); a cold image sends none, and so does a run that cannot read the product_uuid (it is readable by root only). A local run reads it only as root, and only when / is the running system's own root, so a run inside a chroot of an image sends none. A network interface whose address is not its permanent one (a random or assigned MAC) is left out, and a machine with neither a product_uuid nor such an interface sends none. verify sends none, because it reads the target. A run inside a container is not told apart from a run on the machine the container runs on, and sends that machine's hint. Two virtual machines defined with one UUID (a libvirt definition copied to a second hypervisor with its <uuid>) report one hint. It is never a billing key: seats are counted by fingerprint.
Until 2 October 2026 an opt-in beta channel also reported diagnostics; the channel is retired and no build from 2.2.0 on contains the reporting code. See The beta channel (retired).
The Translation Index
The Translation Index is the curated PostgreSQL database that turns "what is installed on this CentOS 7 box" into "what to install, and how to configure it, on Rocky 9". It is what the Vrtmv API serves.
What it covers
- Packages. Canonical packages (a distro-neutral identity, e.g.
cpkg:openssh), the native packages that realise them on each release, and per-(source, target)translations. - Name normalisation & sub-packages. Rename and split/merge relationships across families.
- Repository routing. Which repository a package comes from on the target.
- Config paths. Where a package's configuration moves across a migration (chunk C relocations), including format-incompatible cases surfaced as caveats.
- Service units. systemd unit differences, including instance templates.
- Service accounts. The users and groups a package expects to exist.
- MAC policy. SELinux/AppArmor posture per release.
Conditionals
A translation can carry conditionals: a predicate (from a locked rule vocabulary) plus an effect to apply when the predicate is true on the source. The API resolves each predicate into a full tree and returns it; the client evaluates it locally against the inventory. This is why a translation can adapt to the specific source without the source's state ever leaving the host.
Predicates that a cold image genuinely cannot answer (for example, whether a kernel module is currently loaded) evaluate to unknown and are surfaced as attestation caveats — never silently assumed.
Served, not shipped
The index is never embedded in the client or copied to the customer. It is kept current centrally and served through the authenticated API. This protects the curated asset and is the commercial control point: translations are licensed per account under contract, not distributed as a copyable, drifting local file.
Parity Attestation
Attestation is what separates Vrtmv from a package-renaming script. Every migration produces evidence that an auditor can rely on.
The attestation report
vrtmv migrate writes, alongside the Ansible role, a vrtmv-attestation.json. It records:
- the source and target releases;
- the depersonalized VM fingerprint and the anchors it was derived from (audit transparency);
- each translated canonical, its target packages, and its graded confidence;
- packages that resolved to no canonical, and canonicals with no translation to the target;
- manual runbook steps and caveats from conditionals that fired or could not be evaluated;
- config-path relocations that need operator attention.
Because conditionals are evaluated locally against the real inventory, the report is specific to the host that produced it — not a generic mapping.
Honesty over completeness
The report distinguishes clearly between what Vrtmv translated, what it could not, and what it could not determine. An unevaluable predicate becomes a caveat, not an assumption. A canonical with no vetted translation is reported as untranslated rather than guessed. Silence — the absence of a row — is treated as a truthful "nothing to report", not a gap to paper over.
This discipline is deliberate: a fabricated mapping that looks authoritative is worse than an acknowledged gap, because an auditor may rely on it.
Fingerprints
The VM fingerprint is a one-way hash derived from stable host anchors (such as machine-id, fstab, and boot UUIDs). It lets Vrtmv recognise the same VM across runs — for metering and for linking a maintenance re-scan to its initial migration — without carrying any host identity off the machine.
Signing
When the service has signing enabled, vrtmv migrate also writes vrtmv-attestation.sig.json — a cryptographic signature over the report, produced by Vrtmv's own key rather than a self-signed operator key.
The engine keeps workload data local: it sends only the report's SHA-256 digest and the OS pair to the API, which signs a compact envelope binding that digest to the authenticated account, the moment, and Vrtmv's key id. The signature file carries the signature, the exact bytes it covers, and a how_to_verify note. Vrtmv's public key is published, unauthenticated, at /v1/attest/pubkey.
An audit firm verifies in two steps: recompute the SHA-256 of vrtmv-attestation.json and confirm it matches the digest in the signed envelope; then verify the Ed25519 signature against the published public key. What this attests is provenance — that a Vrtmv-authenticated account produced this exact report at this time, signed by Vrtmv — and it is tamper-evident: any later edit to the report changes its digest and breaks verification.
Provenance & Confidence
Every assertion Vrtmv makes is traceable and graded. These two properties are what make the output audit-defensible.
Provenance
Every row in the Translation Index carries a provenance reference, and every provenance record points to a real, published source — a vendor packaging guideline, an upstream document, an FHS reference, or per-engagement evidence. Bulk imports from packaging guidelines qualify; rows that cannot point at a source do not exist.
Confidence, graded honestly
Translations are graded, and the grade is reported:
| Confidence | Meaning |
|---|---|
high | Vendor-documented or upstream-verified. |
medium | Holds consistently across a family or rebuild relationship, but not yet Vrtmv-validated. |
low | An educated guess. |
untested | The default for any cross-family translation not yet exercised. |
By default the client suppresses untested rows from findings (--include-untested=false). You opt in explicitly when you want to see them.
Why this matters
The alternative — inferred or "this probably works" mappings presented as fact — produces findings worse than no findings, because they carry unearned authority into an audit. Vrtmv's curation discipline forbids creating a rule without verifiable vendor evidence, and grades everything else honestly so a reader always knows how much weight a row can bear.
Installation
vrtmv is a single self-contained binary. It runs on Linux (x86-64) where your disk images or mounted sources live.
From a release build
Download the vrtmv binary for your platform, make it executable, and place it on your PATH:
chmod +x vrtmv
sudo mv vrtmv /usr/local/bin/
vrtmv --version
From source
The client is a standalone Rust crate. With a recent Rust toolchain (1.82+):
git clone <repository-url>
cd engine
cargo build --release
# binary at target/release/vrtmv (or $CARGO_TARGET_DIR/release/vrtmv)
What you need on the host
- Nothing extra for
--root(an already-mounted filesystem) or--sshcollection. - For
--image(mounting a cold disk image), Vrtmv shells out to standard system tools and needs root plus:losetupfor raw images;qemu-nbdand thenbdkernel module for qcow2/vmdk;mount, andlvmtooling for LVM-backed roots.
The block-layer attach is always read-only, so the source image is never modified.
Verify the download
Every release tarball ships a Sigstore bundle and a CycloneDX SBOM. The
instructions inside the tarball (README) are the procedure — cosign verify-blob against the GitHub Actions identity. MCP is a subcommand of this
same binary, so that check is the MCP check too.
Next
Set up your account credentials in Authentication, then either run the Quickstart or register the local MCP server with your agent (mcp).
Authentication
Translation lookups (resolve, translate, config-paths, migrate, inventory --resolve, readiness) authenticate to the Vrtmv API with a per-account bearer token. drift also requires an account (paid entitlement per source fingerprint).
assess needs no account. vrtmv mcp uses this same token — there is no
separate MCP key. Store it as the OS user the agent will run as.
Storing a token
vrtmv auth <token>
This writes the token to a credentials file with owner-only permissions. Show the current status, or clear it:
vrtmv auth # show masked token + stored URL
vrtmv auth --clear # remove stored credentials
To point at a non-default API endpoint, store a URL alongside the token:
vrtmv auth <token> --url https://api.vrtmv.com
Environment variables
Credentials can also come from the environment, which takes precedence over the stored file:
| Variable | Purpose |
|---|---|
VRTMV_API_KEY | Bearer token. |
VRTMV_API_URL | API base URL. |
Transport security
The API base URL must be https://. The client refuses to send your bearer token over cleartext http:// to a non-loopback host — the token is your licensing credential, and it is attached to every request. A local development server on localhost/loopback is allowed over http://, and an explicit VRTMV_ALLOW_INSECURE=1 overrides the check with a warning.
Managing tokens & usage
An account can hold multiple tokens, and each token's usage is metered against the account. See Accounts, quota & billing.
Sources: image, root, SSH
Every extraction command (inventory, drift, migrate, and readiness per line) accepts a source three ways. They are mutually exclusive.
--image <file> — a cold disk image
Vrtmv detects the format from magic bytes and mounts it read-only. Supported formats:
| Format | Notes |
|---|---|
| raw | attached via losetup |
| qcow2 | attached via qemu-nbd |
| vmdk | attached via qemu-nbd |
| OVA | a tar bundle of a VMDK — extract first |
| ploop | needs ploop tools — extract first |
Mounting needs root and the relevant tooling (see Installation). LVM-backed roots are activated automatically; btrfs subvolumes and multi-device/RAID are not yet handled. The attach is read-only at the block layer, so a journal replay can never modify the source.
vrtmv assess <image>detects the format from magic bytes alone — no mounting, no root, no VM access.
--root <dir> — an already-mounted filesystem
Point Vrtmv at a directory that is the root of the source filesystem (a loop mount you manage, a snapshot, a sidecar-attached volume). No privileges beyond read access are required.
A symbolic link in the root is followed inside the root, as the machine it came from would follow it: /var/run -> /run is the root's /run, not the /run of the machine running vrtmv. This holds for --image and --ssh as well. A link written with the path the root is mounted at (/mnt/rebuild/opt/job) leads nowhere and is read as that.
--ssh user@host — a running Linux VM or bare-metal server
Collect state from a live host over SSH into a local bundle, then analyse it exactly as if it were an image. This is how Vrtmv reverse-engineers workloads that can't be imaged:
- Running Linux VMs — a live guest you can't or don't want to snapshot.
- Legacy bare-metal servers — physical machines that predate the virtualization estate, with no disk image to hand. Vrtmv treats them as first-class migration sources: collect over SSH, translate, and rebuild the workload on a modern VM or target OS.
Options:
| Flag | Purpose |
|---|---|
--ssh-port <n> | Non-default SSH port. |
--ssh-key <file> | Identity file (otherwise agent / ssh_config). |
--jump user@bastion | ProxyJump / bastion. |
--sudo | Wrap the remote read in sudo (needs passwordless sudo). |
Host and jump arguments are validated against argument-injection before any process is spawned.
Which to use
Cold image (--image) is the most audit-defensible: the source is never booted and cannot change during analysis. --root is operationally simplest when you already have the filesystem mounted. --ssh is for running hosts you cannot take offline.
Quickstart
This walks from a cold CentOS 7 image to a migration plan for Rocky 9.
1. Look inside the image
No account or mounting required — this reads magic bytes:
vrtmv assess centos7.vmdk
2. Extract the inventory
vrtmv inventory --image centos7.vmdk
# add --resolve to map packages to canonicals via the API
vrtmv inventory --image centos7.vmdk --resolve
3. Classify the configuration (needs an account)
vrtmv drift --image centos7.vmdk
drift separates /etc into what to carry (real workload config), what to hold back as a caveat (host identity, accounts, SSH keys), what is vendor-default, and what to skip (secrets, generated noise).
4. See feasibility before committing
vrtmv migrate --image centos7.vmdk --target rocky9 --preflight
--preflight reports counts and blocking steps, writes no files, and consumes no usage credit.
5. Produce the migration plan
vrtmv migrate --image centos7.vmdk --target rocky9 -o out/
This writes:
out/roles/vrtmv_migration/tasks/main.yml— the Ansible role that rebuilds the workload on Rocky 9;out/vrtmv-attestation.json— the signed, host-specific parity report.
Commands
vrtmv is a single binary with these subcommands.
Bills? shows whether the command claims a paid instance seat. The billable seat kinds are migration, verify, and license — the last is vrtmv license/enroll, the lightweight way to claim a seat without a full run (a seat is 402-gated quota, not a metered charge). drift and cve-scan are license-gated and never metered: drift is refused on a VM not already licensed by a migration, verify, or license, while cve-scan auto-claims a seat when the account has capacity (VRTMV-620) and is refused only when capacity is exhausted. See Accounts, quota & billing.
| Command | Account? | Bills? | What it does |
|---|---|---|---|
assess | no | no | Detect a disk image's source format from magic bytes. |
inventory | optional | no | Extract distro + installed packages from a source. |
drift | yes | no (license) | Classify /etc into carry / caveat / default / skipped; derive service accounts. |
resolve | yes | no | Map native package names to canonical slugs. |
translate | yes | no | Fetch target-distro packages for canonicals. |
config-paths | yes | no | Fetch config-path relocations for canonicals. |
migrate | yes | yes (full run) | Full pipeline → runbook (--format) + signed attestation. Plan-only — never executes. |
verify | for --sign | yes (seat) | Prove a rebuilt target matches the attestation migrate wrote. |
license | yes | yes (seat) | License (seat) one or more hosts by fingerprint alone — no translation or attestation. Alias enroll. |
identity | no | no | Write the operator instance stamp /etc/vrtmv/instance-id, so a cloned host has its own billing identity. |
cve-scan | yes | no (license) | Match installed packages against vendor advisories; report still-vulnerable CVEs, signed. |
patch | yes | no | Render (or --apply over SSH) a role bumping vulnerable packages to their vendor-fixed version. |
run | yes | yes | Unattended migration: plan → apply the role over real SSH → carry config → verify. |
deploy | no | no | Deploy the workload to OpenShift/Kubernetes (VM or container). |
readiness | yes | no | Fleet migration-readiness report across many sources. |
remediate | no | no | Record accept decisions for readiness refusal clusters into a resolutions store. |
cluster-migrate | for --emit-roles | yes (--emit-roles) | Cluster sources by similarity; per-host variance report, optionally a role per cluster. |
cluster-verify | for --sign | yes (seat) | Verify a rebuilt fleet once per class, then borrow the verdict (alias verify-fleet). |
coupled-migrate | yes | yes | Migrate N coupled nodes (replicated / quorum DB) as one subject. |
container-fleet | yes | yes (once per host) | One Containerfile per workload across a fleet, from one manifest. |
ui | — | no | Local migrate client (loopback only). |
panel | — | no | Local operator panel: assess → plan → verify (loopback only; never applies). |
mcp | yes | via migrate | Local MCP server on stdio. vrtmv mcp --print-config prints the agent fragment. |
auth | — | no | Store / show / clear API credentials. |
docs / help | — | no | Bundled documentation. |
resolve, translate, and config-paths are direct windows onto the API — mainly for scripting and debugging. Most users run assess, drift, migrate, verify, and readiness.
migrate output formats
migrate --format <list> selects one or more runbook formats (comma-separated; default ansible). Every non-preflight run also writes the signed attestation regardless of format. See migrate for the full behavior.
--format | Emits | Requires | Notes |
|---|---|---|---|
ansible (default) | roles/vrtmv_migration/tasks/main.yml | — | Installs the translated packages and reconciles config. |
terraform | terraform/main.tf + terraform/cloud-init.yaml | — | Workload as cloud-init attached to a compute resource. |
container | container/Containerfile | --entrypoint, an Index base image | Buildable OCI image; operator runs podman build. Refuses rather than guess. |
image | image/ (kickstart or preseed + build-image.sh) | --target-base, --firmware | qcow2 build recipe for a fresh bootable target (rpm/dpkg families). Refuses rather than guess. |
Conventions
- Extraction commands take a source as
--image,--root, or--ssh(see Sources). -o/--outsets an output directory where relevant.- Local-only commands (
assess) never call the API and need no account.driftandcve-scanare license-gated and never metered —driftis refused on a VM not already licensed by amigrationorverify, whilecve-scanauto-claims a seat when the account has capacity.ui/panelthemselves do not bill; amigratethey launch records the same usage event as the CLI.
assess
Detect a disk image's source format from its magic bytes. No mounting, no root, no account. Records no usage event and does not bill — the free top-of-funnel; a prospect can run it before paying.
vrtmv assess <image>
| Argument | Purpose |
|---|---|
<image> | Path to the disk image (VMDK, qcow2, OVA, ploop, raw). |
assess is the fastest way to confirm Vrtmv recognises a source before you invest in mounting or migrating it. It reads only the leading bytes of the file, so it works on very large images without cost.
Recognised formats: vmdk, qcow2, ova, ploop, raw. See Sources for which of these can be mounted directly (--image) versus extracted first.
inventory
Extract the distribution and installed packages from a source. Extraction is entirely local; add --resolve to also map packages to canonicals via the API. Does not record a billable usage event: inventory is absent from /v1/usage (local, or the free /v1/resolve lookup).
vrtmv inventory --image centos7.vmdk
vrtmv inventory --root /mnt/vm
vrtmv inventory --ssh user@host --sudo
vrtmv inventory --image centos7.vmdk --resolve
| Flag | Purpose |
|---|---|
--root / --image / --ssh | The source (see Sources). |
--ssh-port, --ssh-key, --jump, --sudo | SSH collection options. |
--resolve | Also resolve installed packages to canonical slugs via the API (needs an account). |
What it reads
- The distribution and version from
/etc/os-release(falling back to/usr/lib/os-release). - Installed packages from the dpkg status database (Debian family) or the rpm database (EL family — sqlite, ndb, or BerkeleyDB, auto-detected).
Without --resolve, inventory is a purely local report and needs no account. With --resolve, only package identifiers are sent to the API.
resolve
Map native package names to canonical slugs for a given release, directly against the API. Mainly for scripting and debugging; migrate does this for you as part of the pipeline.
vrtmv resolve --distro "CentOS Linux" --version 7 \
--name openssh-server --name httpd
| Flag | Purpose |
|---|---|
--distro | Source distribution, as the index names it (e.g. CentOS Linux). |
--version | Source version (e.g. 7). |
--name | A native package name. Repeatable; at least one is required. |
Names that resolve are returned with their canonical slug (e.g. cpkg:openssh); names with no mapping are returned as unresolved. Each call is counted against the account request log. That is not a billable usage event: the billable seat kinds are migration and verify.
translate
Fetch the current translation for canonical packages, from a source release to a target release. Direct API access for scripting and debugging.
vrtmv translate \
--source-distro "CentOS Linux" --source-version 7 \
--target-distro "Rocky Linux" --target-version 9 \
--slug cpkg:openssh --slug cpkg:httpd
| Flag | Purpose |
|---|---|
--source-distro, --source-version | The source release. |
--target-distro, --target-version | The target release. |
--slug | A canonical slug (e.g. cpkg:httpd). Repeatable; at least one is required. |
Each translation comes back with its target packages, a graded confidence, any caveats, and any conditionals (predicate trees the client evaluates locally). A canonical with no vetted translation for this pair simply has no result — silence is the correct signal. Does not record a billable usage event: the billable seat kinds are migration and verify.
config-paths
Fetch config-path relocation rules for canonical packages, source release → target release. Direct API access; migrate applies these automatically. Does not record a billable usage event — an Index lookup, not POST /v1/usage. Only usage kind migration is billable.
vrtmv config-paths \
--source-distro "CentOS Linux" --source-version 7 \
--target-distro "Rocky Linux" --target-version 9 \
--slug cpkg:httpd
| Flag | Purpose |
|---|---|
--source-distro, --source-version | The source release. |
--target-distro, --target-version | The target release. |
--slug | A canonical slug. Repeatable; at least one is required. |
Config-path rules describe where a package's configuration moves across a migration — for example, a default file that relocates, or a directory that changes name. A rule marked format-incompatible is surfaced as a caveat rather than silently applied. Most canonicals return no rule, because most configuration does not move; that absence is expected, not an error.
drift
Classify /etc into the files that actually encode this workload versus vendor-shipped defaults and host-specific noise. Requires a Vrtmv account. drift claims no seat and is not billable; it is license-gated: it runs free on a VM already licensed by a prior migration, verify, license/enroll, or cve-scan, and is refused with 402 UNLICENSED_VM on a VM that has never been seated. The billable seat kinds are migration and verify; drift and cve-scan are the license-gated kinds. The two differ on an unlicensed VM: cve-scan auto-claims a seat when the account has capacity (VRTMV-620), whereas drift requires the VM to already be licensed.
vrtmv drift --image centos7.vmdk
vrtmv drift --root /mnt/vm --json
vrtmv drift --ssh user@host --sudo --preserve-ssh
| Flag | Purpose |
|---|---|
--root / --image / --ssh | The source (see Sources). |
--ssh-port, --ssh-key, --jump, --sudo | SSH collection options. |
--preserve-ssh | Carry host SSH keys (public and private) instead of holding them back as a caveat. Off by default. |
--json | Emit the full classification as JSON instead of a text summary. |
The four lanes
| Lane | Meaning |
|---|---|
| carry | Workload configuration to reproduce on the target. |
| caveat | Host identity, accounts, and SSH keys — acknowledged, not blindly carried. |
| default | Vendor-shipped and unmodified — nothing to do. |
| skipped | Secrets, generated files, and noise — deliberately not read or carried. |
Modification is decided by checksum against the package's own recorded baseline (dpkg .md5sums and conffiles; the rpm backend reconstructs file ownership). Symlinks are never followed, /etc/shadow and secrets are skipped unread, and reads are size-bounded.
Service accounts
drift also derives the service accounts a workload depends on — from file ownership and from systemd User=/Group= directives — and harvests unit enablement, masks, and operator-authored units. Where an owner cannot be resolved, that gap is reported rather than guessed.
data
Migrate the payload — the application data that no package owns and translation therefore cannot carry: uploads, document roots, /opt application trees, database files. Package translation reproduces the operating system; data handles what the workload exists to serve. It runs entirely locally, calls no API, does not bill, and never moves a byte itself — it emits runbooks the operator runs.
Two phases:
# 1. discover — what non-distro data is on the source, and how to move it
vrtmv data discover --image centos7.vmdk
vrtmv data discover --root /mnt/vm --json
# 2. plan — emit runbooks for the paths you choose
vrtmv data plan --root /mnt/vm \
--copy /srv/www --copy /var/lib/mysql -o vrtmv-data-out
discover
Walks the source's data roots (/var, /opt, /srv, /home, /usr/local, …; never /etc — that's drift's job), asks the packaging backend "does any package own this?" for every file, and groups everything unowned into candidate locations. Volatile and cache trees (/var/cache, /var/log, /tmp, the package DBs themselves) are pruned; symlinks are never followed. Spool directories under /var/spool are listed, apart from the crontab directories in /var/spool/cron and the at queue in /var/spool/at, which the scheduled-jobs check reports.
Each location is classified and given a recommendation:
| Recommendation | Meaning |
|---|---|
| byte-copy | Plain data — safe to copy byte-for-byte, and attestable. |
db-export (engine) | A database data directory — its on-disk files must not be raw-copied across versions or engines. Routed to a logical export/import instead. |
| spool: … | A directory under /var/spool holding files no package owns. What follows says what the files are, see below. In --json its kind is spool. |
Not every file in a spool is queued work. A Debian or Ubuntu postfix keeps a chroot in /var/spool/postfix (etc/hosts, certificates, libraries), anacron keeps timestamps in /var/spool/anacron, and on the Red Hat family /var/spool/mail holds delivered mailboxes. So a spool row says one of three things about its files:
queued: <size> in <n> file(s): files in the queue of a layoutdiscoverknows. This is work in flight, and the choice is yours: drain it on the source before cutover, or carry it by naming the spool with--copy.queued: none foundmeans the queue directories were read and held no file no package owns. If part of the spool was not read it saysqueued: not knowninstead, and if some queued files were counted and another part was not read it saysqueued: at least <size> in <n> file(s). A queue directory that is a symbolic link, or a file of that name, counts as not read:discoverdoes not follow links, so what is behind one is not counted as this spool's queue even when the link points into a tree that is walked.- A description of the rest, such as
service files, not queued mailornot a queue: delivered mailboxes, with its size and file count. Draining the queue does not change this figure. not classified: <size> in <n> file(s): the spool's layout is not onediscoverknows, so it does not say whether the files are queued work. Look at the directory.
The layouts told apart are postfix (queue: active, bounce, corrupt, defer, deferred, hold, incoming, maildrop, saved, trace), exim4 and exim (queue: input, msglog), cups (queue: the c<number> and d<number>-<number> files directly in the directory), and two that hold no queue, anacron and mail. A directory with one of the first three names that holds none of its queue directories is not classified. Every other name under /var/spool is not classified. In --json a classified spool has layout, queued_files and queued_bytes; a spool with no layout key is not classified, and its two counts are 0 without meaning that nothing is queued.
A spool that could not be read is listed at the top as NOT READ with the reason, and in --json under not_read. That covers a spool directory the user cannot list, a spool that is a link into a tree discover does not walk, a link at or inside a queue directory of a known layout wherever it points, and any --ssh source, where /var/spool is not collected. It is not a report that the spool is empty. Unreadable directories outside /var/spool are only counted, in skipped.
LOCATION SIZE FILES RECOMMENDED
/var/lib/mysql 8.0 KiB 1 db-export (mysql)
/home/appuser/uploads 4.0 KiB 1 byte-copy
/srv/www/site 14 B 1 byte-copy
plan
Takes the paths you select with --copy (repeatable) and writes four artifacts to -o <out>:
| Artifact | What it is |
|---|---|
vrtmv-data-copy.sh | The byte-copy runbook — rsync -aHAX --numeric-ids per data path. |
vrtmv-data-manifest.sha256 | Per-file sha256 of the source, sha256sum -c-compatible — the byte-parity attestation. |
vrtmv-db-runbook.md | Step-by-step export/import commands for each detected database, with vendor references. |
vrtmv-data-attestation.json | The machine-readable attestation record (paths, hashes, verify verdicts). |
A path that matches a database signature is always routed to the DB runbook, never to a raw copy — even if you pass it to --copy.
Attestation
The manifest proves the copy is byte-identical. After copying, on the target:
sha256sum -c vrtmv-data-manifest.sha256 # every line must say OK
Optionally, point the tool at a destination tree and it re-hashes it for you, marking each file match / differ / missing / unreadable in the attestation JSON (it still never performs the copy or the dump):
vrtmv data plan --root /mnt/vm --copy /srv/www --verify-dest /mnt/target/srv/www -o out
The exit code is the answer. It is 0 only when at least one file was compared, every compared file matches, and every --copy path was compared. A missing or differing file, a file that could not be read, or a --copy path that was not compared (a database that is exported, a path that is not on the source or has no files there) exits 1, and stderr has one line with the counts. That holds when another path in the same run matches in full: the line then reads NOT CHECKED in full and names the path that was left out, so a mistyped --copy is not a pass. An empty destination is the case this is for: every file is missing and the exit is 1. A destination that cannot be listed is refused before anything is compared, and is not reported as empty. A link below the destination is not followed: a file whose path passes through a link is differ, as it is for verify --data-dir. The plan is still written when the exit is 1.
--verify-dest names the directory that holds the content of the one --copy path, as above. For a --copy path that is one file, give the copy of the file, or the directory the copy is in, where it is looked up by its name: --copy /etc/motd --verify-dest /mnt/target/etc/motd and --verify-dest /mnt/target/etc say the same. Given the copy itself, only its bytes are compared: its name is yours to choose and is not checked, so --verify-dest /backup/motd.copy verifies too. Given the directory, the name is what finds it. A file is accepted as the destination of one --copy path that is a file on the source and is refused for anything else. With --dest-dir /data/vol the copy of /srv/www is at /data/vol/srv/www, so that is the directory to give. With more than one --copy every path is looked up directly under the same directory, which is rarely what was copied. Two --copy paths that are files of the same name (/srv/a/x.txt and /srv/b/x.txt) would both be x.txt in that directory, where one copy would pass for both, so that is refused: the error names both files, nothing is compared and nothing is written. The same collision between a file and a file inside a --copy directory, or between files of two --copy directories, is not detected. Verify one path per run, or use verify --data-dir, which takes a directory per path.
--verify-dest refuses a destination that has a directory above it which an account other than root and the one running vrtmv can write to. That account can rename what is in the directory, so it can put another tree where the destination was while it is being read. The error names the directory, its owner and its mode, nothing is compared and nothing is written. What counts: an owner that is neither root nor you, or a group or world write bit. A sticky directory such as /tmp is passed as a parent, because there only the owner of an entry can rename it. A link on the way is followed, and its owner and the directories it leads through are judged the same. A directory above the destination that cannot be looked at is refused too: it is not taken as one nobody else can write to. The destination itself is not judged. A copy that kept its owners belongs to whoever owned the source, so its owner says nothing about the copy; that owner can still change the files in it. A group write bit is counted whoever is in the group, so a directory with mode 775 and a group only you are in is refused.
--allow-shared-dest compares anyway. The comparison's line on stderr is followed by one that starts SHARED and repeats what the refusal would have said, and the attestation records allow_shared_dest: true and the same text as verify_dest_shared. Neither field is written by a run without the flag, and verify_dest_shared is written only when there was something to let through.
A local directory as the destination
By default the copy script sends each path to $TARGET:<path> over SSH. With --dest-dir <dir> it fills a directory on the machine the script runs on instead, which is the form to use when the data is going into a volume for a container built by migrate --format container:
vrtmv data plan --root /mnt/vm --copy /srv/www --copy /var/lib/app --dest-dir /data/vol -o out
SOURCE_ROOT=/mnt/vm bash out/vrtmv-data-copy.sh
podman run -v /data/vol/srv/www:/srv/www -v /data/vol/var/lib/app:/var/lib/app ...
data plan does not create or read the directory. The script does the work when you run it, with SOURCE_ROOT set to the source root (/ on the source host itself). It places each path at <dir><path>, keeps owners and modes, and compares the copy with vrtmv-data-manifest.sha256, which it expects beside it. Run it as root unless every file belongs to the user running it.
The script refuses a destination that is a symbolic link or that already holds anything, and leaves it untouched.
It also refuses a destination that an account other than root and the one running the script can write to, and one that has such a directory above it, by the rule --verify-dest uses, here with the destination itself judged as well. It names the directory, its owner and its mode. The script stages in <dir>/.vrtmv-partial, and an account that can write to <dir> can rename that directory away and put a link in its place while the script copies: measured as root, a path was copied through such a link into a directory that account could not write to. A directory above the destination that the script cannot look at is refused, not taken as safe. The script looks twice: before it makes the destination, which may be absent then, and again once the destination is there. In a sticky directory such as /var/tmp any account can make a name that is absent, so a destination another account made between the two looks is refused at the second, and so is one that is gone again by then. A destination the script makes has no group or world write bit whatever the umask is. The check is made by the script when it runs, on the machine it runs on; data plan does not look at the directory. A plan made with --allow-shared-dest writes a script that goes on instead. It says what it went past before it writes anything and again on its last line, and the attestation records allow_shared_dest: true. The flag does not make the copy safe: the script has no defence against the swap other than refusing the directory.
If any file cannot be placed, it names each one, removes what it copied and exits 1. That covers a file that is gone from the source or changed since the plan, a file added to the source since the plan, and an owner or mode that was not kept. The last line of a failed run says what is in the destination after the cleanup: nothing, or the names that are still there, which can be names the script did not make. It checks the owner, group, mode and type of every entry and the sha256 of every file. It does not check timestamps, ACLs, extended attributes or hard links.
A database is never copied by this script. Its section in vrtmv-db-runbook.md names the directory (<dir><path>) that the import has to fill. With --source-offline, on an engine where a cold copy is allowed, the database's directory is copied by the script like any other path, under the same warning lines as in the SSH form.
With --dest-dir, data plan refuses a set of --copy paths in which one is under another or one is given twice, and names the pair. Give the outer path only. The script holds the same rule on its own: it makes every directory between the destination and a copy itself, and stops at a symbolic link or at a path that an earlier copy already placed, so that no copy is made through a link from the source to somewhere outside the destination.
A file whose name contains a newline cannot be placed. The script fails by name and leaves nothing.
One plan fills one empty directory, once. A Kubernetes claim is not a destination yet.
Named podman volumes as the destination
With --dest-volume <path>=<volume>, given once for every --copy path that is copied, the script creates one named podman volume per path and fills it. Use it when the container is to mount volumes by name and not directories:
vrtmv data plan --root /mnt/vm --copy /srv/www --copy /var/lib/app \
--dest-volume /srv/www=www --dest-volume /var/lib/app=appdata -o out
SOURCE_ROOT=/mnt/vm HELPER_IMAGE=localhost/myapp:1 bash out/vrtmv-data-copy.sh
podman run -v www:/srv/www -v appdata:/var/lib/app ...
data plan creates nothing and does not run podman. The script does, when you run it, on a machine that has podman and the source root. A volume is not a directory the script can copy into from outside, so the copy is made inside a helper container: the source path is mounted read-only, the volume read-write, the container has no network, and the copy and the checks are done by the image's own cp, find and sha256sum. HELPER_IMAGE names that image. It must already be in podman's local storage, since the script pulls nothing, and it needs /bin/sh, GNU coreutils and GNU findutils. The image you built from the migration's Containerfile is the usual choice. The script looks the name up once and runs the image by its id.
The top of each volume is the content of its path: /srv/www/index.html is index.html in the volume www, and the volume's top directory gets the owner and mode of /srv/www. The script makes the same checks as the local-directory form, on the same list: owner, group, mode and type of every entry, and the sha256 of every file.
Each volume must not exist: the script creates the volumes it fills and refuses, before it creates any, if one of them is already there. If any file cannot be placed it names each one, removes every volume it created in that run, the ones that filled correctly included, and exits 1. A helper container that ends without its last line (it was killed, or the image has no shell) is such a failure. A helper image without GNU find is one too.
Run the script as root unless every file belongs to the user running it. Rootless podman maps only its own user and its subordinate ids into the container, so a file owned by anyone else cannot be given its owner, and the script fails on it by name.
A database the plan exports gets no volume from the script. --dest-volume is still accepted for its path, and its section in vrtmv-db-runbook.md then names the volume the import has to fill. --dest-volume is refused for a path that is not one of the --copy paths, for a path the plan found missing or could not measure, for a --copy path that is a single file, when a copied path has no volume, when two paths name one volume, and when one copied path is under another. It cannot be combined with --dest-dir.
To check a volume afterwards, see verify --data-volume.
Kubernetes claims as the destination
With --dest-claim <path>=<claim>, given once for every --copy path that is copied, the script packs what an init container in the cluster will fetch:
vrtmv data plan --root /mnt/source --copy /srv/www --copy /var/lib/app \
--dest-claim /srv/www=www-data --dest-claim /var/lib/app=app-data -o out
SOURCE_ROOT=/mnt/source bash out/vrtmv-data-copy.sh
data plan writes out/claims/<claim>.sha256 for each copied path: the sha256 and name of every file under it, as sha256sum -c reads them. The script checks every file of the source against that list, then packs out/claims/<claim>.tar with GNU tar, keeping numeric owners, modes, links and empty directories. Set ARCHIVE_DIR to pack somewhere else; the list is copied beside the archive. If the source has changed since the plan, or holds a file the plan does not list, the script names the path, removes every archive it wrote in that run and exits 1.
Nothing is uploaded and nothing in a cluster is touched. Upload each .tar and .sha256 to one place in an S3-compatible bucket, then give that place to vrtmv deploy --kind container --data-source-url, which renders the claim and the init container that fills and checks it. The claim's name is the name of its two files, so use the same name in both commands.
A database the plan exports gets no archive. --dest-claim is still accepted for its path, and its section in vrtmv-db-runbook.md then names the claim. --dest-claim is refused for the same cases as --dest-volume, and for a file name with a control character in it. A claim's name is lowercase letters, digits and -. It cannot be combined with --dest-dir or --dest-volume.
Not covered: a volume that already exists (one you created with a driver or mount options of your own), timestamps, ACLs, extended attributes, and rootless podman with files of more than one owner. A Kubernetes claim is not a destination yet.
Databases
Detected by their vendor-documented on-disk data directory (and, for relocated dirs, a distinctive marker file). The runbook emits the supported dump/restore for each:
| Engine | Export → Import |
|---|---|
| PostgreSQL | pg_dumpall → psql |
| MySQL / MariaDB | mysqldump --all-databases → mysql |
| MongoDB | mongodump → mongorestore |
| Redis | redis-cli SAVE + copy dump.rdb |
| Elasticsearch | snapshot → restore |
| InfluxDB | influx backup → influx restore |
| Cassandra | nodetool snapshot → sstableloader |
| etcd | etcdctl snapshot save → restore |
Every command is a real vendor-documented procedure, cited in the emitted runbook — Vrtmv surfaces the steps; the operator runs them.
| Flag | Purpose |
|---|---|
--root / --image / --ssh | The source (see Sources). |
--copy <path> | A guest path to include (repeatable). |
--verify-dest <dir> | Re-hash this destination tree to attest byte-parity locally. For a --copy of one file, the copy of the file or the directory it is in. Refused for two --copy files of the same name, and below a directory another account can write to. Exits 1 unless every compared file matches and every --copy path was compared. |
--dest-dir <dir> | Write the copy script for a local directory instead of $TARGET over SSH. |
--allow-shared-dest | Go on with a --verify-dest or --dest-dir destination that another account can write to or above. Refused without it. Recorded in the attestation. Needs one of the two. |
--dest-volume <path>=<volume> | Write the copy script for named podman volumes, one per copied path. Repeatable. |
--dest-claim <path>=<claim> | Write the copy script and the lists of hashes for Kubernetes claims, one per copied path. Repeatable. |
-o, --out <dir> | Output directory for the runbooks + manifest. |
--json | Also print the attestation JSON to stdout. |
probe
Ask the source a question whose answer depends on its data, and keep the answer so that verify can ask a container the same question and compare. A container that starts on an empty data directory is running, built from the right image and passes every static check. A question such as "how many keys does Redis hold" or "what does GET / return" is what tells it from a correct one. vrtmv cannot work that question out for your application, so you supply it as a command.
It runs locally, calls no API and does not bill.
# 1. on the source, while it is running
vrtmv probe record --probes probes.json --ssh root@source -o probe-record.json
# 2. later, against the container you started from the rebuilt image
vrtmv verify --attestation out/vrtmv-attestation.json --root /mnt/rebuilt \
--probe-record probe-record.json --probes probes.json --probe-container web
The probes file
{"probes": [
{"id": "keys", "description": "how many keys Redis holds", "command": ["redis-cli", "DBSIZE"]},
{"id": "home", "command": ["sh", "-c", "curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1/"], "timeout_secs": 10}
]}
id is 1 to 64 letters, digits, -, _ or ., and names the probe in every report. command is an argument list, run as given: there is no shell unless you name one. timeout_secs defaults to 30 and is from 1 to 3600. description is for you and is not kept. A file with no probe, a repeated id, an empty command or a timeout outside 1 to 3600 (0 included) is refused before anything runs.
The comparison verify makes is byte for byte on what the command prints to standard output. vrtmv does no trimming, no pattern matching and no tolerance. So make each command print only what should be the same on the source and in the container: a count, a status code, a checksum of a page. A probe that prints a timestamp, a process id, an uptime or a hostname will differ every time.
A definition that names a credential is not run: passing a credential to the source or to a container is not built. If a probe needs to log in, arrange that on the source and in the container (a client configuration file, for example) and keep it out of command.
record
| Flag | Purpose |
|---|---|
--probes <path> | The probes file. |
--ssh <user@host> | The running source host. --ssh-port, --ssh-key and --jump as for other commands. |
--container <name> | A source that is itself a running container. Probes run through podman exec. |
--runtime <podman|docker> | The runtime for --container. Default podman. |
--root <dir>, --image <file> | A source that is not running. Nothing is run; see below. |
-o, --out <path> | Where to write the probe record. |
--json | Print the record as JSON instead of a text summary. |
Each probe is run once on the source. An answer is recorded only when the command exits 0 and prints something. Every other outcome is written as not_recorded with the reason:
- the command exited non-zero, or printed nothing;
- it could not be run: the binary is not there (exit 127), cannot be executed (126),
sshcould not connect (255), or the runtime could not exec in the container (125); - it did not finish within its timeout, was killed by a signal, or printed more than 1 MiB. Over
--sshthe timeout covers connecting and logging in as well as the command, and the reason says so: a host that does not answer, or an SSH agent that does not answer, uses all of it before the command starts.sshasks an agent named bySSH_AUTH_SOCKfirst even when--ssh-keyis given; - the source is a mounted root or an image. Those are not running, so nothing there can answer, and the command is not run anywhere.
A probe that fails on the source is not recorded on purpose. If its failure were kept as the answer, the same failure in the container would be a match.
record exits 0 when every probe was recorded and 1 otherwise. The record is written either way.
What the command prints to standard error goes to your terminal and nowhere else.
What the record holds
For each probe: its id, a sha256 of its definition, and either the answer or the reason there is none. An answer is the exit status, the number of bytes printed, their sha256, and the time. A reason is a code, with the exit status or the timeout where there is one ({"code": "exited", "exit": 4}); the words you see for it are written by the vrtmv that prints them. The record does not hold the command, its arguments, or what it printed. That is deliberate: a command line can carry a password and the output can be customer data, and the record is a file you hand to someone else.
Two things in the record can be guessed at by someone who holds it:
- The sha256 and the byte count of the output do not hide a short answer. A key count or a status code can be found by trying the likely values.
- The sha256 of the definition is over the command, word for word, and
timeout_secs. It does not hide a command either: someone who can guess the whole command line can confirm the guess, and if the only part they do not know is a short password written into it, they can try passwords. Keep credentials out ofcommand.
verify reads the commands from the probes file again, and runs a probe in the container only when the file defines it with the same command and the same timeout_secs as when the record was taken. A probe whose definition changed is not_compared, with a reason that names it. id and description are not part of the definition's sha256.
verify refuses a record it cannot hold to this form: an id that is not a probe id, a reason that is not one of the codes, a time or a sha256 that is not shaped like one, a record from --root or --image with an answer in it, or a record with no definition sha256 (one written before this was added; take it again).
migrate
The full pipeline: inventory → resolve → translate → evaluate conditionals → emit an Ansible role plus a JSON attestation.
vrtmv migrate --image centos7.vmdk --target rocky9 -o out/
vrtmv migrate --root /mnt/vm --target rhel9 --preflight
vrtmv migrate --image leap.qcow2 --target leap15 --format image \
--target-base https://download.opensuse.org/.../install --firmware uefi -o out/
| Flag | Purpose |
|---|---|
--root / --image / --ssh | The source (see Sources). |
--ssh-port, --ssh-key, --jump, --sudo | SSH collection options. |
--target <spec> | Target OS (default rocky9). See Supported targets. |
-o, --out <dir> | Output directory (default vrtmv-out). |
--format <list> | Output format(s): any of ansible, terraform, container, image (comma-separated; default ansible). |
--entrypoint <cmd> | Required by --format container: the one command the image runs (vrtmv never chooses it). Split on whitespace; quoting inside the value is not interpreted. |
--entrypoint-unit <unit> | For --format container: the unit on the source that --entrypoint stands for, as you assert it. Must be one of the source's enabled units, or the run stops with nothing written. vrtmv does not derive it; it notes on stderr when the unit's ExecStart runs another program than the entrypoint. The attestation records it and names every other unit the plan would have enabled as not started. See Enabled units. |
--chart-name <name> | For --format container: the name of the Helm chart written beside the Containerfile, under container/chart/<name>/. Lowercase letters, digits and -, starting and ending with a letter or digit, at most 53 characters; anything else is an error. Without the flag the name is made from the file name of the entrypoint's program (/usr/sbin/nginx gives nginx), and when none can be made the run stops before the source is read and asks for the flag. It names the chart's directory and nothing in the cluster. Without --format container the flag is an error. See The Helm chart. |
--chart-image <image> | For --format container: the image the Helm chart runs, written into its values.yaml, as <repository>:<tag> or <repository>@sha256:<digest>. A reference with neither a tag nor a digest is an error. vrtmv builds no image and pushes none; without the flag the chart names no image and does not render until a values file gives one. Without --format container the flag is an error. |
--preserve <path> | For --format container, repeatable: carry this path into the image unchanged, with the libraries it loads. For software the target has no package for. A path the target carries a package for is refused, and without --format container the flag is an error. The image is graded preserved-as-is by verify, never equivalent. vrtmv never sets it; the untranslatable-software check prints the arguments a source has. See Containerize as-is. |
--bake-secret <path> | For --format container, repeatable, one path per flag: copy this file into the image although vrtmv classed it as secret material or could not classify it. Without the flag such a file is not added to the image. Anyone who can pull the image can read a file copied this way. The attestation lists each named path under container_secrets.baked_in. A path that was not classed secret or unclassified on this source is an error that lists the paths the flag could name, and without --format container the flag is an error. See Secret material. |
--data <path>=<class> | For --format container, repeatable: where a data directory of the source goes. volume writes a VOLUME line and the volume is filled outside the build; disposable means not carried. A declaration covers everything under its path and the most specific one decides. vrtmv never picks a class: while a directory data discover lists has none, no Containerfile is written and the run lists them. image copies the path into the image: its files are listed and hashed, the script written beside the Containerfile stages them, and verify checks them in the built image. A database directory is never image-class. Without --format container the flag is an error. See Data directories. |
--assume-undetermined <check> | For --format container: proceed although the named feasibility check (kernel-modules, entrypoint, local-state, scheduled-jobs; comma-separated) could not be determined. untranslatable-software is accepted as a name and always has nothing to assume, because that check never reports itself as not determined. The attestation keeps the check as not determined and marks it assumed by the operator, and verify names it in its report. Never overrides a blocking finding, and records nothing on a source that is blocked. Without --format container it does nothing and says so on stderr. Naming a check that did answer changes nothing and prints a note on stderr naming it (--assume-undetermined scheduled-jobs had nothing to assume: the check was determined), so one flag works across a fleet and a stale one is visible. An unknown check name is an error. |
--no-base-digest | For --format container: write the Containerfile's FROM with the base image's tag and do not ask its registry for the digest. Without it migrate asks the registry, with no credentials, what the tag names now, writes FROM with that digest and records it in the attestation as container_target.base_image_resolution; when the registry does not answer it keeps the tag and says why in both files. With the flag the attestation says the registry was not asked. See Base image per target. |
--target-base <ref> | Required by --format image: the boot substrate / install source the qcow2 is built from. |
--firmware <bios|uefi> | Required by --format image: the boot mode. Boot-critical and never guessed. |
--preflight | Report counts and blocking steps only; write no files and consume no usage credit. |
Retired: engagement tagging
--engagement, --vm-id and --migration-type last worked in v0.7.0-beta.6 and have been removed. They are still accepted, and migrate now exits with an error naming the change rather than reporting an unknown flag.
The API endpoint they wrote to bound the authenticated account and then discarded it, so any account could create or overwrite migration records under any engagement it could name. An engagement has no owner column in any schema, so the endpoint could not be authorised and was removed rather than gated.
There is no replacement flag, and the migration itself is unaffected — re-run without them.
The host's identity
migrate bills the host by its identity: the stamp /etc/vrtmv/instance-id, else /etc/machine-id, else /var/lib/dbus/machine-id. A host with none is refused. When the identity is /var/lib/dbus/machine-id (there is no /etc/machine-id, as on OpenRC Gentoo) or a stamp, it is one that image clones copy, and migrate prints a one-line note: on stderr after the Source: line. It is a warning: if the host was cloned, run vrtmv identity on it (--force if the stamp came from the template) before you migrate it.
Output
A non-preflight run always writes vrtmv-attestation.json (the host-specific parity report). When the service has signing enabled it also writes vrtmv-attestation.sig.json, a Vrtmv-signed signature over that report (see Attestation → Signing). It then writes the runbook in each requested --format:
ansible(default) →roles/vrtmv_migration/tasks/main.yml— an Ansible role that installs the translated packages and reconciles config.terraform→terraform/cloud-init.yaml+terraform/main.tf— the translated workload packaged as cloud-init, wrapped in acloudinit_configwhose rendered output attaches to a compute resource asuser_data. This is the reusable foundation for the coming cloud targets (EC2 and friends); today it targets a VM you attach the cloud-init to.container→container/Containerfile— a buildable OCI image from the translated target. Requires--entrypoint(vrtmv never chooses the primary service) and a provenance'd base image in the Index for that target. Blocked / NotDeterminable / missing Index row / missing--entrypoint/ a data directory with no--dataclass refuse rather than emit. Operator runspodman build. Opt-in: default remains ansible. A whole machine is a supported source, as the extraction of one workload you name: one image runs one process, and other enabled services, scheduled jobs and local data are reported and not carried. Software the target has no package for is not in the image either: the run names it on stderr with the--preserveargument that would carry it, and sets nothing. Container output: scope, checks and base images has the scope statement, every feasibility check with what it reads, and the base image per target.image→image/— a qcow2 build recipe for a fresh bootable target: the target-native unattended install (vrtmv.kskickstart for rpm-family targets,preseed.cfgfor dpkg-family) plusbuild-image.sh, a driver that runs the target distro's own installer undervirt-install. Requires--target-base(the boot substrate) and--firmware bios|uefi; a missing base, an unrecognised firmware, or a target family the backend cannot build (only rpm and dpkg are supported) refuse rather than emit. vrtmv does not assemble image bytes — the one privileged step (root + qemu-kvm) runs on a builder host you control, viabuild-image.sh. Shipped in v2 for openSUSE Leap (an rpm-family target, so it uses the kickstart recipe).
Request ansible and terraform with --format ansible,terraform. Add container with --format ansible,container --entrypoint '/usr/sbin/httpd -DFOREGROUND', or image with --format ansible,image --target-base <ref> --firmware uefi.
How the image and container backends behave
Three properties hold for both artifact backends, and they are the same discipline the Ansible role already follows:
- A fresh build from the plan, not a clone of the disk. Each backend builds a fresh target from the migration plan — the translated package set and the carried config, installed onto a target base. vrtmv emits each carried file's path and never its contents: for the container,
migratewritescontainer/stage-carried.sh, which copies the files from the source into the build context (Staging the carried config); for the image, you place them. The image recipe also lists the service enable/mask decisions; theContainerfiledoes not, because a container image starts one command and no services. The source's kernel, initramfs, bootloader and vendor plumbing are the target's to provide and are never carried; for the image they come from the target distro's own install machinery. An image that copied the source disk would be the wrong machine and would earnverify'sfailedverdict against its own attestation (ADR §7.1). - Refusal-first, and non-fatal. When a backend cannot stand behind what it would emit, it refuses and says why on stderr rather than emitting a placeholder — a container with no
--entrypointor no Index base image, an image with no--target-baseor unknown--firmware. A refusal is the designed outcome of a gate, not a crash: the other requested formats still write (ADR §7.2). - A signed parity attestation, always. Every non-preflight run writes
vrtmv-attestation.jsonregardless of--format, and when the service has signing enabled it also writes the Vrtmv-signedvrtmv-attestation.sig.json(see Attestation → Signing). After the image is built, prove it matches the promise withverify:vrtmv verify --attestation out/vrtmv-attestation.json --image <built.qcow2>(ADR §7.5).
Services the target will not start
The role enables what the source had enabled, where it can name the target's unit. For some services it cannot, and those the role does not attempt:
- an OpenRC or SysV init script linked into a runlevel, unless the Index maps it onto a unit of the target: by the package that owns it, or by a row for that very script, described under A package's OpenRC script. A script that no package owns never maps. The role writes a unit for one such script only: a declarative OpenRC script, described under A unit written from an OpenRC script.
- a systemd unit you enabled that has no translation onto the target, or whose package is not in the install list, or whose enablement link pointed somewhere vrtmv could not identify.
migrate prints how many there are:
note: 35 service(s) the source started at boot will NOT start on the target. vrtmv-attestation.json names each with its reason, under aspects.services.refused as `enabled:<name>`. The role's last task lists them and fails the play until it is run with -e vrtmv_services_not_started_acknowledged=true.
The role lists them in a comment in its services section, and its last task, Report services the source started at boot that this target will not start, prints the same list with the reason for each and fails the play. So a play over a source like this ends with exit code 2 until you have read the list. Start each service on the target by hand or decide it is not needed, then run the play with -e vrtmv_services_not_started_acknowledged=true: the list is still printed, by the task before, and no longer fails. The variable accepts that list and nothing else.
On an OpenRC source the list is long, because it holds the init system's own boot steps (hostname, fsck, localmount and the like) beside your workload: vrtmv does not decide which of them the target needs. Scripts that no package owns come first, then the rest grouped by owning package.
A package's OpenRC script
A script with a start() of its own is a program, and vrtmv reads none. What such a script does is known of the file its package installs, so the Index says it, one row for a script: the package that installs it, its name, the software it starts, and the unit on the target that does the same. Two kinds of script need a row of their own:
- a script whose package is not the daemon's. Gentoo's
/etc/init.d/php-fpmis installed byapp-eselect/eselect-phpand starts thephp-fpmofdev-lang/php. On Rocky Linux 9 the row namesphp-fpm.service. - a script that serves several services through links. Gentoo's
/etc/init.d/openvpnis one script, andopenvpn-1194 -> openvpnis an instance of it that reads/etc/openvpn/openvpn-1194.conf. On Rocky Linux 9 the row names the templateopenvpn-server@%i.service, which reads/etc/openvpn/server/%i.conf. The instance is the one the script derives from its name: the text after the first dot, or the whole name when it has no dot. Soopenvpn-1194becomesopenvpn-server@openvpn-1194.service, andopenvpn.officebecomesopenvpn-server@office.service.
The Index has these two rows, for a Gentoo source and a Rocky Linux 9 target, and no other.
The role enables the unit, and the script is not in the list above, when all of this holds:
- the script is the file its package installed: the checksum the source's package database recorded for it matches. For a link, that is the script it leads to, in
/etc/init.dand one step away; - it is started in the
defaultrunlevel and no other; - each file OpenRC reads before the script (the same files as for a declarative script) is absent, holds only comments, or is a file the same package installed and nobody changed. Gentoo's openvpn installs
/etc/conf.d/openvpnwith its defaults set, and that file as installed is no obstacle; - the software the row names is in this migration's install list. Rocky Linux 9 has openvpn in EPEL alone, so an openvpn instance is mapped only with
--target-repos, whichmigrateandvrtmv runboth take; - for an instance: its file is on the source and is a regular file; no line of it starts with a word the row lists, written plainly, with a leading
--or in quotes (cd,remoteandconfigfor openvpn: withcdorremoteGentoo's script does what the target's unit does not, and a file withremoteis a client's; withconfigthe file reads another, whose lines are not checked, so an instance that shares options through a file is not mapped either); and it does not name a path under the directory the source's script ran in (/etc/openvpn/).
Anything else keeps the script in the list, and its reason there names the row and the check that was not met.
The role does not put an instance's file where the target's unit reads it. It copies a carried file to the path it had on the source (/etc/openvpn/openvpn-1194.conf), and the unit reads /etc/openvpn/server/openvpn-1194.conf and runs in /etc/openvpn/server/. The role names each move in its comment Config files that move on the target: the instance's file, and each carried file beside it that it names (a word of one of its lines, after the first, that is a file in /etc/openvpn/ when read as a path from there, so ca ca.crt and also ifconfig-pool-persist ipp.txt). Nothing else under /etc/openvpn/ is said to move: not the file of an instance that was not mapped (a client's stays at the path it had, and on Rocky Linux 9 belongs in /etc/openvpn/client/), and not a file only that one names. vrtmv run copies a file its plan lists as moving to that path as well as to its own. For openvpn on Rocky Linux 9 its plan lists the moves only in a run --target-repos; without the flag run maps no openvpn instance, writes nothing into /etc/openvpn/server/, and you move the files. The role's task Check what each unit mapped from an init script reads on this target looks for the instance's file at the unit's path, and the role's last task fails the play and names the file while it is not there, with or without -e vrtmv_services_not_started_acknowledged=true: the unit is enabled and would fail at boot. Move the file, and with it what it names beside itself (ca ca.crt, key server.key); the check does not look for those. A private key is never carried, so it is in no move and you bring it; for a certificate the role asks for your decision, as for any other. An instance is not mapped when the source already has a file at the unit's path (/etc/openvpn/server/openvpn-1194.conf).
For php-fpm the row names the unit and nothing else. Gentoo keeps its pool files under /etc/php/fpm-php<version>/, and Rocky Linux 9 reads /etc/php-fpm.conf and /etc/php-fpm.d/: the role does not move them, so the unit starts with the pool the target's package ships until you bring yours.
A migrate run against an API that has no answer for scripts prints warning: the Index could not be asked for the units of init scripts, maps what the owning package's canonical maps, as before, and names a link to a package's script with that reason.
A unit written from an OpenRC script
An openrc-run script that only sets variables tells OpenRC to start one command. For a script like that, the Ansible role writes a systemd unit to /etc/systemd/system/<script>.service, enables it, and the script is not in the list above. migrate says so:
The role writes 1 systemd unit(s) from declarative OpenRC init scripts and enables them: hadoop-datanode -> hadoop-datanode.service. It does not bring the program a script starts or its account, and its last task fails when one is missing.
vrtmv reads the script and does not run it. A unit is written when all of this holds:
- the source is OpenRC, no package owns the script, and some other init script on the source does have an owner (otherwise vrtmv cannot tell your script from a package's);
- the script is a regular file, started in the
defaultrunlevel and no other; - each file OpenRC reads before the script is absent or holds only comments:
/etc/conf.d/<script>and/etc/conf.d/<script>.default, and for a script with a dot in its name, such ashadoop.datanode, also/etc/conf.d/hadoopand/etc/conf.d/hadoop.default; - its first line is
#!/sbin/openrc-run, and it has nothing but comments, assignments ofdescription,command,command_args,command_user,command_background,pidfileanddirectory, and adepend()that names onlynet; commandandpidfileare set,command,pidfileanddirectoryare each one absolute path, neitherpidfilenordirectoryhas a..component (systemd does not load a unit with such a working directory, and ignores such aPIDFile=; the rule holds withcommand_backgroundtoo, where the unit has noPIDFile=line), and no value has a shell expansion, an escape, a nested quote or a character the shell would act on;descriptionis printable ASCII.
| In the script | In the unit |
|---|---|
description | Description= |
command, command_args | ExecStart= |
command_user="user:group" | User=, Group= |
command_background="yes" and pidfile | Type=simple. systemd runs the command itself, and nothing writes the pidfile. |
pidfile without command_background | Type=forking and PIDFile= |
directory | WorkingDirectory= |
need net in depend() | Wants= and After=network-online.target |
use net, want net, after net | After=network-online.target |
the default runlevel | WantedBy=multi-user.target |
Anything else keeps the script in the list, and its reason there says what was found: a shell function such as start(), a variable not in the table (start_stop_daemon_args, supervisor, chroot), a dependency on a service other than net, a symbolic link that does not lead to a package's script, a file under /etc/conf.d that sets a variable for it.
The unit is not the whole service. The role does not copy the program the script starts and does not create its account. It does not create the directory the script names either. After writing the unit it checks that the user and group exist, that the command is an executable file and that the directory is one the service can enter, and its last task fails the play and names what is missing: an enabled unit whose command is absent fails at boot. Put the program, the account and the directory in place and run the play again. /etc/rc.conf and the environment OpenRC gave the command are not carried. To stop the service systemd signals every process in it, where start-stop-daemon signalled the one in the pidfile. systemd enters the script's directory as the service's user, where start-stop-daemon entered it as root, so a directory only root can enter starts the service on the source and fails it on the target. For a script with a command_user the role therefore has systemd run both checks, in a transient unit with the same User= and Group= (systemd-run --wait --collect), so each check has the groups the service will have, the user's other groups among them, and a number is taken where the script gives one for the user or the group. A command the user cannot execute and a directory it cannot enter are named in the same list, as its command <path> is not an executable file for <user> here and its directory <path> is not a directory <user> can enter here. Where the user or the group is absent, the list names the account and nothing else for that unit, and the next run checks the rest. The check reads permissions. It does not start the command, so a file system mounted noexec, a script whose interpreter is absent and an SELinux denial are still found at boot. It needs systemd running on the target and the role applied as root; where it cannot run, the list names the command and the directory.
The role does not write over a unit it did not write. If a unit file of that name is already on the target, in /etc/systemd/system with other content or in one of systemd's other unit directories, the play stops before the write and names the file.
Only the Ansible role writes unit files, so this happens only when --format has ansible and has neither container nor image. In any other run the script stays in the list, and its reason says its script is declarative. cluster-migrate does not write these units.
The attestation promises the unit as enabled:<script>.service, as it does for a unit a package provides, and verify looks for that. It does not record that vrtmv wrote the unit.
Not in the list: a unit enabled by the source distribution's own preset or by an image build. The target applies its own presets, so vrtmv does not know that the target will leave those off; the attestation names them as refused.
coupled-migrate writes this role for each node and asks for the same variable before its runbook touches the first node.
The same task reports a unit the role did try to enable and could not. That always fails the play, with or without the variable. If the unit's file is one the role copies from the source (/etc/systemd/system/<unit>) and vrtmv_source_root was not set, the report says the file was not copied, which is why systemctl could not find the unit.
Preflight vs full run
--preflight is the feasibility check: it runs the same analysis, prints the translatable/unresolved/untranslated counts and the number of blocking manual steps, and stops. It writes nothing and is not billable. A full run records one depersonalized migration usage event; whether that event is billable depends on your plan and target (see Accounts, quota & billing).
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
| Flag | Purpose |
|---|---|
--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 / --ssh | The rebuilt target (see Sources). |
--ssh-port, --ssh-key, --jump, --sudo | SSH collection options. |
--sign | Ask 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. |
--json | Emit 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-kubectl | Reach 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
servicesclass. 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 ispartialand the exit is 1. - The units the image does not start are printed next to the verdict whenever there are any, as a
NOT STARTEDline, and are in the JSON underservices_reading.container.units_not_startedandunits_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. Whenmigrateread the asserted unit'sExecStarton 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.verifydoes 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
| State | When | Exit code |
|---|---|---|
verified | Every 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 source | The 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 |
differs | A 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 named | The 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 named | The 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 named | A 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.
| State | A directory | Over both |
|---|---|---|
accepted_as_shared | The 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_shared | Only 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_known | Nothing 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.
| State | When | Exit code |
|---|---|---|
verified | Every listed entry is in the image as recorded. | unchanged |
differs | An 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_checked | An 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 listed | The 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.
| State | When | Exit code |
|---|---|---|
match | The command exited 0 and printed the same bytes on the source and in the container. | unchanged |
differs | It ran in both and the container's exit status is not 0, or the bytes are not the same. | 1 |
not_compared | The 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_recorded | The 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: theEntrypointandCmdin 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,PathandArgs).
Each is compared with the attested entrypoint split into words at white space, which is how migrate writes the ENTRYPOINT line.
| Side | State | When | Exit code |
|---|---|---|---|
image | same | The image's Entrypoint is the attested words, in order, and it has no Cmd. | unchanged |
image | differs | Anything 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 |
container | same | The container was started with the attested words and no other. | unchanged |
container | differs | It was started with another command: run --entrypoint, or words after the image name. | unchanged |
| either | not_compared | The 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.
license
Seat one or more hosts against your account by fingerprint alone — the lightweight, first-class way to claim instance seats. Also spelled vrtmv enroll. It collects only the VM fingerprint (the cheap identity step) and calls POST /v1/enroll; it does no inventory extraction, translation, or attestation, so it returns in seconds rather than the minutes a full migrate or verify takes.
Bills a seat. license records the license usage kind, a billable seat kind alongside migration and verify (a seat is 402-gated quota, never a metered charge). It is capacity-gated: a host is refused when the account is out of seats. It is idempotent — re-licensing a seated host claims nothing new, and a later migrate/verify of a licensed host does not claim a second seat (the seat dedups on the fingerprint). Needs credentials.
Use it to light up spare seats ahead of time, or to license a fleet so drift — which is refused on an unlicensed VM — can run free on those hosts. cve-scan auto-licenses a host itself when the account has capacity (VRTMV-620), so it does not need this first.
vrtmv license disk.vmdk
vrtmv license --root /mnt
vrtmv enroll --ssh user@host
vrtmv license --image a.qcow2 --image b.qcow2 --ssh user@host3 --json
vrtmv license --manifest fleet.txt
| Flag | Purpose |
|---|---|
<image> (positional) | A single disk image to license (equivalent to one --image). |
--root <path> | License an already-mounted root filesystem (repeatable for the fleet form). |
--image <path> | License a disk image, mounted read-only (repeatable). |
--ssh <user@host> | License a running host over SSH (repeatable). |
--manifest <path> | A manifest of sources, one per line (the same format readiness uses). |
--json | Emit the per-host result as JSON instead of a text summary. |
The fleet form licenses every host in one invocation. A host that cannot be reached or identified, or that the account has no capacity for, is reported as not licensed with a reason and the run continues; the command exits non-zero if any host was not licensed, so a pipeline never reads a partial result as a clean fleet-wide success.
In the fleet form every host is identified before any is seated. Hosts that report one identity (see identity) are clones of one image, or one host listed under several names: each of them is refused, POST /v1/enroll is not called for any of them, and the reason names the hosts and the file the identity came from. The other hosts are seated.
A host with no billable identity is refused before any network call — write a stable /etc/vrtmv/instance-id (or run vrtmv identity) first. See Accounts, quota & billing for the seat model.
identity
Write an operator instance stamp, /etc/vrtmv/instance-id, so a host has its own billing identity. The stamp is a random 128-bit id written as 32 hex characters, mode 0444. It calls no API and bills nothing.
A host is billed as one seat per identity. vrtmv reads the identity from the stamp when there is one, else from /etc/machine-id, else from /var/lib/dbus/machine-id. A host with none of them is refused by the billed commands until it has one.
vrtmv identity # the host you are on
vrtmv identity --root /mnt/web03 # a mounted image or root
vrtmv identity --force # replace a stamp that came with the image
vrtmv identity --ssh root@web03 # a running host, over SSH
vrtmv identity --ssh admin@web03 --sudo --jump ops@bastion
| Flag | Purpose |
|---|---|
--root <path> | Write under this root instead of /. |
--ssh <user@host> | Write the stamp on a running host over SSH. The login must be root, or pass --sudo. Not with --root. |
--ssh-port <port> | SSH port (default 22). |
--ssh-key <path> | SSH identity file (default: the agent and ~/.ssh/config). |
--jump <user@host> | Jump host (ProxyJump). |
--sudo | Write with sudo. Needs passwordless sudo on the host. |
--force | Replace a valid stamp that is already there. This changes the host's billing identity. |
A valid stamp is left alone unless you pass --force, and the command says so and exits non-zero. A stamp that is not valid (too short, or all zeros) is replaced. A directory at the path, or a file that cannot be read or is not UTF-8 text, is refused, even with --force: remove it and run again.
With --ssh, the id is made on the machine you run vrtmv on, and so is the decision. The host sends back the file that is at the path, vrtmv judges it by the rule it bills under, and the stamp is written only if the file has not changed since. Two cases differ from a local run, and no stamp is either one: a file larger than 4096 bytes is refused, and a symlink to nothing is replaced by the stamp rather than written through.
Clones
An identity that is copied with an image is not a host's own. systemd writes a new /etc/machine-id at first boot when the image left it empty. On a host without systemd, such as OpenRC Gentoo, nothing rewrites /var/lib/dbus/machine-id, and nothing rewrites a stamp written in the template. Every clone of that image then reports one identity.
So when the identity comes from /var/lib/dbus/machine-id (there is no /etc/machine-id) or from a stamp, migrate prints a one-line note that says so. The note is a warning, not a refusal. If the host was cloned, run vrtmv identity on it, or vrtmv identity --ssh user@host from where you run vrtmv; if the stamp came from the template, add --force.
A fleet run sees clones directly. When two or more hosts of one run report one identity, they are clones of one image or one host listed under several names, such as a hostname and an IP address. The commands that bill refuse each of those hosts. They emit, attest and report nothing for it, the rest of the fleet goes on, and the run exits non-zero. Those commands are cluster-migrate --emit-roles, coupled-migrate (it refuses the whole cluster), container-fleet, license and cve-scan-fleet (see cve-scan). The refusal names the hosts and the file the identity came from:
web-03, web-07, web-12 report one machine identity (machine-id from /var/lib/dbus/machine-id). They are clones of one image, or one host listed under several names. On each clone, run `vrtmv identity` (add --force if the stamp came from the template); if they are one host, keep one of its names in the manifest. Then re-run. Nothing was emitted or billed for these hosts.
cluster-migrate without --emit-roles, assess-fleet and readiness print the same finding as a warning and go on. If the hosts are one host under two names, remove one of the names from the manifest instead.
Once each clone has its own stamp, each one is its own seat.
cve-scan
Match a source's installed packages against known vendor advisories for its detected release, narrow to the CVEs that are genuinely still unpatched (installed_version < fixed_version, using the same family-aware comparator the translation engine uses), and report them. With --sign the verdict is Ed25519-signed via the same /v1/attest/sign path verify uses, so the result is an audit-grade proof rather than a printout.
Never billable — a cve_scan records no metered charge. It is license-gated (VRTMV-437): a scan runs against a VM the account has licensed. On a VM that is not yet licensed, cve-scan now auto-claims a seat when the account has capacity (VRTMV-620), so the scan just works and the host becomes licensed; the seat is a lightweight license, never a metered charge. A 402 is returned only when the account is genuinely out of capacity (free quota, credits and the per-account cap all exhausted) — not as a licensing step. When a scan claims a seat the command says so on stderr.
A claimed seat stays claimed: there is no command that releases it, so scanning a host you never intend to migrate uses capacity you might have wanted for one you do. Seats go to hosts in the order they are scanned, so on a 10-seat account a scan of a larger fleet seats the first ten unlicensed hosts and refuses the rest. To choose which hosts hold seats, license them first. The capacity check happens before the scan and the seat is claimed after it, so hosts scanned at the same time by separate cve-scan processes can all pass the check; the ones past capacity are still scanned, are not seated, and get a usage not recorded note carrying the quota refusal.
Needs credentials — the advisory lookup and any --sign go to the API.
vrtmv cve-scan --root /mnt/snap/web01 --sign -o report/
vrtmv cve-scan --image host.qcow2 --arch x86_64 --json
vrtmv cve-scan --ssh deploy@web01.corp --sudo -o report/
| Flag | Purpose |
|---|---|
--root / --image / --ssh | Exactly one source (see Sources). |
--ssh-port, --ssh-key, --jump, --sudo | SSH collection options. |
--arch <arch> | Scope arch-specific advisories (e.g. x86_64). Advisories with no arch recorded always apply. |
--sign | Ask the API to Ed25519-sign the report. Needs credentials. |
-o, --out <path> | Write the scan report JSON here. |
--json | Emit the report as JSON instead of a text summary. |
A fleet: hosts that report one identity
cve-scan-fleet scans the sources of a manifest, or of repeated --root/--image/--ssh, and writes one report for the fleet. Its usage post is what seats an unlicensed host, so it waits until every host is scanned. Hosts that report one identity (see identity) are clones of one image, or one host listed under several names. Each of them is refused: no usage post, so no seat, and its findings are left out of the report. Its row has the state error and a reason that names the hosts and the file the identity came from, and the JSON report lists them under refused_shared_identity. The other hosts are reported and seated as before, and the run exits non-zero. Run vrtmv identity on each clone, or remove the second name from the manifest, and scan again.
A quiet scan is not always a clean one
Zero findings has three meanings, and only one of them is "clean":
- No advisory data for the release. The lookup joins the advisory tables for the detected release; with no rows there it matches nothing before any version comparison. The report carries
advisories_for_release(how many advisory rows the Index holds) and printsadvisory data NONE for this release— this scan proves nothing. EOL releases are the likeliest to have no feed and the likeliest to need scanning. - End of life. Even with a feed, a release the vendor has stopped patching has a frozen feed. The report carries
support_status(supported,eol-no-extension,eol-extension-active,eol-extension-lapsed, or absent) and prints an END OF LIFE banner where a quiet result is unmeasured rather than safe. - Genuinely patched. A supported, covered release with an empty
findingsarray.
If you build on --json, treat empty findings as clean only when advisories_for_release > 0 and support_status == "supported". Fail closed — otherwise the least-covered hosts in an estate read as its safest.
To act on the findings, see patch, which bumps exactly the still-vulnerable packages to their vendor-fixed version.
patch
Render an Ansible role that bumps exactly the packages a cve-scan found still vulnerable to their vendor-fixed version, staying on the same release — no cross-distro translation. It reuses the scan/narrow path directly, then groups findings by package: when one package has several CVEs with different fixed versions, it is bumped to the highest one so a single task clears them all. With --apply it also executes the role over SSH against the same host and re-scans to confirm.
Needs credentials — the advisory lookup and any --sign go to the API. Like cve-scan, the underlying scan is license-gated and never metered; patch itself adds no billable charge. rpm/dpkg only — Portage has no reliable exact-version pin via ansible.builtin.package, so patch refuses rather than emit a task that might not pin the version it claims.
vrtmv patch --root /mnt/snap/web01 --all-fixable --sign -o patch/
vrtmv patch --root /mnt/snap/web01 --cve CVE-2026-1234 --cve CVE-2026-5678 -o patch/
vrtmv patch --ssh deploy@web01.corp --all-fixable --apply -o patch/
| Flag | Purpose |
|---|---|
--root / --image / --ssh | Exactly one source (see Sources). --apply requires --ssh. |
--ssh-port, --ssh-key, --jump, --sudo | SSH collection options. |
--cve <id> | Patch only this CVE (repeatable). Mutually exclusive with --all-fixable. |
--all-fixable | Patch every CVE the scan finds still unfixed. |
--arch <arch> | Scope arch-specific advisories (e.g. x86_64). |
--sign | Ask the API to Ed25519-sign the patch plan. Needs credentials. |
--apply | Execute the role over SSH against the same host, then re-scan to confirm. Requires --ssh. |
-o, --out <path> | Output directory for the role + report (default vrtmv-patch). |
--json | Emit the plan as JSON instead of a text summary. |
A requested --cve that matches no current finding — not applicable to the release, not installed, or already fixed — is reported in unmatched_cves, never silently dropped.
--apply
Runs the patch immediately over SSH against the exact host it was scanned from (the same ansible-playbook invocation run uses), then re-scans fresh and diffs against what was targeted. It never claims a fix worked because ansible-playbook exited 0 — it re-checks, and its exit code reflects the confirmed outcome: it fails if the playbook reported failures or if the re-scan still finds any targeted CVE.
Three things --apply does that render-only does not:
- Requires
--ssh. A mounted--root/--imageis a static filesystem, not a running system to execute against, and refuses--apply. - Excludes epoch-ambiguous packages from what it auto-applies (a non-zero-epoch rpm's pinned spec is a documented best-effort gap). These are named in
excluded_epoch_ambiguousand left for manual patching. - Always renders a rollback role alongside (
roles/vrtmv_patch_rollback/), targeting the pre-patch version. It is not run automatically — it is there so a bad patch has an immediate undo. Rolling back reintroduces the CVEs the forward patch fixed; it is not itself a security action.
The executed role also appends a non-blocking, reported restart-recommendation check (needs-restarting -r on rpm, /var/run/reboot-required on Debian/Ubuntu): a package can be upgraded on disk while the running process keeps the old code loaded, and the package database cannot see that.
run
migrate and data plan stay plan-only: they write an Ansible role and attested runbooks, and a human applies them. run is a separate, explicitly-invoked command for an operator or pipeline that wants that same chain executed unattended instead: plan → apply the role over real SSH → carry the promised config → optional workload-data copy → verify → optional Kubernetes/OpenShift cutover. A full run records the same billable-eligible migration usage event as migrate; the following verify does not meter.
vrtmv run --root centos7-mnt --target rocky9 --target-ssh root@10.0.1.20 -o out/
vrtmv run --image centos7.vmdk --target rhel9 --target-ssh admin@10.0.1.30 \
--copy /srv/www --copy /var/lib/mysql --notify-webhook https://hooks.example.com/vrtmv
vrtmv run-status -o out/
| Flag | Purpose |
|---|---|
--root / --image / --ssh | The source (see Sources). With --ssh, --copy runs directly on the source host — see below. |
--target <spec> | Target OS (default rocky9). See Supported targets. |
--target-ssh <user@host> | Where to apply the migration. A real host run connects to and changes. |
--target-ssh-port, --target-ssh-key, --target-jump, --target-sudo | Target SSH connection options, mirroring the source's. |
-o, --out <dir> | Output directory for the role, attestation, verify report, and run-state file (default vrtmv-out). |
--format <list> | Must include ansible (default) — that's what run applies. |
--copy <path> | A guest data path to copy onto the target automatically (repeatable). Omit to carry OS/config only, as migrate alone promises. |
--resume | Skip phases already recorded complete under --out instead of restarting. |
--target-repos | Plan with the target's repositories that are not in its default set (EPEL, a vendor's), as migrate --target-repos does. See below. |
--retries <n> | Attempts per external step — ssh, ansible-playbook, API calls (default 3; 1 disables retry). |
--not-before <epoch seconds> | Wait until this time before touching the target; planning still runs immediately. |
--notify-webhook <url> | Best-effort JSON POST on the terminal state (done/blocked/failed). Never fails the run. |
--require-health, --health-key-file, --health-max-age-mins, --health-strict | The same vrtmn HealthReport gate verify --require-health offers, checked before run can report done. |
--job-id <id> | Stamp an external id into the run-state file and any webhook payload. Generated if omitted. |
--cutover | After a passing verify (and health gate, if requested), APPLY the cutover manifest instead of only emitting it. Requires --deploy-image or --deploy-disk-url. |
--rollback-cutover | Delete the Kubernetes object a prior --cutover applied. Reads <out>/deploy/vrtmv-cutover.json (namespace/name/kind). Does not reverse apply or data copy. |
--traffic-cutover | Drain → forward → health from operator-authored <out>/deploy/vrtmv-traffic.json (kind: command argv only). Does not guess a VIP. Health fail rolls back completed traffic steps. Also runs as a run phase after verify/--cutover when that json is present; absence is silence. |
--rollback-traffic | Reverse completed traffic steps. Independent of --rollback-cutover. Unread vrtmv-traffic-state.json refuses rather than looking like nothing completed. |
--deploy-image / --deploy-disk-url, --deploy-disk-size, --deploy-storage-class | The cutover VM's disk source — a containerDisk image, or an imported disk via CDI DataVolume. Same shape as deploy. |
--deploy-namespace, --deploy-name | Namespace and name for the cutover manifest. |
--deploy-server, --deploy-token, --deploy-insecure-skip-tls-verify, --deploy-kubectl | Cluster connection for --cutover. Omit --deploy-server/--deploy-token to use the current oc/kubectl login. |
vrtmv run-status -o <dir> prints the last recorded phase/verdict without running anything — poll this from a pipeline instead of tailing output.
Phases and the run-state file
Progress is written to <out>/.vrtmv-run-state.json after each phase (plan, apply, config_carry, data_copy, verify, cutover). --resume re-reads it and skips whatever already completed. run always re-runs planning even on resume — it's cheap, and it's what holds the source root open for the phases after it.
Packages the target has only in another repository
Without --target-repos, run plans as migrate does without it: a package the target carries only in a repository outside its default set is not installed, the report names it, and a service of that software is not enabled. With --target-repos the role run applies enables the repository on the target and installs those packages. That is a change to the target's repository policy, and passing the flag is what authorises it.
It decides more than the package. A service is planned only when its software is in the install list. Rocky Linux 9 has openvpn in EPEL alone, so a Gentoo source's openvpn-1194 is mapped onto openvpn-server@openvpn-1194.service only in a run with --target-repos; see A package's OpenRC script. In that run the plan lists the instance's file and the carried files it names beside itself as moving to /etc/openvpn/server/, and the config carry copies each to that path as well as to the path it had. The carry comes after the role, so while the role runs the file is not yet at the unit's path, and the play ends with a failure: the role's last task names the file, unless an earlier report has already stopped the play (a package that did not install under its own name, see below). run prints the warning and goes on, as it does for any failure the role reports. The role enables the unit and does not start it. A private key is never carried: you bring server.key, and the unit does not start without it. The target's openvpn is the version EPEL has, which may be older than the source's; whether the instance's file suits it is yours to check.
Pass --target-repos again with --resume. The plan is built again on every invocation, and a resumed run without the flag plans without those packages.
What it refuses to automate
Database export/import. If the data plan includes a database, run stops and names it as a blocking manual step rather than running an export/import command with no verified credentials to run it with. Follow <out>/data/vrtmv-db-runbook.md by hand, then --resume.
Workload data from an --ssh source runs on the source host itself. SSH collection only pulls the paths config analysis needs, not arbitrary application data, so the locally collected root has nothing to copy from. run instead discovers, hashes, and copies --copy paths directly on the source over its own SSH connection — database classification is the same path match data plan uses, so a database is still refused. The copy needs the orchestrator's ssh-agent forwarded to the source so it can authenticate to the target without the target's key ever being copied there: use an agent that already has the target's key loaded, or pass --target-ssh-key to load one into a throwaway agent.
A partial package-install does not abort the run
The generated role installs packages individually and reports whenever any package can't install under its own name — including the common case where the target's base already provides that capability under a different name (curl-minimal providing curl on an EL9-family target). That makes ansible-playbook itself exit non-zero, but run doesn't treat it as fatal: it logs the failure and continues to config-carry and verify, because verify's provides-aware check is the real judge of parity, not one Ansible task's exit code.
Exit codes
Only a Done outcome with verdict equivalent exits zero. Blocked (a translation blocker, a refused unattended step, or a non-equivalent verify verdict) and Failed (a genuine infrastructure error — couldn't connect, couldn't run ansible-playbook) both exit non-zero, so a pipeline can't go green on an incomplete migration.
deploy
Deploy a workload to a namespace on an OpenShift or Kubernetes cluster — either a KubeVirt VirtualMachine or a Deployment + Service. Give it the info; it renders an auditable manifest, then applies it with oc (or kubectl). Does not record a billable usage event: cluster apply is local to your kubeconfig. Only usage kind migration is billable.
# A VM from a containerDisk image
vrtmv deploy --kind vm -n prod --name web01 \
--image quay.io/example/rhel9-container-disk:latest --cpu 2 --memory 4Gi
# A VM booting from the migrated disk (imported via CDI)
vrtmv deploy --kind vm -n prod --name web01 \
--disk-url https://mirror.internal/disks/web01.qcow2 --disk-size 40Gi
# A container, exposed with a Service + Route
vrtmv deploy --kind container -n staging --name api \
--image registry.example.com/api:1.4 --replicas 3 --port 8080 --route \
--attestation out/vrtmv-attestation.json
# A container with volume-class data: a claim per path, filled before it starts
vrtmv deploy --kind container -n staging --name site \
--image registry.example.com/site:1 --port 80 \
--attestation out/vrtmv-attestation.json \
--data-manifest plan/vrtmv-data-attestation.json \
--data-claim /srv/site=site-data:10Gi \
--data-source-url https://nyc3.digitaloceanspaces.com/my-bucket/site-plan \
--data-credentials-secret bucket-credentials
| Flag | Purpose |
|---|---|
--kind <vm|container> | What to deploy. |
-n, --namespace <ns> | Target namespace. |
--name <name> | Workload name (DNS-1123). |
--image <ref> | OCI image (container) or containerDisk image (vm). |
--disk-url <url> + --disk-size <q> | VM only: import the migrated disk (CDI DataVolume) instead of a containerDisk. |
--storage-class <name> | VM only: StorageClass for the imported disk. |
--cpu <n>, --memory <q> | vCPU cores and memory (default 1 / 2Gi). |
--replicas <n> | Container only (default 1). |
--port <n>, --route [--host <fqdn>] | Container only: expose via a Service, and optionally an OpenShift Route. |
--server <url>, --token <t> | Cluster API + bearer token. Omit to use your current oc login. |
--insecure-skip-tls-verify | Skip API-server TLS check (labs only). |
-o, --out <path> | Where to write the manifest (default vrtmv-deploy.yaml). |
--dry-run | Render the manifest but don't apply it. |
--kubectl | Apply with kubectl instead of oc. |
--attestation <file> | Container only: the vrtmv-attestation.json that migrate --format container wrote. --kind container needs this or --no-attestation. See A container's data. |
--no-attestation | Container only: render without reading an attestation. The Deployment has no volume and an annotation says nothing was read. |
--data-claim <path>=<claim>:<size> | Container only: the claim for a volume-class data path, and the size it asks for. Repeatable, one for every volume-class path. |
--data-manifest <file> | Container only: the vrtmv-data-attestation.json of vrtmv data plan --dest-claim, for the file hashes. |
--data-source-url <url> | Container only: where in an S3-compatible bucket the archives and lists were uploaded. No query and no @ in the URL. |
--data-source-region <region> | Container only: the region requests are signed for (default us-east-1). |
--data-credentials-secret <name> | Container only: a Secret in the namespace with the keys AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY. Omit for a bucket that needs none. |
--data-helper-image <image> | Container only: the image the init container runs. Default: registry.access.redhat.com/ubi10/ubi@sha256:27e14f4987d7abe56664d7e1b1dddcd0226d4ca593da5726f0f65697a17d0fee. |
--data-storage-class <name> | Container only: the StorageClass of the claims. Omit for the cluster's default. |
How it works
deploy renders the manifest, keeps it as an auditable artifact (vrtmv-deploy.yaml, on brand with vrtmv's other outputs), then applies it. Cluster access is your current oc login (recommended) or explicit --server/--token. When you pass --token, vrtmv hands it to oc through a private temporary kubeconfig rather than the command line, so the credential never appears in the process list. --dry-run renders without a cluster.
Inputs are validated before the cluster is touched — names as DNS-1123 labels, memory/disk as Kubernetes quantities, the image as a reference, and --disk-url/--host as quote-free values — so a mistake errors with guidance instead of producing a bad object.
A container's data
A Deployment with no volume starts a workload that has data on an empty directory, and the pod reports as running. So deploy --kind container reads the data declarations of the migration (migrate --format container --data <path>=<class>) from --attestation, and does one of three things:
- The attestation declares no volume-class path: the Deployment is rendered with no volume, and the output says so.
- It declares volume-class paths and every one can be given a claim that is filled and checked: the manifest gets a
PersistentVolumeClaimper path and an init container per claim, described below. - Anything else is refused, the paths are named, and no manifest is written. That covers a volume-class path with no
--data-claim, a path the data plan manifest has no file hashes for, a manifest that lists fewer files under a path than the attestation counted on the source, a volume-class path that has another declared path under it, and--replicasabove 1.
With neither --attestation nor --no-attestation it refuses as well. --no-attestation is your statement that nothing is to be read: the Deployment has no volume, the output says vrtmv does not know whether the workload has volume-class data, and the manifest carries the annotation vrtmv.com/data: "not read: rendered with --no-attestation". An attestation written by 2.2.0 or earlier, or by a migrate that was not --format container, has no data declarations and is refused by --attestation.
Secret files the image was built without
migrate --format container leaves secret files out of the image and records each in the attestation's container_secrets. deploy reads that record from --attestation and writes a read-only mount for each file marked delivery: mount, at the file's own path, from one Secret named <name>-vrtmv-secrets. vrtmv does not create that Secret and never reads it. You create it from the source with the script migrate wrote, and deploy prints the command:
sh out/container/stage-secrets.sh /mnt/src kubernetes web-vrtmv-secrets shop
To have the Secret as a manifest file instead, for kubectl apply -f, for committing encrypted, or for a secret store, give manifest and a file name: sh out/container/stage-secrets.sh /mnt/src manifest web-vrtmv-secrets shop web-secrets.yaml. Nothing is created in the cluster, and the file holds the contents of the secret files. See Mounting the files left out.
Until the Secret exists the pod does not start. The manifest carries the annotation vrtmv.com/secrets, which reads one of three ways:
"<n> mounted, <m> left out and not mounted": the attestation has the record. The output names each file that is left out and not mounted, with the reason."not recorded by the attestation": the attestation has nocontainer_secrets, because a build before this one wrote it. vrtmv does not know whether the image lacks a file it needs, says so, and mounts nothing. This is not the same as an image with no secret left out."not read: rendered with --no-attestation".
An attestation whose record names a key this build would not make for the path is refused. See Mounting the files left out.
What the init container does
The data arrives in three steps, and vrtmv runs the last one only by way of the manifest:
vrtmv data plan --copy <path> --dest-claim <path>=<claim>writes a script and, underclaims/, the list of hashes<claim>.sha256. See Kubernetes claims as the destination.- You run the script, which packs
<claim>.tar, and upload both files to one place in an S3-compatible bucket (S3, DigitalOcean Spaces, MinIO). deployrenders the claim and an init container that reads the two files from--data-source-url.
The init container fetches <url>/<claim>.sha256 and compares its sha256 with the one deploy computed from --data-manifest, so a list that is not the manifest's is refused. It then fetches <url>/<claim>.tar, unpacks it into a working directory on the claim, and requires that the regular files it unpacked are exactly the files of the list and that each one's sha256 matches. Only then does it move the files to the claim's data directory, which is what the workload's container mounts over the path, and write a record beside it. Any failure ends the init container with a non-zero exit: nothing is in data, the workload's container does not start, and the pod shows Init:Error. The cluster starts the init container again, so a bucket that is put right is picked up without a new deploy.
A pod's init containers run every time the pod starts. A claim that has its record is not filled again, so what the workload has written since is not replaced by the source's files. The record names the list of hashes the claim was checked against, and the init container compares it with the list this Deployment was rendered from:
- The same list, and
datais there: it exits 0 and the workload starts. - Another list: it exits non-zero and the workload does not start. This is what happens when you plan the data again after the source changed and deploy with the new manifest on a claim that is already filled. A new data plan does not reach a filled claim. The pod spec would otherwise say the files were checked against a manifest they were never compared with. Deploy with the manifest the claim was filled from (
kubectl rollout undoputs the previous pod spec back), or name a new claim and fill that. To fill the same claim from the new plan, delete the Deployment and then the claim (kubectl delete deployment <name>,kubectl delete pvc <claim>) and deploy again with the new manifest; the claim is made and filled afresh, and what the workload wrote on the old one is deleted with it. The init container's message names both lists by sha256 and says the same. A plan of an unchanged source gives the same list, and is not this case. - The record is there and
datais not: it exits non-zero. The cluster would otherwise make an empty directory for the mount.
A claim that holds a data directory and no record is refused too: vrtmv did not put it there.
The Deployment's strategy is Recreate and each claim is ReadWriteOnce. The init container runs as root, because it has to give each file its owner; a cluster that forbids that refuses the pod.
What is checked, and what is not
Checked before the workload starts: the bytes of every regular file, and that no regular file is there that the manifest does not list. Carried by the archive and not checked: owners, modes (a setuid bit included), links, empty directories and special files. A link or a fifo in the archive is placed as it is and is on no list. Not carried: ACLs and extended attributes. A file name with a control character in it is refused by data plan, since the list is read line by line. A name that is not UTF-8 is in the manifest under another spelling, so the init container does not find it and fails.
A database the data plan exports has no file hashes by design. Its claim is created and mounted, no init container fills it, and the output says NOT FILLED. The engine starts on an empty directory and you run the import of the database runbook against it.
The bucket's credential
vrtmv never reads it. You create the Secret; deploy writes its name into the pod spec, and the cluster gives the two values to the init container as environment variables. They are not given to the workload's container. They are in no manifest, report or attestation vrtmv writes. A --data-source-url with a query string or an @ anywhere in it is refused without being repeated, because a pre-signed URL or user:password@ would put a credential into the manifest. That rules out a prefix with an @ in its name too: a password can hold a /, so the end of the host cannot be told.
The default image is Red Hat's UBI 10, about 80 MB to pull, from a registry that needs no login. It is pinned by digest, so it does not change until a vrtmv release changes it. Any image with /bin/sh, tar, find, sha256sum, sed, sort and a recent curl can be named instead; the script checks that each is there, and not that it is the GNU one, which is what was run. The request is signed by curl's --aws-sigv4: curl 8.5 and 8.12 were run against MinIO, and 7.81 was refused by it with 403. vrtmv ships no image of its own.
The init container gives up on a store it cannot connect to in 30 seconds or that sends nothing for five minutes, and the cluster starts it again. Those two limits were not exercised.
The eight flags above are refused with --kind vm.
To check a claim afterwards, see verify --data-claim.
From a migration
vrtmv migrate can emit the deploy manifest alongside the role and attestation when you give it a disk source:
vrtmv migrate --root /mnt --target rocky9 -o out \
--deploy-image quay.io/you/rocky9:latest --deploy-namespace prod --deploy-name web01
# → out/deploy/vrtmv-deploy.yaml (review, then: oc apply -f)
Prerequisites
- The OpenShift CLI
oc(orkubectlwith--kubectl) on yourPATH. - For
--kind vm: OpenShift Virtualization / KubeVirt on the cluster; for--disk-url, the CDI (Containerized Data Importer) operator. - For
--route: an OpenShift cluster (Routes are OpenShift-specific).
readiness
Scan many sources and emit an aggregated fleet migration-readiness report. The batch, non-billable sibling of migrate --preflight: no Ansible role and no per-VM attestation, just the readiness verdict across a fleet.
vrtmv readiness --manifest fleet.txt --target rocky9 -o report/
vrtmv readiness --image a.vmdk --image b.qcow2 --target rhel9
| Flag | Purpose |
|---|---|
--manifest <file> | A manifest of sources, one per line (each may set its own target). |
--image / --root / --ssh | Ad-hoc sources instead of a manifest (each repeatable). |
--target <spec> | Default target for entries that do not specify one (default rocky9). |
-o, --out <dir> | Output directory (default vrtmv-readiness). |
--format <list> | Report formats: any of json, md, csv (default all three). |
--include-untested | Count untested-confidence translations toward "ready" (by default they force a "review" verdict). |
--stop-on-error | Stop at the first VM that fails to scan instead of continuing. |
Verdicts
Each VM is graded ready, review, or blocked, with the gaps that drove the verdict. By default, translations of untested confidence force review rather than ready — you opt into counting them with --include-untested. The report rolls the fleet up per target and highlights the most common blockers.
Run vrtmv docs readiness for the manifest format.
remediate
Walk the refusal clusters in a readiness report and record decisions into a resolutions store. It reads a fleet-readiness.json you already generated and, for each open refusal, records an accept plus a reason once — a decision that is then applied to every host that raised the same refusal on the next readiness --resolutions <store>. It is interactive, runs no scan and touches no plan; it only writes the store. For scripts, edit the store JSON directly.
Fully local — no API call, no credentials. It does not bill and is not a metered SKU: the billable seat kinds are migration and verify, and remediate records no usage event. It records a decision; it never makes a promise, so accepting a cluster resolves how that refusal is reported, not what the migration does.
vrtmv remediate --report vrtmv-readiness/fleet-readiness.json --resolutions resolutions.json
| Flag | Purpose |
|---|---|
--report <path> | The fleet-readiness JSON to read refusal clusters from. |
--resolutions <path> | The resolutions store to write — created if absent, merged if it exists. |
Feed the resulting store back into a fleet run so the accepted refusals no longer force a "review" verdict:
vrtmv readiness --manifest fleet.txt --resolutions resolutions.json -o out/
cluster-migrate
Cluster many sources by measured similarity and emit a per-host variance report. It groups N sources by the Jaccard similarity of their operator-attributed workload features — services, carried config, firewall, tuning, audit, MAC, never base-OS packages — and writes, per cluster, the majority role and each host's two-directional delta to it. The variance report is the product: it flags the one host of a group that differs from its peers. By default the run is fully local — no API call, no credentials, no charge.
With --emit-roles it also emits a parameterised, runnable Ansible role per cluster. That path resolves packages against the Index (so it needs credentials and a --target), and each emittable host's role emission is a billable per-VM migration. It refuses per-host for any coupled or undetermined-coupling datastore node — those belong to coupled-migrate.
vrtmv cluster-migrate --manifest fleet.txt -o report/
vrtmv cluster-migrate --root /mnt/app01 --root /mnt/app02 --similarity-threshold 0.6 -o report/
vrtmv cluster-migrate --manifest fleet.txt --emit-roles --target rocky9 -o out/
| Flag | Purpose |
|---|---|
--manifest <path> | Sources, one per line (same format as readiness). |
--image / --root / --ssh | Ad-hoc sources instead of a manifest (repeatable). |
--similarity-threshold <0.0-1.0> | Similarity at/above which two sources join one cluster (default 0.5). Lower merges more aggressively into fewer, broader roles; higher splits into more, tighter ones. Stated in the report because it directly controls the pattern count. |
-o, --out <path> | Output directory (default vrtmv-cluster). |
--format json,md | Report formats to write (default both). |
--continue-on-error | Continue past a source that fails to scan instead of aborting the run. |
--emit-roles | Also emit a runnable role per cluster (billable per-VM; needs credentials + --target). |
--target <os> | Target OS for the emitted roles (e.g. rocky9). Required with --emit-roles. |
Hosts that report one identity
A host is billed by its identity (see identity). Two or more sources of one run that report one identity are clones of one image, or one host listed under several names. With --emit-roles each of them is refused before anything is emitted for it: no role, no attestation, no usage report. The refusal names the hosts and the file the identity came from. The other hosts emit, and the run exits non-zero. Without --emit-roles the run bills nothing, so it prints the same finding as a warning and writes the report as before. Run vrtmv identity on each clone, or remove the second name from the manifest, and run again.
cluster-verify
Verify a rebuilt fleet once per class, then borrow the verdict. It clusters the hosts by their operator features (a candidate generator — it proposes who might share a build, it never decides it), runs a full verify on one representative per class, and puts every other member through the membership gate: a host whose attestation carries a build-determinant triple digest byte-identical to the representative's borrows that representative's signed verdict with no target read; any mismatch, or an absent proof, falls back to a full direct verify. A mismatched host is never granted a borrowed verdict. Also available as verify-fleet.
The output separates verified-directly from verified-by-class-membership counts, so a borrowed verdict is never presented as an independent one. --sign signs each representative's report and needs credentials; borrowed reports inherit the representative's signature by design and are not re-signed. Like verify, each verified representative claims a billable seat (402-gated, never metered) rather than recording a usage event; borrowed verdicts claim nothing new, so a whole class costs one seat.
vrtmv cluster-verify --manifest rebuilt-fleet.txt -o fleet/
vrtmv cluster-verify --manifest rebuilt-fleet.txt --sign --json
vrtmv verify-fleet --manifest rebuilt-fleet.txt -o fleet/
Each manifest line names a rebuilt host, its source (which clusters it), the migrate attestation carrying the membership proof, and the rebuilt target a direct verify reads:
<name> source=<kind>:<loc> attest=<path> target=<kind>:<loc>
where <kind> is root, image, or ssh.
| Flag | Purpose |
|---|---|
--manifest <path> | Manifest of rebuilt hosts, one per line (format above). |
--similarity-threshold <0.0-1.0> | Similarity at/above which two hosts join one candidate class (default 0.5). Lower merges more aggressively; higher splits. Stated in the report. |
-o, --out <path> | Output directory for the fleet report and each host's parity report (default vrtmv-fleet-verify). |
--sign | Sign each representative's parity report. Needs credentials. |
--json | Emit the fleet report as JSON to stdout instead of the human summary. |
coupled-migrate
Treat N coupled nodes as one migration subject. Where cluster-migrate groups independent hosts by similarity, coupled-migrate handles nodes that share state — a replicated database, a quorum cluster — and must move as a unit. The manifest keeps the <name> <kind> <locator> [target] shape and adds role= and from= to express the topology. A directed, asynchronous single-primary topology is plannable end to end; a quorum topology (Galera and similar) is named as one subject but the ring is not executed, and single-node dump/restore stays refused.
Emitting per-node attestations needs a --target. A directed-async edge is attested from a --soak window: without a soak record the cluster stays Inconclusive and is not billed. A quorum subject does not reconstitute from a soak — the soak attests an edge, not a mesh.
Billing follows migrate: each node that produces a migration attestation records one depersonalized migration usage event, billable per your plan and target (see Accounts, quota & billing). A node left Inconclusive for want of a soak, or a quorum ring that is not executed, is not migrated and records nothing.
vrtmv coupled-migrate --manifest topology.txt --target rocky9 -o out/
vrtmv coupled-migrate --manifest topology.txt --target rocky9 --soak edge-soak.json -o out/
| Flag | Purpose |
|---|---|
--manifest <path> | Topology-aware manifest, with role= / from= on each line. |
-o, --out <path> | Output directory for the cluster record and per-node attestations (default vrtmv-coupled). |
--target <os> | Target OS (e.g. rocky9). Required to emit per-node attestations. |
--soak <path> | A SoakRecord from vrtmv soak summarise --out for the edge window. Without it the cluster stays Inconclusive and is not billed. |
Services the roles will not start
Each node's role is the one migrate writes, so it fails at its last task while the node's source started services at boot that the role will not start. The runbook's plays stop the sequence at the first failure. Left to the roles, a cutover would stop after its first node, with that node applied and the others not.
So when any node has such services, coupled-migrate prints the count for each node, and cluster-migrate.yml begins with a play that runs on the controller and refuses the cutover before any node is touched. It names each node, how many services, and the role file that lists them with reasons. Read those lists, then run the playbook with -e vrtmv_services_not_started_acknowledged=true: the first play passes and so does each role's last task. The one variable accepts every node's list.
The first play is not written when no node has such services. It needs localhost to be reachable by the playbook run: a --limit that leaves localhost out skips it, and each role then fails at its own last task as above.
Nodes that report one identity
Each node is billed by its identity (see identity). When two nodes of the manifest report one identity, they are clones of one image or one node listed twice. The cluster is one subject, so the whole run is refused before the cluster record, the attestations, the runbook or any usage report is written. The refusal names the nodes and the file the identity came from. Run vrtmv identity on each clone, or fix the manifest, and run again.
container-fleet
Emit one Containerfile per workload for many hosts from a single manifest. The batch form of migrate --format container: each host is read and planned once, and every workload the manifest names on it is rendered with its own entrypoint.
vrtmv container-fleet --manifest fleet.txt --target rocky9 -o out/
vrtmv container-fleet --manifest fleet.txt --json
| Flag | Purpose |
|---|---|
--manifest <file> | Hosts and their workloads, one workload per line. |
--target <spec> | Default target for a host line that names none (default rocky9). |
-o, --out <dir> | Root output directory (default vrtmv-container-fleet). Each workload writes <out>/<host>/<workload>/Containerfile and, when the host carries config, stage-carried.sh beside it. Run that script against the host's source root before building (Staging the carried config). |
--json | Print the per-workload result list as JSON instead of the text summary. |
The manifest
The host columns are the ones readiness and cve-scan-fleet read, [name] kind locator [target], followed by per-workload fields:
web01 image /vmfs/web01.vmdk rocky9 entrypoint=/usr/sbin/httpd -DFOREGROUND
app01 image /vmfs/app01.vmdk rocky9 workload=api entrypoint=/usr/bin/apid --port 8080
app01 image /vmfs/app01.vmdk rocky9 workload=worker entrypoint=/usr/bin/workerd
entrypoint= is the operator's assertion and must be last on the line. workload= names the workload within the host and defaults to the host name. preserve=<path> is repeatable. data=<path>=<class> is repeatable and declares where a data directory goes, as migrate --data does; a host with a data directory left undeclared is refused by name. assume-undetermined=<check> is repeatable and proceeds without a feasibility check that could not be determined, as migrate --assume-undetermined does, for the workload on that line. The host name and the workload id are directory names under --out, so each must be a single plain name. A fleet of hosts has the full rules, including what is refused.
A host read over SSH
An SSH collection reads /etc, the package database, the crontabs and a listing of /opt, /usr/local and /srv. It does not read /var, /home or /root, so whether those hold data is not determined, and a workload on such a host is refused until its line says assume-undetermined=local-state:
web01 ssh web01 rocky9 data=/srv/site=volume assume-undetermined=local-state entrypoint=/usr/sbin/httpd -DFOREGROUND
Declare the data directories you know with data=; the token covers what was not read, on your word. It does not cover a data directory the collection did find, such as files in /root: that one is refused by name until its own data= is on the line. The Containerfile says the check was assumed, gives the reason it could not answer, and lists what was not read; stderr gives the same reason. A data=<path>=image is still refused on an SSH host: the files of a data directory are not fetched, so there is nothing to stage. In 2.3.0 the token was not taken and every host read over SSH was refused.
Result and exit status
Every workload the manifest names ends in one of three states, and the summary names each one by host and workload: emitted, refused with the reason, or not collected when the host could not be read. One host's refusal does not stop the rest. The exit status is zero only when every named workload emitted.
Billing follows migrate: each host that is read, planned and authorized records one depersonalized migration usage event, billable per your plan and target (see Accounts, quota & billing). That is one event per host, as a single migrate --format container of that host records, however many workloads the host produces. A host that could not be read records no usage.
Hosts that report one identity
Every host's identity (see identity) is read before any host is planned. Hosts that report one identity are clones of one image, or one host listed under several names. Each of their workloads is refused before anything is emitted or billed for the host, with a reason that names the hosts and the file the identity came from. The other hosts go on, and the exit status is non-zero. Run vrtmv identity on each clone, or remove the second name from the manifest. A host read over SSH is collected once and its collection is kept on disk until its turn, so a large SSH fleet holds every host's collection at once; a disk image is mounted once to read its identity and again to emit.
ui
Launch a local migrate client for a one-shot migrate / preflight from a
browser. It binds to loopback only. The UI itself does not bill; a migrate
it launches records the same billable-eligible migration usage event as
the CLI.
The operator loop (assess → plan → verify) is panel, an additive
command. vrtmv ui is unchanged.
vrtmv ui # http://127.0.0.1:8765
vrtmv ui --port 9000
| Flag | Purpose |
|---|---|
--port <n> | Port to bind on 127.0.0.1 (default 8765). |
The server listens only on the loopback interface and guards against DNS-rebinding. It is intended for interactive use on the analysis host, not as a shared service.
panel
Launch a local operator panel for the assess → plan → verify loop. It binds to
loopback only and never applies. The panel itself does not bill; a migrate it
launches records the same billable-eligible migration usage event as the CLI,
and verify does not meter.
vrtmv panel # http://127.0.0.1:8766
vrtmv panel --port 9000
vrtmv panel --trust-host fleet.vrtmv.com --out /var/lib/vrtmv/fleet
| Flag | Purpose |
|---|---|
--port <n> | Port to bind on 127.0.0.1 (default 8766). |
--trust-host <host> | Accept one extra Host header besides localhost. Set only when the panel runs behind a trusted, authenticating reverse proxy — the panel still binds 127.0.0.1, so the proxy stays the only reachable front and the auth boundary. Omitted keeps the strict loopback-only guard. |
--out <dir> | Default fleet directory the read views use when a request carries no ?out=, so a hosted single-fleet dashboard shows its fleet without an out= in every URL. Omitted keeps the per-request behaviour. |
As of v1.2.1 the panel serves the redesigned Fleet Management Console: one shared theme across its views, with a light / dark / auto toggle that remembers your choice. The views and their data are unchanged — only the presentation. Both new flags default off and are for running the console behind a trusted proxy; vrtmv panel --port N is unchanged, and the console stays read-only.
This is additive to ui, which remains the one-shot migrate client.
The panel is the browser form of the console loop:
assess a source, write a plan, verify a rebuild. After write-plan it shows the
same apply instructions the console prints (ansible-playbook role path,
vrtmv data for carry). Apply stays on run; the panel never runs it.
The server listens only on the loopback interface and rejects a non-local Host header, a cross-origin POST, a non-JSON Content-Type, and OPTIONS. It is intended for interactive use on the analysis host, not as a shared service.
mcp
Speak Model Context Protocol on stdio so an agent on this host can collaborate
with vrtmv. Same binary, same API token as auth. Signed the same
way as every other vrtmv release.
One path to a running server
-
Install the signed binary — Installation. The tarball README is the verification procedure (
cosign verify-blobagainst the Sigstore bundle). MCP is a subcommand of that binary, not a second download. -
Store the API token as the same OS user the agent will run as:
vrtmv auth <token> -
Print the agent fragment with this binary's absolute path:
vrtmv mcp --print-configPaste that JSON into the agent's MCP servers config (Claude Desktop, Cursor, Grok, …). The file is the agent's; this command only prints the fragment. It does not embed the token —
vrtmv authalready stored it. -
Restart the agent. Ask it to call
vrtmv_whoami. If that returns your account, the server is registered and authenticated.
The agent must run as the same user that ran vrtmv auth. VRTMV_API_KEY still
overrides the stored token if you set it in that user's environment.
Flags
| Argument / flag | Purpose |
|---|---|
| (none) | Stdio MCP server. Stdout is the protocol. |
--print-config | Print the agent fragment and exit. Stdout is JSON, not the protocol. |
Tools
| Tool | Calls the API? | What it returns |
|---|---|---|
vrtmv_whoami | yes (GET /v1/account) | The authenticated account |
vrtmv_assess | no | Same JSON as vrtmv assess --json |
vrtmv_migrate | yes (Index + billing) | Package identifiers, counts, artifact paths. confirm=true (or preflight=true). |
vrtmv_verify | usage/sign only | Same JSON as vrtmv verify --json |
vrtmv_data_discover | no | Same JSON as vrtmv data discover --json |
vrtmv_data_plan | no | Artifact paths + attestation. confirm=true. Never executes. |
vrtmv_db_plan | yes (Index) | Database version attribution vs the target OS |
vrtmv_db_upgrade_path | yes (Index edges) | Ranked curated upgrade paths. Executes nothing. |
vrtmv_get_status | yes (GET /v1/version) | Public release status. Not the internal board. |
vrtmv_whats_new | yes (version + findings) | Releases and this account's later fixes (newest 50 considered) |
vrtmv_export_collaboration | yes (account + findings) | Newest 50 findings: id, title, dates, status, release |
vrtmv_updates | yes (version + findings) | One poll for releases and this account's newest 50 findings |
vrtmv_ask_question | sometimes (POST /v1/questions) | Local answer, or a tracked id. confirm=true. |
vrtmv_list_questions | yes (GET /v1/questions) | This account's questions |
vrtmv_get_question | yes (GET /v1/questions/:id) | One question; 404 if not yours |
vrtmv_report_finding | yes (POST /v1/findings) | Id of a redacted finding. confirm=true. |
vrtmv_list_findings | yes (GET /v1/findings) | This account's findings (newest 50 by default) |
vrtmv_get_finding | yes (GET /v1/findings/:id) | One finding; 404 if not yours |
vrtmv_assess takes exactly one of root, image, ssh — a path on this
host, never file contents. It is the free front door: local, unbilled.
vrtmv_migrate is the same path as vrtmv migrate. Preflight is free and
writes nothing. A real migrate requires confirm=true, seats the VM once
per the billing model, and writes the attestation and role under out.
The tool result is package identifiers and paths, not playbook bytes.
vrtmv_verify does not bill: /v1/attest/sign records no usage, and kind
verify is seat-only. Only usage kind migration is billable.
vrtmv_data_discover / vrtmv_data_plan / vrtmv_db_plan /
vrtmv_db_upgrade_path are the data and database lane: plan-only.
source_offline is the explicit assertion that the source was quiesced.
Live vrtmv run --db-upgrade stays on the CLI with --db-backup-dir.
Findings and questions are this account only. Another tenant's id is 404, not 403. Writes to those queues are capped at 10 per minute and 50 per rolling day — tighter than the plan's API rate limit — so a runaway agent cannot fill triage.
This is not the hosted Index adapter. Workload data stays on this host.
auth
Store, show, or clear the local API credentials so the CLI, GUI, and vrtmv mcp authenticate without environment variables. Local only — records no usage event and does not bill.
vrtmv auth <token> # store a token
vrtmv auth <token> --url https://api.vrtmv.com
vrtmv auth # show masked token + stored URL
vrtmv auth --clear # remove stored credentials
| Argument / flag | Purpose |
|---|---|
<token> | The API token to store. Omit to show current status. |
--url <url> | Also store a custom API base URL. |
--clear | Remove the stored credentials. |
The credentials file is written with owner-only permissions, and the token is shown masked. See Authentication for environment-variable precedence and the HTTPS requirement.
The Vrtmv API
The Vrtmv API (vrtmv-api) is the authenticated service the client talks to. It is the only thing that connects to the Translation Index, and it is the account, licensing, and metering boundary.
Authentication
Every protected endpoint requires Authorization: Bearer <token>. Tokens are issued per account and are stored server-side only as a SHA-256 hash — the plaintext is never persisted. An account can hold multiple tokens, and each request is attributed to the token that authenticated it. See Authentication for the client side.
Request shape
- Requests and responses are JSON.
- Batch endpoints (
resolve,translate,config-paths) accept up to 1000 items per call. - Request bodies are size-limited and requests are time-bounded; a burst cannot exhaust the connection pool.
What the client sends
Only identifiers: package names, canonical slugs, a (source, target) release pair, and — for metering — a depersonalized VM fingerprint. Image contents and configuration never reach the API.
See Endpoints for the routes and Accounts, quota & billing for the commercial model.
Endpoints
All routes are under /v1. Every route except health requires a bearer token.
| Method & path | Auth | Purpose |
|---|---|---|
GET /v1/health | public | Liveness check. |
POST /v1/resolve | bearer | Native package names → canonical slugs for a release. |
POST /v1/translate | bearer | Canonicals → target packages (+ confidence, caveats, conditionals). |
POST /v1/config-paths | bearer | Canonicals → config-path relocations for a release pair. |
POST /v1/usage | bearer | Record a depersonalized VM usage event (preflight / readiness / cve_scan / drift / migration / verify). |
POST /v1/authorize | bearer | Pre-check whether a usage event would be allowed (the non-recording twin of /v1/usage); returns the gate decision without claiming a seat. |
POST /v1/enroll | bearer | License (seat) a VM by fingerprint — the lightweight first-class alternative to seating via a full migrate/verify. Capacity-gated (402 quota); idempotent by fingerprint. |
POST /v1/cve-scan | bearer | Match a VM's packages against advisories (license-gated; never metered; auto-claims a seat when the account has capacity). |
GET /v1/account | bearer | The account's billing/usage summary. |
GET /v1/account/usage | bearer | The account's billable-operation events, newest first. Paginated (limit default 50, max 1000; offset). |
GET /v1/account/vms | bearer | Every licensed VM on the account, with when it first became billable, its last activity, and the operation kinds it has run. Paginated. |
GET /v1/account/statement | bearer | The account's billing statement (instances, rate, annual total). |
GET /v1/account/capacity | bearer | Provisioned licence capacity and the active term (seats granted, valid-from / valid-until), plus a per-term terms breakdown. |
GET /v1/account/entitlements | bearer | Alias of /v1/account/capacity — same payload. |
GET /v1/account/terms | bearer | Alias of /v1/account/capacity — same payload. |
POST /v1/findings | bearer | File a finding (bug / coverage / measurement). Hostnames, domains and paths are stripped. Not billable. |
GET /v1/findings | bearer | This account's findings, newest first (default 50, max 100). |
GET /v1/findings/:id | bearer | One finding belonging to this account. 404 if missing or not yours. |
POST /v1/findings/:id/resolve | bearer, plan internal | Mark fixed. Body is { "channel": "stable" | "beta" } only — the tag is the newest staged release on that channel, not a caller string. |
POST /v1/questions | bearer | File a question. Hostnames, domains and paths are stripped. Not billable. |
GET /v1/questions | bearer | This account's questions, newest first (default 50, max 100). |
GET /v1/questions/:id | bearer | One question belonging to this account. 404 if missing or not yours. |
POST /v1/questions/:id/answer | bearer, plan internal | Write the team's answer. |
Metering & usage
resolve, translate, and config-paths each record a metered request against the account (recorded, not charged — billing is per-instance-seat, not per-lookup). POST /v1/usage records a per-VM event whose kind is one of:
preflight— a feasibility check; free and ungated;readiness— a fleet feasibility check; free and ungated;migration— a real migration; a billable seat kind (claims a paid instance seat, 402 quota-gated);verify— a signed parity attestation; also a billable seat kind (claims the same per-VM seat);cve_scan— a CVE scan; license-gated and never metered. On an unlicensed VM it auto-claims a lightweightlicenseseat when the account has capacity (VRTMV-620), and is refused with402only when capacity is genuinely exhausted. The response carriesseat_claimed: trueon the call that claimed the seat andfalseotherwise;drift— ongoing monitoring; license-gated and never metered, but unlikecve_scanit requires a pre-existing license — refused with402 UNLICENSED_VMon a VM not already seated by amigration,verify, orlicense.
A seat is deduped per VM fingerprint across the billable kinds, so a migration and a later verify of the same VM claim one seat, not two.
The body may also carry host_hint: 64 lowercase hexadecimal characters, a one-way hash of the host's hardware id keyed with its machine-id (see Where your data goes). It is optional, and a body without it is recorded as before. Any other value is refused with 400 and nothing is recorded. The hint is stored with the event and counts the hosts that report under one fingerprint; it changes no seat, gate or charge.
POST /v1/enroll is a third way to claim that same seat, on its own. It records a license event — also a billable seat kind — from just a fingerprint (plus the host's OS for the statement), with no translation or attestation. It is the first-class licensing verb: seating a VM no longer has to be a byproduct of a minutes-long migrate/verify. It is capacity-gated exactly like migration/verify (402 quota when the account is out of seats), never meters an overage, and is idempotent — re-enrolling a seated VM claims nothing new, and a later migration/verify of an enrolled VM does not claim a second seat (same fingerprint dedup). Request body is { "vm_fingerprint": "...", "source": { "distro": "...", "version": "..." } }; the response is { "seated": true, "already_licensed": <bool> }.
Capacity and terms
GET /v1/account/capacity (aliases /v1/account/entitlements and /v1/account/terms) reports what the account holds and through when. It returns both a pooled summary and a per-term breakdown:
{
"account_id": 42,
"licensed_seats": 500,
"license_valid_from": "2026-01-15",
"license_valid_until": "2029-01-14",
"active_pov_seats": 20,
"pov_ends_at": "2026-12-31",
"free_quota": 10,
"billable_vms_used": 214,
"terms": [
{ "kind": "license", "source": "subscription", "seats": 480, "valid_from": "2026-01-15", "valid_until": "2029-01-14", "note": null },
{ "kind": "license", "source": "manual", "seats": 20, "valid_from": "2026-06-01", "valid_until": "2027-05-31", "note": "PO reference" },
{ "kind": "pov", "source": null, "seats": 20, "valid_from": "2026-10-02", "valid_until": "2026-12-31", "note": null }
]
}
The pooled fields (licensed_seats, license_valid_from, license_valid_until, active_pov_seats, pov_ends_at) collapse every active grant into one figure: licensed_seats is the total active licence seats and license_valid_until is the latest end across them. For an account whose seats run through different dates that single term is the longest one, which is why the breakdown exists.
free_quota is the account's free allowance and billable_vms_used is the count of billable VMs (the set /v1/account/vms enumerates), so free_quota + licensed_seats + active_pov_seats is total provisioned capacity and billable_vms_used is consumption. Both are account-level pool figures, never a per-VM assignment.
terms un-collapses that. Each entry is one active term:
kindislicenseorpov.sourceis the licence grant source (subscription,manual, orpromoted-from-pov) for a licence entry, andnullfor a POV (kindalready names it).seatsis the seat count for that term. Licence terms group active grants by (start, end, source, note); a POV term is a single POV.valid_from/valid_untilare the inclusive start and the human last-active day (YYYY-MM-DD). The stored upper bound is exclusive, sovalid_untilis the day before it.noteis the grant's commercial context (a PO number, an amount — shape only), ornull.
Only currently-active capacity appears — the same set the 402 seat gate counts. A revoked, expired, not-yet-started, or converted-POV grant is excluded from both the pooled totals and terms. The array is empty when the account holds no active capacity.
Per-VM visibility and usage history
GET /v1/account/vms
Every VM that has done a billable operation on the account — the per-host view of what is licensed. Paginated with limit (default 50, max 1000) and offset; page until has_more is false.
{
"vms": [
{
"vm": "a3f9c21b4e07",
"first_billable_at": "2026-02-04 16:22:08+00",
"licensed": true,
"last_seen_at": "2026-09-30 08:11:52+00",
"kinds": ["cve-scan", "migration"]
}
],
"limit": 50,
"offset": 0,
"count": 1,
"has_more": false
}
vmis the first 12 characters of the VM fingerprint — a stable per-VM key within the account.first_billable_atis when the VM first did a billable operation: its "licensed since" date.licensedis alwaystruefor a listed VM — membership in this list is being licensed, and it is permanent (re-running work on the VM is never blocked and never claims a new seat).last_seen_atis the most recent operation of any kind on the VM.kindsis the distinct operation kinds the VM has run, sorted.
This reports, per VM, whether it is licensed and since when. It does not label a VM with a seat type (paid / free / POV): seats and POV are account-level pools with no per-VM assignment, so there is no per-VM seat kind to report. Use /v1/account/capacity for the pool totals.
GET /v1/account/usage
The account's billable-operation events, newest first (at descending, id the stable tiebreak). Paginated with limit (default 50, max 1000) and offset; page until has_more is false to read the entire history.
{
"events": [
{
"id": 48213,
"kind": "migration",
"vm": "a3f9c21b4e07",
"source": "CentOS Linux 7",
"target": "Rocky Linux 9",
"at": "2026-09-30 08:11:52+00"
}
],
"limit": 50,
"offset": 0,
"count": 1,
"has_more": false
}
limit and offset echo the effective (clamped) values used; count is the number of events returned; has_more indicates a further page exists; id is a stable per-event key for deduping across pages.
Errors
Errors return a JSON {"error": "..."} with a standard status code:
| Status | Meaning |
|---|---|
400 | Malformed request (empty batch, over the 1000-item limit, bad kind). |
401 | Missing, invalid, or disabled token. |
402 | Seat quota reached (a billable migration / verify / license, or an auto-enrolling cve_scan with no capacity left), or UNLICENSED_VM for a drift on a VM with no seat — see Accounts, quota & billing. |
404 | Unknown release, artifact, or token id. |
Accounts, quota & billing
vrtmv bills one price per distinct instance, per year. A seat is claimed by a real migration, a signed verify, or a lightweight license (the vrtmv license/enroll verb); analysis and feasibility checks are free, and re-running any kind against the same VM never bills twice.
On-Demand is $799 per instance a year, Pro $599 (bought 10 at a time) and Fleet $399 (100 at a time). Each price covers the migration, its signed parity attestation, and ongoing monitoring. There is no separate one-time fee.
What is billable
A usage event is billable-eligible only when all hold:
- its
kindis a billable seat kind —migration,verify, orlicense(preflight and readiness are always free;cve_scananddriftare license-gated and never metered —driftis refused on a VM not already seated by a migration, verify, or license, whilecve_scanauto-claims a lightweightlicenseseat when the account has capacity (VRTMV-620) and is refused only when capacity is genuinely exhausted); - the target is not a free target (
api.free_target, which is currently empty — every target bills); - the account is not on an unlimited plan.
Billing counts distinct VMs by fingerprint, so re-migrating the same VM does not bill twice.
The waterfall
Each account's position is computed as a waterfall:
- Free quota — a number of billable instances included at no charge (10 by default).
- Annual subscription — beyond the free quota, each instance is billed at the account's tier rate, per year, as a single subscription whose quantity tracks the instance count.
GET /v1/account returns this summary — free quota, billable instances, paid instances, the
per-instance annual rate and the annual total — which is what an account page renders.
Prepaid credits were retired on 2026-08-07 along with the one-time migration fee, since a credit pre-bought a per-migration charge that no longer exists. The
credits_*fields remain in the API response for compatibility and report zero. The ledger is preserved and closed: no new grants can be created. To comp an account, its free quota is raised instead.
Licensing became first-class with
vrtmv license/enroll(POST /v1/enroll). Until then the only way to seat a VM was as a byproduct of a full migrate or verify — minutes of translation and attestation. Enrolment claims the same per-instance seat from just a fingerprint, so spare seats can be lit up directly and a VM can be licensed before it is ever migrated (which is what letscve-scan/driftrun on it). It changes how a seat is claimed, not what a seat costs: the price, the per-fingerprint dedup, and the 402 capacity gate are all unchanged.
Seeing your seats and terms
GET /v1/account/capacity shows what the account holds and through when. Alongside the pooled
totals (licensed_seats and the active license_valid_from/valid_until), it returns a
terms array with one entry per active term — each licence term (grouped by window, source and
note) and each active POV, with its own seat count and dates. An account whose seats run through
different dates therefore shows each term rather than a single pooled figure collapsed to the
longest one. The gate enforces each term per grant; terms is how a customer verifies that
breakdown. /v1/account/entitlements and /v1/account/terms are aliases of the same payload.
The same response also carries free_quota (the free allowance) and billable_vms_used (how
many VMs are seated), so free_quota + licensed_seats + active_pov_seats reads as provisioned
capacity against billable_vms_used consumed, without a second call.
See Endpoints for the response shape.
GET /v1/account/vms lists those seated VMs one by one: each VM's fingerprint, when it first
became billable (its "licensed since" date), its last activity, and the operation kinds it has
run. It answers "which hosts are licensed, and since when" — the per-host view. Because seats and
POV are account-level pools with no per-VM assignment, it reports per-VM licensed status, not a
per-VM seat type. See Endpoints.
Enforcement
- A non-paying account whose free quota is exhausted is refused a new billable seat (migration or verify) with 402 Payment Required.
- A paying account soft-overages into invoiced on-demand usage rather than being blocked.
- An absolute hard cap applies to every non-unlimited account: once it reaches the per-account VM limit, further billable seat events are hard-blocked with 402 until the limit is raised. Unlimited accounts (negotiated large estates) are exempt.
Feasibility checks (preflight, readiness) are never gated.
Data boundary
Metering carries only a one-way VM fingerprint and the release pair — never host identity or workload data. See Security posture.
Supported targets
migrate and readiness take a target as a compact spec — a distribution name followed by a major version, with no separator.
rocky9 rhel10 alma9 ubuntu2404 debian12
Vrtmv expands the spec to the distribution and version the Translation Index uses. rocky9 is the default target for both migrate and readiness.
| Spec example | Expands to |
|---|---|
rocky9 | Rocky Linux 9 |
rhel10 | Red Hat Enterprise Linux 10 |
alma9 | AlmaLinux 9 |
ubuntu2404 | Ubuntu 24.04 |
debian12 | Debian 12 |
The set of source→target pairs that carry vetted translations is defined by the Translation Index; a pair with no coverage yields untranslated canonicals rather than guesses. There are no free targets — every target bills the same, Red Hat Enterprise Linux included. See Accounts, quota & billing.
Container base images
When migrate --format container emits a Containerfile, the FROM line is not a guess. It is
the provenance'd base image the Translation Index records for the target release (FR-22 C2), read
from the container_base_image table (sql/43, extended for openSUSE Leap by sql/214) and
returned to the engine with the translation. The same value is written into the Containerfile
and recorded in the signed attestation, so a build and its attestation can never name different
bases.
Two rules make the FROM deterministic and auditable:
- Family and tag only, never a digest. A pinned
@sha256:…digest is a session pin; the tag is the stable reference a registry resolves and verifies at pull time. The table'sCHECKconstraint structurally refuses a digest. - Absence is a refusal, not a placeholder. A target with no measured row makes
migrate --format containerrefuse atFROMrather than write a placeholder. Red Hat Enterprise Linux is unseeded on purpose — UBI is not RHEL, and a RHEL image lives behind a subscription registry this Index has not fetched — and SUSE Linux Enterprise is unseeded until its BCI catalog is measured.
The targets below are the ones carrying a measured base image today. The table is generated by
scripts/check-container-base-images-docs.py and bound by CI: change a row in sql/43,
sql/214 or sql/218, or the engine's list of left-out packages, and regenerate, or the
pipeline fails.
The third column lists the packages the engine leaves out of the dnf install line for that
image. Some base images ship a reduced variant of a package (curl-minimal, coreutils-single)
that provides the full package's name and conflicts with it, so asking dnf for the full package
aborts the build. The engine leaves those names out and says so in a comment in the
Containerfile. verify then reads the built image's own rpm database and counts a promised
name as present when an installed package provides it, reporting name (provided by variant).
The variant measured for each image is the one in parentheses. The column is read from the table
the engine itself uses
(BASE_IMAGE_PROVIDED in engine/src/render.rs), which was measured against each dnf base
image, not derived from the release number. On an apt or zypper base image the engine leaves
nothing out.
The fourth column lists the other names the measurement found on that image: names the engine
does not leave out, where asking the image's package manager for the name does not do what was
asked. Each entry is the name, and in parentheses the installed package it meets. Unless the
entry says otherwise, asking for the name aborts the install. Oracle Linux 8 and 9 ship the
full curl, libcurl and coreutils, so there it is the reduced names that abort. Ubuntu
26.04 ships coreutils-from-uutils, and apt there refuses the other coreutils-from-*
packages. On Debian 12 apt does something else, and the entry says so: it removes the
installed package, installs the name and reports success, so the build goes green with a
package missing that the image shipped. The first install line in the Containerfile does not
stop apt from doing that. The translations in sql/ send none of the names in this column
to the target they are listed for.
In both columns nothing means the image was measured and no such name was found, and
not measured means the measurement was not run on that image. The two openSUSE Leap images
are not measured: no probe here handles zypper. What was measured is recorded per image in
container_base_probe and container_base_variant (sql/218), and an image with no record
there fails the CI check, so it cannot be printed as nothing. The measurement asks about
names that conflict with an installed package directly. A name whose dependencies conflict
with the image is not covered, on any image.
| Target | Base image (FROM) | Left out of the install line (provided by) | Not left out, and must not be asked for (the image ships) |
|---|---|---|---|
| AlmaLinux 8 | almalinux:8 | coreutils (coreutils-single), libcurl (libcurl-minimal) | nothing |
| AlmaLinux 9 | almalinux:9 | coreutils (coreutils-single), curl (curl-minimal), libcurl (libcurl-minimal) | nothing |
| AlmaLinux 10 | almalinux:10 | coreutils (coreutils-single), libcurl (libcurl-minimal) | nothing |
| Amazon Linux 2 | amazonlinux:2 | nothing | libcrypt-nss (libcrypt) |
| Amazon Linux 2023 | amazonlinux:2023 | coreutils (coreutils-single), curl (curl-minimal), gnupg2 (gnupg2-minimal), libcurl (libcurl-minimal) | amazon-linux-repo-s3 (amazon-linux-repo-cdn) |
| Debian 12 | debian:12 | nothing | libelogind0 (libsystemd0): apt removes the package the image ships and reports success |
| Debian 13 | debian:13 | nothing | nothing |
| Oracle Linux 8 | oraclelinux:8 | nothing | coreutils-single (coreutils), libcurl-minimal (libcurl) |
| Oracle Linux 9 | oraclelinux:9 | nothing | coreutils-single (coreutils), curl-minimal (curl), fips-provider-next (openssl-fips-provider-so), libcurl-minimal (libcurl) |
| Rocky Linux 8 | rockylinux/rockylinux:8 | coreutils (coreutils-single), libcurl (libcurl-minimal) | nothing |
| Rocky Linux 9 | rockylinux/rockylinux:9 | coreutils (coreutils-single), curl (curl-minimal), libcurl (libcurl-minimal) | nothing |
| Rocky Linux 10 | rockylinux/rockylinux:10 | coreutils (coreutils-single), libcurl (libcurl-minimal) | nothing |
| Ubuntu 18.04 | ubuntu:18.04 | nothing | nothing |
| Ubuntu 20.04 | ubuntu:20.04 | nothing | nothing |
| Ubuntu 22.04 | ubuntu:22.04 | nothing | nothing |
| Ubuntu 24.04 | ubuntu:24.04 | nothing | nothing |
| Ubuntu 26.04 | ubuntu:26.04 | nothing | coreutils-from-busybox (coreutils-from-uutils), coreutils-from-gnu (coreutils-from-uutils), coreutils-from-toybox (coreutils-from-uutils) |
| openSUSE Leap 15 | opensuse/leap:15 | not measured | not measured |
| openSUSE Leap 16 | opensuse/leap:16 | not measured | not measured |
Minimal base images
The Index also records, per target, the distribution's minimal image, the package manager that
image ships, and the package names that must stay out of an install line on it. translate --json shows them for the target as container_minimal_base. migrate --format container
writes the image above and does not start from them. They are here for thin images, which
start from the minimal image and install only the packages one workload needs, with the
image's own package manager. Thin images are not released: verify does not grade one yet.
A target is in this table only if its catalog publishes a minimal image. Ubuntu, Amazon Linux and openSUSE Leap publish none under the image name used above, and have no row. Red Hat Enterprise Linux has none for the same reason it has no row above.
The last column is measured in each image with its own microdnf and does not follow from
the table above. A minimal image is not always the smaller one: Rocky Linux 9's full image
ships curl-minimal and libcurl-minimal, and its minimal image ships the full curl and
libcurl, so on the minimal image it is the -minimal names that cannot be installed. Each
entry is the name, and in parentheses the package the image ships in its place. Asking for
the name aborts the install, with one exception that is marked: on the three EL8 images
microdnf does not abort, it installs the i686 build of the package beside the x86_64 one
and reports success.
nothing and not measured mean what they mean in the table above. The two Debian images
were measured with apt, and their entry is marked the same way: apt does not abort, it
removes the package the image ships.
The table is generated from sql/217 and sql/218 by the same script and bound by the same
CI check.
| Target | Minimal base image | Package manager | Kept out of the install line (the image ships) |
|---|---|---|---|
| AlmaLinux 8 | almalinux:8-minimal | microdnf | coreutils (coreutils-single), libcurl-minimal (libcurl): the i686 build is installed beside it |
| AlmaLinux 9 | almalinux:9-minimal | microdnf | coreutils (coreutils-single), curl (curl-minimal), libcurl (libcurl-minimal) |
| AlmaLinux 10 | almalinux:10-minimal | microdnf | coreutils (coreutils-single), libcurl (libcurl-minimal) |
| Debian 12 | debian:12-slim | apt | libelogind0 (libsystemd0): apt removes the package the image ships and reports success |
| Debian 13 | debian:13-slim | apt | nothing |
| Oracle Linux 8 | oraclelinux:8-slim | microdnf | coreutils (coreutils-single), libcurl-minimal (libcurl): the i686 build is installed beside it |
| Oracle Linux 9 | oraclelinux:9-slim | microdnf | coreutils (coreutils-single), curl-minimal (curl), fips-provider-next (openssl-fips-provider-so), libcurl-minimal (libcurl) |
| Rocky Linux 8 | rockylinux/rockylinux:8-minimal | microdnf | coreutils (coreutils-single), libcurl (libcurl-minimal): the i686 build is installed beside it |
| Rocky Linux 9 | rockylinux/rockylinux:9-minimal | microdnf | coreutils (coreutils-single), curl-minimal (curl), libcurl-minimal (libcurl) |
| Rocky Linux 10 | rockylinux/rockylinux:10-minimal | microdnf | coreutils (coreutils-single), libcurl-minimal (libcurl) |
For how a target spec like rocky9 expands to one of these releases, see
Supported targets. For where the FROM is recorded in the attestation, see
Parity Attestation.
Operator overrides and digest pinning
The Index names each image by tag. migrate pins it by digest when it writes the
Containerfile: it asks the image's registry what the tag names at that moment and writes
FROM with that digest. See
Base image per target for what is asked, what is
recorded and what happens when the registry does not answer. The tables above are the tags;
no digest is stored in the Index.
Whether an operator can override the base image is still deferred to an owner decision. The
trade-offs are written up in docs/container-base-image-override-options.md.
Container output: scope, checks and base images
vrtmv migrate --format container writes a Containerfile for one workload taken from a source host. This page says what that covers when the source is a whole machine, which checks decide whether a Containerfile is written, what each check reads, and where the base image comes from.
Everything quoted in a box on this page is the text the engine prints. CI compares the boxes and the check table against the engine source, so the page cannot drift from them unnoticed. The target -> base image table lives on its own page, Container base images, generated from the Index and bound by its own check.
What a container build takes from a host
A full machine with several enabled services is a supported source. vrtmv treats it as the extraction of one workload, and you say which one with --entrypoint. vrtmv does not choose it and does not attest that it is the right one. It attests that it built the image around what you asserted.
What you get:
container/Containerfilewith four parts.FROMis the base image the Index holds for the target (see Container base images), by the digest its tag named whenmigrateran (see Base image per target).RUNinstalls the packages the attestation promises, in two lines. The first installs the translated package set, less any names the base image already provides under a conflicting variant; those are listed per image on the same page and named in a comment in theContainerfile. The second installs the same-name packages, see Same-name packages. OneADD vrtmv-carried.tar /adds the carried config files the image is given, each named in a comment above it; the ones it is not given are named in a comment with the reason, see Config the image is not given.ENTRYPOINTis your--entrypoint.container/stage-carried.sh, when the plan carries config. It copies the carried files intocontainer/vrtmv-context/and writescontainer/vrtmv-carried.tar, the archive theADDline reads. See Staging the carried config.- Comments in the
Containerfilefor config that moves on the target, manual steps from conditional rules, and anything vrtmv could not translate. vrtmv-attestation.json, as for every format. When aContainerfilewas written it carries acontainer_targetobject withbase_image,base_image_resolution,entrypoint,source_classandunits, the statement about the source's enabled units described under Enabled units, and acontainer_config_left_outlist when carried config was left out of the image. When the container output was refused there is nocontainer_targetkey.
What you do not get:
- Other enabled services. Nothing in the image starts them. They are listed by the
entrypointcheck so you can decide where each one goes, and the attestation names each one as not started. See Enabled units. - Scheduled jobs. An image has no cron or at daemon, so they stop. They are reported by the
scheduled-jobscheck. - Local state. Files on the host that no package owns are reported by the
local-statecheck as a size per tree and are not copied. You declare where each data directory goes, see Data directories. - Secret material. A private key, a password database or any file under
/etcthat its group and others cannot access is not added to the image. Each is named in theContainerfileand in the attestation, and most are mounted when the container runs. See Secret material. - Kernel modules. A container uses the kernel of the machine it runs on. A module an operator declared to load at boot refuses the output; one a distribution package declares by default is named for you to acknowledge.
- A built image. vrtmv writes the
Containerfileand you runpodman build.
One thing that may surprise you: the package set and the config list are the whole host's, not only the asserted workload's. vrtmv does not know which packages belong to which service, so RUN installs every translated package and every same-name package, and the archive holds every carried config file the plan's decision puts in an image (see Config the plan decided), less the source's package-manager and init configuration and less secret material. The other services' software is in the image. It is not started.
Enabled units
An image has no init. Nothing in it reads the link that systemctl enable writes, so a unit cannot be enabled in an image, and a link that is there says nothing about what the image runs. A package's install script writes such a link when the target distribution enables that package by default: installing cron and nginx in a debian:12 container leaves a link for each, and installing nginx in a rockylinux:9 container leaves none.
So a container plan does not promise that units are enabled. Its attestation says three things in container_target.units instead.
entrypoint_program: the first word of your--entrypoint.verifyrequires it to be in the image as a file with an execute bit. This is the one check of the image's units that can fail the verdict.entrypoint_unit: the unit you say the entrypoint stands for, when you give--entrypoint-unit <unit>. It is your assertion and is recorded asasserted_by: operator. vrtmv checks one thing about it: the source has that unit enabled. If it does not,migratestops before anything is written and lists the source's enabled units. vrtmv also reads the unit'sExecStarton the source and records whether its program is the entrypoint's (same-program,differs,nonewhen the unit has noExecStart, ornot-readwith the reason). A difference is printed on stderr and stops nothing:nginx -g 'daemon off;'and a wrapper script are honest differences. vrtmv does not pick the unit by matchingExecStart, because several units on one host can start the same program. Without--entrypoint-unitno unit is accounted to the entrypoint, and the report says so.not_started: every unit the plan would have enabled on a machine, less the entrypoint's unit, each by name with the reason.verifyprints the list beside the verdict every time it is not empty. These units do not change the verdict. To carry another of them, runmigrateagain with that workload's--entrypoint.not_started_never_promised: the other units the source had enabled, each by name with the plan's reason for not enabling it on a machine either. The image does not start these. Withnot_startedand the entrypoint's unit, this is every systemd unit the source had enabled.verifycounts and names them in the sameNOT STARTEDline, as their own group.
The list holds the units the Ansible role would enable: enabled by the operator, with a name on the target, and with a package the plan installs. A unit the source's own distribution enabled by preset was never promised for a machine either, and neither was one the plan refused for another reason, such as a unit whose package is not in the install list. Each is under aspects.services.refused, as before, and is named again in not_started_never_promised with the same reason. On a real host these are usually the larger group: the distribution's own services. Units masked on the source are listed under masks_not_written: the Containerfile writes no mask, and nothing starts those units.
With these three an image of a host with enabled units can be equivalent: its packages and config match, the program it starts is in it, and the units it does not start are named in the signed attestation and in the report. equivalent on an image does not say the host's other services are running. The NOT STARTED line next to the verdict says which are not.
What verify does not check here: that the image is configured to start the attested entrypoint. It reads the image's files, and the configured entrypoint is in the image's configuration. The report lists this under unverified, unless you name a running container with verify --probe-container: then the image's Entrypoint and Cmd are read from the runtime and compared. A command named without a slash is looked for in /usr/local/sbin, /usr/local/bin, /usr/sbin, /usr/bin, /sbin and /bin, the path a runtime uses when the image sets none; an image that sets another PATH needs the entrypoint given by its full path.
One attestation, two targets
One attestation is written per migrate run, whatever --format lists. A run with --format container alone emits nothing that enables a unit, so its attestation promises none: aspects.services.promised is empty and units.services_promise is replaced. verify reads such an attestation as an image's without being told.
A run with --format ansible,container also writes a role, and the role does enable the units on the machine it builds. That attestation keeps the promise for the machine and carries the container statement beside it, with services_promise: kept-for-a-machine. verify cannot tell from the target which of the two it was handed (the role's test targets are containers too), so you say: verify --target-kind container for the image, --target-kind machine for the machine. Without the flag such an attestation is read as a machine's, as it was before the flag existed, and the report says that --target-kind container reads it as the image. Verifying the image without the flag therefore reports the units as missing.
An attestation written by 2.2.0 or earlier has no units statement. It is graded as it always was, from the links, and --target-kind container is refused for it. Run migrate again to get the statement.
Same-name packages
A package the Index has curated a translation for is installed by the first RUN line. A package the Index has not curated, which the target's repositories publish under the name it has on the source, is a same-name package. The attestation lists them under passthrough_packages and counts them in packages_promised, and the Ansible role installs them in a block of their own. On most hosts they are most of the packages: 263 of 318 on the Rocky Linux 8 host measured for this page.
The second RUN line installs them. It is best-effort, as the role's block is: a name the target's repositories turn out not to have, or that cannot be installed beside what is already there, is passed over and the build goes on. For each such name the build prints
vrtmv: same-name package not installed: <name>
Read the build's output for those lines. vrtmv verify counts same-name packages in its passthrough class, which does not change the verdict, so an image missing one can still be equivalent. If the package that owns your --entrypoint is one of the names printed, the image has no program to start.
A same-name package the base image already provides under a conflicting variant is left out of the line, as for the first line, and named in a comment under it. verify treats the two sets differently here. It credits a translated package through the variant's Provides. It looks for a same-name package under its own name only, so it lists such a package as missing in the passthrough class: coreutils on a Rocky Linux 9 image, which has coreutils-single.
With --no-passthrough, or when the source and target are of different package families, there are no same-name packages and no second line.
Releases before this one wrote no line for them: see the release notes (VRTMV-752).
What you must supply:
--entrypoint, the one command the image runs. It is split on whitespace into exec form and written as given, so--entrypoint '/usr/sbin/httpd -DFOREGROUND'becomesENTRYPOINT ["/usr/sbin/httpd", "-DFOREGROUND"]. Shell quoting inside the value is not interpreted: an argument that contains a space is split into two. If your command needs one, point--entrypointat a wrapper script.- A decision for each finding marked
[DECIDE]invrtmv assess. --data <path>=volumeor--data <path>=disposablefor each data directory on the source. Without one, noContainerfileis written. See Data directories.- The source, mounted or extracted, for the staging script to copy the carried config from. vrtmv emits paths and never file contents, so
migratewrites neithercontainer/vrtmv-context/norcontainer/vrtmv-carried.tar. See Staging the carried config. - The data, in the image or in a volume. That is your call and vrtmv does not make it.
Staging the carried config
The Containerfile adds the carried config files with one line, ADD vrtmv-carried.tar /, and names each of them in a comment above it. migrate does not write that archive. It writes container/stage-carried.sh beside the Containerfile and says so:
The Containerfile adds … carried config file(s) from container/…, and migrate does not write that archive. Before building, run: sh <out>/container/…
So a build is three commands:
vrtmv migrate --root /mnt/src --target rocky9 --format container --entrypoint /usr/sbin/httpd -o out
sh out/container/stage-carried.sh /mnt/src
podman build out/container
The script takes one argument, the source root: the directory migrate --root read, or the same source mounted or extracted when migrate read it with --image or --ssh. It is written from the same list as the comment in the Containerfile, so the archive holds exactly the files named there. vrtmv data does not stage them: it plans the copy of workload data, not of config.
Each file is copied into container/vrtmv-context/ and the copy is checked. Where migrate recorded a sha256 for the file, the copy must hash to it, so a source that changed after migrate ran is caught. migrate records none for a file it could not hash; that copy is compared with the source byte for byte. That comparison shows the copy is what is on the source now. It cannot show the source is the one migrate read, so for such a file a wrong or stale mount is not caught, and a file that is empty there is staged empty. A clean run gives both counts; if the second is not zero, check the source root yourself.
When every file is staged the script writes container/vrtmv-carried.tar from the copies. It needs GNU tar for that. The archive holds the carried files and nothing else:
- One layer. The carried config is one instruction in the
Containerfilehowever many files there are, so a host with hundreds of carried files builds with the same number of layers as a host with two. In 2.2.0 and earlier each file was its ownCOPYline and its own layer, and a host with more carried files than the storage driver takes layers did not build. - The permission bits are kept. Owners are not: every file in the archive is owned by root, as a
COPYmade it. A set-user-ID or set-group-ID bit is not kept either. With the owner changed to root, the bit would make a program that ran as an account on the source run as root in the image. The script prints a line for each carried file that has one on the source; if the workload needs the bit, set the owner and the bit in aRUNline of your own after theADD. - No directories. A directory the image already has above a carried file keeps the owner and mode its package gave it. One the image lacks is made by the build, owned by root with mode 755. If you replace the
ADDwith aCOPYofvrtmv-context/you lose this: that gives every directory on the way the owner and mode of the staged one. - A file name with a space in it is carried. A name with a newline in it is carried too, and the comment in the
Containerfileshows a?where the newline is.
A file that cannot be staged is named on stderr with the reason: it is not on the source root, it could not be looked at there (a directory above it cannot be searched by the user running the script, or something above it is a link that cannot be followed or is not a directory), it is no longer a regular file, it is reached through a link that leaves the source root, it cannot be read, the copy failed, or the copy does not match. "Not on the source root" means the directory it should be in was searched and it is not there: check that the source is the one migrate read. "Could not be looked at" and "cannot be read" name the uid: run the script as a user who can read the source. The script goes on to the remaining files, names every one it could not stage, and exits 1. Nothing is left at that file's place in vrtmv-context/, including a copy from an earlier run, and no archive is written, so podman build stops at the ADD line. An archive from an earlier run is removed as soon as the script has found the Containerfile beside it, so a run that stops for any reason, a wrong argument or a missing tool included, leaves none. An empty or missing config file does not reach an image this way. Do not build until the script exits 0.
A plan that carries no config, or whose carried config is all left out of the image, has no ADD vrtmv-carried.tar line and no script, unless it has image-class data, which the same script stages. With --preserve, the preserved files are staged separately, by the script vrtmv data preserved writes; run both before building.
Config the image is not given
Some carried config is on the source and has no reader in an image. The staging script does not stage it, so it is not in the archive, and the comment above the ADD line does not name it. The Containerfile names each file in a comment, with its owning package, the rule and the reason:
# 4 carried config file(s) are NOT copied into this image. Each is on the source and has no reader
# in the image. The attestation lists the same files under container_config_left_out.
# /etc/inittab (sys-apps/sysvinit, init) — init configuration. An image starts the one command in ENTRYPOINT and runs no init, so nothing reads it
The attestation lists the same files under container_config_left_out, each with path, owner when the scan attributed one, rule and reason. Those paths are not in carried_configs, so vrtmv verify does not look for them in the image and does not report them missing.
rule | What is left out | Why |
|---|---|---|
source-package-manager | /etc/portage and everything under it, /etc/make.conf, /etc/dispatch-conf.conf, /etc/etc-update.conf | Portage configuration. The image's packages are installed by the target's package manager. |
source-environment | /etc/env.d and everything under it, /etc/profile.env, /etc/csh.env | Gentoo's env-update compiles these into the login environment. Nothing in the image runs it. |
init | /etc/inittab, /etc/rc.conf, /etc/runlevels and /etc/conf.d and everything under them | Configuration of sysvinit and OpenRC. An image starts one command and runs no init. |
Read the list before you build. A file under /etc/conf.d can hold a daemon's options, and a file under /etc/env.d a variable the workload needs. Neither reaches the image. Put the option in your --entrypoint or a wrapper script, and set the variable with ENV.
Config the plan decided
The plan gives every carried file a decision, and the archive follows it. A file the plan copies, translates or regenerates is in the archive. So is a file the plan leaves to your decision, such as a TLS certificate issued to the old host name: vrtmv keeps it in the image and says so. A file the plan marks obsolete on the target, or made no decision for, is not in the archive.
The Containerfile names both kinds in comments under the ADD line, each with the plan's reason:
# 1 of the file(s) above the plan left to YOUR decision. vrtmv decided for this
# image: kept. Each is in the image as it is on the source. The attestation signs
# each with decided_by_vrtmv. To keep one out, remove it from the image in a later
# layer or mount your own over it:
# /etc/pki/tls/certs/localhost.crt
# <the plan's reason>
# 1 carried config file(s) are NOT in this image, by the plan's decision for each.
# The attestation does not sign them as carried:
# /etc/dnf/modules.d/httpd.module [obsolete on the target]
# <the plan's reason>
A kept file is in the attestation's carried_configs with its source_sha256, like any carried file, and with "decided_by_vrtmv": "kept". Its decision.disposition.action is still decide. vrtmv verify checks the image for it. The key that belongs to a kept certificate is secret material and is not in the image.
The members of vrtmv-carried.tar are the paths of carried_configs in the attestation of the same run. The one exception is a file you named with --bake-secret that the plan would not have put in an image: it is in the archive and in container_secrets.baked_in, and not in carried_configs.
The Ansible role does not copy a file left to your decision. With --format ansible,container the one attestation signs the kept files all the same, so verify on a VM built from the role reports each of them missing.
Config owned by a package with no target
A file is not left out for its owning package. Where the plan's outcome for the owner is no_target, the image installs nothing for that package, and the file is still copied. The Containerfile names each such file in a second comment:
# 3 carried config file(s) owned by a source package the image installs nothing for ARE copied:
# a package of another name in the image may read such a file, and vrtmv does not know what reads a file.
# /etc/pam.d/su (sys-auth/pambase)
A file is read by a program, not by the package that ships the file, and vrtmv has no list of what the target's packages read. Two hosts set this:
- Debian ships nginx's config in
nginx-commonand its program innginx-core. Going to Rocky Linux 9 there is nonginx-common, and Rocky'snginxreads/etc/nginx/nginx.conf. - Gentoo ships
/etc/pam.d/suand/etc/pam.d/logininsys-auth/pambaseand/etc/mailcapinnet-mail/mailbase. The Index records that Rocky Linux 9 has no package for either. Rocky'sutil-linuxships and reads the two PAM files, and itsmailcapships the third.
In both, leaving the file out would have built an image that runs with the distribution's file in place of yours, and verify would not have looked for it.
Read this list too. A file in it may be one nothing in the image reads, and it may be one that is wrong for the target: Gentoo's /etc/pam.d/su can name a PAM module Rocky does not ship. vrtmv copies the file as it is on the source.
What this does not do:
- It does not read the files. The answer is from the path and the owner the scan recorded.
- A file is left out for its path only. Config owned by a source package with no target is copied and named, as above.
--preservechanges none of this. The path rules still apply.- The attestation does not record which copied files have an owner with no target. Only the
Containerfilecomment does. The owner and its outcome are both in the attestation, undercarried_configsandpackage_outcomes. - It applies to the container image only. The Ansible role and the image recipe carry these files to a VM as before.
- With
--format ansible,containerthere is one attestation for both. The files left out of the image are not in itscarried_configs, soverifydoes not check them on a VM built from the role either, though the role copies them there. - Package-manager configuration of the rpm and dpkg families, repository definitions and release files were already held back for every format, and are reported as before.
Secret material
An image layer can be read by anyone who can pull the image. So a file on the source that is secret material is not in the archive the Containerfile adds, and neither is a file vrtmv could not classify. The staging script does not stage them. The Containerfile names each one in a comment with the reason, the owner, the mode and the size, and the attestation records the same under container_secrets.
The files left out reach the container as mounts when it runs, from secrets that a second script creates from the source. See Mounting the files left out. vrtmv itself never reads a secret file to deliver it and never writes one.
A file is secret when one of these holds. The rules run in this order and the first that matches is the recorded reason.
| Rule | What it matches | Is the file opened? |
|---|---|---|
| Path | An ssh host private key or host certificate (ssh_host_*_key, ssh_host_*cert*). A Kerberos keytab (*.keytab). shadow, gshadow and their backups, /etc/security/opasswd. sasldb, sasldb2, sasl_passwd. chap-secrets, pap-secrets, ipsec.secrets, *.secrets, chrony.keys. /etc/wireguard/*.conf. Anything under a directory named private. id_rsa, id_dsa, id_ecdsa, id_ed25519. key3.db, key4.db, password, passwords, .htpasswd, opensearch.keystore. *.p12, *.pfx, *.jks. *.key, *-key.pem, *.pem.key. A Vault token or approle secret id under /etc/vault.d or /etc/vault. | No |
| Mode | A regular file under /etc with no permission bit for its group or for others, whoever owns it: 0600 and 0400, and also 0700, 0500 and 0000. A set-user-ID, set-group-ID or sticky bit does not change this. | No |
| Contents | A -----BEGIN … PRIVATE KEY----- line anywhere in the file (PKCS#8, encrypted PKCS#8, RSA, EC, DSA, OpenSSH, PGP). A PuTTY private key. A DER private key (PKCS#8, encrypted PKCS#8, RSA, DSA, EC). A PKCS#12 store. A Java key store. A Kerberos keytab. | Yes, and only when the first two rules did not match. Nothing read is kept. |
There are three answers, not two:
- secret: a rule matched.
- not secret: every rule ran and none matched. The file is copied as before.
- unclassified: a rule could not run. The file could not be examined or read, it is larger than 5 MiB, or its mode on the source is not known. An unclassified file is left out of the image exactly as a secret is, and is named as
NOT CLASSIFIEDwith the reason. It is never treated as not secret.
The mode rule sweeps in files that hold no secret. /etc/ssh/sshd_config is 0600 on an Enterprise Linux host and is left out. That is deliberate: a file readable only by its owner was made private by someone, and vrtmv does not second-guess it. To copy such a file, name it with --bake-secret.
Mounting the files left out
migrate writes container/stage-secrets.sh beside the Containerfile when at least one left-out file is mounted. You run it against the source root, the same directory you give stage-carried.sh. It reads the files there and hands them to podman or to the cluster. It holds paths and names and none of the contents.
For podman, one secret per file:
sh out/container/stage-secrets.sh /mnt/src podman web
Each secret is named <prefix>-<key>, where the key is made from the file's path: /etc/pki/tls/private/site.key becomes etc__pki__tls__private__site.key. The script prints the option to give podman run for each file, one per line, in the form --secret web-<key>,type=mount,target=<path>,mode=<mode>.
For Kubernetes, one Secret holding every file:
sh out/container/stage-secrets.sh /mnt/src kubernetes web-vrtmv-secrets shop
vrtmv deploy --kind container --name web --namespace shop --image registry/web:1 \
--attestation out/vrtmv-attestation.json
deploy reads the list from the attestation and writes one read-only volumeMounts entry per file into the Deployment, each at the file's own path, from a Secret named <name>-vrtmv-secrets. It prints the staging command with that name filled in. Set KUBECTL=oc to have the script use oc.
To get the same Secret as a file, and create nothing in a cluster:
sh out/container/stage-secrets.sh /mnt/src manifest web-vrtmv-secrets shop web-secrets.yaml
This writes a Kubernetes Secret manifest to web-secrets.yaml: one Secret of that name and namespace, type Opaque, with every mounted file under the key the Deployment expects. It needs base64 and no kubectl, and it does not contact a cluster. The file is written with mode 0600 whatever your umask is. A file of that name from an earlier run is replaced; a link or a directory of that name is refused. The script looks at the name again when it puts the file in place, and refuses if a directory or a link to one has appeared there since.
The file holds the contents of the secret files. base64 is an encoding and hides nothing: anyone who can read the file has the keys. vrtmv does not read it and does not write it; the script does, on the machine you run it on. What to do with it is one of three things:
- Apply it:
kubectl apply -f web-secrets.yaml, then delete the file. - Commit it encrypted: encrypt it first, for example with sops, and commit only the encrypted file. Do not commit the file the script wrote.
- Load it into a secret store (Vault, a cloud secret manager, External Secrets) and delete the file.
The Secret's name must be one Kubernetes takes (lower-case letters, digits, - and ., with each part between dots starting and ending in a letter or digit) and so must the namespace (lower-case letters, digits and -); the script checks both before it writes, since it has no cluster to refuse them. The same limit applies as for a Secret created directly: all the files together must fit in 1 MiB.
The Containerfile has one RUN mkdir -p line for the directories the files are mounted in, so a mount point's directory exists whatever the packages installed.
The script fails closed. It looks at every file before it creates anything. If one is not a regular file on the source root, or cannot be read by the user running the script, each such file is named, nothing is created, and the script exits 1. The same goes for a file with a symbolic link among the directories above it: migrate follows no link, so that file is not the one it named, and a link with an absolute target would lead to a file of the machine the script runs on. The source root itself may be reached through a link. If podman fails part of the way through, the secrets that run created are removed again. A container whose secret does not exist does not start, under podman and in a cluster alike, so a failed staging run cannot produce a service running without its key. A secret of the same name from an earlier run is replaced. The manifest form fails closed the same way: the file is written whole or not at all, and when it is not written a manifest from an earlier run is left as it was. While it runs, the script works in a directory named .vrtmv-secrets.XXXXXX beside the file, which only your account can enter, and removes it when it ends. If the script is killed outright that directory can stay behind with part of the manifest in it; delete it.
Three kinds of file are left out and not mounted. Each is named as NOT MOUNTED with the reason in the Containerfile, in the attestation and in the deploy output.
- The source's password hashes:
shadow,gshadow, their backups and/etc/security/opasswd. Mounted over the image's own file they would replace the image's accounts. - The source's ssh host keys and host certificates. A container holding them answers as the source host.
- A file whose path has a character other than letters, digits and
/ . _ + @ % : -, or whose key would be longer than 200 characters. A podman mount option and a--from-fileargument cannot carry such a path. Mount it by hand.
What the mounts do not do:
- Every file is mounted owned by root, with the mode it has on the source (0400 when the mode is not known). A uid on the source need not be the same account in the image, so vrtmv does not set one. A service that does not run as root and reads a 0600 key needs the owner set: add
uid=andgid=to the podman option, or anfsGroupto the pod. - Nothing checks that the file staged is the file
migratesaw. No digest of a secret is recorded, by design, so a source that changed aftermigrate, or a wrong source root that has a file at the same path, is not caught. - A Kubernetes Secret holds 1 MiB. If the files are larger together the script says so and creates nothing.
- An unclassified file is mounted the same way as a secret one. Under a umask of 077 over
--ssh, where every carried file is unclassified, that is every carried config file. - The files are mounted with
subPath, so a change to the Secret does not reach a running pod. Restart it.
Copying a secret into the image anyway
--bake-secret <path> copies one file that was classed secret or unclassified. It takes one path per flag, exactly as the source has it, with no patterns. The Containerfile then names it with the other carried files and has a comment saying it is in the image because the operator named it, the staging script puts it in the archive (owned by root with the permission bits it has on the source, as every carried file is, so a 0640 file a service account owned is root's 0640 file in the image), and the attestation lists it under container_secrets.baked_in. A path that was not classed secret or unclassified is refused, and the error lists the paths the flag could name on this source.
What the attestation records
When a Containerfile was written, vrtmv-attestation.json has a container_secrets object:
excluded: one entry per file left out, withpath,state(secretorunclassified),reason,owner,group,uid,gid,modeandsize. Each also hasdelivery:mount, with thesecret_keyandmount_modeit is mounted under, ornot-mounted, withnot_mounted_reason.staging_script:container/stage-secrets.sh, present when at least one file is mounted.baked_in: the same fields for each file--bake-secretnamed.owner_not_recordedandmode_not_recorded, each present with the reason when no entry can have that field.
No contents and no digest of a listed file are recorded. A file under excluded is not in carried_configs, so verify does not look for it in the image. A file under baked_in that the plan carried stays in carried_configs without a source_sha256, so verify checks that it is present and not that its bytes match. verify does not check that an excluded file is absent from the image.
If you ask for --format ansible,container in one run, the two formats share one attestation, and its carried set leaves the excluded files out. verify of a machine built from that run does not check them. migrate says so on stderr. Run the formats separately if you need both checked.
Over --ssh
An SSH collection is unpacked on the machine running migrate by the account running it. Two things follow.
- The owner on disk is that account, so no entry has an
owneror auid, andowner_not_recordedsays why. - An unpack by an account that is not root clears the mode bits its umask names. Under umask 022 or 027 a 0600 file still reads 0600 and the mode rule works. Under a umask that clears every group and other bit (077), every file reads 0600 and the mode rule can tell nothing. vrtmv detects that from the collected
/etc/passwd, classes every carried file that is not a key by name or contents as unclassified, and leaves them all out. Runmigrateunder umask 022, or against a mounted root.
What detection does not cover
- Only
/etcis read. A key under/opt,/srvor a home directory is not detected. - A
--preservepath and an image-class--datapath are copied whole. Their files are not classified. - A symbolic link is not followed, so a key reached only through a link that leaves
/etcis neither copied nor named. - A secret written inline in a config file with ordinary permissions, such as a password in a 0644 file, is not detected by these rules.
- A private key in a format not listed above is detected only by its path or its mode.
One image, one process
One image runs one asserted process. A host that runs several daemons becomes one image per workload: run migrate once per workload with a different --entrypoint each time.
There is no init-in-container model and no supervisor model. vrtmv does not write a Containerfile that starts systemd or a process supervisor, and it does not attest one. If you put a supervisor behind --entrypoint yourself, the attestation records the command you gave and nothing about what that command goes on to start.
When a source has more than one enabled unit, the entrypoint finding says:
Supply the entrypoint, or split the workload. An image runs one process; these units include OS plumbing as well as the workload, and which is primary is not a fact this source records.
Without --entrypoint, no Containerfile is written and migrate prints the candidates:
a container image runs one command and vrtmv does not choose it — re-run with --entrypoint. Candidates from this source: … No Containerfile emitted.
The emitted file marks where the command came from:
# Entrypoint as asserted by the operator (--entrypoint); vrtmv did not choose it.
The feasibility checks
Every check runs on every source. Each one either reports a finding, reports that it could not look, or reports nothing. vrtmv assess prints all of them under Container feasibility: in one run, along with the number of checks it judged by — "no blocker found" is only ever a statement about those checks, never a claim about the source as a whole.
One run lists every check that could not run, never just the first. A source that trips three checks for three different reasons — an unreadable module directory, local state it could not measure, and an at-spool it never fetched — reports all three, not the first one it reached. The report does not short-circuit; the engine runs the five checks unconditionally and each records its own gap into one list, and every consumer of that list (the report, the Containerfile refusal, the collection self-check) iterates the whole of it. The engine test every_unmeasured_check_surfaces_in_one_run_it_does_not_stop_at_the_first holds this.
| Check | Severity | What it reads on the source | From an SSH collection |
|---|---|---|---|
kernel-modules | blocking or decision | /etc/modules, the modules-load.d directories under /etc, /run, /usr/local/lib, /usr/lib and /lib, OpenRC's /etc/conf.d/modules, the executable scripts in /etc/sysconfig/modules and /etc/rc.modules, and the package database for who owns each file | Yes. All of them are fetched. |
entrypoint | decision | Enabled systemd units, from the enablement links under /etc/systemd/system | Yes, for a systemd source. |
local-state | decision | Files no package owns, from a walk of the source. Needs a readable package database. | Partly. /opt, /usr/local and /srv only, as a size listing; /home and /root are named as not examined. |
scheduled-jobs | decision | /etc/crontab, /etc/cron.d, /var/spool/cron, /var/spool/cron/crontabs, /var/spool/at, /var/spool/cron/atjobs | Yes, as root. The at spools are listed on the host (job file name and owning uid) and never fetched. |
untranslatable-software | decision | The installs no package owns under /opt, /usr/local, /srv, /home and /root, and whether each holds an executable file. On migrate, also the plan's outcome for each installed package and that package's file list. | Partly. Installs under /opt, /usr/local and /srv are named and cannot be preserved from the collection; /home and /root are named as not looked at. |
A blocking finding means no image. A decision means an image is possible and something is yours to decide first.
kernel-modules
Fires when the source declares modules to load at boot. It names each one with the file that declares it. What the declaration means depends on who made it, and that is read from the package database, not from the directory the file is in.
An operator's declaration blocks. That is anything in /etc/modules or /etc/modules-load.d, a file no package owns wherever it sits, and a package's file that has been edited:
a container shares the host's kernel and cannot load a module. An image built from this source is not self-sufficient: it works only where the host has already loaded these, which is a fact about the target host that this source cannot establish.
Confirm the target host loads these, and record that dependency — or keep this workload on a VM. Do not assume the image will load them.
A distribution's default is a decision, and a Containerfile is still written. That is a file a package owns whose bytes still match what the package shipped. Each module is named with its file and its package, in assess and on the attestation:
Acknowledge each one: confirm the target host loads it, or that the workload does not use it. The image will not load any of them.
If the package database cannot say which of the two a file is, the check reports kernel-modules as not measured. It is never treated as a decision. That happens when the database is missing or was only partly read, and when it records no checksum this build can compare, which is the case for every file on an apk (Alpine) source:
— the first is a decision and the second blocks, so neither is claimed
If one of the directories exists and cannot be read, or one of them or a boot script is itself a symlink, the check also reports kernel-modules as not measured. If an operator's module was found and something else could not be read or attributed, it reports the blocker and adds kernel-modules-list to say the list may be short.
An SSH collection fetches every path this check reads. The kernel command line (modules-load=), modules loaded by an initramfs, and a service's own modprobe are not read by any source mode.
entrypoint
Always fires. It lists the enabled units as candidates and asks you to assert one.
an image needs one command, and 'which service is the primary one' is often not a fact about the source at all. It is recorded as the operator's assertion, in its own claim class — vrtmv attests that it built the image around what you asserted, never that it determined the primary service.
Because this check always fires, the best verdict a source can get from assess is "feasible, once decided".
The candidates are what the source starts at boot. On a systemd source those are its enabled units. Init scripts are read when the source has OpenRC's /etc/runlevels, or has no /etc/systemd/system: a script linked into a runlevel (/etc/runlevels/<level>/ on OpenRC, an S link under /etc/rc<N>.d on SysV) is listed among the enabled units under the script's name. A source that has both /etc/systemd/system and SysV init scripts is read as systemd only, and its init scripts are not listed.
When init scripts were found and none is linked into a runlevel, the finding lists the scripts and reads:
… script-init service(s): …
When the enabled units could not be read and no init scripts were found, for example on a source with neither a systemd tree nor init scripts, it reads:
enabled units could not be read: …
Neither is a report that the source runs nothing. The check stays a decision and does not report itself as not measured in these cases. The last form is for a source whose systemd tree was read and enables nothing:
no enabled units were found on this source
local-state
Fires when files exist that no package owns. It gives bytes per top-level tree.
a build copies what the Containerfile names. Anything here that is data rather than software does not travel, and a container that starts with an empty data directory looks healthy while serving nothing.
Decide per tree, including any named as not examined: bake it into the image, mount it as a volume, or confirm it is disposable. This check cannot tell which it is — a total says how much, never what.
It reports not measured when no package database could be read, or when the walk examined no files.
Spool directories under /var/spool are walked. Files in one that no package owns are counted in the /var total, and the spool is also named on its own. Not every such file is queued work: a Debian or Ubuntu postfix keeps a chroot in its spool, and anacron keeps timestamps in one. So each spool is named with what its files are: queued for files in the queue of a layout the check knows, a description such as service files, not queued mail for the rest, and not classified when the layout is not one the check knows, where it does not say whether the files are queued work. The data discover page lists the layouts. Queued work has a different choice from other data: drain it before cutover, or carry it. When queued work is found, the action text has this sentence:
Queued work in a spool has its own choice:
A spool holding service files alone, such as postfix with an empty queue, is named as queued: none found and the action text does not have that sentence.
The crontab directories under /var/spool/cron and the at queue in /var/spool/at are not listed here. scheduled-jobs reports those. A spool that could not be read (a directory the user cannot list, or a link into a tree the walk leaves out such as /tmp) is named as not examined with the reason. It is never reported as empty. A queue directory of a known layout that is a symbolic link, such as /var/spool/postfix/deferred, is named the same way wherever it points, and its spool says queued: not known and not queued: none found. When some queued files were counted and another part of the spool was not read, the figure is a floor and says queued: at least. A /var/spool that holds only empty directories adds nothing to the finding.
Over SSH the listing covers /opt, /usr/local and /srv, as sizes without contents, up to 10,000 entries. Past that the totals are a floor and assess says so; if the listing stopped at the cap with nothing unpackaged seen, or could not be taken at all, the check reports local-state as not measured. /home, /root and /var/spool are not read, and the finding names them as not examined. Application data elsewhere, for example a database directory under /var/lib, is not listed and is not named. A mounted root or disk image is walked in full.
scheduled-jobs
Fires when the source has cron or at jobs. It gives the two counts.
an image ordinarily runs one process and no scheduler, so these simply stop. Nothing fails loudly — the work just never happens.
Move these to the platform's scheduler, or run them as separate one-shot containers. Carrying the crontab into the image does not make them run.
Over SSH the cron spools are fetched. The at spools are not: an at job file is a generated script that embeds the submitting user's environment, so the collection lists the spool on the host, records each job file's name and owning uid, and no job script leaves the host. The count comes from that listing, so an empty at spool and a spool with jobs both give an answer.
The check reports scheduled-jobs as not measured when the listing could not be taken, for example a login that is not root and has no --sudo, or a collection made by a release before 2.2.0, which recorded no listing:
so an empty job list here is indistinguishable from a host with no scheduled work; no claim is made either way
With jobs found and a spool that could not be listed, it reports the finding and adds scheduled-jobs-list to say the list may be short. Either way a check has not run, so the container output is refused (see below).
untranslatable-software
Fires when the source has software the migration installs nothing for. The image is built from the target's packages, so that software is not in it. This check names it and prints the --preserve argument for each thing --preserve would accept. It never sets the flag, and it is never a blocker.
It names two kinds of software.
An install no package owns, under /opt, /usr/local, /srv, /home or /root, that holds at least one executable file. A directory with no executable file is treated as data and is not named. vrtmv does not look for a target package for unpackaged software, and the finding says so.
An installed package the plan installs nothing for, that ships a program (a file in a bin or sbin directory, or under libexec). This half runs only on migrate, which has the plan. assess calls no API and judges no package, and prints:
One of them, untranslatable-software, looked only at software no package owns. Whether the target has a package for each installed one is the Index's answer, and `migrate --format container` reports it.
A package is named only in a state --preserve accepts, and only when there is evidence the target lacks it: the Index records that the target has no package for it, or the target's repositories were asked for the name and answered that they do not have it. A package the plan installs nothing for without that evidence is counted and not named, because the target may publish it under the same name. A package with that evidence that ships no program in a bin, sbin or libexec directory is named as such and no argument is printed for it. Most are libraries or data, and a preserved program brings the libraries it links to. vrtmv does not look in such a package for a program somewhere else, a daemon directly under /opt/<vendor>/ for example, and does not ask what --preserve would answer for one. If one of them is software you run, the image will not hold it. A package is said to ship no program only when its file list was read: on a source whose package database was read in part, or when a path the package lists in such a directory is reached through a link, is absent or could not be examined, the package is listed under not looked at: instead.
Every argument printed has been through the same resolution --preserve performs, on this source. That is the check of what the path is and whether it is eligible. It is not the whole of what migrate --preserve does: working out the libraries a program loads happens only when the flag is given, and can still refuse a path this check printed. On the CentOS 7 image this was measured on, nine arguments were printed, eight were accepted, and one was refused at that step with its reason. Software that resolution refuses is still named, with the reason, as something the image will not hold: a lone file in /usr/local/bin, for example, or a tree that holds a database data directory.
the image is built by installing the target's packages, so software with no target package is not in it unless it is carried unchanged. A carried file is attested as the source's bytes and is never attested equivalent.
An image with a preserved path is graded preserved-as-is by `verify`, never equivalent: what is attested is that the carried files are the source's bytes, not that they run. Container output only.
On a source another check blocks, the software is named and no argument is printed:
Another check found a blocker, and --preserve does not get around one: no Containerfile is written with or without it. This software still needs a decision once the blocker is gone.
This check does not report itself as not measured. Where it could not look, it says so inside the finding, as not looked at: followed by the tree and the reason, so it does not read as clear. The same goes for software it looked at and could not tell whose it is: on a source whose package database was read in part, a file of a package that was not read looks like one no package owns, so every install found is listed there with that reason and none is said to be missing from the image, on assess and on migrate. A tree or path that could not be read is listed the same way. For anything under not looked at:, whether the image will hold it is not known. It does not refuse the container output for that: an SSH collection never fetches /home or /root, and refusing every SSH source over software this check exists only to point at would be the wrong trade. What it could not look at is yours to check.
vrtmv migrate --format container prints the finding on stderr on every run, whether or not a Containerfile is written, and records it on the attestation under container_feasibility. Once a unit is named to --preserve, it is no longer in the finding.
What an SSH collection supplies, per check
An SSH collection streams a fixed path set, not the whole disk, and takes three listings on the host in place of file contents: the kernel-module version directories, the /opt, /usr/local and /srv trees, and the at spools. A fourth listing, of the sockets the source is listening on, feeds no check; see Listening ports of the source. Per check:
entrypoint: fully supplied. Every path the systemd scan reads is collected.scheduled-jobs: supplied when the login can list the at spools, which in practice means root or--sudo. Otherwise it is not measured and says which spool it could not list.kernel-modules: fully supplied. Every directory and script the check reads is fetched, with the package database that attributes each file.local-state: a size listing of/opt,/usr/localand/srv, capped at 10,000 entries./home,/rootand/var/spoolare named as not examined. Data elsewhere is not listed.untranslatable-software: the installs under/opt,/usr/localand/srvare named from the same listing. Their files are not fetched, so whether one holds a program is not known and none can be preserved from the collection; each is named with that reason./homeand/rootare named as not looked at. Packages are judged onmigrateas from any source.
A collection that could not gather a check's evidence leaves that check not measured, and the reason names what was missing. It is never read as "none". A mounted root or a disk image has none of these limits.
What decides whether a Containerfile is written
The checks produce one of four verdicts. assess prints it as the first line under Container feasibility:.
BLOCKED — this source cannot be a container image as it stands.
NOT DETERMINABLE — no blocker was found, AND a check could not run. This is not a statement that it is feasible.
FEASIBLE, ONCE DECIDED — no blocker found, and the decisions below are unmade.
NO BLOCKER FOUND — every check ran and none fired.
A blocker outranks a check that could not run. A check that could not run outranks everything else, including a clean result from the other checks.
migrate --format container writes a Containerfile only for the last two. When it refuses it prints one line on stderr and carries on with the other formats you asked for. The exit status does not change, so test for the file, or for container_target in the attestation, and not for the exit status. The line starts:
container: no Containerfile emitted —
followed by one of these reasons.
For a blocker, with each blocking finding and its action:
this source cannot be a container image and no Containerfile was emitted:
For a check that could not run, with every such check and its reason in the same line, separated by semicolons:
container feasibility could not be determined — a check could not run, so vrtmv cannot confirm there is no blocker, and no Containerfile was emitted:
For a missing --entrypoint, the message under One image, one process. For a target with no base image, the message under Base image per target below.
Proceeding without a check that could not run
--assume-undetermined <check> lets a run proceed when the named check could not be determined. Name one or more of kernel-modules, entrypoint, local-state, scheduled-jobs, comma-separated. migrate says so on stderr:
container: proceeding without the … check, which could not be determined — recorded on the attestation as assumed by the operator
vrtmv does not claim the check passed. In the attestation's container_feasibility.checks the check stays not-determined, keeps its reason, and gains assumed_by_operator: true. The Containerfile carries a comment:
feasibility check could not be determined for this source. The operator proceeded with
The line after it, # The check's reason:, is the reason the check gave, the one the attestation keeps.
verify names each assumed check in its report, under assumed by the operator in the text and as container_checks_assumed_by_operator in the JSON. That does not change the verdict or the exit code.
Naming local-state also proceeds without the data paths that were not read and have no --data declaration, see A path that was not read.
The flag never overrides a blocking finding: a blocked source stays blocked whatever is named. A check that found a blocker has answered, also when its list may be incomplete, so nothing is recorded as assumed for it. Nothing is recorded for any other check on a blocked source either, because the run does not proceed; stderr says did nothing: another check found a blocker. Naming a check that did answer on this source changes nothing, so one flag can be passed across a fleet, and the run says so by name on stderr every time:
had nothing to assume: the check was determined
Feasibility is recorded on the attestation only for a run that asked for --format container. Without it the flag assumes nothing, the attestation is the same as without the flag, and stderr says:
did nothing: this run did not ask for --format container, so no feasibility check is recorded
A name that is not a check is an error and the run stops before the source is read.
Listening ports of the source
An image does not say which port its process serves, and vrtmv deploy writes a Service only for the --port you give. To make that choice easier, an SSH collection lists the sockets the running source is listening on, and migrate --format container records them on the attestation under source_listening, whether or not a Containerfile is written. The key has one of three states:
observed: the listing was taken to the end.socketsholds one entry per protocol, address family, port and binding, withprograms, the executables of the processes that held the socket. An emptysocketsmeans the source was listening on nothing.observed_byisvrtmvandran_as_uidis the uid the listing ran as.source-at-rest: the source was a mounted root or an image. Nothing is listening there, and vrtmv does not infer a port from a config file or from the firewall.not-observed: the source was read over SSH and the listing failed or was cut short.reasonsays which. It says nothing about the source.
"source_listening": {
"state": "observed",
"observed_by": "vrtmv",
"method": "proc-over-ssh",
"ran_as_uid": 0,
"sockets": [
{ "protocol": "tcp", "family": "ipv4", "port": 80, "bound": "any", "programs": ["/usr/sbin/nginx"] },
{ "protocol": "tcp", "family": "ipv4", "port": 5432, "bound": "loopback", "programs": ["/usr/bin/postgres"] }
]
}
bound is any, loopback or specific; the address itself is not recorded. A program is the path the kernel gives for the running executable, so a python3 that is a link reads as the file it points to. When the Containerfile is written, migrate prints how many ports were seen and which of them the entrypoint's program held on an address other than loopback.
What it does not do: no port is written into the Containerfile or filled into deploy, and verify does not read the key. A listing taken by a login that is not root names no program of another user. Sockets in another network namespace on the source, such as a container's, are not listed. The listing is of the moment the source was collected: a service that was stopped then is not in it.
Base image per target
The base image is read from the Index, one provenance'd row per target release, as a repository and tag. A tag can name another image tomorrow, so migrate pins it: when it writes a Containerfile it asks the image's registry what the tag names now, and writes FROM <registry>/<repository>@sha256:<digest> with the tag in the comment above. Every build of that file starts from the same image.
- What is asked. The registry in the image's name, or Docker Hub for a name with none, over HTTPS and with no credentials, following a redirect only to another HTTPS address: the manifest under the tag, and for the source's architecture the manifest and the image config the tag's index lists. No layer is downloaded.
migratecomputes each digest from the bytes it received, and does not record one the bytes do not match. - Which digest. For a tag that names an index (one image per architecture), the index's, so the same
Containerfilebuilds on any architecture the tag had. For a tag that names one image, that image's. - What the attestation records.
container_target.base_imageis still the Index's name. Beside itcontainer_target.base_image_resolutionhasstate: pinned, therepository,tag,digest,digest_of(indexormanifest) and thefromthat was written, and underplatformthe image for the source's architecture: itsmanifest_digest,config_digest,layer_digests(as the registry stores them) andlayer_diff_ids(the same layers uncompressed, which an image built on this base has as its first layers). The architecture is read from the ELF header of the entrypoint's program on the source, or of/bin/shor/usr/bin/env. When it cannot be read, or the index has no image for it or more than one,platformhasstate: not_recordedand areason, and the digest inFROMstands. - When the registry does not answer.
FROMkeeps the tag, the comment above it says why, and the attestation hasstate: tagwith the samereason: the registry could not be reached, it wants a login, it does not have the tag, or it answered with something that is not a manifest. The run goes on and exits as it would have. An air-gapped run waits up to five seconds for the connection, once, and no single request is given more than twenty seconds. A line on stderr says which of the two happened. --no-base-digestwrites the tag without asking, and the attestation says the registry was not asked.
What this does not do. It reads no registry that wants a login and none over plain HTTP. It does not read a mirror from your registries.conf: the digest is the one the named registry served, and FROM names that registry in full so the builder asks the same one. Nothing compares the image you build with the recorded layers yet, and verify does not read base_image_resolution. container-fleet writes no attestation and still writes FROM with the tag. The install lines are chosen by the Index's name for the image, as before; what the pinned image ships is what the tag held when migrate ran.
The target -> base image table, and the rules that make the FROM deterministic, live on their own page: Container base images. That page is generated from the Index SQL (sql/43, sql/214) and bound by CI, so it is the single source of truth for the FROM a target gets — this page does not repeat it.
A target that is not in that table has no base image, and the container output is refused:
the Index has no provenance'd container base image for … so vrtmv will not guess a FROM and no Containerfile was emitted
There is no flag to assert a different base image for --format container. --target-base applies to --format image only. The base image is recorded in the attestation as container_target.base_image, and the digest FROM names as container_target.base_image_resolution.
Data directories
A build copies what the Containerfile names, and it names packages and carried config. A data directory is neither, so a container built from a host with data starts with that directory empty and looks healthy. migrate --format container therefore asks you where each data directory goes, and writes no Containerfile until you have said.
You say it with --data <path>=<class>, once per path:
| Class | What it means | What is written |
|---|---|---|
volume | The path is a volume, mounted when the container runs and filled outside the build. | A comment and a VOLUME line. |
disposable | The path is not carried. The container starts without it, by your decision. | A comment. |
image | The path is copied into the image at build time. | A comment, one ADD vrtmv-data.tar / line for all image-class paths, and the entries in the staging script. See Image-class paths. A database directory is never accepted as image. |
vrtmv never picks a class. It cannot tell a directory the workload only reads from one it writes, so the class is your assertion, recorded as yours. Nothing checks it: a path the workload writes that you declare disposable is gone, and vrtmv has no way to notice.
The directories that need a class are the rows of vrtmv data discover: each directory under /var, /opt, /srv, /home, /root, /usr/local, /data, /mnt or /media that holds a file no package owns, with databases and spools among them. That is more than the hosts assess classes as stateful, which looks for database engines: a web root under /srv is on the list. On most real hosts it is also a longer list than you expect, because shell history under /root and a service's state file under /var/lib are files no package owns. A declaration covers everything under its path and the most specific one decides, so --data /var=disposable --data /var/lib/redis=volume declares Redis's directory a volume and the rest of /var not carried. / cannot be declared.
A directory that --preserve carries needs no class.
With a directory left undeclared, migrate writes the attestation and no Containerfile, and lists every one:
data director(ies) on this source have no class, and vrtmv does not pick
Each entry has its file count and size, and says when it is a database or a spool. Run vrtmv data discover first to see the list without running a migration.
With every directory declared, the Containerfile has a block before ENTRYPOINT:
# Data on the source and the class the operator declared for each path (--data).
# /srv/site — volume — 21 file(s), 164 B on the source
VOLUME ["/srv/site"]
# /var/lib/redis — volume — 1 file(s), 1.2 KiB on the source (database: redis at /var/lib/redis)
VOLUME ["/var/lib/redis"]
# /var/lib/app — disposable — 10 file(s), 40 B on the source
A VOLUME line moves no data. The volume is empty until you fill it: vrtmv data plan --copy <path> --dest-dir <dir> writes the script that fills a directory you then mount, and for a database it writes the export and import runbook. For named podman volumes use --dest-volume <path>=<volume> in place of --dest-dir: the script then creates and fills one volume per path. For a Kubernetes cluster use --dest-claim <path>=<claim>: the script packs an archive per path, and vrtmv deploy --kind container renders a claim per path with an init container that fetches the archive and checks every file before the workload starts.
The attestation records the same list under container_data, with asserted_by: operator: each declared path with its class, file count and size and the detected directories it covers, plus undeclared when the run was refused. It is written whenever --format container was asked for, emitted or refused. It is not part of the verify verdict. verify reads it and names every path in its report. A volume is compared only when you name the directory mounted there (verify --data-dir <path>=<dir> --data-manifest <file>) or the podman volume (verify --data-volume <path>=<volume> --data-helper-image <image> --data-manifest <file>) or the Kubernetes claim (verify --data-claim <path>=<claim> --data-namespace <ns> --data-manifest <file>); without that the path is listed as not checked, and an image whose volume is empty gets the same verdict and exit code as one whose volume is full. See Data paths.
A declaration can be deeper than a row of data discover, which stops at three path components. With --data /var/lib/app=disposable --data /var/lib/app/uploads=volume the files under uploads are counted on their own and are the volume's, and /var/lib/app is recorded with the rest. Declared alone, /var/lib/app/uploads decides only its own files, and the rest of /var/lib/app still needs a class. When the row is a database or a spool, the deeper path is recorded as part of it, with part_of naming the row: --data /var/lib/redis/scratch=disposable reads (part of the database: redis at /var/lib/redis).
A declaration names a directory. A declared path that is a file on the source is refused, and the refusal names the directory the file is in. Its own path would decide nothing, since the walk counts a file in its directory, and a VOLUME at that path would be a directory in the container.
An image declaration is refused as a database when the path is inside a database directory, or holds one that this declaration decides. --data /var=image --data /var/lib/redis=volume is not that: the Redis directory has its own class, nothing under it is listed for the image, and /var is accepted or refused on its own files.
A declared path is looked up inside the source. A link in it, absolute or relative, is resolved against the source's root and never against the machine running vrtmv, so /srv/db -> /var/lib/redis declared image is refused as the source's Redis directory.
Image-class paths
--data <path>=image puts the path in the image. It is for content the workload reads and does not write: whatever a container writes there is gone when the container is replaced. The data is in every registry the image is pushed to.
migrate walks the path and records, under container_data.image in the attestation, every file, directory and symbolic link under it that no package owns: a file with its sha256 and size, a link with its text, and each entry with its numeric owner (uid and gid) and, for a file or a directory, its mode. A file a package owns is not listed, because the target's package provides it. Empty directories are listed. The file count is the one the declaration's comment shows.
migrate writes no data. The staging script beside the Containerfile, the same one that stages carried config, writes the archive the ADD line reads:
sh out/container/stage-carried.sh /mnt/source
podman build -t app out/container
The script checks every entry against what migrate recorded before it writes anything: a file's sha256, and each entry's owner and mode. Then it writes vrtmv-data.tar from the source with GNU tar, which records each entry's owner and mode as they are on the source. Recording them does not take root; reading every file does take a user who can. If one entry is not as recorded, it is named, no archive is written, an archive from an earlier run is removed, and the build stops at the ADD line.
image-class data entr(ies) are as migrate recorded them
Owners are carried as numbers. On a target of another family the same number can belong to a different account, or to none; the accounts check of verify is what reports whether the source's accounts exist on the target. The directories above the declared path are not in the archive and stay as the base image has them. That was measured for a path whose parent the base image has (/srv); a path whose parent the build has to create was not run. Extended attributes, ACLs and SELinux labels are not carried. A socket, a FIFO or a device node under the path is not carried either, and the Containerfile names each one.
An image-class path is refused, by name, when its list would not be the whole of it:
| What is found | Why it is refused |
|---|---|
| A database directory at, above or anywhere under the path | A database is never copied into an image. Declare it volume. |
| A symbolic link under the path that leads to data outside it | What it leads to would not be in the image. A link that stays under the path, or leads into a system tree such as /usr/share, is recreated as it is. |
| A file or directory under the path that could not be read, or a part of it the data walk did not read | The list would be short. --assume-undetermined local-state does not stand in for it. |
| The declared path is reached through a symbolic link | Declare the directory where it is. |
The path is under a path declared volume | A volume mounted there hides what the image holds under it. |
| The path is outside the directories the data walk reads | There is no list of its files. |
| The source is an SSH collection | A collection does not fetch the files of a data directory, so there is nothing to hash. |
A --preserve unit is at, above or under the path | Both would copy the same files. |
cannot be image-class
A more specific declaration under an image-class path decides its own files, and they are not in the archive: --data /srv/site=image --data /srv/site/uploads=volume copies the site and leaves the uploads to a volume.
Not done: nothing checks that the workload only reads the path. The list is taken when migrate runs, so a file that changes afterwards fails the staging script and migrate has to be run again. The build was measured with podman 4.9.3; a build with Docker was not run.
A declaration that decides nothing is recorded and said on stderr, not refused, so one set of flags can be passed across hosts:
decided nothing:
A path that was not read
A path the data walk could not read is not an empty directory and is not given a class for you. It is reported as not determined, and the Containerfile is refused until you do one of two things. Declare a class for the path, on your own knowledge of the host: the declaration is recorded with no file count and with the reason vrtmv has none. Or pass --assume-undetermined local-state, which proceeds without every such path and records them under container_data.not_determined with assumed_by_operator: true. When the local-state check could not answer for a reason of its own, the same flag assumes that too, and container_data.local_state_not_determined repeats the reason so that this key alone says everything that was assumed about data. In container_feasibility the local-state check keeps what it found and gains the reason its list is in doubt (the local-state-list entry). The flag does not stand in for a declaration on a directory that was read and holds data.
What counts as not read:
- a directory under a data root that could not be listed;
- a symbolic link under a data root, owned by no package, that points outside the data roots and outside the system trees, for example
/var/lib/mysql -> /disk2/mysql. The walk follows no link, and/disk2is not searched; - a directory at the top of the source that is not a data root or a system directory and is not empty, for example
/exportor/u01; - a symbolic link, under a data root or at the top of the source, that points into a data root at a path that is not on the source as read, for example
/u01 -> /mnt/vol1/u01with nothing mounted at/mnt/vol1; - a symbolic link at the top of the source that points outside the data roots and the system trees, for example
/export -> /disk2/export, and a data root that is itself such a link, for example/home -> /disk2/home. A data root that is a link is never walked through, whatever the link's text: followed,/home -> var/home2 -> /disk2/homewould be read on the machine running vrtmv at the second hop. The link is resolved inside the source at every hop. Where it ends under a data root on the source (/home -> var/home), the files are listed there under their own path and nothing is reported for the link. Where it ends in a system tree or a tree the walk leaves out (/home -> usr/home,/data -> var/cache/data), no other walk reads those files, so they are read there, inside the source, and listed under the data root's name (/home/alice). A data root that links to/or to/usris the exception and is named here, since/usr/localis already walked under its own name; - a data root that could not be looked at, for example one that is a link to itself;
- a mount point the source's
/etc/fstabnames, outside the system trees, that is empty or missing as read, for example an empty/srv/pgdatawith anext4line for it. That is what a database on its own filesystem looks like in an image of the root disk, or under a--rootwith only the root filesystem mounted. A mount point with anything in it was read and is walked. A line whose filesystem types are alliso9660orudfis removable optical media and is left out, for example the/dev/sr0 /media/cdrom0 udf,iso9660 user,noautoline of a Debian installed from a disc; no other line is,noautoor not. Only/etc/fstabis asked: a filesystem mounted some other way (a systemd mount unit, autofs) is not known here; - every mount point, when the source has an
/etc/fstabthat could not be read, or one that is a link to a file that is not on the source. It is reported against/, which no declaration covers, so only--assume-undetermined local-stateproceeds, and each declared path is recorded with its count as a floor. A source with no/etc/fstabat all is not this; - the whole walk, when the source has no readable package database. There is then no list to declare against. With
--assume-undetermined local-stateyour declarations are recorded as given, with no counts, and the attestation says the walk did not run; - everything outside
/opt,/usr/localand/srvon an SSH collection, which does not read/var,/homeor/root. Every--format containerrun from--sshon a host with nothing declared for those trees therefore needs--assume-undetermined local-state. Declare what you know, for example--data /var/lib/redis=volume, and assume the rest.
The Helm chart
When migrate --format container writes a Containerfile it also writes a Helm chart for the same workload, under container/chart/<name>/. The chart describes the Deployment that deploy --kind container writes: the workload's container, a claim and an init container for each volume-class data path, and the mounts of the secret files the image was built without. migrate does not run Helm and installs nothing.
<name> is the file name of the entrypoint's program, made lowercase with every other character written -, or the name you give with --chart-name. It is the chart's directory and its name in Chart.yaml. The Deployment, the Service and the Secret are named after the release you install, not after the chart. The source's host name is not used.
container/chart/nginx/
Chart.yaml
values.yaml
values.schema.json
files/vrtmv-fill.sh
templates/NOTES.txt
templates/_helpers.tpl
templates/deployment.yaml
templates/pvc.yaml
templates/service.yaml
A chart an earlier run left under container/chart/ is removed when a new one is written, whatever its name.
What the chart holds and what you give it
The chart holds what migrate read: the volume-class data paths, in the order the attestation records them, and each mounted secret file with its key and mode. It does not hold what migrate cannot know. You give those in a values file of your own (helm install -f <file>), and leave values.yaml as written:
| Value | What it is |
|---|---|
image.repository, with image.tag or image.digest | The image you built from the Containerfile and pushed. A digest wins over a tag. --chart-image writes it into values.yaml. |
data.claims | For each volume-class path: claim (the claim's name) and size, and optionally storageClass and listSha256. The list is given whole, with the same paths in the same order. |
data.source.url, data.source.region, data.source.credentialsSecret, data.manifestSha256 | Where the init containers fetch what vrtmv data plan --dest-claim packed, the name of the Secret with the bucket's keys, and the sha256 of the data plan's manifest. |
attestationSha256 | The sha256 of vrtmv-attestation.json, written onto the Deployment. The chart cannot hold it, because the attestation records the chart. |
service.ports | The ports of the Service, each a port and optionally targetPort, name and protocol. vrtmv does not know which ports the workload listens on. With none there is no Service and the container declares no port, and the notes Helm prints say so. |
probes.startup, probes.readiness, probes.liveness | A Kubernetes probe each, written under the container as given. None by default. |
replicas, resources, imagePullSecrets, image.pullPolicy, service.type | As in any chart. imagePullSecrets are names of Secrets, never a credential. |
nodeSelector, tolerations, affinity, podLabels, podAnnotations | Where the pod runs. A pod label named app is not written: the Deployment selects its pods by it. |
secrets.secretName | The Secret the files are mounted from, when it is not <release name>-vrtmv-secrets. |
Nothing else is a value. The environment, the security context, extra volumes and sidecars are not: each would make the workload something other than what was migrated.
Helm checks a values file against values.schema.json on lint, template, install and upgrade, and refuses:
- a chart with no image. The chart as written does not render, unless you gave
--chart-imageand the workload has no volume-class data.helm lint --strict <chart>on it alone fails, andhelm lint --strict <chart> -f <values>is the check; - a volume-class path with no claim, a path the migration did not declare, or the paths in another order;
secrets.mountsorsecrets.notMountedthat differ from whatmigratewrote. The keys are the onesstage-secrets.shmakes, and a mode is a decimal number (384 is0600);- a tag, a digest, a claim name, a size or a port that is not one.
The templates refuse two more, as deploy does: replicas above 1 for a workload with a claim, and a listSha256 with no data.source.url.
What Helm cannot check is what deploy checks by reading the data plan's manifest: that the manifest lists as many files as the migration counted, and that a listSha256 is the digest of the list data plan wrote. deploy does not write a values file for the chart yet. Until it does, run deploy --kind container --dry-run with the same answers first: it makes those checks and stops at the one that fails.
What the chart does and does not do
- It creates no Secret. The Deployment mounts the files from a Secret that
container/stage-secrets.shcreates from the source, as under Mounting the files left out. Create it beforehelm install: until it exists the pod does not start, andhelm install --waitfails.helm uninstallleaves it. - Every claim is kept. Each claim has
helm.sh/resource-policy: keep, sohelm uninstallleaves the claim and the data on it. Delete a claim withkubectl delete pvcwhen its data is no longer wanted. There is no value that turns this off. - Objects say Helm manages them. The Deployment and the claims have the label
app.kubernetes.io/managed-by: Helm, where thosedeployapplies sayvrtmv. Helm refuses to install over a Deployment thatdeployapplied withkubectl. - A failed upgrade is an outage for a workload with a claim. A claim is
ReadWriteOnce, so the old pod is stopped before the new one starts. Usehelm upgrade --atomic --timeout <t>, which rolls a failed upgrade back. - No Route and no Ingress.
Given the values deploy has, the chart renders the objects deploy writes, apart from the label and the keep policy above. The engine's tests compare the two as YAML for one workload with two claims and two mounted files, where Helm is installed; the pipeline's image has no Helm, so that comparison does not run there. The chart has not been installed on a cluster from a migrate run: what was installed and measured is a chart of this layout written by hand.
The chart's version
version in Chart.yaml is the version of the chart's layout, 0.1.0: what its values are and mean. It is not vrtmv's version, which is the annotation vrtmv.com/engine-version, and not the workload's: Chart.yaml has no appVersion, because vrtmv does not know one. Two charts of the same version are told apart by their digest.
What the attestation records
container_chart is present when a chart was written:
"container_chart": {
"path": "container/chart/nginx",
"name": "nginx",
"version": "0.1.0",
"files": [{"path": "Chart.yaml", "sha256": "…"}, {"path": "files/vrtmv-fill.sh", "sha256": "…"}],
"tree_sha256": "…"
}
tree_sha256 is the sha256 of the lines <sha256> <path> over the files, sorted by path. You can compute it again in the chart's directory:
cd out/container/chart/nginx
find . -type f | sed 's,^\./,,' | LC_ALL=C sort | xargs sha256sum | sha256sum
It is not the digest of an archive helm package writes: packaging the same chart twice gives two different archives. A chart with an edited template, or an edited values.yaml, is not the chart the attestation records. verify does not read the chart yet.
container-fleet writes no chart.
A fleet of hosts
container-fleet emits one Containerfile per workload for many hosts from a single manifest, so an estate of multi-daemon hosts is not one migrate --format container invocation per host. It reads the same host manifest cve-scan-fleet reads — the columns are unchanged — and adds per-workload tail tokens:
entrypoint=<argv>is the operator's assertion for this workload, exactly asmigrate --entrypoint. It must be the last field on the line, because its value runs to the end of the line and may contain spaces. A host with noentrypoint=is refused by name; vrtmv still does not choose one. A#afterentrypoint=is refused: it could be a trailing comment or part of the argv, and either guess would change the assertion that is recorded. Put comments on their own line, and put an argv that needs a#in a wrapper script on the image.workload=<id>names the workload within the host. It defaults to the host name, and a host that names more than one workload must give each a distinct id.preserve=<path>is repeatable — the same carry asmigrate --preserve, per workload.data=<path>=<class>is repeatable — the same declaration asmigrate --data, per workload, see Data directories. A host with a data directory left undeclared is refused by name with the list, and the rest of the fleet is still emitted. A path with a space in it cannot be written in the manifest; run that host on its own withmigrate.assume-undetermined=<check>is repeatable and takes a comma-separated list: the same word asmigrate --assume-undetermined <check>, described above under Proceeding without a check that could not run, per workload. It applies to its own line only, so two workloads of one host each say it. A host read over SSH needsassume-undetermined=local-state, because an SSH collection does not read/var,/homeor/root; declare what you know withdata=and assume the rest. A data directory that was detected still needs its owndata=: the token is for what was not read.container-fleetwrites no attestation, so the record is theContainerfile: a comment names each check that was assumed and gives the reason the check could not answer, and the data block lists the paths that were not read. Stderr says what the token did for each workload, with the same reason, including when a named check did answer and there was nothing to assume, so one token can be on every line of a manifest. It never overrides a blocking finding. A name that is not a check is refused with its line before any host is read. The bare wordassume-undeterminednames no check and that workload is refused. A workload on a line without the token, on a host where a check could not be determined, is refused by name and the rest of the fleet is still emitted; the refusal repeats whatmigratewould say and names the token.
A host runs one image per asserted process, with no supervisor, so a multi-daemon host is two or more lines that share the same name, kind, locator and target and differ only in their workload:
app01 image /vmfs/app01.vmdk rocky9 workload=api entrypoint=/usr/bin/apid --port 8080
app01 image /vmfs/app01.vmdk rocky9 workload=worker entrypoint=/usr/bin/workerd
Each workload's Containerfile is written to a deterministic path, <out>/<host>/<workload>/Containerfile, with its stage-carried.sh beside it: run sh <out>/<host>/<workload>/stage-carried.sh <source-root> for each workload before building it, as under Staging the carried config. The path is fixed so the key is a function of the manifest alone and can be written straight into an object store. The host name and the workload id are the two directory names in that path, so each must be a single plain name: no / or \, not . or .., and no leading -. A manifest with a name or id that breaks this is refused, naming the line, before any host is read. A host line with no name takes the last component of its locator, and is refused the same way when the locator has none. The entrypoint is recorded per workload in that workload's attestation, the same container_target.entrypoint a single host records.
One host's refusal does not stop the rest. Every host and workload appears in the run summary in exactly one of three states — emitted, refused with the reason by host and workload, or not collected when the host could not be read — so none is silently missing. The run exits non-zero if any named workload did not emit, so a pipeline can gate on it.
Billing is unchanged. A migration is metered once per host, keyed on the host's fingerprint, exactly as a single migrate --format container of that host is. A second workload emitted from the same host is no additional charge: a host is seated once, however many images it produces.
Containerize as-is: --preserve
vrtmv migrate --format container builds an image by installing the target distribution's packages. Software the target has no package for is not in that image. --preserve <path> carries such software into the image unchanged, with the shared libraries it loads.
The two are different claims. Translated software can be verified equivalent. Preserved software is attested as the source's bytes and nothing more: verify grades the image preserved-as-is, never equivalent, and does not say the program runs.
Available from 2.2.0. Container output only: there is no --preserve for the Ansible, Terraform or image formats.
When to use it
Use it for software the migration cannot install on the target:
- an install no package owns, under
/opt,/usr/local,/srv,/homeor/root: a vendor binary, an in-house application; - a file of an installed package the target has no package for: an interpreter the target distribution dropped, a vendor package the Index has no row for.
Do not use it for software the target carries. vrtmv refuses that, and names the target package. Installing the target's package is the path that can be verified equivalent and that the target's package manager will patch.
Prefer the target's own package where one exists under another name or in another repository. For an install no package owns, vrtmv does not look for one: it establishes that the software is unpackaged on the source, which is a narrower fact than the target having nothing for it.
It is not the path for data. A directory of user files is vrtmv data's, and a database's data directory is refused here and goes through the database export.
How vrtmv tells you a source has such software
The untranslatable-software feasibility check names it, from 2.3.0. vrtmv assess prints it under Container feasibility:, and vrtmv migrate --format container prints it on stderr and records it on the attestation. See the check for what it reads.
It is a decision for you, and it prints the --preserve argument for each thing the flag would accept. vrtmv never sets the flag. A run without --preserve writes the same translated Containerfile as before and says what is not in it.
What it tells you is narrower on assess than on migrate. assess calls no API, so it names software no package owns and judges no installed package. migrate has the plan and also names packages.
What you run
vrtmv migrate --root <src> --target rocky9 --format container \
--entrypoint "/opt/vrtmv/preserved/bin/opt/acme/bin/acmed -f" \
--preserve /opt/acme \
--preserve /usr/bin/python2.7 \
-o out
vrtmv data preserved --attestation out/vrtmv-attestation.json -o out
The flag is repeatable. Each path resolves to one unit: the whole unowned install the path is in, or the whole package that owns the file. Without --format container the flag is an error.
What the run produces:
- The
Containerfileis the translated image as before, with a fenced block added, opening with# ---- vrtmv PRESERVED AS-IS: not translated, not attested equivalent ----. Everything that translates is still installed from the target's packages. - A preserved file under one of the five directories above keeps its path. Every other preserved file, and every library and the loader it needs, goes under
/opt/vrtmv/preserved/sysroot, so the source's libc never replaces the target's. - A generated script,
check-kept-paths.sh, and two lines in the block that copy it to/opt/vrtmv/preserved/and run it before any preserved file is copied to a path it keeps. The build stops when the image already has something at one of those paths. - One generated launcher per preserved executable, under
/opt/vrtmv/preserved/bin, runs it under the carried loader with the carried libraries. Your--entrypointis not rewritten; point it at the launcher. - Two image labels,
com.vrtmv.preserved-as-isandcom.vrtmv.preserved-units, and a marker file at/etc/vrtmv/preserved-as-is. - The attestation records each preserved file's path, size, mode and sha256.
migrate copies no file contents. vrtmv data preserved writes a staging script and a sha256 manifest, and copies nothing either. You run the script against the source root. It copies each attested file into the build context, hashes the copy, and stops at the first one that does not match the attestation.
A script tree needs its interpreter in the image as well: the target's package for it, or the interpreter named to --preserve as its own unit.
What --preserve refuses
Each refusal names its reason, exits non-zero and writes no Containerfile.
- A path owned by a package the target carries, whether through a curated translation, a same-name package in the target's repositories, or a repository the default set leaves out (the message names
--target-repos). - A package that is part of the source distribution's own base.
- A package whose translation installs nothing for a reason the plan does not show to be the target having none.
- A path in no named install and owned by no package: a file directly under
/opt, a path under a category directory of/usr/localsuch as/usr/local/bin, a path under a dot directory, or a path outside the five directories. - A unit that is inside a database data directory, or contains one.
- A path that is a symbolic link, or goes through one. Name the path it leads to.
- A source whose package database could not be read, or was read in part. "No package owns it" cannot be told from "could not ask".
- An SSH collection, for a unit whose files were not fetched. An SSH collection lists
/opt,/usr/localand/srvand does not fetch them, and does not collect/homeor/root. Use--rootor--image. - A source the feasibility checks block or could not determine.
--preserveis not a way around a blocker. - A program that is run by a loader other than glibc's, that needs a library the source does not have, or whose library list depends on an input that was not evaluated.
Character-set conversion: what migrate tells you
From 2.3.0. Whenever a launcher sets GCONV_PATH, migrate --preserve reads the preserved files for a use of glibc's iconv and says which of three things it found. It says it in three places, so you see it before you build or deploy: on stderr, in a comment in the Containerfile block, and in the attestation under preserved.closure.gconv.iconv_use.
| What it prints | state in the attestation | What it means |
|---|---|---|
N preserved file(s) use iconv | uses-iconv | A preserved program or a library carried with it calls iconv_open or iconv, or a preserved script has the word iconv in it. Each file is named. |
no use of iconv was found | no-use-found | Every preserved file that is a program was read and none does. |
whether the preserved files use iconv could not be told | not-determined | No use was found, and at least one file could not be answered for. Each is named with the reason. This is not a finding that they do not. |
What to do with each:
- With a use, the preserved program converts character sets, and in the image it does so with the old glibc's modules, as on the source. Find what it starts. A program from the target image that it starts and that converts is the case that was measured to hang or crash. Start those with
GCONV_PATHunset. A preserved shell script that runsiconvruns the target image'siconvunless you preserved one, and that is the measured hang. - With none found, the same limit still applies to anything the preserved program starts. The launcher sets the variable whatever the program does.
- With could-not-tell, treat it as a use until you know otherwise.
What is read: the dynamic symbol table of each preserved file and of each carried library that is not the old glibc's own, and the text of each preserved file that starts with #!, up to 1 MiB.
What is not read, so what no use of iconv was found does not say:
- which programs a preserved program starts, and whether those convert;
- a conversion glibc makes without
iconvbeing called. Its multibyte and wide-character functions use the same modules in a locale whose character set needs one, and so doesfopenwithccs=; - anything a program loads at run time from outside the preserved files;
- what a script's interpreter converts. A script without the word
iconvis always could-not-tell; - a statically linked program, which is always could-not-tell;
- a text file that does not start with
#!, such as a shell fragment another script sources. It is not counted either way, with the wordiconvin it or without; - a program that converts with GNU libiconv or ICU. Those do not read
GCONV_PATHand are not reported.
A script is matched on text, so a comment that mentions iconv counts as a use.
The claim the output carries
verify never returns equivalent for an image with preserved files. 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. It is not a pass. Exit 0 still means equivalent and nothing else, and there is no flag that maps 5 to 0. A pipeline that has decided to accept a preserved image gates on 5. With preserved files, verify never exits 0 or 3: a target-limited report exits 1, and under --require the preserved files are always a required class.
What is attested about a preserved file is that the file in the image has the same sha256 as the file on the source. Not that it runs, and not that it behaves as it did.
What it does not do
None of the following is softened by the image building.
- It does not say the program runs on the target.
- The library list is a floor. Libraries a program loads at run time with
dlopenare not in it, and the attestation records run-time loading as unmeasured. - Name and user lookups can load the target's libraries into the preserved program. The image carries the old glibc's own name-service modules, so lookups answered from files and DNS work as on the source. Which modules are tried comes from the target's
/etc/nsswitch.conf. When a lookup falls through to a module only the target has, the target's copy is loaded into a process running on the source's old libc. vrtmv does not test that and does not say it is safe. GCONV_PATHis inherited by child processes. The launcher sets it so the preserved program uses the old glibc's character-set conversion modules, and every program the preserved program starts inherits it. A target program started that way is given the old modules while running on the target's glibc. Measured with a CentOS 7 source on Rocky Linux 9: the target'siconvconverting ISO-8859-15 to UTF-8 did not return and was killed at 20 seconds. It is not one character set. Of the 89 names that image'siconvlists, 41 had a conversion from the character set still running at 3 seconds and 16 had one that crashed, UTF-16 and UTF-32 among them. Conversions to a character set were not affected. The same conversion also hung on a Debian 12 image. It reaches every target program below the preserved one, however many steps down. A target program that starts a preserved one is not affected. This is a limit of carrying the old glibc and it is not going to be removed: the ways found of removing it change a carried file or rest on loader behaviour glibc does not document. Start target programs from a preserved program withGCONV_PATHunset.migratetells you whether the preserved files convert at all; see Character-set conversion. The launcher does not limit the variable to the program it wraps. With target programs started that way the same conversion returned the right bytes. Set-user-ID and set-group-ID programs ignore the variable, which is glibc's rule. That was not measured.- Ownership and set-user-ID bits are lost at
COPY, and directory modes at staging. File modes are carried; owners, set-user-ID and set-group-ID bits and directory modes are not. A file owned by a service account on the source is root-owned in the image. A private directory becomes 0755, because the staging script creates directories and does not copy them.migrateprints a line for each preserved file that has such a bit. File capabilities and other extended attributes are not recorded. - The launcher covers the program it wraps. A child process started by absolute path runs under the target's loader. A set-user-ID bit has no effect on a program run through an explicit loader. Absolute paths inside a binary are not rewritten.
- The architecture is checked at build time, not at
migratetime. ARUNline in the block comparesuname -minside the build with the architecture of the preserved files and fails the build when they differ. AContainerfilefor the wrong platform is still written. The comparison is the kernel's machine name, so a 32-bit x86 image built on an x86_64 kernel passes the check and is mislabelled. The same may hold for 32-bit arm on an aarch64 kernel; that was not measured. Without emulation, a build for another platform stops at that line with the runtime's own error and not vrtmv's message. - A preserved file under
/opt,/usr/local,/srv,/homeor/rootkeeps its path, andCOPYwould replace a target file at the same path. The build checks for that and stops: the log has a linevrtmv: <path> is already in this image, and the preserved file from the source would replace itfor each such path, and a build that runs the check and passes has a linevrtmv: none of the <n> path(s) the preserved files keep is in this image. A rebuild that takes that step from the layer cache does not run it again and prints nothing from it: podman's log has--> Using cacheunder the step. So the line missing from a rebuild's log does not mean the check was left out. Build with--no-cacheto see it. A cached step belongs to the image it ran on: when the base tag was pointed at an image that has the file, the rebuild ran the check again and failed. It also stops when a directory above a kept path is in the image as something other than a directory. It does not check a merge: when the image has the same directory with other files in it, the build passes and the preserved files land beside them. It does not look at/opt/vrtmv/preservedor the marker, at anything added to the image after the block, or at a build that runs as a user other than root. The check runs in the image, so a build for another platform without emulation stops at that line with the runtime's own error. The script uses only shell builtins and was built on all 19 base images in Container base images with podman, and on one with kaniko. Docker was not run. The script stays in the image at/opt/vrtmv/preserved/check-kept-paths.sh. - The host kernel is not the source kernel. An old userland can depend on kernel behaviour a current host has turned off. vrtmv does not test for it.
- Preserved software is unpatched. The source's old libc and other libraries are in the image outside the target's package manager, and
dnf updatewill never touch them. verify --sshcannot grade a preserved image, because an SSH collection does not fetch the preserved bytes. The result isinconclusive. Use--rootor--image.- Kernel modules, one process per image and the feasibility checks are unchanged by this flag. An operator-declared module still refuses the output with
--preserve. - No licence check. vrtmv records what was carried and asserts no right to copy or redistribute it.
Older verify builds
- Released
verifybuilds before 2.2.0 cannot read a preserve attestation. It is written so that 2.1.0 and earlier stop with an error. Verifying a preserved image needs 2.2.0 or later. - An older
verifygiven a plain attestation for the same source still reads a preserved image as it would any other. A plain attestation is one from a run without--preserve, or a preserve attestation unwrapped by hand. 2.1.0 then printsequivalentand exits 0, and the signing service signs it, because it signs a digest and does not see the verdict. Nothing in a later release can change a binary that is already released.verifyfrom 2.2.0 returnsinconclusivein that case, from the marker file. - The boundary is keyed on the attestation and on the marker. An attestation with the
preservedkey removed is graded as a plain migration, and an image with the marker deleted is not recognised as preserved. Both are tampering.
Security posture
Vrtmv is built for regulated environments. The security model follows from one principle: workload data stays on the customer host, and every off-host interaction is minimal and authenticated.
Data locality
Image contents, /etc, accounts, and keys are never transmitted. The only data that leaves the host is package identifiers (for translation), a one-way VM fingerprint (for metering) and, with a usage event, a one-way host hint that counts the hosts sharing a fingerprint. See Architecture.
No current build reports diagnostics. Until 2 October 2026 there was an opt-in beta channel whose builds also reported diagnostics on every run. The channel is retired, the reporting code was removed from the engine, and 2.2.0 is the first release built without it. Stable builds never sent these reports. The vrtmv diagnostics command, which printed a build's channel, was removed at the same time. What happens to reports that beta builds already sent, and what a beta build that is still installed does, is on The beta channel (retired).
Transport
The client refuses to send its bearer token over cleartext http:// to a non-loopback host; the API base URL must be https://. A loopback development server is the only exception, plus an explicit opt-out for controlled testing.
Credentials
Tokens are stored client-side in an owner-only file and shown masked. Server-side they are held only as a SHA-256 hash; the plaintext is never persisted. Tokens can be disabled per token or per account.
Untrusted input
The client parses cold, potentially hostile disk images. Package-database reads are size-bounded to prevent memory exhaustion, image parsing is panic-contained so a malformed database is a clean error rather than a crash, and paths supplied by the API are confined to the mounted image root. Block-layer attach is always read-only, so analysis cannot modify the source. Disk mounting shells out to standard, audited system tools rather than reimplementing block-device handling.
Service integrity
The API parameterises every database query, authorises every non-public route against the authenticated account, and bounds request size, duration, and batch size. Metering is attributed per token and recorded to an auditable log.
The beta channel (retired)
Vrtmv retired the beta channel on 2 October 2026. There is one release channel now, stable. This page is kept for accounts that were enrolled: it says what changed, what an installed beta build does today, and what happens to the reports those builds sent.
What changed
- No current release reports diagnostics. Beta builds reported diagnostics on every run; that was the condition of the channel. The reporting code was removed from the engine on 3 October 2026, and 2.2.0 is the first release built without it. Stable builds never sent these reports.
- The
vrtmv diagnosticscommand is gone. It printed a build's channel and what the build reported, and it was removed with the reporting code. A 2.2.0 or later binary does not have it. - The endpoint that received reports has been removed from the API's code. The same change makes the table that stored them refuse new rows. Both take effect when an API built from that code is deployed. This page does not record whether that has happened yet, so it does not say that a report sent today is refused.
- Enrolment is closed. The portal no longer offers a beta request, approval or download.
What leaves the host from a current build is described in Architecture and Security posture.
If a beta build is still installed
A beta build is one whose version ends in -beta.N; vrtmv --version prints it. The
last beta version was 1.1.0-beta.7.
An installed beta build still contains the reporting code and still tries to send a report after each command. Until the API is deployed without the receiving endpoint, assume that report is still accepted and stored. After that it is refused, and the command's result does not change: the send was written so that a failure never affects the command, with a short timeout and no retry.
The way to stop a beta build reporting is to replace it with a stable build. See Installation.
Reports already sent
Reports an account's beta builds sent while it was enrolled are kept for 180 days from the day they were received and then deleted by a scheduled purge. The purge still runs. Retiring the channel did not extend that period.
The portal page that listed these reports and let a member delete them was removed
with the channel. Vrtmv will delete an account's reports before the 180 days are up on
request: write to support@vrtmv.com.
The record of the enrolment itself is kept: who requested it, who approved or withdrew it, and under which version of the terms. So is the note that a deletion happened and how many reports it covered. Neither holds the content of any report.
What a report contained
For reference, this is the closed list of fields a beta build sent, one report per command. No field was meant to carry image contents, configuration, host names, addresses, file paths, user names, command arguments or the package list. Most fields could not, because each was a number or a value from a fixed list. Two kinds of field were text:
- The OS name and version fields were short strings. For the source they were taken
from its
os-releasefile. They were limited in length and not otherwise filtered. error_detailwas free text. It is described under the table.
| Field | What it was |
|---|---|
engine_version | The binary's version |
channel | Always beta |
command | The subcommand that ran, without its arguments |
outcome | ok, error or panic |
exit_code | The process exit code |
error_kind | A short classification, on failure |
error_detail | The error message, as free text, after the filter described below |
source_distro, source_version | The source OS name and version from its os-release, on commands that have one |
target_distro, target_version | The target OS |
counts | Sizes only, never names |
duration_ms | How long the command took |
features | Which optional code paths ran, from a fixed list |
error_detail was filtered twice, once in the binary before sending and again by
Vrtmv before storing. The filter replaced what it
recognised by shape: file paths, host names and URLs, IP addresses, email addresses
and long credential-shaped strings. It was a filter, not a proof. A host name that
looks like an ordinary word, or a company name that another tool wrote into an error
message, would have passed through, so a stored report may contain one. Deleting an
account's reports, as described above, removes them.