We built seven objects by hand on a lab OpenStack cloud, one openstack CLI command at a time, and then tried a Terraform import of that existing infrastructure with OpenTofu's import blocks. The first thing that broke was the configuration OpenTofu wrote for us. It failed its own plan with 14 errors. The version we wrote by hand afterwards planned, on its first apply, to destroy two security group rules and quietly empty a subnet's DNS server list.
An import block on its own changes nothing in the cloud. The catch is that it runs in the same apply as everything else the plan found, so the first apply imports the object and then changes whatever your configuration forgot to say. Here is what the run found, on OpenTofu 1.13.2 with the OpenStack provider 3.4.0: a network, a subnet, a security group with two ingress rules, a port with a fixed address and a 1 GB volume, all created with the openstack CLI and deleted when we finished. CloudSpinx gets clicked-together clouds into code for engineering teams in Kenya through its DevOps and infrastructure-as-code practice. The import block and the plan are OpenTofu's own; the attribute quirks below belong to the OpenStack provider.
Generated config from import blocks failed its own plan
Seven import blocks, one per object, no resource blocks, then tofu plan -generate-config-out=generated.tf. OpenTofu wrote the file and refused it in the same run: 14 errors across 5 of the 7 resources, plus a warning that config generation is experimental. Only the network and the security group came out clean.
Error: Conflicting configuration arguments
with openstack_networking_secgroup_rule_v2.https,
on generated.tf line 10:
(source code not available)
"remote_ip_prefix": conflicts with remote_group_id
Every error traced to a value the generator wrote for an argument nobody had set. The rule got remote_group_id = "" beside its real remote_ip_prefix, and the provider counts an empty argument as present, so a rule with one real source looked like it named three. The volume tripped on empty snapshot_id, source_vol_id and backup_id, the subnet on no_gateway = false beside its gateway and on two empty IPv6 modes, the port on an empty binding profile. OpenTofu's documentation on generated config shows the AWS flavor of this, two IPv6 arguments on one instance, and the fix here was the same idea: delete the arguments the generator filled with empty or default values. Eleven lines deleted, nothing else touched, and the plan read 7 to import, 0 to add, 0 to change, 0 to destroy.
That clean plan is worth less than it looks. The port block it accepted has no fixed_ip and says security_group_ids = null, so the file is silent on the port's address and on the group that guards it. Every reference between objects is a pasted UUID. A clean plan after generation means the file does not contradict the cloud. It does not mean the file describes it.
Why the first plan after a Terraform import wanted to destroy rules
The other route in is the one many tutorials teach: write the resource blocks yourself from what the console shows, add the import blocks, plan. We did that in a fresh state, with names, the CIDR, ports and sources, and the port's subnet and group as references. We left the descriptions out, as you would copying from a console in a hurry.
# openstack_networking_secgroup_rule_v2.ssh must be replaced
# (imported from "52866c71-c182-4a0f-8ded-2caf711dde4b")
# Warning: this will destroy the imported resource
-/+ resource "openstack_networking_secgroup_rule_v2" "ssh" {
- description = "ssh from office" -> null # forces replacement
Plan: 7 to import, 2 to add, 4 to change, 2 to destroy.
In this provider a rule's description forces replacement, so both rules were planned as destroy, then create. The four in-place changes looked like housekeeping, descriptions cleared, except one. The subnet's update also removed its DNS server, - "192.0.2.53", because our block never mentioned dns_nameservers. That line sits under "4 to change", and a reviewer skimming the summary for "destroy" never reaches it.
We applied it. One apply imported all seven objects, deleted both rules, recreated them with new IDs, and left the subnet with an empty DNS server list.
How long was a rule gone? Our first two attempts to time it failed. OpenTofu's output reached our timestamping pipe in a burst, so the stamps came out a few milliseconds apart and timed the pipe, not the apply, and a loop listing the rules about every 2 seconds never caught one missing. The provider's debug log did. On a second replacement the DELETE was answered at 07:02:07.218 and the new rule's POST at 07:02:07.341, 0.424 seconds after the DELETE went out. The rule was missing for somewhere between 0.017 and 0.424 seconds. That was one rule, with nothing else in the plan, on a lab cloud.
create_before_destroy hits a duplicate rule
The textbook answer to a destroy-first replacement is create_before_destroy. On this resource the apply failed:
Error: Error creating openstack_networking_secgroup_rule_v2: Expected HTTP response code [201 202]
when accessing [POST http://192.0.2.10:9696/v2.0/security-group-rules], but got 409 instead:
{"NeutronError": {"type": "SecurityGroupRuleExists", "message": "Security group rule already exists.
Rule id is 2d44e86a-92dd-4d68-8c20-7aeeaf1d3634.", "detail": ""}}
Neutron's duplicate check does not count the description, so the replacement it was asked to create first is, as far as Neutron can tell, the rule already sitting there. The failure was safe: the create ran first, failed, and the old rule stayed. OpenTofu's lifecycle documentation warns that "many remote object types have unique name requirements or other constraints", and this is one.
One brownfield recipe that circulates goes the other way and starts every imported resource on ignore_changes = all. We tried that too: the rule's description dropped from the code again, the lifecycle line added, and the plan said "No changes." The rule still has a description, the code says it has none, and the plan has stopped telling you. Delete that line a year later in a pull request about something else and the replacement is back.
The fix that worked is the boring one. Copy the description into the config, so nothing forces a replacement at all.
Backing out of an import without deleting anything
An import that went wrong tempts you to delete the block and start over. Once the object is in state, that plan reads 1 to destroy, "because ... is not in configuration". prevent_destroy will not save you. On the volume it stopped a forced replacement with "Resource instance cannot be destroyed", but when we deleted the whole block the setting went with it and the plan went straight back to destroying the volume, which is what the same docs page says it does. What backs out cleanly is a removed block:
removed {
from = openstack_blockstorage_volume_v3.data
lifecycle {
destroy = false
}
}
Written without the lifecycle part, our plan read 0 to add, 0 to change, 0 to destroy, 1 to forget, and OpenTofu warned that leaving it out "could lead to unwanted behavior", so write the destroy = false the docs show.
One more thing the run showed by accident. The first state, built from the generated file, still held the old rule IDs after the second state replaced them. Its plan wanted to create both rules again and put back every description and the DNS server, and nothing warned that another state owned the same objects. Import each object into exactly one root module.
The order we work to, then, is short:
- Import one module's worth of objects at a time, into one state, with import blocks rather than the
importcommand, so the plan shows the batch before anything lands. - Generate the config, delete what the provider rejects, then replace pasted IDs with references and read every in-place line, not the summary.
- Put
prevent_destroyon anything holding data, knowing it guards replacements and not deleted blocks. OpenTofu's resource-leveldestroy = falseis stored in state and turns a deleted block into a forget, by the same docs page; we did not test that one. - Tidy afterwards, one change and one plan at a time, the plan printed by your CI pipeline on every pull request rather than on someone's laptop.
What this run did not answer
We had no server in the estate, so we do not know what a running instance's traffic sees in the fraction of a second a rule is gone, or what its resolver gets at the next DHCP lease once the subnet's DNS list is empty. We ran OpenTofu, not Terraform, and OpenStack, not AWS. The generator's empty strings and the description that forces replacement belong to this provider, which matters if you run a private OpenStack cloud, and AWS has quirks of its own (the docs' IPv6 example is one of them). Seven objects on local state is not forty modules behind a remote backend. The more objects in one plan, the more lines you have to read.
Seven objects and one engineer do not need us, and neither does a handful of servers one person looks after: the console is fine there, and the order above is enough. The estate that does is the one in our rebuild case study: built by hand, now eight Google Cloud projects written down as OpenTofu, under a live product. If yours is moving that way, or is already moving from Terraform to OpenTofu at the same time, send the inventory of what exists, what is already in code, where the state lives and the pipeline that applies it, through the DevOps scoping form with "None, built by hand" or "Some Terraform" picked under infrastructure as code, or on WhatsApp at +254 713 403 044. You get the import batches in order, which objects plan clean and which need a decision before anyone applies, and one figure for the move. It costs nothing and commits you to nothing.