+++ title = "Terraform workflow using Guix and Emacs" date = "2024-06-05" author = "Peter Tillemans" email = "pti@snamellit.com" [taxonomies] tags = ["emacs", "guix"] categories = ["programming"] +++ # Terraform Deployments Terraform allows infrastructure to be defined to deploy applications and other solutions as code and supports a plethora of on-premise and cloud deployment targets. It is essentially based on building a graph of dependencies between resources, data and modules using the terraform language. Due to the nature of the beast these things tend to run in the CI pipelines which makes editing these files frustrating as the edits have to be committed, pushed, runners have to be scheduled and usually the deploy pipeline is not the first job. So good local tooling is needed to get fast feedback. # Terraform tooling on GUIX In order to run terraform I need to first package it as it is not available in the GUIX repositories. ``` scheme (define-public terraform (package (name "snam-terraform") (version "1.8.4") (source (origin (method url-fetch) (uri (string-append "https://releases.hashicorp.com/terraform/" version "/terraform_" version "_linux_amd64.zip")) (sha256 (base32 "1i181cmzwlrx8d40z1spilcwgnhkzwalrg8822d23sqdmrs7a5hj")))) (build-system binary-build-system) (supported-systems '("x86_64-linux")) (arguments '( #:install-plan `(("." ("terraform") "bin/")) #:phases (modify-phases %standard-phases ;; this is required because standard unpack expects ;; the archive to contain a directory with everything inside it, ;; while babashka's release .tar.gz only contains the `bb` binary. (replace 'unpack (lambda* (#:key inputs #:allow-other-keys) (system* (which "unzip") (assoc-ref inputs "source")) #t))))) (inputs `(("libstdc++" ,(make-libstdc++ gcc)) ("zlib" ,zlib))) (native-inputs `(("unzip" ,unzip))) (synopsis "A tool to describe and deploy infrastructure as code") (description "Terraform allows you to describe your complete infrastructure in the form of code. Even if your servers come from different providers such as AWS or Azure, Terraform helps you build and manage these resources in parallel across providers.") (home-page "https://hashicorp.com/terraform") (license #f))) (define-public snam-terraform-1.6 (package (inherit terraform) (version "1.6.6") (source (origin (method url-fetch) (uri (string-append "https://releases.hashicorp.com/terraform/" version "/terraform_" version "_linux_amd64.zip")) (sha256 (base32 "002g0ypkkfqy5nf989jyk3m1l7l0455hsaq11xfhr5lbv4zqh5yi")))))) ``` I immediately added support to build older versions because that's what the customer is on and terraform is quite version dependent AFAICT. Now I can create a manifest for this project. I usually bootstrap them with `guile shell --export-manifes go gopls` or similar and then add stuff when it comes up. ``` scheme ;; What follows is a "manifest" equivalent to the command line you gave. ;; You can store it in a file that you may then pass to any 'guix' command ;; that accepts a '--manifest' (or '-m') option. (specifications->manifest (list "go" "gopls" "google-cloud-sdk" "postgresql" "snam-terraform-1.6" ; from snamellit channel )) ``` # Direnv support In order to manage my project environment and align it with the CI environment I added the expected variables and use the guix support in the stdlib of direnv. This will create a guix environment configured from the manifest. ``` scheme use guix export DB_URL="postgresql:///myproj" export DB_USER="xyz" export DB_PASSWORD="secret" export PGPASSWORD=$DB_PASSWORD export VAULT_TOKEN="" export APPTIO_URL=https://acme.tpondemand.com export APPTIO_TOKEN= export OPENAI_API_KEY= export VAULT_ADDR=https://vault.acme.com export STATE_BUCKET=com-acme-test-myproj-tf-state export TF_VAR_project_short=myproj export TF_VAR_project=com-acme-test-${TF_VAR_project_short} PATH_add ./node_modules/.bin ``` # Emacs support ## Direnv Support Emacs *direnv mode* will load the configuration from the *.envrc* file when opening a file in that project. The variables and apps are then available for complition, LSP, shell, etc. ``` elisp ;; enable direnv mode (direnv-mode) ``` I just enable it globally because I want that always, not just for terraform. ## Terraform Support Enable some syntax highlighting and more importantly documentation help. Also set `format-on-save` and the indent to 2 spaces ``` elisp ;; configure terraform support (require 'terraform-mode) (add-hook 'terraform-mode-hook (lambda () (outline-minor-mode 1))) (custom-set-variables '(terraform-indent-level 2) '(terraform-format-on-save t)) ``` and expose the functionality in similar keybindings as I use for LSP support : ``` (evil-define-key 'normal terraform-mode-map (kbd "c k") #'terraform-open-doc (kbd "c f") #'terraform-format (kbd "c F") #'terraform-format-buffer (kbd "c n") 'flymake-goto-next-error (kbd "c p") 'flymake-goto-prev-error) ``` ** will now open a browser window with the documentation of the terraform element under the cursor. This does need terraform to be installed though. ## Add support to Makefile In order to save me from remembering the commandlines and because I keep the terraform files in a *terraform* directory I make some *make* targets to quickly access them. ``` makefile tfinit: terraform -chdir=terraform init -backend-config="bucket=${STATE_BUCKET}" tfcheck: terraform -chdir=terraform validate tfapply: terraform -chdir=terraform apply tfplan: terraform -chdir=terraform plan tflint: docker run --rm -v `pwd`/terraform:/data -t ghcr.io/terraform-linters/tflint ``` # Workflow tips Most of it is essentially hidden in the normal workflow. i.e. opening a terraform file will load it, saving formats it. The *compile* feature can be used to do file checking and linting. Magit commit - push triggers the CI pipeline to run the deploy. It is easy to test things out locally with the Makefile and it reduces the number of steps in the CI script , so less things which can do weird things.