From Imperative to Declarative: Importing Resources With Terraform Before and After Terraform Version 1.5
If you are lucky to work on a greenfield project, you might not even be aware of the possibility of importing existing resources into your Terraform state. Most of us have to interact with legacy infrastructure of some sort. Legacy, in this context, does not have to mean old. It could be something you created earlier using Terraform — but now you need to move the resource to another Terraform state file.
What does it mean to import an existing resource into your Terraform state? An existing resource is simply a resource created through other means than your current Terraform configuration where you want to import the resource. The resource might have been created using another infrastructure-as-code tool, it might have been created using point-and-click in a GUI, or it might even have been created using a different Terraform configuration. No matter how it was created, you might be interested in having the new Terraform configuration you are writing manage the resource for you.
This is where the import operation comes into the picture. In simple terms, when you import a resource, you create a space for the resource in your state file and fill in the details corresponding to the imported resource. You must also write the corresponding HCL to work with the resource. The end result is that Terraform created the resource to start with. You can now do whatever Terraform can operate on your imported resource.
Terraform version 1.5 introduced a new experience for importing resources. Spoiler alert: it is pretty easy to use, and it works well! Before Terraform version 1.5, you had to use the Terraform CLI to import a resource into your state. It also works well, but it is ultimately an imperative operation and could be difficult to achieve. The new experience uses the new import block, which makes the import experience declarative in nature.
In this article, I will compare the traditional way of importing resources into your Terraform state with the new-and-improved experience using the import block!
What We Will Be Importing
As always, it is easier to demonstrate something if the complexity of the example is kept low, so that is what I will strive for here as well. I will work with the Azure provider for Terraform, and I will create two sample resources:
- A resource group
- A storage account
If you are new to Azure and Terraform, there is a lot of setting up your environment that you will need to go through, the details of which I will not include here.
You can use any method to set up the two resources that will be imported, I will use the Azure CLI. First, I will create a resource group:
$ az group create
--name rg-terraform-import
--location swedencentral
This command returns the following output:
{
"id": "/subscriptions/<sub id>/resourceGroups/rg-terraform-import",
"location": "swedencentral",
"name": "rg-terraform-import",
...
"type": "Microsoft.Resources/resourceGroups"
}
The output is truncated a bit, but I kept the important parts. The id is especially important because we will need it to identify which resource to import. Next, I will create a storage account in my resource group:
$ az storage account create
--name sttfimport
--resource-group rg-terraform-import
--location swedencentral
--sku Standard_LRS
This command returns the following output:
{
"id": "/subscriptions/<sub id>/resourceGroups/rg-terraform-import/providers/Microsoft.Storage/storageAccounts/sttfimport",
"kind": "StorageV2",
"location": "swedencentral",
"name": "sttfimport",
"resourceGroup": "rg-terraform-import",
"sku": {
"name": "Standard_LRS",
"tier": "Standard"
},
...
"type": "Microsoft.Storage/storageAccounts"
}
I have once again truncated the output but kept the important details. Again, the id is needed during the import operation.
Pre-Terraform Steps
Now we move on from the Azure CLI to Terraform. I will define my Terraform configuration in a single file named main.tf. Common for all of my examples in this post is that I need to specify that I want to use the Azure provider (named azurerm):
// main.tf
terraform {
required_providers {
azurerm = {
source = "hashicorp/azurerm"
}
}
}
provider "azurerm" {
features {}
}
I run terraform init to initialize the configuration, and then I make sure that my state is empty by running terraform state list:
$ terraform state list
No output is returned, as expected.
Importing Resources Using the CLI
Now, we are ready to start importing resources. First, we will use the traditional method using the CLI.
You must have a target in your Terraform configuration when importing a resource. A target is a resource block corresponding to the resource you want to import. To achieve this, I add two resource blocks, one for my resource group and one for my storage account:
// main.tf
// ...
resource "azurerm_resource_group" "rg" {
name = "rg-terraform-import"
location = "swedencentral"
}
resource "azurerm_storage_account" "st" {
name = "sttfimport"
resource_group_name = azurerm_resource_group.rg.name
location = azurerm_resource_group.rg.location
account_tier = "Standard"
account_replication_type = "LRS"
}
This looks exactly like it would have been if I wanted to create these resources from scratch. However, if I tried to run terraform apply on this configuration, it would fail because the two resources already exist.
To import resources with the CLI, I will use the terraform import command. The general format of the command is:
$ terraform import [options] ADDR ID
The two important pieces of this command are the following:
- ADDR is the address in your Terraform configuration where the HCL for this resource is located. This is the target I was talking about before. Each resource has an address in your Terraform configuration. For resources in your root module, the address is simply resource_type.symbolic_name. Taking my storage account as an example, the address is azurerm_storage_account.st
- ID is the resource ID of the resource you want to import. This ID corresponds to an identifier in “the real world.” This is how Terraform knows which resource you want to target. This is why the id field in the output from the Azure CLI commands was important. That id corresponds to the ID in the terraform import command
With this knowledge in our backpack, we can perform the imports. I start with my resource group:
$ terraform import azurerm_resource_group.rg /subscriptions/<sub id>/resourceGroups/rg-terraform-import
The following output is produced:
azurerm_resource_group.rg: Importing from ID "/subscriptions/<sub id>/resourceGroups/rg-terraform-import"...
azurerm_resource_group.rg: Import prepared!
Prepared azurerm_resource_group for import
azurerm_resource_group.rg: Refreshing state... [id=/subscriptions/<sub id>/resourceGroups/rg-terraform-import]
Import successful!
The resources that were imported are shown above. These resources are now in
your Terraform state and will henceforth be managed by Terraform.
The output indicates that the import succeeded! I continue with my storage account:
$ terraform import azurerm_storage_account.st /subscriptions/<sub id>/resourceGroups/rg-terraform-import/providers/Microsoft.Storage/storageAccounts/sttfimport
This time the following output is produced:
azurerm_storage_account.st: Importing from ID "/subscriptions/<sub id>/resourceGroups/rg-terraform-import/providers/Microsoft.Storage/storageAccounts/sttfimport"...
azurerm_storage_account.st: Import prepared!
Prepared azurerm_storage_account for import
azurerm_storage_account.st: Refreshing state... [id=/subscriptions/<sub id>/resourceGroups/rg-terraform-import/providers/Microsoft.Storage/storageAccounts/sttfimport]
Import successful!
The resources that were imported are shown above. These resources are now in
your Terraform state and will henceforth be managed by Terraform.
Once again, the import was successful! Let’s see what our state contains using terraform state list like before:
azurerm_resource_group.rg
azurerm_storage_account.st
Since we have the resources in our state, what happens if we run terraform plan? The following output is returned:
azurerm_resource_group.rg: Refreshing state... [id=/subscriptions/<sub id>/resourceGroups/rg-terraform-import]
azurerm_storage_account.st: Refreshing state... [id=/subscriptions/<sub id>/resourceGroups/rg-terraform-import/providers/Microsoft.Storage/storageAccounts/sttfimport]
Terraform used the selected providers to generate the following execution plan. Resource actions are
indicated with the following symbols:
~ update in-place
Terraform will perform the following actions:
# azurerm_storage_account.st will be updated in-place
~ resource "azurerm_storage_account" "st" {
+ cross_tenant_replication_enabled = true
id = "/subscriptions/<sub id>/resourceGroups/rg-terraform-import/providers/Microsoft.Storage/storageAccounts/sttfimport"
~ min_tls_version = "TLS1_0" -> "TLS1_2"
name = "sttfimport"
tags = {}
# (35 unchanged attributes hidden)
# (4 unchanged blocks hidden)
}
Plan: 0 to add, 1 to change, 0 to destroy.
First of all, the plan says 0 to add. That is good! It means Terraform knows about the resource group and the storage account, so it won’t try to create them. However, we also see 1 to change. This is a common occurrence when you import resources. This has to do with default values used by the Azure CLI versus default values used by the Azure provider for Terraform, there is a difference between them, so Terraform will correct this difference — hence the 1 to change for the storage account. In general, it is safe to accept the changes, but it is a good idea to look through what changes there are just to be sure.
You might have noticed that I did not use a -dry-run flag or something similar when I imported my resources. That is because it does not exist for this command. It either works or it doesn’t. Remember to take a backup of your state file before importing resources! Funny how I waited until the end of this section before I added that very important warning. You’re welcome!
Importing Resources Using the Resource Block
Now, let us look at the new import experience using the import block.
As in the previous example, I need to have a target for my resource, so I once again add the resource group and storage account to main.tf:
// main.tf
// ...
resource "azurerm_resource_group" "rg" {
name = "rg-terraform-import"
location = "swedencentral"
}
resource "azurerm_storage_account" "st" {
name = "sttfimport"
resource_group_name = azurerm_resource_group.rg.name
location = azurerm_resource_group.rg.location
account_tier = "Standard"
account_replication_type = "LRS"
}
The next step is to add two import blocks:
// main.tf
// ...
import {
id = "/subscriptions/<sub id>/resourceGroups/rg-terraform-import/providers/Microsoft.Storage/storageAccounts/sttfimport"
to = azurerm_storage_account.st
}
import {
id = "/subscriptions/<sub id>/resourceGroups/rg-terraform-import"
to = azurerm_resource_group.rg
}
We can see the similarity with the terraform import CLI command. I provide an id to identify the resource in Azure and a to property to indicate the target resource in my Terraform configuration. We have basically moved the imperative terraform import command to declarative code in import blocks.
Now, we follow the regular Terraform workflow and run a terraform plan:
azurerm_resource_group.rg: Preparing import... [id=/subscriptions/<sub id>/resourceGroups/rg-terraform-import]
azurerm_resource_group.rg: Refreshing state... [id=/subscriptions/<sub id>/resourceGroups/rg-terraform-import]
azurerm_storage_account.st: Preparing import... [id=/subscriptions/<sub id>/resourceGroups/rg-terraform-import/providers/Microsoft.Storage/storageAccounts/sttfimport]
azurerm_storage_account.st: Refreshing state... [id=/subscriptions/<sub id>/resourceGroups/rg-terraform-import/providers/Microsoft.Storage/storageAccounts/sttfimport]
Terraform used the selected providers to generate the following execution plan. Resource actions are
indicated with the following symbols:
~ update in-place
Terraform will perform the following actions:
# azurerm_resource_group.rg will be imported
resource "azurerm_resource_group" "rg" {
id = "/subscriptions/<sub id>/resourceGroups/rg-terraform-import"
location = "swedencentral"
name = "rg-terraform-import"
tags = {}
}
# azurerm_storage_account.st will be updated in-place
# (imported from "/subscriptions/<sub id>/resourceGroups/rg-terraform-import/providers/Microsoft.Storage/storageAccounts/sttfimport")
~ resource "azurerm_storage_account" "st" {
access_tier = "Hot"
account_kind = "StorageV2"
account_replication_type = "LRS"
account_tier = "Standard"
(... output truncated ...)
}
Plan: 2 to import, 0 to add, 1 to change, 0 to destroy.
I removed some of the output because there was a lot of it! The important thing is that we see 2 to import in the summary at the bottom. We have now safely validated that the import should work without triggering the import itself. If we are satisfied with the plan, we can go ahead and apply the changes using terraform apply:
(... output truncated ...)
Apply complete! Resources: 2 imported, 0 added, 1 changed, 0 destroyed.
I can verify that my resources are imported by taking a look at my current state using terraform state list:
azurerm_resource_group.rg
azurerm_storage_account.st
So, now we have imported our resources. What should we do with the import blocks that we added? What happens if we run terraform apply again? It is safe to leave the blocks in our code if we wish. They can serve as documentation that shows these resources were imported. You can safely remove the import blocks if you do not need that kind of documentation. Nothing will happen to the imported resources.
Generating Terraform Configuration for Imported Resources
There is another feature (actually an experimental feature) you can use with the new import block. There is a new flag for the terraform plan command that allows us to let Terraform generate resource blocks for the resources we import. To try this out, I remove my resource blocks from main.tf, but I leave the import blocks as is:
terraform {
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "3.64"
}
}
}
provider "azurerm" {
features {}
}
import {
id = "/subscriptions/<sub id>/resourceGroups/rg-terraform-import/providers/Microsoft.Storage/storageAccounts/sttfimport"
to = azurerm_storage_account.st
}
import {
id = "/subscriptions/<sub id>/resourceGroups/rg-terraform-import"
to = azurerm_resource_group.rg
}
Next, I run terraform plan and add the -generate-config-out flag:
$ terraform plan -generate-config-out=imported.tf
Terraform creates a file called imported.tf (you provide the name to the -generate-config-out flag) containing the two imported resources. It then performs a regular plan operation.
In my example, several errors were generated during the plan because some of the property values that Terraform set for my storage account were invalid. This is unfortunate, but we can’t expect everything to work since this is an experimental feature. However, I can easily edit the imported resource configuration to make it work. This still speeds up the import process!
Summary
Importing resources is a necessary evil sometimes, so it is good to know it is fairly easy to perform. The new import block turns the previous imperative experience using terraform import to a declarative experience that follows the regular Terraform workflow.
My example was fairly basic. Things can quickly become complicated if you import multiple resources and shuffle some resources around your state. Remember to back up your state file before doing any operations to get back to where you started.
From Imperative to Declarative: Importing Resources With Terraform Before and After Terraform 1.5 was originally published in Better Programming on Medium, where people are continuing the conversation by highlighting and responding to this story.