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).