How a lab experiment in “can this be done entirely through the API?” became an
open-source migration toolkit — and why the hardest part wasn’t moving the
objects, but proving they actually worked once they landed.
The Challenge
An Aria Automation 8.18 environment is years of accumulated decisions.
IP pools typed in by hand at 2am during a cutover. Network profiles bound to
particular portgroups because that was the only VLAN available at the time.
Blueprints that grew a property at a time. Event subscriptions wired to vRO
workflows nobody has opened since the person who wrote them left. Projects with
zone assignments and role bindings that encode an org chart from two
reorganisations ago.
None of it is written down anywhere except the platform itself.
VCF Automation 9.1 is the forward path, and for most estates the vendor’s
supported migration is the right answer. But there is a specific case it handles
badly, and it is a common one: a side-by-side migration onto fabric that does
not match. New vCenters. Different portgroup names. A management vCenter on
the target that is already backing a Region and therefore cannot be registered
again as a VM-Apps cloud account.
In that case you are not upgrading an environment. You are rebuilding one, and
the only complete description of what you are rebuilding is the source platform’s
own API.
So the lab question became: can a migration be done 100% through the API?
That is a question with a measurable answer, and measuring it honestly turned out
to matter more than the tool that came out of it.
The Journey
Measuring the ceiling first
Before writing a migrator it was worth knowing what a migrator could possibly
reach. The object universe of an Aria Automation organization is 174 tables
across 11 service databases. Excluding transient and engine-internal state, the
migratable universe is 124.
110 of those 124 are reachable by API — 89%.
The 14 that are not are all the same thing: dep_deployment, dep_resource,
dep_request, compute_state, disk_state, and their relatives. Deployments
and the machines inside them.
They are perfectly readable. GET /deployment/api/deployments works fine. What
does not exist is a create-from-payload endpoint, and there will not be one,
because a deployment is the record of a request the platform executed, not a
document you author.
So the honest answer is: 100% API for configuration, yes. 100% API for an
environment, no. A tool that claims otherwise is going to discover the gap
after cutover, which is the worst possible time.
That framing shaped everything. The tool does the 89% properly and quantifies
the other 11% in writing before anyone commits, rather than letting it surface as
a surprise.
9.1 is not 8.18 wearing a new hat
The first real discovery was that VCF Automation 9.1 authenticates nothing like
Aria 8.18.
9.1 is VMware Cloud Director-derived. /api/versions returns VCD
SupportedVersions. The familiar Aria CSP login endpoint returns 404. Auth is
HTTP Basic against a session endpoint, and the bearer token comes back in the
X-VMWARE-VCLOUD-ACCESS-TOKEN header — not in the response body, where every
client library expects it.
And the sharp edge: the IaaS API is tenant-scoped. A provider-scoped token
authenticates successfully and then returns HTTP 500 on every single /iaas/*
path. Not 401, not 403. A 500, which reads like a broken appliance rather than a
scope mistake, and sends you debugging the wrong thing entirely.
Meanwhile the IaaS API surface is nearly identical — both platforms report
latestApiVersion 2021-07-15. So this was never a translation problem. It is a
fidelity problem: read everything, understand what each object means, and
rebuild it somewhere the fabric underneath is different.
The design decision that made it portable
Everything hinges on one choice: references are (kind, name), never ids.
An id is meaningless in another environment. The moment a capture stores
cloudAccountId: 8f3c..., it can only ever be replayed into the environment it
came from. Storing (cloud_account, "vc-prod-01") instead makes the capture a
description of an environment rather than a snapshot of one.
That gives the whole tool its shape:
EnvA (8.18) ──extract──> IR ──plan──> emit ──> EnvB (9.1)
│ │
└────── verify ────────┘
The intermediate representation is the seam. Extract, plan and emit each touch
only the IR, so supporting a new source version means writing an extractor, and
supporting a new target means writing an emitter. Nothing else moves.
It also produces the artifact that turned out to be the most valuable deliverable
of the whole exercise: environment.yaml — the entire environment as one
reviewable, diffable, version-controllable document. For a lot of teams that
document is worth more than the migration, because it is the first time the
platform’s configuration has existed anywhere they can read it.
Names are not stable, and matching on them silently lies
The same vCenter is vcenter.lab1.example.com on the source and vSphere8 on
the target. So discovered objects have to match on a stable identity — endpoint
hostname for a cloud account, MoRef for a region.
That is not sufficient either, and the failure mode is nasty.
Two vCenters in one lab both reported the region MoRef
Datacenter:datacenter-3. Matching on MoRef alone silently mapped one datacenter
onto the other. No error. No warning. Everything downstream anchored to the wrong
fabric, and the first symptom was a deployment landing in the wrong place weeks
later.
Identity now has to be scoped by cloud account. And where identity still is
not enough, the operator declares the substitution explicitly:
fabric_map:
vc-mgmt.example.com: vc-wld01.example.com
object_map:
fabric_network:
DVPG_FOR_VM_MANAGEMENT: wld-cls-vds-01-pg-mgmt
Anything unmapped stays blocked rather than guessed at. Which target network
stands in for which source network is a decision, not an inference. The tool’s
job is to notice that a decision is required and refuse to proceed without it —
not to pick the closest-looking match and hope.
Four defects that returned HTTP 200 and did nothing
This is the part that changed how the tool thinks about success.
Four separate bugs reached a live target while every other check passed:
| read shape | write shape | |
|---|---|---|
| flavor profile | flavorMappings |
flavorMapping |
| zone membership | computes |
computeIds |
| project zones | zones |
zoneAssignmentConfigurations |
| image mapping | entry carried the source fabric id | entry needs the target’s |
In each case the field name you read an object with is not the field name you
write it with. Send the read shape back and the API accepts it — HTTP 200 —
and applies nothing.
The objects existed. The names matched. The counts reconciled perfectly. And the
environment could not deploy a single machine.
No presence check can catch that, because every object is genuinely present. The
only thing that catches it is asking a different question entirely: can a
project on this target actually reach a region that holds an image, a flavor, a
storage profile and a network?
That placement check now runs at the end of both import and verify. It is the
difference between “the migration completed” and “the migration worked”, and
those turn out to be very different claims.
The subscription that existed and never fired
A related discovery, found the same way.
Event-broker subscriptions were being created without an explicit subscriberId,
on the reasonable-sounding theory that the platform assigns its own. It does
assign one — it binds the subscription to the authenticated caller, which
during a migration is the migration tool’s own API principal, not the
vro-gateway service account the workflow actually needs.
The result: the subscription exists. It verifies as present. It reconciles on
count. It will never fire a workflow, and nobody finds out until a day-2
operation silently does nothing in production.
The fix is to resolve the target’s own vro-gateway-* and abx-* client ids at
emit time and set subscriberId explicitly. But the more useful outcome was
adding a binding audit to verify, so “this subscription is bound to
something that cannot run it” became a reportable state rather than a discovery.
Secrets, and the references nobody looks for
Secret values cannot be migrated. Aria masks them server-side; they are
write-only through the API, and no amount of cleverness changes that. The tool
creates them with a REPLACE-ME:: placeholder and says so loudly.
The harder problem is secret references. They are not confined to the secret
object — they are inline ((secret:v1:...)) tokens scattered through blueprints,
property groups, ABX action constants and subscription payloads. Handling only
the secret kind means every one of those references arrives on the target
pointing at nothing, and fails at deploy time with an error naming a blueprint
rather than a secret.
So the scanner walks every captured payload and staged artifact, and emits a
locator per hit: object kind, object name, JSON path. secrets-references.md
lists every consumer. A reference named by a payload with no matching secret in
the capture is reported as dangling, with a non-zero exit code, because that
is the one case no amount of working through a to-do list will fix.
Undo, and why it was harder than it sounds
purge removes exactly what import created, tracked in state.yaml. That
turned out to be the feature that made the tool usable at all — being able to run
a migration, look at the result, throw it away, and run it again is what turns a
high-stakes cutover into an iteration loop.
Getting it right took two goes. import links projects to zones by PATCH; the
first purge implementation then tried to delete both, and the API refused in
both directions:
- against the project: remove all cloud zones associated with this project first
- against the zone: cannot delete cloud zone as it is being used by project/s
A circular reference, so neither ever went. Five objects survived every purge,
and the next import silently reused them — which is exactly the orphan problem
state.yaml exists to prevent.
purge now runs an unlink pass first, the inverse of import’s link pass. And
identity bindings have to be carried explicitly through that PATCH, because these
endpoints treat PATCH as a full-object update: send only the link field and you
clear every administrator, member, viewer and supervisor on the project.
The zone that was created empty
The last one is a good example of why the placement check earns its keep.
When fabric_map collapses two source cloud accounts onto one target account,
the migrated zone can land on a region whose computes already belong to the
target’s own zone. A compute belongs to exactly one zone, so the membership
link is correctly refused — and the zone is created empty.
Every profile check passes. Image, flavor, storage, network all present in the
region. And the project still cannot place a machine, because its zone contains
nothing.
That is now caught at plan time, before the push, because the target baseline
already knows it. The dry run says so rather than the emitter discovering it
after the object exists.
The Results
A full migration runs end to end and repeatably:
export → import (dry run) → import --push → verify → purge
- 31 configuration objects and 57 vRO packages moved and confirmed on the
target - 89% API reach measured against the 174-table object universe, with the
remaining 11% counted and reported in writing rather than discovered later - 104 tests covering classification, reference resolution, placement, purge
ordering, link shapes, mapping resolution, counts and the secret scanner —
all running against fixtures, no live appliance required environment.yaml— the whole environment as one diffable document, which
several people have found more immediately useful than the migration itself- Undo that works, so a migration becomes something you can iterate on
Equally important is what the tool refuses to do. Anything it cannot place is
reported as blocked, needs special handling, or unknown write surface.
Nothing is ever dropped quietly. import will not push while anything is
blocked, because a partial import leaves you with two environments to reconcile
by hand — strictly worse than not having started.
Lessons Learned
Presence is not correctness. HTTP 200 means the request was accepted, not
that it did anything. Four defects reached a live target while every object was
present and every count reconciled. If your verification only asks “is it there”,
you are not verifying — you are counting. Ask instead whether the pieces still
connect.
Refusing to guess is a feature, not friction. Every place the tool blocks
instead of inferring is a place an earlier version silently did the wrong thing.
Which network stands in for which network is a decision that belongs to whoever
knows the estate. A tool that guesses is not saving them work, it is hiding a
choice they needed to make.
Undo changes the risk profile completely. The ability to purge exactly what
was created is what turns a one-shot cutover into something you can rehearse.
Almost every bug described above was found because it was cheap to run the
migration again.
Count what you cannot carry, before anyone commits. “You have N deployments
with M resources and 18 months of request history; a rebuild keeps the
configuration and orphans all of it” is a sentence people can act on. It is also
the only honest way to recommend a different approach when that is genuinely
the better answer — which for a large brownfield estate it often is.
Date your evidence. Every endpoint note in the source carries the environment
and month it was verified against, because an API that returns 400 today may not
tomorrow. A note without a date is a claim without evidence. Where a behaviour
was observed once and never corroborated, the tool prints it as unvalidated
rather than presenting an inference as a fact.
The intermediate representation is the whole design. Because extract, plan
and emit only ever touch the IR, adding a source version is an extractor and
adding a target is an emitter. Everything the tool learned about 8.18 → 9.1 is
reusable for whatever comes next.
The Repository
aria-vcfa-migrator —
GPL-3.0, v0.7.1.
python3 -m venv ~/.venv/migrator && . ~/.venv/migrator/bin/activate
pip install -e .
cp configs/env.example.yaml ~/env.yaml
export ENVA_PASSWORD='...' ENVB_PASSWORD='...'
migrator assess --config ~/env.yaml # what a rebuild CANNOT carry
migrator export --config ~/env.yaml # capture the source as YAML
migrator import --config ~/env.yaml # dry run: what would change
migrator import --config ~/env.yaml --push
migrator verify --config ~/env.yaml
migrator purge --config ~/env.yaml --yes
Start with docs/RUNBOOK.md — it is the order the phases have to happen in, and
which ones are safe to skip. docs/COVERAGE.md has the full 174-table matrix if
you want to check the 89% claim yourself. docs/ROADMAP.md lists the known gaps
honestly, including the ones still open.
Built and proved out in the essential.coach lab against a live Aria
Automation 8.18 source and a VCF Automation 9.1 target. No customer data,
topology or naming is present in the repository — the example configs are
illustrative, and every lab-specific hostname has been genericised.
It is released at 0.7.1 rather than 1.0.0 deliberately. The roadmap has real
open items: workload onboarding is not built, selective per-project import does
not exist yet, and every behaviour here was established against a single 8.18/9.1
pair. Calling it 1.0 would be the same category of claim as an HTTP 200 that
applied nothing.
Contributions especially welcome on endpoint behaviour from estates this has
not seen — if a documented 400 or 404 does not reproduce for you, that is the
most useful bug report this project can receive.
