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/Containerfile with four parts. FROM is the base image the Index holds for the target (see Container base images), by the digest its tag named when migrate ran (see Base image per target). RUN installs 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 the Containerfile. The second installs the same-name packages, see Same-name packages. One ADD 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. ENTRYPOINT is your --entrypoint.
  • container/stage-carried.sh, when the plan carries config. It copies the carried files into container/vrtmv-context/ and writes container/vrtmv-carried.tar, the archive the ADD line reads. See Staging the carried config.
  • Comments in the Containerfile for 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 a Containerfile was written it carries a container_target object with base_image, base_image_resolution, entrypoint, source_class and units, the statement about the source's enabled units described under Enabled units, and a container_config_left_out list when carried config was left out of the image. When the container output was refused there is no container_target key.

What you do not get:

  • Other enabled services. Nothing in the image starts them. They are listed by the entrypoint check 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-jobs check.
  • Local state. Files on the host that no package owns are reported by the local-state check 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 /etc that its group and others cannot access is not added to the image. Each is named in the Containerfile and 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 Containerfile and you run podman 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. verify requires 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 as asserted_by: operator. vrtmv checks one thing about it: the source has that unit enabled. If it does not, migrate stops before anything is written and lists the source's enabled units. vrtmv also reads the unit's ExecStart on the source and records whether its program is the entrypoint's (same-program, differs, none when the unit has no ExecStart, or not-read with 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 matching ExecStart, because several units on one host can start the same program. Without --entrypoint-unit no 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. verify prints the list beside the verdict every time it is not empty. These units do not change the verdict. To carry another of them, run migrate again 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. With not_started and the entrypoint's unit, this is every systemd unit the source had enabled. verify counts and names them in the same NOT STARTED line, 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' becomes ENTRYPOINT ["/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 --entrypoint at a wrapper script.
  • A decision for each finding marked [DECIDE] in vrtmv assess.
  • --data <path>=volume or --data <path>=disposable for each data directory on the source. Without one, no Containerfile is 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 migrate writes neither container/vrtmv-context/ nor container/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 Containerfile however 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 own COPY line 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 COPY made 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 a RUN line of your own after the ADD.
  • 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 ADD with a COPY of vrtmv-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 Containerfile shows 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.

ruleWhat is left outWhy
source-package-manager/etc/portage and everything under it, /etc/make.conf, /etc/dispatch-conf.conf, /etc/etc-update.confPortage 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.envGentoo'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 themConfiguration 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-common and its program in nginx-core. Going to Rocky Linux 9 there is no nginx-common, and Rocky's nginx reads /etc/nginx/nginx.conf.
  • Gentoo ships /etc/pam.d/su and /etc/pam.d/login in sys-auth/pambase and /etc/mailcap in net-mail/mailbase. The Index records that Rocky Linux 9 has no package for either. Rocky's util-linux ships and reads the two PAM files, and its mailcap ships 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.
  • --preserve changes none of this. The path rules still apply.
  • The attestation does not record which copied files have an owner with no target. Only the Containerfile comment does. The owner and its outcome are both in the attestation, under carried_configs and package_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,container there is one attestation for both. The files left out of the image are not in its carried_configs, so verify does 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.

RuleWhat it matchesIs the file opened?
PathAn 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
ModeA 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
ContentsA -----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 CLASSIFIED with 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-file argument 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= and gid= to the podman option, or an fsGroup to the pod.
  • Nothing checks that the file staged is the file migrate saw. No digest of a secret is recorded, by design, so a source that changed after migrate, 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, with path, state (secret or unclassified), reason, owner, group, uid, gid, mode and size. Each also has delivery: mount, with the secret_key and mount_mode it is mounted under, or not-mounted, with not_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-secret named.
  • owner_not_recorded and mode_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 owner or a uid, and owner_not_recorded says 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. Run migrate under umask 022, or against a mounted root.

What detection does not cover

  • Only /etc is read. A key under /opt, /srv or a home directory is not detected.
  • A --preserve path and an image-class --data path are copied whole. Their files are not classified.
  • A symbolic link is not followed, so a key reached only through a link that leaves /etc is 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.

CheckSeverityWhat it reads on the sourceFrom an SSH collection
kernel-modulesblocking 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 fileYes. All of them are fetched.
entrypointdecisionEnabled systemd units, from the enablement links under /etc/systemd/systemYes, for a systemd source.
local-statedecisionFiles 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-jobsdecision/etc/crontab, /etc/cron.d, /var/spool/cron, /var/spool/cron/crontabs, /var/spool/at, /var/spool/cron/atjobsYes, as root. The at spools are listed on the host (job file name and owning uid) and never fetched.
untranslatable-softwaredecisionThe 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/local and /srv, capped at 10,000 entries. /home, /root and /var/spool are named as not examined. Data elsewhere is not listed.
  • untranslatable-software: the installs under /opt, /usr/local and /srv are 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. /home and /root are named as not looked at. Packages are judged on migrate as 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. sockets holds one entry per protocol, address family, port and binding, with programs, the executables of the processes that held the socket. An empty sockets means the source was listening on nothing. observed_by is vrtmv and ran_as_uid is 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. reason says 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. migrate computes 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 Containerfile builds on any architecture the tag had. For a tag that names one image, that image's.
  • What the attestation records. container_target.base_image is still the Index's name. Beside it container_target.base_image_resolution has state: pinned, the repository, tag, digest, digest_of (index or manifest) and the from that was written, and under platform the image for the source's architecture: its manifest_digest, config_digest, layer_digests (as the registry stores them) and layer_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/sh or /usr/bin/env. When it cannot be read, or the index has no image for it or more than one, platform has state: not_recorded and a reason, and the digest in FROM stands.
  • When the registry does not answer. FROM keeps the tag, the comment above it says why, and the attestation has state: tag with the same reason: 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-digest writes 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:

ClassWhat it meansWhat is written
volumeThe path is a volume, mounted when the container runs and filled outside the build.A comment and a VOLUME line.
disposableThe path is not carried. The container starts without it, by your decision.A comment.
imageThe 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 foundWhy it is refused
A database directory at, above or anywhere under the pathA database is never copied into an image. Declare it volume.
A symbolic link under the path that leads to data outside itWhat 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 readThe list would be short. --assume-undetermined local-state does not stand in for it.
The declared path is reached through a symbolic linkDeclare the directory where it is.
The path is under a path declared volumeA volume mounted there hides what the image holds under it.
The path is outside the directories the data walk readsThere is no list of its files.
The source is an SSH collectionA 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 pathBoth 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 /disk2 is 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 /export or /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/u01 with 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/home would 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 /usr is the exception and is named here, since /usr/local is 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/fstab names, outside the system trees, that is empty or missing as read, for example an empty /srv/pgdata with an ext4 line for it. That is what a database on its own filesystem looks like in an image of the root disk, or under a --root with only the root filesystem mounted. A mount point with anything in it was read and is walked. A line whose filesystem types are all iso9660 or udf is removable optical media and is left out, for example the /dev/sr0 /media/cdrom0 udf,iso9660 user,noauto line of a Debian installed from a disc; no other line is, noauto or not. Only /etc/fstab is 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/fstab that 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-state proceeds, and each declared path is recorded with its count as a floor. A source with no /etc/fstab at 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-state your declarations are recorded as given, with no counts, and the attestation says the walk did not run;
  • everything outside /opt, /usr/local and /srv on an SSH collection, which does not read /var, /home or /root. Every --format container run from --ssh on 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:

ValueWhat it is
image.repository, with image.tag or image.digestThe image you built from the Containerfile and pushed. A digest wins over a tag. --chart-image writes it into values.yaml.
data.claimsFor 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.manifestSha256Where 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.
attestationSha256The sha256 of vrtmv-attestation.json, written onto the Deployment. The chart cannot hold it, because the attestation records the chart.
service.portsThe 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.livenessA Kubernetes probe each, written under the container as given. None by default.
replicas, resources, imagePullSecrets, image.pullPolicy, service.typeAs in any chart. imagePullSecrets are names of Secrets, never a credential.
nodeSelector, tolerations, affinity, podLabels, podAnnotationsWhere the pod runs. A pod label named app is not written: the Deployment selects its pods by it.
secrets.secretNameThe 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-image and the workload has no volume-class data. helm lint --strict <chart> on it alone fails, and helm 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.mounts or secrets.notMounted that differ from what migrate wrote. The keys are the ones stage-secrets.sh makes, and a mode is a decimal number (384 is 0600);
  • 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.sh creates from the source, as under Mounting the files left out. Create it before helm install: until it exists the pod does not start, and helm install --wait fails. helm uninstall leaves it.
  • Every claim is kept. Each claim has helm.sh/resource-policy: keep, so helm uninstall leaves the claim and the data on it. Delete a claim with kubectl delete pvc when 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 those deploy applies say vrtmv. Helm refuses to install over a Deployment that deploy applied with kubectl.
  • 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. Use helm 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 as migrate --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 no entrypoint= is refused by name; vrtmv still does not choose one. A # after entrypoint= 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 as migrate --preserve, per workload.
  • data=<path>=<class> is repeatable — the same declaration as migrate --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 with migrate.
  • assume-undetermined=<check> is repeatable and takes a comma-separated list: the same word as migrate --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 needs assume-undetermined=local-state, because an SSH collection does not read /var, /home or /root; declare what you know with data= and assume the rest. A data directory that was detected still needs its own data=: the token is for what was not read. container-fleet writes no attestation, so the record is the Containerfile: 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 word assume-undetermined names 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 what migrate would 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.