Search code examples
goterraformhcl

How can I have a Golang script modify a value in my Terraform (HCL format) file?


I am attempting to do a small bit of automation on a Terraform file I have, that defines an Azure Network Security Group. Essentially I have a website & SSH access that I only want to allow to my public IP address, which I can obtain from icanhazip.com. I am hoping to write my IP into the relevant part of the .tf file with a Golang script (essentially set the value for security_rule.source_address_prefixes).

I am attempting to use hclsimple library in Golang, as well as having tried gohcl, hclwrite and others, but I'm not getting anywhere with converting the HCL file into Golang structs, essentially.

My Terraform file (HCL format I believe) is as follows:

resource "azurerm_network_security_group" "my_nsg" {
  name                = "my_nsg"
  location            = "loc"
  resource_group_name = "rgname"

  security_rule       = [
           {
               access                                     = "Deny"
               description                                = "Desc"
               destination_address_prefix                 = "*"
               destination_address_prefixes               = []
               destination_application_security_group_ids = []
               destination_port_range                     = ""
               destination_port_ranges                    = [
                   "123",
                   "456",
                   "789",
                   "1001",
                ]
               direction                                  = "Inbound"
               name                                       = "AllowInboundThing"
               priority                                   = 100
               protocol                                   = "*"
               source_address_prefix                      = "*"
               source_address_prefixes                    = [
                  # obtain from icanhazip.com
                  "1.2.3.4"
               ]
               source_application_security_group_ids      = []
               source_port_range                          = "*"
               source_port_ranges                         = []
            },
           {
               access                                     = "Allow"
               description                                = "Grant acccess to App"
               destination_address_prefix                 = "*"
               destination_address_prefixes               = []
               destination_application_security_group_ids = []
               destination_port_range                     = ""
               destination_port_ranges                    = [
                   "443",
                   "80",
                ]
               direction                                  = "Inbound"
               name                                       = "AllowIPInBound"
               priority                                   = 200
               protocol                                   = "*"
               source_address_prefix                      = ""
               source_address_prefixes                    = [
                # obtain from icanhazip.com
                   "1.2.3.4"
                ]
               source_application_security_group_ids      = []
               source_port_range                          = "*"
               source_port_ranges                         = []
            }
        ]
}

And this is as far as I have got with my Golang script, in attempting to represent the above data as structs, and then decoding the .tf file itself (I copied a couple of methods locally from hclsimple in order to have it decode a .tf file, as suggested in their docs.

package main

import (
    "fmt"
    "io"
    "io/ioutil"
    "log"
    "os"
    "path/filepath"
    "strings"

    "github.com/hashicorp/hcl/v2"
    "github.com/hashicorp/hcl/v2/gohcl"
    "github.com/hashicorp/hcl/v2/hclsimple"
    "github.com/hashicorp/hcl/v2/hclsyntax"
    "github.com/hashicorp/hcl/v2/json"
)

type Config struct {
    NetworkSecurityGroup []NetworkSecurityGroup `hcl:"resource,block"`
}

type NetworkSecurityGroup struct {
    Type              string         `hcl:"azurerm_network_security_group,label"`
    Name              string         `hcl:"mick-linux3-nsg,label"`
    NameAttr          string         `hcl:"name"`
    Location          string         `hcl:"location"`
    ResourceGroupName string         `hcl:"resource_group_name"`
    SecurityRule      []SecurityRule `hcl:"security_rule,block"`
}

type SecurityRule struct {
    Access                                 string   `hcl:"access"`
    Description                            string   `hcl:"description"`
    DestinationAddressPrefix               string   `hcl:"destination_address_prefix"`
    DestinationAddressPrefixes             []string `hcl:"destination_address_prefixes"`
    DestinationApplicationSecurityGroupIds []string `hcl:"destination_application_security_group_ids"`
    DestinationPortRange                   string   `hcl:"destination_port_range"`
    DestinationPortRanges                  []string `hcl:"destination_port_ranges"`
    Direction                              string   `hcl:"direction"`
    Name                                   string   `hcl:"name"`
    Priority                               int      `hcl:"priority"`
    Protocol                               string   `hcl:"protocol"`
    SourceAddressPrefix                    string   `hcl:"source_address_prefix"`
    SourceAddressPrefixes                  []string `hcl:"source_address_prefixes"`
    SourceApplicationSecurityGroupIds      []string `hcl:"source_application_security_group_ids"`
    SourcePortRange                        string   `hcl:"source_port_range"`
    SourcePortRanges                       []string `hcl:"source_port_ranges"`
}

func main() {
    // lets pass this in as a param?
    configFilePath := "nsg.tf"

    // create new Config struct
    var config Config

    // This decodes the TF file into the config struct, and hydrates the values
    err := MyDecodeFile(configFilePath, nil, &config)
    if err != nil {
        log.Fatalf("Failed to load configuration: %s", err)
    }
    log.Printf("Configuration is %#v", config)

    // let's read in the file contents
    file, err := os.Open(configFilePath)
    if err != nil {
        fmt.Printf("Failed to read file: %v\n", err)
        return
    }
    defer file.Close()

    // Read the file and output as a []bytes
    bytes, err := io.ReadAll(file)
    if err != nil {
        fmt.Println("Error reading file:", err)
        return
    }

    // Parse, decode and evaluate the config of the .tf file
    hclsimple.Decode(configFilePath, bytes, nil, &config)

    // iterate through the rules until we find one with
    // Description = "Grant acccess to Flask App"

    // CODE GO HERE
    for _, nsg := range config.NetworkSecurityGroup {
        fmt.Printf("security rule: %s", nsg.SecurityRule)
    }
}

// Basically copied from here https://github.com/hashicorp/hcl/blob/v2.16.2/hclsimple/hclsimple.go#L59
// but modified to handle .tf files too
func MyDecode(filename string, src []byte, ctx *hcl.EvalContext, target interface{}) error {
    var file *hcl.File
    var diags hcl.Diagnostics

    switch suffix := strings.ToLower(filepath.Ext(filename)); suffix {
    case ".tf":
        file, diags = hclsyntax.ParseConfig(src, filename, hcl.Pos{Line: 1, Column: 1})
    case ".hcl":
        file, diags = hclsyntax.ParseConfig(src, filename, hcl.Pos{Line: 1, Column: 1})
    case ".json":
        file, diags = json.Parse(src, filename)
    default:
        diags = diags.Append(&hcl.Diagnostic{
            Severity: hcl.DiagError,
            Summary:  "Unsupported file format",
            Detail:   fmt.Sprintf("Cannot read from %s: unrecognized file format suffix %q.", filename, suffix),
        })
        return diags
    }
    if diags.HasErrors() {
        return diags
    }

    diags = gohcl.DecodeBody(file.Body, ctx, target)
    if diags.HasErrors() {
        return diags
    }
    return nil
}

// Taken from here https://github.com/hashicorp/hcl/blob/v2.16.2/hclsimple/hclsimple.go#L89
func MyDecodeFile(filename string, ctx *hcl.EvalContext, target interface{}) error {
    src, err := ioutil.ReadFile(filename)
    if err != nil {
        if os.IsNotExist(err) {
            return hcl.Diagnostics{
                {
                    Severity: hcl.DiagError,
                    Summary:  "Configuration file not found",
                    Detail:   fmt.Sprintf("The configuration file %s does not exist.", filename),
                },
            }
        }
        return hcl.Diagnostics{
            {
                Severity: hcl.DiagError,
                Summary:  "Failed to read configuration",
                Detail:   fmt.Sprintf("Can't read %s: %s.", filename, err),
            },
        }
    }
    return MyDecode(filename, src, ctx, target)
}

When I run the code, essentially I am struggling to define NetworkSecurityGroup.SecurityRule, and receive the following error with the above code:

2023/05/24 11:42:11 Failed to load configuration: nsg.tf:6,3-16: Unsupported argument; An argument named "security_rule" is not expected here. Did you mean to define a block of type "security_rule"?
exit status 1

Any advice much appreciated


Solution

  • So it's not currently possible with https://github.com/minamijoyo/hcledit (see here https://github.com/minamijoyo/hcledit/issues/50 - this suggests that hclwrite itself would need a change to facilitate)

    So I've worked around as suggested by @Martin Atkins:

    I've created a single locals.tf file containing a locals variable, which I then reference in the NSG security rules:

    locals {
        my_ip = "1.2.3.4"
    }
    

    and now I simply obtain my IP and use sed to update the value in the locals.tf file

    my_ip=$(curl -s -4 icanhazip.com)
    sed -i "s|my_ip = \".*\"|my_ip = \"$my_ip\"|" locals.tf