Getting started¶
Everything below is copy-and-paste-able and only needs kubectl pointed at a
cluster that already runs Argo CD in the argocd namespace. Set the two
variables first.
export VERSION=v0.0.6 # release of the Argo CD Terraform Controller
export TF_NAMESPACE=team-a # namespace that will host Terraform objects
1. Install the controller¶
kubectl apply -f https://github.com/redhat-developer/argocd-terraform-controller/releases/download/${VERSION}/install.yaml
kubectl -n argocd-terraform-controller-system rollout status deploy/argocd-terraform-controller-controller-manager
This creates the Terraform CRD, RBAC and the controller Deployment in
argocd-terraform-controller-system. The Deployment carries two environment
variables you may want to change later: RUNNER_POD_IMAGE (the runner image,
which contains terraform and tofu) and ARGOCD_NAMESPACE (defaults to
argocd, used to reuse Argo CD repository credentials).
Alternatively, with Helm from the OCI chart:
helm install argocd-terraform-controller \
oci://quay.io/redhatworkshops/argocd-terraform-controller-helmchart --version ${VERSION#v} \
--namespace argocd-terraform-controller-system --create-namespace \
--set runner.namespaces="{${TF_NAMESPACE}}"
2. Create the runner identity in the Terraform namespace¶
Runner Pods run as the tf-runner ServiceAccount of the Terraform object's
namespace and need to write Secrets, ConfigMaps and Leases there (state,
plans, outputs, locks). Repeat for every namespace that hosts Terraform
objects (the Helm chart does this for runner.namespaces):
kubectl create namespace ${TF_NAMESPACE} --dry-run=client -o yaml | kubectl apply -f -
kubectl apply -n ${TF_NAMESPACE} -f - <<EOF
apiVersion: v1
kind: ServiceAccount
metadata:
name: tf-runner
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: tf-runner
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: argocd-terraform-controller-runner-role
subjects:
- kind: ServiceAccount
name: tf-runner
EOF
The sample used below manages a Namespace and a ConfigMap with the Kubernetes
provider, so for this walkthrough also let tf-runner do that (use a narrower
ClusterRole in real deployments; cloud providers instead get credentials through
spec.runner.podTemplate.spec.env / envFrom, workload identity or spec.fileMappings):
kubectl create clusterrolebinding tf-runner-${TF_NAMESPACE}-sample \
--clusterrole=cluster-admin --serviceaccount=${TF_NAMESPACE}:tf-runner
3. Integrate with Argo CD¶
Three configuration-only changes to a stock Argo CD (details in Argo CD integration):
# Health assessment and resource actions (approve-plan, replan, suspend, resume, force-unlock)
kubectl patch configmap argocd-cm -n argocd --type merge \
--patch "$(curl -fsSL https://github.com/redhat-developer/argocd-terraform-controller/releases/download/${VERSION}/argocd-cm-patch.yaml)"
# UI extension, installed by an init container on argocd-server. The patch
# mounts the extension into the server container, whose name differs by
# install method (upstream install.yaml: "argocd-server"; Helm chart: "server"),
# so detect it and rewrite the patch to match.
SERVER_CONTAINER=$(kubectl get deploy argocd-server -n argocd -o jsonpath='{.spec.template.spec.containers[0].name}')
curl -fsSL https://github.com/redhat-developer/argocd-terraform-controller/releases/download/${VERSION}/argocd-server-extension-patch.yaml \
| sed "s/- name: argocd-server/- name: ${SERVER_CONTAINER}/" > /tmp/argocd-terraform-server-patch.yaml
kubectl patch deployment argocd-server -n argocd --patch-file /tmp/argocd-terraform-server-patch.yaml
kubectl -n argocd rollout status deploy/argocd-server
# Optional: let the controller reuse Argo CD repository credentials
kubectl apply -n argocd -f https://raw.githubusercontent.com/redhat-developer/argocd-terraform-controller/${VERSION}/config/argocd/argocd_repo_reader_binding.yaml
Verify the extension was installed:
kubectl -n argocd exec deploy/argocd-server -- ls /tmp/extensions/resources/
# extension-terraform.js
Argo CD loads extensions at startup and browsers cache them; hard-refresh the UI (Ctrl/Cmd+Shift+R) after the rollout.
4. Create an Application that syncs a Terraform object¶
The UI extension renders inside Applications, so sync the Terraform object through Argo CD rather than applying it directly. This Application syncs the sample from this repository:
kubectl apply -n argocd -f - <<EOF
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: terraform-sample
spec:
project: default
source:
repoURL: https://github.com/redhat-developer/argocd-terraform-controller
targetRevision: ${VERSION}
path: config/samples
destination:
server: https://kubernetes.default.svc
namespace: ${TF_NAMESPACE}
syncPolicy:
automated: {}
EOF
The sample (config/samples/infra_v1alpha1_terraform.yaml)
fetches examples/kube-namespace from Git, runs
it with approvePlan: auto, writes the outputs to the Secret
terraform-sample-outputs and destroys the resources when the object is deleted.
Watch it progress:
kubectl get terraform -n ${TF_NAMESPACE} -w
After a couple of minutes (provider download included):
kubectl get terraform terraform-sample -n ${TF_NAMESPACE} -o jsonpath='{.status.binary}{"\n"}{.status.lastAppliedRevision}{"\n"}'
kubectl get secret -n ${TF_NAMESPACE} tfstate-default-terraform-sample terraform-sample-outputs
kubectl get namespace terraform-demo
kubectl get configmap tfplan-default-terraform-sample-readable -n ${TF_NAMESPACE} -o jsonpath='{.data.tfplan}'
In the Argo CD UI click Terraform in the left sidebar: the dashboard lists
team-a/terraform-sample with its state; click it for the graph (Git source →
Terraform → the state/plan/outputs objects → the kubernetes_namespace and
kubernetes_config_map resources it manages, with their IDs), the plan output,
outputs, conditions and events. Inside the terraform-sample Application the
Terraform node also has a Terraform tab, and the view switcher gains a
Terraform view.
Try manual approval¶
kubectl patch terraform terraform-sample -n ${TF_NAMESPACE} --type merge \
-p '{"spec":{"approvePlan":"","vars":[{"name":"namespace","value":"terraform-demo-2"}]}}'
The next plan has changes and waits: the object is Suspended in Argo CD with the plan ID in its message. Approve it with the Approve plan button in the Terraform tab, or:
PLAN=$(kubectl get terraform terraform-sample -n ${TF_NAMESPACE} -o jsonpath='{.status.plan.pending}')
kubectl patch terraform terraform-sample -n ${TF_NAMESPACE} --type merge -p "{\"spec\":{\"approvePlan\":\"${PLAN}\"}}"
(The Application shows the patched object as OutOfSync; with
automated.selfHeal Argo CD would revert it — see the GitOps considerations in
Argo CD integration.)
5. Clean up¶
kubectl delete application terraform-sample -n argocd --cascade=foreground # prunes the Terraform object, which destroys terraform-demo
kubectl delete clusterrolebinding tf-runner-${TF_NAMESPACE}-sample
kubectl delete -f https://github.com/redhat-developer/argocd-terraform-controller/releases/download/${VERSION}/install.yaml
Using cloud credentials¶
The sample above only needs Kubernetes access, so the runner's ServiceAccount
is enough. Real stacks need provider credentials delivered to the runner Pod:
a credentials file through spec.fileMappings, environment variables through
spec.runner.podTemplate.spec.envFrom, or workload identity on the tf-runner
ServiceAccount. All three, with copy-and-paste examples, are in
Variables and credentials.
What happens on each reconciliation¶
- The source is resolved (
git ls-remote/ registry HEAD) and fetched when the revision changed; the tree is archived and shipped to the runner. - A runner Pod
<name>-tf-runneris created (or reused) in the object's namespace and connected over mTLS. - The backend configuration, variables and files are generated;
initand workspace selection run. - If nothing changed since the last apply, a refresh-only plan detects drift.
- Otherwise a plan is produced and stored in a Secret (plus a readable copy in a ConfigMap). With manual approval the object waits here.
- The approved plan is applied, outputs are written, health checks run.
- The runner Pod is deleted (
spec.runner.alwaysCleanup) and the object is requeued afterspec.interval.