Sometimes, it is useful to be able to synchronize the contents of a git repository with an OCI bucket. A common use case is if you're hosting a static website in an OCI bucket, and you want to have automation manage that bucket for you. This blog post will demonstrate using OCI devops, and a small bit of terraform to manage the contents of an OCI storage bucket. Note that there are a couple of issues that you'll encounter and I'll discuss them in this post, with solutions.
In this post, we're going to use an OCI Devops project to host a git repository, which will contain a terraform script and build.yaml file to deploy a git subdirectory to an OCI bucket automatically.
Using terraform and git allows for easy managed maintenance of a bucket's content. This can be useful for many purposes, e.g. if you're hosting a static website in a bucket and want to keep the website's code in a git repostory.
The basic premise is very simple - we push some code to git, a trigger invokes a build process, the build process deploys the bucket contents. However, git, when used in a build system, such as OCI DevOps, utilizes what's known as a "shallow" clone of the git repository. This shallow clone contains no history, which means that the contents of the filesystem are not properly timestamped, and you can't recover them from the git log, as that's not fully present in a shallow clone. We need the file timestamps to allow the tracking of files to work - without tracking the files, terraform considers every commit to have changed every file, which is a significant inefficiency, and could have security risks, depending on context. To solve this problem, we use a couple of git tricks. Save this snippet in a bash file fix_git.sh
and mark the file as executable.
#!/bin/sh git fetch --unshallow for f in $(git ls-files) ; do touch --date=@$(git log -1 --date=unix --format='%cd' "$f") "$f"; done
This snippet does two things: it "unshallows" the git repository (essentially "filling in" the missing history) and it lists all files in the commit, and touches them with the date of the commit where they were last modified. This gives a completely consistent view of the timestamps of the checked out code, which means the terraform state tracking can reliably determine a minimal change set.
Running terraform on OCI devops is quite straightforward, however,you need to persist the terraform state elsewhere, as the OCI devops instance will not retain that state long term for you. This can easily be accomplished using terraform's remote_state feature. OCI buckets can be used to persist remote state, and we'll set up secrets in a vault to store the remote state access keys. Save this as remotestate.tf
terraform { backend "s3" { bucket = "terraform" key = "[project]/terraform.tfstate" region = "us-ashburn-1" endpoint = "[endpointurl]" skip_region_validation = true skip_credentials_validation = true skip_metadata_api_check = true force_path_style = true workspace_key_prefix = "[project]_workspaces" } } # this requires `terraform init -backend-config="access_key=[customersecretid]" -backend-config="secret_key=[customersecretvalue]"` to be run to init the backend. A one off config
This terraform snippet sets up the remote state backend for terraform to talk to OCI. We will inject the access_key and secret_key from OCI vault secret values and use the OCI devops integration to include them in the build. The [project] and [endpointurl] are both values that depend on the specifics of the project. The endpoint URL is the access to OCI object storage. You can usually find it in the "Buckets" page on the OCI console for your tenancy. You will probably want to tweak the region as well.
A second issue you may encounter (follow the bug here for more information) is that OCI devops, when using the resource principal (which you should be using), is unable to target regions other than the region it is running on. This can be worked around fairly easily, by using a terraform wrapper script that forces the OCI_RESOURCE_PRINCIPAL_REGION environment variable. Save this file as runtf.sh
and mark this file as executable.
#!/bin/bash export OCI_RESOURCE_PRINCIPAL_REGION=$(if grep -q "PROD" .terraform/environment; then echo "us-ashburn-1"; else echo "us-phoenix-1"; fi) terraform "$@"
This script reads the environment from the .terraform. It presumes a good convention in how you use terraform, namely that you have a workspace targetting each of your primary deployment environments. (Personally, I target each environment to a different region as well, which is why I require this).
Now, finally, we come to the build_spec.yaml
script. This specifies the build steps to get your project to build and deploy to the TARGET_ENV specified.
version: 0.1 component: build timeoutInSeconds: 10000 shell: bash failImmediatelyOnError: true env: vaultVariables: ACCESS_KEY: "[ACCESS_KEY_VAULT_SECRET_OCID]" SECRET_KEY: "[SECRET_KEY_VAULT_SECRET_OCID]" steps: - type: Command name: Init terraform command: terraform init -no-color -backend-config="access_key=${ACCESS_KEY}" -backend-config="secret_key=${SECRET_KEY}" - type: Command name: Select workspace command: terraform workspace select ${TARGET_ENV} -no-color - type: Command name: Refresh terraform command: ./runtf.sh refresh -no-color - type: Command name: Fix git dates command: ./fix_git.sh - type: Command name: Apply terraform command: ./runtf.sh apply -no-color -auto-approve
This runs 5 steps.
The core terraform script, which does the magic of syncing a local directory (src, here - see the ${path.module}/src base_dir) to an OCI bucket ([bucket_name] here, which we should configure). We use the template_files pre-packaged terraform module, as that has the capability to identify common file types and attach appropriate Content-Type tags to the files. This can be useful when using a bucket as a static host.
We also include the oci provider, with a region specified based on the workspace, as discussed earlier. This may or may not be relevant for your usecase. We'll save this file as terraform.tf
provider "oci" { auth = "ResourcePrincipal" region = (terraform.workspace == "PROD") ? "us-ashburn-1" : "us-phoenix-1" } module "template_files" { source = "hashicorp/dir/template" base_dir = "${path.module}/src" } data "oci_objectstorage_namespace" "os_namespace" { } resource "oci_objectstorage_object" "objects" { for_each = module.template_files.files namespace = data.oci_objectstorage_namespace.os_namespace.namespace bucket = "[bucket_name]" object = each.key content_type = each.value.content_type source = abspath(each.value.source_path) }
To put this demo together, we need to create a terraform state bucket, a couple of secrets in a vault to store access keys to said bucket, an OCI devops project, and then populate it with the appropriate elements.
build_spec.yaml
file, the terraform.tf
file, the remotestate.tf
file, the runtf.sh
file, the fix_git.sh
file, as well as a src subdirectory containing the files you want in your target bucket.This is just a short overview of what I consider to be a surprisingly useful process. Many modifications and tweaks can be added to this process. For example, I have added in a download step to include some distributed binary files as part of a bucket, rather than storing them in the GIT repository. You can use the same basic process to do other terraform maintenance activities such as synchronizing OCI function builds, or maintaining synchronization of Resource Manager templates and stacks generated by said templates.