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.

Example inputmain.tf
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
}
StackToIT assessmentassessment.json · summary.conditional_units
[
  {
    "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.

Console summaryconsole.txt
────────────────────────────────────────────
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 time
Dependency ordermigration/waves.csv
wave,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.

Data-bearing unitsmigration/data-movement.md
## 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 questionFile of the runWhat it contains
What exists today?assessment.jsonEvery 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.mdModule calls, how each loaded, units by module and wave
What depends on what?migration/waves.csv, architecture/dependencies.mmdDependency order and the graph behind it
Where does data live?migration/data-movement.mdStateful units, target service, provisioned capacity, open data questions
What is still unknown?decisions.json, migration/prerequisites.mdDecisions, 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. 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. 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. 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. 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. 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. 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.