migration
The migration tool that could not phone home

The two products had the same name. That was most of the problem.
A customer who had spent four years building a private cloud on vRealize Automation 7 had blueprints, property definitions, entitlements, approval policies, reservations, business groups, event subscriptions, and a pile of vRealize Orchestrator workflows wired into the edges of all of it. Version 8 had projects, cloud zones, cloud templates in YAML, Service Broker, and a different API. Some of the old concepts had a direct counterpart. Some had a counterpart that behaved differently in one specific way that nobody found until the first request failed. Some had nothing at all.
So the honest description was not "upgrade". It was "reimplement, on a platform that does about ninety percent of what you were relying on, and we need to work out which ten percent is yours."
Working that out was the job. And in 2019 the way we worked it out was that a consultant flew in, was given read access to a production appliance, and spent between two and four weeks clicking through it with a spreadsheet open.
What Professional Services was actually selling
This is the part that decided everything else about the tool, so it is worth being blunt about it.
A services engagement does not begin with migrating anything. It begins with a number: how long this will take and what it will cost. That number goes into a statement of work, the customer's procurement reads it, and once it is signed it is the shape of the next six months for everyone involved.
Before the tool, that number came out of a conversation. An experienced consultant would look at an environment for a couple of weeks and say something like "six to sixteen weeks, depending on what the Orchestrator content turns out to be doing." That range is not incompetence. It is an accurate expression of how much was genuinely unknown. But a range with a factor of nearly three in it is a bad thing to put in front of a customer, and it is a worse thing to plan a team against.
The interesting failure was not that the estimates were wrong. It was that they were unarguable. When a customer pushed back on sixteen weeks, there was nothing to push back against — no list, no per-item reason, nothing that could be walked through line by line until both sides agreed on what was actually in the environment. The consultant's judgement was the whole artefact.
So the tool we set out to build was not, in the first instance, a migration tool. It was an instrument for producing an argument. Every number it printed had to trace back to a specific object in the customer's environment and a specific sentence about why that object was a problem.
I spent a lot of the early work not writing code. I sat in on the manual runs and wrote down what the consultant did: which API they hit first, what they looked for, which finding made them go and look at something else, and — most usefully — which question they asked the customer and at what point. That sequence, written down, was the automation procedure. The tool is mostly that runbook with a database under it.
Collect, classify, report
The shape it settled into has three stages, and keeping them separate turned out to matter more than anything clever inside any of them.
Collect talks to the customer's environment and writes down what is there. It makes no judgements. It has no opinions about vRA 8. If we later decide that some property pattern is interesting, we do not want to go back to the customer and ask to run the collector again; we want to re-run the classifier over data we already have.
Classify reads the collected inventory and attaches verdicts to it. It never touches the network. This means it is fast, it is repeatable, and it can be re-run twenty times on a laptop on the flight home.
Report turns verdicts into something a human argues with — a browsable list, a per-object detail page with the offending field highlighted, and an export.
Nearly every good property of the tool comes from that split. The collection is the expensive, risky, once-per-engagement thing. Everything after it is cheap and can be wrong without consequence.
The constraint that picked the stack
Here is the requirement that shaped all of it: no data leaves the customer's network. Ever.
Not "we encrypt it in transit". Not "we anonymise it". A vRA inventory is a description of a company's internal systems — hostnames, network segments, naming conventions, who is entitled to request what, the names of business units that have not been announced yet. Customers in regulated industries were not going to allow that to be uploaded to a VMware-hosted service, and asking would have cost more goodwill than the feature was worth.
Which means the tool is not a service. It is a thing you hand over. It runs on the customer's jump box, or on the consultant's laptop on the customer's VPN, often with no route to the internet at all.
That single sentence decides the architecture:
- One container, one port, one volume. Delivered as a
docker savetarball, transferred by whatever mechanism the customer already trusted for binaries. If it had needed a second container for a database, that becomes another image to explain, another line in a security review, and another thing to go wrong on a host where you cannotdocker compose pull. - No licence check, no telemetry, no update ping. Anything that phones home is a thing that fails in an air-gapped environment, and it fails at the worst moment, which is in front of the customer.
- The UI is served from the same process, on localhost. No CDN, no external fonts, no analytics script. This sounds obvious and it is the detail most likely to be got wrong by habit — a single Google Fonts link turns a working tool into a five-second white screen on an isolated network.
So: Node and Express serving both a small JSON API and the built frontend assets. Angular with Clarity for the UI, which was the boring correct choice for two reasons — it is what the products we were inspecting were built with, so the tool looked like it belonged and needed no design work at all, and the component set already had the two things this UI is made of, which are dense data tables and a detail panel. SQLite through Sequelize for storage. Docker as the delivery format rather than as an orchestration story.
None of that is an exciting stack. All of it is a stack that a consultant can get running on a locked-down Windows jump box in fifteen minutes, which was the actual requirement.
The database is a file, and the file is the deliverable
Choosing SQLite for packaging reasons gave us a property I did not anticipate and came to rely on: the entire assessment is one file.
Copy customer-name.sqlite off the jump box and you have the engagement. Open
it in another instance of the tool and you get the same report, months later,
on a different continent, with no access to the customer's environment at all.
The pre-sales conversation could happen in an office in Sofia against data
collected in Frankfurt, and the person having it could click into any object
and see exactly what the collector saw.
It also made "what changed" nearly free. A second collection into the same file is another run, and two runs in the same file is a diff — a customer who was assessed in March and re-assessed in September gets a list of what they added in between, which is often the most interesting slide in the deck.
The related decision was how much structure to impose on collection. The answer was: as little as possible.
Every collected object got the same handful of columns — its type, its native identifier, its name, the tenant it came from, the run that found it — and then the raw JSON exactly as the API returned it, in a text column. The classifier reads the JSON. The columns exist for counting and joining.
The reason is version drift. We were collecting from 7.3, 7.4, 7.5 and 7.6 environments, and those releases disagree about the shape of a meaningful fraction of the objects. If the collector had parsed into a normalised schema, every one of those disagreements becomes a schema migration in a tool that ships to air-gapped sites as an image tarball. Parsing late meant a shape surprise was a rule change, which is a config file, which someone can email.
Rules had to be data
The first version of the classifier had the rules in JavaScript, because that is what you do. It was wrong within two engagements.
The rules are not universal facts about vRA 7. A large fraction of them are
facts about this customer — that everything prefixed LEG_ is scheduled for
decommission and can be excluded, that their custom Orchestrator plug-in is
the one thing in the environment nobody can rewrite, that their approval
policies are all approving the same two people and can be collapsed. A
consultant discovers these on day three and needs them reflected in the report
on day three, on a laptop, with no build toolchain and no route to a registry.
So rules became a file that the tool loads at startup, and each rule declares what it looks at, what it matches, what it concludes, and — this is the part that mattered — the sentence explaining itself:
- id: blueprint-property-uses-vro-picker
applies_to: blueprint
match:
path: properties[*].valueList
equals:
type: vro-workflow
verdict: assisted
effort_days: 0.5
because: >-
The request form fills this field by running an Orchestrator action.
Cloud Assembly can do the equivalent with a custom resource, but the
action has to be re-registered against the new endpoint and the form
re-authored by hand. The logic survives; the wiring does not.
Three things about that shape earned their place.
because is not a comment. It is rendered, verbatim, in the report the
customer reads. A verdict without a sentence attached is not allowed to
exist, because the entire purpose of the tool is to turn judgement into
something that can be disagreed with. If we could not write the sentence, we
did not understand the rule well enough to ship it.
effort_days is deliberately crude and deliberately per-rule. Summing it
gives the estimate. It is not accurate for any individual object and it does
not need to be — it needs to be consistent, so that two environments assessed
by two people produce numbers that mean the same thing. Consistency was the
thing the manual process could not offer at all.
match is a small declarative predicate over the raw JSON rather than a
general expression. That was contentious internally and I would make the same
call again. A rule language that can run arbitrary code is a rule language
that will eventually be running arbitrary code, on a customer's data, written
by someone on an aeroplane at eleven at night.
We also adopted a rule about rules: a finding becomes a rule when it shows up in two engagements, and stays a per-engagement note when it has only shown up in one. Without that, the shared rulebase gradually becomes one loud customer's opinions.
Three buckets, not two
The tempting model is binary: migratable, or not.
The useful model has three verdicts. Automatic — the platform's own migration path handles it and the tool can verify that it did. Assisted — it migrates, but a person has to make one decision, and here is the decision. Manual — nothing carries across and this must be rebuilt.
The middle bucket is the product. Automatic items do not consume consulting days, and manual items are usually few and obvious. Almost all of the real work — and therefore almost all of the estimate, and almost all of the argument with the customer — lives in the set of things that technically migrate but need somebody to decide something first. A binary report hides exactly the number everyone is trying to find.
The output that consultants ended up leading with was a single table: 412 blueprints, 96 automatic, 268 assisted, 48 manual, with the assisted ones grouped by which rule caught them. That table is an agenda. It says here are the four questions we need to sit down and answer, and here is how many objects hang off each one.
The two things that nearly sank it
The collector was too rude to a production appliance. The first version walked the API the way you would walk a test fixture: parallel, greedy, and from the top. Against a real environment with a few hundred blueprints and several thousand deployed items it took hours, and worse, it was noticeable — which is unforgivable, because the appliance it is interrogating is a production system that the customer's developers are using to request machines while you are pointing a tool at it.
It had to become polite and resumable. Bounded concurrency, a small delay between pages, and — the part that made it survivable — a checkpoint after every single page, in the same transaction as the page itself:
// One page in, one transaction: the objects and the cursor that covers them
// are committed together, or neither is. A run that dies at 04:00 resumes at
// the page it died on rather than at the beginning.
await db.transaction(async (tx) => {
await Inventory.bulkCreate(rows, { transaction: tx })
await Cursor.upsert(
{ run: runId, type, skip: skip + rows.length, done: rows.length < pageSize },
{ transaction: tx },
)
})
Once collection is resumable, a dropped VPN is an inconvenience rather than a lost afternoon of the customer's goodwill. That change did more for the tool's reception than any feature.
The collector also only ever issued GET. Not by convention — the HTTP client
that the collection code was given had no other method on it. You cannot ask a
customer's operations team to let an unfamiliar binary loose on their
production automation platform and then rely on the promise that it will
behave. Making the write path structurally absent is a cheaper guarantee than
a code review, and it is a much better sentence to say in a security meeting.
SQLite pushed back when the UI read while the collector wrote. The
classic: a consultant refreshes the inventory page mid-collection and
something throws SQLITE_BUSY. The fix is three lines and is the price of
admission for using SQLite in anything concurrent at all:
const db = new Sequelize({
dialect: 'sqlite',
storage: path.join(DATA_DIR, `${engagement}.sqlite`),
// One connection. Two of our own writers into one file is how you meet
// SQLITE_BUSY; the pool is the simplest place to make that impossible.
pool: { max: 1, idle: 10_000 },
logging: false,
})
await db.query('PRAGMA journal_mode = WAL')
await db.query('PRAGMA synchronous = NORMAL')
await db.query('PRAGMA busy_timeout = 5000')
WAL is the one that matters: it lets readers carry on against the last
committed state while a write is in flight, which is precisely the shape of
this application. busy_timeout is the backstop for anything that reaches the
file from outside the process. synchronous = NORMAL is a considered
trade — we are willing to lose the last transaction of a collection run to a
host crash, because the run is resumable and the data is a copy of something
that still exists.
What we chose not to automate
Two things, both on purpose.
The tool never wrote to the target. Where it did transform — generating
vRA 8 cloud template YAML from a vRA 7 blueprint — it wrote files to a
directory for a human to review, and stopped. Not because posting to the API
was hard, but because the risk is asymmetric to a comical degree. A wrong YAML
file costs somebody twenty minutes of reading. A wrong POST into a
customer's new production automation platform costs a relationship. When one
side of a trade-off is "an afternoon" and the other is "the engagement", the
default is not a close call.
Deployed items were reported, never migrated. A blueprint is a document and documents can be rewritten. A customer's four thousand running virtual machines, which the old platform believes it owns and manages the lifecycle of, are not a document. Everything about how those get adopted by the new platform was a per-customer conversation involving people well above my pay grade, and the correct thing for the tool to do was count them accurately, put the number in front of everyone early, and refuse to imply that it had an answer.
Being explicit about the second one in the report changed conversations. The number is large, it appears on the first page, and nobody discovers it in month four.
The most-used feature was the CSV export
I want to record this because it was humbling and it is probably general.
We built a browsable UI with filtering, grouping, drill-down and a per-object detail view. Consultants used it, and then they clicked Export, opened the CSV in Excel, and built their own pivot table.
The first reaction to this is disappointment, and the first instinct is to build the pivoting into the UI. That is the wrong instinct. The consultant is not pivoting because our grouping is inadequate. They are pivoting because the next thing that happens to this data is that it goes into a customer presentation, and the tool that makes customer presentations lives next to Excel and not next to our web app. We were never going to win that, and we should not have wanted to.
So the export got promoted from an afterthought to a designed surface: stable
column names, one row per finding rather than per object, the rule id and the
because sentence in their own columns so they survive a copy-paste, and the
effort number as a plain number rather than a formatted string. Making the
handoff good was worth more than making the destination unnecessary.
What I would keep and what I would not
Keep: collection separated from judgement. It is the reason a rule change was a five-minute turnaround instead of a customer visit, and it is the reason the assessment could be argued about six months later.
Keep: the sentence attached to every verdict. The tool's output is used in front of a customer who is entitled to ask "why?" about any line of it. A report that cannot answer that is a report that gets quietly overridden by whoever is most confident in the room, which is exactly the situation we were trying to leave.
Keep: shipping as one file and one image. Every minute spent making the thing installable in a hostile environment came back several times over. The tool that runs is worth more than the better tool that does not.
Not: Angular and Clarity, for this. They were the right organisational choice — free consistency with the products we were inspecting, zero design effort, and immediate familiarity for the users. They were a heavy choice for what is, honestly, twelve tables and a detail panel behind a JSON API. A small team on a tight schedule spends more of its attention on the frontend framework than a tool like this deserves.
Not: rules shipped separately from the image. Making rules data was right. Letting the rule file drift independently of the binary was not: within a few months we had engagements running last quarter's rules against this quarter's collector, and no way to see that from the report. The fix is obvious in hindsight — stamp the rulebase version into every finding and print it on the report — and it was not free to retrofit.
The thing I did not expect to learn is how much of building tooling for consultants is about the argument rather than the automation. The keystrokes we saved were real but they were never the point. The point was that a customer and a delivery team could sit on opposite sides of a table, look at the same list, and disagree about specific rows instead of about a number that came from someone's judgement and could not be taken apart.
vRealize Automation has since been renamed Aria Automation, and the specific 7-to-8 gap this was built for has mostly aged out of relevance. The shape has not: collect read-only, classify separately with rules you can change on site, attach a reason to every verdict, and hand the result over in a format that survives the trip into somebody's slide deck.
Written by
Deyan Peev
Founding Engineer · Sofia, Bulgaria


