CLOUD MIGRATION · DISCOVERY AND INVENTORY
Cloud migration discovery: building the infrastructure inventory
A migration plan is only as good as its inventory. Instead of a spreadsheet of console screenshots, StackToIT builds the inventory from the code that defines the estate: what exists, what may exist, which module owns it, what depends on what, and which services hold data. This guide shows the inventory a real run produced for a small estate.
What exists, and what only may exist
A demo estate written for this guide and assessed by StackToIT on 4 November 2026: a database, a queue, a bucket map, an optional replica and a module. Left: the lines of the input that make existence uncertain. Right: the whole summary.conditional_units list of assessment.json, pretty-printed because the file is a single line; no field is left out.
variable "enable_replica" {
type = bool
default = false
}
resource "aws_s3_bucket" "data" {
for_each = var.buckets
bucket = "acme-${each.key}-${data.aws_caller_identity.current.account_id}"
}
resource "aws_db_instance" "orders_replica" {
count = var.enable_replica ? 1 : 0
identifier = "orders-replica"
replicate_source_db = aws_db_instance.orders.identifier
instance_class = "db.m6g.large"
skip_final_snapshot = true
}[
{
"id": "aws_db_instance.orders_replica",
"source_type": "aws_db_instance",
"presence_cause": {
"kind": "root-variable-overridable",
"where": "count",
"expr": "var.enable_replica ? 1 : 0",
"variables": [
"var.enable_replica"
]
},
"would_be_class": "MANUAL_REVIEW",
"would_be_confidence": "LOW",
"state": "DISABLED_ON_DEFAULTS_BUT_OVERRIDABLE",
"state_inputs": [
"var.enable_replica"
],
"overridden_by": [
"-var-file",
"-var",
"TF_VAR_enable_replica"
]
},
{
"id": "aws_s3_bucket.data",
"source_type": "aws_s3_bucket",
"presence_cause": {
"kind": "root-variable-overridable",
"where": "for_each",
"expr": "var.buckets",
"variables": [
"var.buckets"
]
},
"would_be_class": "DIRECT",
"would_be_confidence": "MEDIUM",
"target": "STACKIT Object Storage",
"state": "PRESENT_ON_DEFAULTS",
"state_inputs": [
"var.buckets"
],
"overridden_by": [
"-var-file",
"-var",
"TF_VAR_buckets"
]
}
]A count of zero is an answer
The replica has count = var.enable_replica ? 1 : 0. With the variable at its default the replica is switched off, so the run lists it as conditional, notes that a variable can switch it on, and keeps its would-be class MANUAL_REVIEW beside it.
A map of buckets is a number of buckets
The for_each over var.buckets creates one bucket per key. The run records the unit as present on defaults, with two instances, and the variable that decides the number. A different variables file changes the inventory, so pass the one your pipeline uses.
Nothing is silently dropped
Units that are off, undecided or decided only at plan time are counted and named. That is how an inventory avoids the usual failure of listing what is running today and forgetting what a switch brings in tomorrow.
The inventory in numbers, and in dependency order
From the same run. Left: the scan summary the engine prints. Right: the first rows of migration/waves.csv, which orders the units by what they depend on.
────────────────────────────────────────────
Source: input
Source files: 2
Resources scanned: 11 (8 in the root module · 3 inside 1 module call)
support resources: 1 (+0 Terraform helpers, out of scope; 1 + 0 = the 1 support node of summary.support.nodes in assessment.json)
Migration units: 7 known-present (7 instances) · 2 conditional
Unit dependencies: 6
Modules: 1 module call (0 external, 1 local): 1 loaded, 0 not downloaded, 0 invalid
module.worker ./modules/queue_worker 2 units (RECONFIGURE 1 · REDESIGN 1) · 3 resources
Source checks: Built-in source checks passed — OpenTofu validation of the source was not run
Source completeness: RUNTIME_TOPOLOGY_PARTIAL — 2 unit(s) have a count/for_each that is not known statically; whether and how often they exist is decided at plan timewave,wave_state,unit,type,module_call,presence,class,confidence,application,application_status,proposed_group
1,MEMBER,aws_sqs_queue.jobs,aws_sqs_queue,,KNOWN_PRESENT,REPLATFORM,MEDIUM,,PROPOSED,jobs-worker
1,MEMBER,aws_vpc.main,aws_vpc,,KNOWN_PRESENT,RECONFIGURE,MEDIUM,,UNASSIGNED,
1,MEMBER,module.worker.aws_cloudwatch_log_group.worker,aws_cloudwatch_log_group,module.worker,KNOWN_PRESENT,RECONFIGURE,MEDIUM,,UNASSIGNED,
2,MEMBER,aws_subnet.db_a,aws_subnet,,KNOWN_PRESENT,RECONFIGURE,MEDIUM,,UNASSIGNED,
2,MEMBER,aws_subnet.db_b,aws_subnet,,KNOWN_PRESENT,RECONFIGURE,MEDIUM,,UNASSIGNED,
2,MEMBER,module.worker.aws_lambda_function.worker,aws_lambda_function,module.worker,KNOWN_PRESENT,REDESIGN,LOW,,PROPOSED,jobs-worker
3,MEMBER,aws_db_instance.orders,aws_db_instance,,KNOWN_PRESENT,REPLATFORM,MEDIUM,,UNASSIGNED,
0,NOT_A_MEMBER,aws_db_instance.orders_replica,aws_db_instance,,CONDITIONAL,MANUAL_REVIEW,LOW,,UNASSIGNED,
0,NOT_A_MEMBER,aws_s3_bucket.data,aws_s3_bucket,,CONDITIONAL,DIRECT,MEDIUM,,UNASSIGNED,Resources and units are different counts
Eleven resources produce nine units: seven known to be present and two conditional. The other two are accounted for: the DB subnet group is the one support resource the console names, and assessment.json folds the Lambda event-source mapping into the function's unit as a part of it. The three resources of the module call are counted with the root ones.
The module is part of the inventory
The local module queue_worker is loaded, its three resources are attributed to module.worker, and its units sit in the same waves as the root ones. A module that cannot be fetched is reported as not downloaded, never skipped without a word.
Waves are an order, not a schedule
Wave 1 holds what depends on nothing, wave 3 the database that needs both subnets. The units that exist only when OpenTofu plans have no wave yet. That is the run saying what it cannot place.
Where the data lives
The most useful part of an inventory for a migration lead is the list of services that hold data. The run writes it as migration/data-movement.md. These are its first lines for the demo estate.
## Stateful source units
| Unit | Source service | Target service | Application | Mechanism | State | Data class | RPO | Downtime tolerance |
|---|---|---|---|---|---|---|---|---|
| aws_db_instance.orders | aws_db_instance | STACKIT PostgreSQL Flex | | UNKNOWN | DECISION_REQUIRED | NOT_PROVIDED | NOT_PROVIDED | NOT_PROVIDED |
| aws_db_instance.orders_replica | aws_db_instance | NO_TARGET_PROPOSED | | UNKNOWN | DECISION_REQUIRED | NOT_PROVIDED | NOT_PROVIDED | NOT_PROVIDED |
| aws_s3_bucket.data | aws_s3_bucket | STACKIT Object Storage | | UNKNOWN | DECISION_REQUIRED | NOT_PROVIDED | NOT_PROVIDED | NOT_PROVIDED |Stateful units are named, not moved
Three units hold data: the database, the replica and the bucket map. StackToIT never moves data; it states that a person must choose a mechanism for each. The database's allocated_storage = 200 is shown as provisioned capacity, not as data size.
What the code cannot say stays blank
Data class, RPO and downtime tolerance read NOT_PROVIDED. RPO and downtime tolerance are values you write into the applications file; the data class comes from the data owner. A blank is more honest than an invented default.
Which question is answered where
| Inventory question | File of the run | What it contains |
|---|---|---|
| What exists today? | assessment.json | Every unit with class, confidence, target, reason and source line |
| What may exist? | assessment.json (conditional units) | Count and for_each expressions, the variables that decide them, the state on defaults |
| What belongs to which module? | assessment.json, migration/technical-sequencing.md | Module calls, how each loaded, units by module and wave |
| What depends on what? | migration/waves.csv, architecture/dependencies.mmd | Dependency order and the graph behind it |
| Where does data live? | migration/data-movement.md | Stateful units, target service, provisioned capacity, open data questions |
| What is still unknown? | decisions.json, migration/prerequisites.md | Decisions, defaults to confirm and units that wait for an answer |
File names as the run writes them.
A discovery sequence that holds up
General practice, built on the artefacts above.
1 · Pick the root modules that matter
Point the engine at the directory that declares the infrastructure, not a subdirectory of a larger configuration. Keep the lock file and the variables files your pipeline uses.
2 · Run it where the code lives
A standard assessment runs on your machine and does not upload the repository. Add --offline when the machine has no network; missing modules are then reported as missing.
3 · Read the counts first
Resources, units, modules, conditional units and source completeness tell you whether the inventory is whole or partial before you read a single row.
4 · Resolve the conditional units
Decide the variables that switch resources on or off, or pass a variables file, and run again. Each conditional unit is a question to the platform owner.
5 · Add what code cannot know
Supply applications, owners, RTO, RPO and downtime tolerance, and get the data class from the data owner. The applications file groups resources into the applications the business recognises.
6 · Compare against reality
Code is not the estate. Resources created by hand are not in it. For AWS, an optional discover command can bring in an inventory you collect with your own CLI credentials so the run can compare it with the source.
Three kinds of source
Terraform and OpenTofu
Root modules with local and remote modules and variables. This is the richest input: it carries configuration, references and counts.
VMware RVTools
An export of virtual machines, disks and networks (for example the vInfo, vDisk and vNetwork tabs). The engine reads the file only and does not connect to vCenter. RVTools carries no subnet masks and no application behaviour, so these are supplied separately.
A SourceBundle archive
An archive selected explicitly with an input option, for sources that were prepared as a bundle.
Questions migration leads ask
Does discovery need access to our cloud account?
No. A standard assessment reads code and variables only, needs no cloud credentials and does not upload the repository. The optional AWS comparison uses the credentials of your own AWS CLI.
What if some resources were created outside Terraform?
They are not in the code, so they are not in the inventory. Compare with an inventory collected from the account, or add them to the code before you assess.
Why are some units missing from the dependency order?
Units whose existence is decided only when OpenTofu plans have no wave. Resolve the variables or data they depend on and the run places them.
How does this relate to the STACKIT Migration Framework?
The framework starts with an assessment phase that builds a fact-based baseline. The inventory above is that baseline, taken from code.
Sources
Your next move: STACKIT.
Tell us about your estate. We show you what StackToIT delivers for it: target architecture, Terraform artifacts and a structured migration plan.