Monday, 28 March 2022

Introduction to Terraform


 

Benefits of using Terraform:
  • Automated way to achieve repeatable and scalable infrastructure. If we manually build infrastructure and then need to repeat the process, a) it would take double the time and b) chance of making an error / applying a different setting are high. If we use IaC tool like Terraform, we define infrastructure with all its parameters once, in a code, and then provision it and destroy it as many times as we wish.
  • Shorter deployment times. No manual work is required.
  • Lower costs. Defining infrastructure is done only once. 
  • All resources are defined in the code => 
  • From Terraform for Site24x7 | Online Help Site24x7:
    • Ensure error-free deployments
    • Handle heterogeneous cloud deployments for fault tolerance
    • Collaborate, obtain specific versions, and create your own environment
    • Spawn and remove testing environments easily
    • Improve deployment speeds
    • Build, version, or change infrastructure safely and efficiently
    • Trace and correct configuration-level issues
  • Cross-platform
  • Human-readable
  • Open source. We can create our own Terraform providers for our specific functionality.
  • Has both a free version (Terraform) and an enterprise version (Terraform Cloud) with no functionality lost between the versions.
  • From Kubernetes: Getting Started with Kubernetes provider | Guides | hashicorp/kubernetes | Terraform | Terraform Registry:
    • drift detection - terraform plan will always present you the difference between reality at a given time and config you intend to apply.
    • full lifecycle management - Terraform doesn't just initially create resources, but offers a single command for creation, update, and deletion of tracked resources without needing to inspect the API to identify those resources.
    • synchronous feedback - While asynchronous behaviour is often useful, sometimes it's counter-productive as the job of identifying operation result (failures or details of created resource) is left to the user. e.g. you don't have IP/hostname of load balancer until it has finished provisioning, hence you can't create any DNS record pointing to it.
    • graph of relationships - Terraform understands relationships between resources which may help in scheduling - e.g. if a Persistent Volume Claim claims space from a particular Persistent Volume Terraform won't even attempt to create the PVC if creation of the PV has failed.

Terraform uses configuration files with extension .tf to define resources that need to be deployed.

General format of tf file content blocks is:
 
<block><parameters> {
    key1 = value1
    key2 = value2
}

block contains information about infrastructure platform and a set of resources within that platform that we want to create.
 
resource Block - A resource block declares a resource of a given type with a given local name. The name is used to refer to this resource from elsewhere in the same Terraform module, but has no significance outside of the scope of a module.

Example: Create a file on a local host where Terraform is installed. 


# https://www.terraform.io/language/expressions/references#filesystem-and-workspace-info
resource "local_file" "foo" {
    filename = "${path.cwd}/temp/foo.txt"
    content = "This is a text content of the foo file!"
}

resource is the reserved word and is the block name. Each resource is uniquely defined by the combination of the resource type and name. In our example:

  • "local_file" is a predefined string which denotes the resource type:
    • local - resource provider (by convention, resource type names start with their provider's preferred local name)
    • file - resource type
  • "foo" is the logical resource name; this is a local name that can be used from the same Terraform module

Inside the curly braces (resource block) are arguments for resource. They are specific for the given resource type. In this example, we are using local_file | Resources | hashicorp/local | Terraform Registry.

Here are some resource providers:
  • local
  • aws
  • azure
  • gcp
  • ...

Each resource type has its own set of arguments.


Example: Create Amazon Machine Image (AMI)

aws-ec2.tf:

resource "aws_instance" "my_webserver" {
    ami = "ami-0123456789abcdef" 
    instance_type = "t2.micro"
}


Example: S3

aws-s3.tf:

resource "aws_s3_bucket" "my_data" {
    bucket = "webserver-bucket-org-1234" 
    acl = "private"
}



Simple Terraform workflow:
  • write .tf file
  • (optional) run terraform fmt command which scans .tf files in current working directory and formats them in the canonical format. This improves the readability of configuration files.
  • run terraform init command. It:
    • checks the .tf config file
    • initializes the working directory containing .tf file
    • from .tf file it finds out what type of resource(s) we want to create
    • it initializes specified providers - it downloads plugin(s) required in order to work on these resources
    • is a safe command. It can be many times without affecting the infrastructure that is being deployed.
  • (optional) run terraform validate to validate configuration file syntax
  • run terraform plan command in order to review the execution plan 
  • run terraform apply command to apply changes
  • run terraform show command in order to see the changes made; it prints the current state of the infrastructure; it prints all resources and values of their attributes. Use -json flag to print it in JSON format.
  • run terraform destroy to destroy created resources
 

terraform init

 
Let's first initialize working directory where .tf file resides:
 
/demo-terraform/local_resources_demo$ ls -a
.  ..  create_local_file_demo.tf
 
/demo-terraform/local_resources_demo$ cat create_local_file_demo.tf
resource "local_file" "foo" {
    filename = "${path.cwd}/temp/foo.txt"
    content = "This is a text content of the foo file!"
}
 
/demo-terraform/local_resources_demo$ terraform init

Initializing the backend...

Initializing provider plugins...
- Finding latest version of hashicorp/local...
- Installing hashicorp/local v2.2.2...
- Installed hashicorp/local v2.2.2 (signed by HashiCorp)

Terraform has created a lock file .terraform.lock.hcl to record the provider
selections it made above. Include this file in your version control repository
so that Terraform can guarantee to make the same selections by default when
you run "terraform init" in the future.

Terraform has been successfully initialized!

You may now begin working with Terraform. Try running "terraform plan" to see
any changes that are required for your infrastructure. All Terraform commands
should now work.

If you ever set or change modules or backend configuration for Terraform,
rerun this command to reinitialize your working directory. If you forget, other
commands will detect it and remind you to do so if necessary.

 
 
From the output above, we can see that Terraform used resource type information ("local_file") to deduct that it needs to download and install hashicorp/local plugin.

hashicorp/local is an example of the source address. It is the identifier used by Terraform to locate and download the plugin from the registry. The first value (e.g. hashicorp) represents the organisational namespace and the second one is type which is the name of provider (e.g. local, aws, google etc...). Source address can also optionally start with the hostname which is the name of the registry where plugin is located:

registry.terraform.io/hashicorp/local
 
By default, Terraform downloads the latest version of plugins but it is possible to specify desired version as well, if we want to prevent using the (latest) version which introduces breaking changes.
 
If we look at the directory, we can see that terraform init created one directory and one file:
 
/demo-terraform/local_resources_demo$ ls -a
.  ..  create_local_file_demo.tf  .terraform  .terraform.lock.hcl

 
 
.terraform directory contains downloaded plugins (executable binaries) for providers that are specified in .tf file. In our case, provider is named local:

/demo-terraform/local_resources_demo/.terraform$ ls -la providers/registry.terraform.io/hashicorp/local/2.2.2/linux_amd64/terraform-provider-local_v2.2.2_x5
-rwxr-xr-x 1 bojan bojan 13864960 Mar 28 13:27 providers/registry.terraform.io/hashicorp/local/2.2.2/linux_amd64/terraform-provider-local_v2.2.2_x5

.hcl file contains list of providers with their details. This is something like package-lock.json file in Node projects:

/demo-terraform/local_resources_demo$ cat .terraform.lock.hcl
# This file is maintained automatically by "terraform init".
# Manual edits may be lost in future updates.

provider "registry.terraform.io/hashicorp/local" {
  version = "2.2.2"
  hashes = [
    "h1:5UYW2wJ320IggrzLt8tLD6MowePqycWtH1b2RInHZkE=",
    "zh:027e4873c69da214e2fed131666d5de92089732a11d096b68257da54d30b6f9d",
    "zh:0ba2216e16cfb72538d76a4c4945b4567a76f7edbfef926b1c5a08d7bba2a043",
    "zh:1fee8f6aae1833c27caa96e156cf99a681b6f085e476d7e1b77d285e21d182c1",
    "zh:2e8a3e72e877003df1c390a231e0d8e827eba9f788606e643f8e061218750360",
    "zh:719008f9e262aa1523a6f9132adbe9eee93c648c2981f8359ce41a40e6425433",
    "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
    "zh:9a70fdbe6ef955c4919a4519caca116f34c19c7ddedd77990fbe4f80fe66dc84",
    "zh:abc412423d670cbb6264827fa80e1ffdc4a74aff3f19ba6a239dd87b85b15bec",
    "zh:ae953a62c94d2a2a0822e5717fafc54e454af57bd6ed02cd301b9786765c1dd3",
    "zh:be0910bdf46698560f9e86f51a4ff795c62c02f8dc82b2b1dab77a0b3a93f61e",
    "zh:e58f9083b7971919b95f553227adaa7abe864fce976f0166cf4d65fc17257ff2",
    "zh:ff4f77cbdbb22cc98182821c7ef84dce16298ab0e997d5c7fae97247f7a4bcb0",
  ]
}

 

terraform validate


It requires terraform init to have been executed. If not, if configuration directory is not initialized, we might get the error like:

$ terraform validate
Error: Missing required provider
│ 
│ This configuration requires provider registry.terraform.io/hashicorp/local, but that provider isn't available. You may be able to
│ install it automatically by running:
│   terraform init

Error: Missing required provider
│ 
│ This configuration requires provider registry.terraform.io/hashicorp/tls, but that provider isn't available. You may be able to
│ install it automatically by running:
│   terraform init

terraform apply might fail in spite of terraform validate reporting no errors. This is because the validate command only carries out a general verification of the configuration. It validates the resource block and the argument syntax but not the values the arguments expect for a specific resource.


terraform plan


Before creating resource we can see the execution plan that will be carried out by Terraform. It is always good to review these actions before they get executed. terraform plan shows actions that will be used to create resources. The output is similar to the one of git diff:

/demo-terraform/local_resources_demo$ terraform plan

Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # local_file.foo will be created
  + resource "local_file" "foo" {
      + content              = "This is a text content of the foo file!"
      + directory_permission = "0777"
      + file_permission      = "0777"
      + filename             = "/home/bojan/dev/github/demo-terraform/local_resources_demo/temp/foo.txt"
      + id                   = (known after apply)
    }

Plan: 1 to add, 0 to change, 0 to destroy.

Note: You didn't use the -out option to save this plan, so Terraform can't guarantee to take exactly these actions if you run "terraform apply" now.


+ symbol denotes resources that will be created.
Resource is listed with all its attributes showing default values for those that we didn't specify explicitly in our .tf file.

In rare occasions, when creation of one module requires another module to be created, we need to do plan (and apply) in stages where we target specific resources. Example:

The kubernetes_manifest resource needs access to the API server of the cluster during planning. This is because, in order to support CRDs in Terraform ecosystem, we need to pull the schema information for each manifest resource at runtime (during planing).

AFAICS, every person who reported seeing similar issues above, configures the attributes of the provider "kubernetes" {..} block using references to other resources' attributes. You can only do this if the referenced resource (the cluster in this case) IS ALREADY PRESENT before you start planing / applying any of the kubernetes_manifest resources. You can achieve this as simply as using the -target CLI argument to Terraform to limit the scope of the first apply to just the cluster and it's direct dependencies. Then you follow up with a second apply without a -target argument and this constructs the rest of the resources (manifest & others). You will end up with a single state file and subsequent updates no longer require this two-step approach as long as the cluster resource is present.

This limitation is stemming from Terraform itself, and the provider tries to push things as far as it can, but there is no way around needing access to schema from the API (Terraform is fundamentally a strongly-typed / schema based system).

$ terraform plan -target module.nginx-virtual-network -target module.nginx-eks-cluster
...

Plan: 31 to add, 0 to change, 0 to destroy.
│ Warning: Resource targeting is in effect
│ 
│ You are creating a plan with the -target option, which means that the result of this plan may not represent all of the changes requested by the current configuration.
│ 
│ The -target option is not for routine use, and is provided only for exceptional situations such as recovering from errors or mistakes, or when Terraform specifically
│ suggests to use it as part of an error message.

$ terraform plan -target module.nginx-eks-cluster-karpenter
...

terraform apply



This command creates resources but it also shows the plan first and asks us to confirm to go ahead with it:
 
/demo-terraform/local_resources_demo$ terraform apply

Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # local_file.foo will be created
  + resource "local_file" "foo" {
      + content              = "This is a text content of the foo file!"
      + directory_permission = "0777"
      + file_permission      = "0777"
      + filename             = "/home/bojan/dev/github/demo-terraform/local_resources_demo/temp/foo.txt"
      + id                   = (known after apply)
    }

Plan: 1 to add, 0 to change, 0 to destroy.

Do you want to perform these actions?
  Terraform will perform the actions described above.
  Only 'yes' will be accepted to approve.

  Enter a value: yes

local_file.foo: Creating...
local_file.foo: Creation complete after 0s [id=db5ca40b5588d44e9ec6c1b4005e11a6fd0c910e]

Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
 
File is created indeed:
 
/demo-terraform/local_resources_demo$ cat ./temp/foo.txt
This is a text content of the foo file!
 
terraform apply also creates terraform.tfstate file which contains details of the created resource:
 
/demo-terraform/local_resources_demo$ cat terraform.tfstate
{
  "version": 4,
  "terraform_version": "1.1.7",
  "serial": 1,
  "lineage": "921cfa36-9ad5-9a2e-66db-d2ef62e7509b",
  "outputs": {},
  "resources": [
    {
      "mode": "managed",
      "type": "local_file",
      "name": "foo",
      "provider": "provider[\"registry.terraform.io/hashicorp/local\"]",
      "instances": [
        {
          "schema_version": 0,
          "attributes": {
            "content": "This is a text content of the foo file!",
            "content_base64": null,
            "directory_permission": "0777",
            "file_permission": "0777",
            "filename": "/home/bojan/dev/github/demo-terraform/local_resources_demo/temp/foo.txt",
            "id": "db5ca40b5588d44e9ec6c1b4005e11a6fd0c910e",
            "sensitive_content": null,
            "source": null
          },
          "sensitive_attributes": [],
          "private": "bnVsbA=="
        }
      ]
    }
  ]
}

 

If we want to prevent terraform plan|apply commands to print out the file content in their output in terminal, we can use sensitive_content argument (instead of content):
 
sensitive_content = "This is a text content of the foo file!"
 
Execution plan in terminal will now contain this line:
 
    + sensitive_content    = (sensitive value)
 
 

terraform show

 
 To see details of the resource created we can use terraform show command:
 
/demo-terraform/local_resources_demo$ terraform show
# local_file.foo:
resource "local_file" "foo" {
    content              = "This is a text content of the foo file!"
    directory_permission = "0777"
    file_permission      = "0777"
    filename             = "/home/bojan/dev/github/demo-terraform/local_resources_demo/temp/foo.txt"
    id                   = "db5ca40b5588d44e9ec6c1b4005e11a6fd0c910e"
}

 
 

Updating already created resources

 
 
In the example above, we created a file with permissions 0777. Let's say we want to change this to permissions 0700.

We first need to edit our original .tf file:
 
/demo-terraform/local_resources_demo$ cat create_local_file_demo.tf
resource "local_file" "foo" {
    filename = "${path.cwd}/temp/foo.txt"
    content = "This is a text content of the foo file!"
    file_permission = "0700"
}
 

Let's review the changes:

/demo-terraform/local_resources_demo$ terraform plan
local_file.foo: Refreshing state... [id=db5ca40b5588d44e9ec6c1b4005e11a6fd0c910e]

Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
-/+ destroy and then create replacement

Terraform will perform the following actions:

  # local_file.foo must be replaced
-/+ resource "local_file" "foo" {
      ~ file_permission      = "0777" -> "0700" # forces replacement
      ~ id                   = "db5ca40b5588d44e9ec6c1b4005e11a6fd0c910e" -> (known after apply)
        # (3 unchanged attributes hidden)
    }


Plan: 1 to add, 0 to change, 1 to destroy.

Note: You didn't use the -out option to save this plan, so Terraform can't guarantee to take exactly these actions if you run "terraform apply" now.

-/+ means that resource will be deleted and then recreated. 
 
Line with # forces replacement comment shows the attribute whose change actually forces the resource replacement.
 
This type of infrastructure is called an immutable infrastructure: change of a single attribute requires re-creation of the entire resource.

demo-terraform/local_resources_demo$  terraform apply
local_file.foo: Refreshing state... [id=db5ca40b5588d44e9ec6c1b4005e11a6fd0c910e]

Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
-/+ destroy and then create replacement

Terraform will perform the following actions:

  # local_file.foo must be replaced
-/+ resource "local_file" "foo" {
      ~ file_permission      = "0777" -> "0700" # forces replacement
      ~ id                   = "db5ca40b5588d44e9ec6c1b4005e11a6fd0c910e" -> (known after apply)
        # (3 unchanged attributes hidden)
    }

Plan: 1 to add, 0 to change, 1 to destroy.

Do you want to perform these actions?
  Terraform will perform the actions described above.
  Only 'yes' will be accepted to approve.

  Enter a value: yes

local_file.foo: Destroying... [id=db5ca40b5588d44e9ec6c1b4005e11a6fd0c910e]
local_file.foo: Destruction complete after 0s
local_file.foo: Creating...
local_file.foo: Creation complete after 0s [id=db5ca40b5588d44e9ec6c1b4005e11a6fd0c910e]

Apply complete! Resources: 1 added, 0 changed, 1 destroyed.

 
Let's check new permissions on the file:

 /demo-terraform/local_resources_demo$ ls -la ./temp/
total 12
-rwx------ 1 bojan bojan   39 Mar 28 16:17 foo.txt


Deleting resources



To delete created resources we need to use terraform destroy command:


$ terraform destroy
local_file.foo: Refreshing state... [id=db5ca40b5588d44e9ec6c1b4005e11a6fd0c910e]

Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
  - destroy

Terraform will perform the following actions:

  # local_file.foo will be destroyed
  - resource "local_file" "foo" {
     
- content              = "This is a text content of the foo file!" -> null
     
- directory_permission = "0777" -> null
     
- file_permission      = "0700" -> null
     
- filename             = "/home/bojan/dev/github/demo-terraform/local_resources_demo/temp/foo.txt" -> null
     
- id                   = "db5ca40b5588d44e9ec6c1b4005e11a6fd0c910e" -> null
    }

Plan: 0 to add, 0 to change, 1 to destroy.

Do you really want to destroy all resources?
  Terraform will destroy all your managed infrastructure, as shown above.
  There is no undo. Only 'yes' will be accepted to confirm.

  Enter a value: yes

local_file.foo: Destroying... [id=db5ca40b5588d44e9ec6c1b4005e11a6fd0c910e]
local_file.foo: Destruction complete after 0s

Destroy complete! Resources: 1 destroyed.


We can verify that temp folder does not contain the test file. Note that temp folder itself remained, although it was created in process of creating a file resource.



Configuration Directory


Configuration directory is the one which contains one or more configuration (.tf) files. Each configuration file can specify one or more resources (resource blocks required to provision the infrastructure). 

We can combine all resources from multiple (smaller) configuration file into a single (large) configuration file.  The common convention is to name that file main.tf

Configuration files can be organised as here:
  • main.tf - main config file containing definition of resources
  • variables.tf - contains variable declarations
  • outputs.tf - contains outputs from resources
  • provider.tf - contains provider definitions

It is possible to use multiple providers (e.g. local, random etc...) in the same configuration directory. 

Whenever we add a resource for a provider that has not been used so far in the configuration directory, we have to initialize the directory by running terraform init command.


Debugging (Logging)


To enable logging we need to set TF_LOG environment variable to a desired log level which can be one of following:
  • INFO
  • WARNING
  • ERROR
  • DEBUG
  • TRACE (most verbose)
Example:

$ export TF_LOG=TRACE

To persist log into a file we need to:
  • set TF_LOG_PATH environment variable to a path to the log file
  • make sure that TF_LOG is set
Example:

$ export TF_LOG_PATH=/tmp/terraform.log

To disable logging into a file:

$ unset TF_LOG_PATH

---





No comments: