From 364a1256518137595552bb95e39f822eefbc06e2 Mon Sep 17 00:00:00 2001 From: 0x48core Date: Fri, 9 Oct 2026 16:56:02 +0700 Subject: [PATCH] feat(lab-10): Terraform for the VPS, firewall, and DNS - envs/local: Docker provider creates the lab 09 fake VPS (image, volumes, container) and writes an Ansible inventory; full plan -> apply -> Ansible -> destroy workflow for free - envs/digitalocean: droplet (sgp1, s-2vcpu-4gb), SSH key, reserved IP, cloud firewall, DNS zone with apex and wildcard records, generated production inventory; token from DIGITALOCEAN_TOKEN only - Remote state example for DigitalOcean Spaces; lock files with macOS and Linux checksums - CI: terraform fmt + validate for both environments (never applies) - Lab 10 guide and knowledge base; roadmap and Ansible docs updated Co-Authored-By: Claude Opus 5.5 --- .github/workflows/terraform.yml | 38 ++++ .gitignore | 2 + README.md | 2 +- ansible/README.md | 2 + .../inventory/production/hosts.yml.example | 3 +- labs/10-terraform/README.md | 167 +++++++++++++++++- labs/10-terraform/knowledge.md | 154 ++++++++++++++++ terraform/README.md | 48 ++++- .../envs/digitalocean/.terraform.lock.hcl | 53 ++++++ .../envs/digitalocean/backend.tf.example | 21 +++ terraform/envs/digitalocean/main.tf | 107 +++++++++++ terraform/envs/digitalocean/outputs.tf | 19 ++ .../digitalocean/terraform.tfvars.example | 8 + terraform/envs/digitalocean/variables.tf | 46 +++++ terraform/envs/digitalocean/versions.tf | 18 ++ terraform/envs/local/.terraform.lock.hcl | 49 +++++ terraform/envs/local/main.tf | 90 ++++++++++ terraform/envs/local/outputs.tf | 13 ++ terraform/envs/local/variables.tf | 30 ++++ terraform/envs/local/versions.tf | 14 ++ .../templates/ansible-inventory.yml.tftpl | 5 + 21 files changed, 882 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/terraform.yml create mode 100644 labs/10-terraform/knowledge.md create mode 100644 terraform/envs/digitalocean/.terraform.lock.hcl create mode 100644 terraform/envs/digitalocean/backend.tf.example create mode 100644 terraform/envs/digitalocean/main.tf create mode 100644 terraform/envs/digitalocean/outputs.tf create mode 100644 terraform/envs/digitalocean/terraform.tfvars.example create mode 100644 terraform/envs/digitalocean/variables.tf create mode 100644 terraform/envs/digitalocean/versions.tf create mode 100644 terraform/envs/local/.terraform.lock.hcl create mode 100644 terraform/envs/local/main.tf create mode 100644 terraform/envs/local/outputs.tf create mode 100644 terraform/envs/local/variables.tf create mode 100644 terraform/envs/local/versions.tf create mode 100644 terraform/templates/ansible-inventory.yml.tftpl diff --git a/.github/workflows/terraform.yml b/.github/workflows/terraform.yml new file mode 100644 index 0000000..50e97ef --- /dev/null +++ b/.github/workflows/terraform.yml @@ -0,0 +1,38 @@ +# Checks the Terraform code on every change (lab 10). Never applies anything: +# no cloud credentials are stored in CI. +name: terraform + +on: + push: + branches: [main] + paths: [terraform/**, .github/workflows/terraform.yml] + pull_request: + paths: [terraform/**, .github/workflows/terraform.yml] + +permissions: + contents: read + +jobs: + validate: + name: fmt and validate (${{ matrix.env }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + env: [local, digitalocean] + defaults: + run: + working-directory: terraform/envs/${{ matrix.env }} + steps: + - uses: actions/checkout@v5 + + - uses: hashicorp/setup-terraform@v3 + + - name: Check formatting + run: terraform fmt -check -recursive -diff ../.. + + - name: Init without a backend + run: terraform init -backend=false -input=false + + - name: Validate + run: terraform validate diff --git a/.gitignore b/.gitignore index f93c991..1a0231c 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,8 @@ kubeconfig* *.tfvars !*.tfvars.example crash.log +# Ansible inventories generated by terraform apply +ansible/inventory/test/terraform.yml .terraform.lock.hcl.bak # Ansible diff --git a/README.md b/README.md index 11662e1..a7bd139 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ Status: ⬜ todo · 🟨 in progress · ✅ done | # | Lab | Status | |---|---|---| | 09 | [Ansible: rebuild the server in one command](labs/09-ansible/) | 🟨 | -| 10 | [Terraform: provision VPS, DNS, firewall](labs/10-terraform/) | ⬜ | +| 10 | [Terraform: provision VPS, DNS, firewall](labs/10-terraform/) | 🟨 | ### Phase 5 — Observability & Kubernetes | # | Lab | Status | diff --git a/ansible/README.md b/ansible/README.md index 7c2d4d1..2e2cfb3 100644 --- a/ansible/README.md +++ b/ansible/README.md @@ -54,6 +54,8 @@ The fake VPS is a privileged container. Fine for testing on your Mac; never a re ## Production +`terraform apply` in [terraform/envs/digitalocean](../terraform/envs/digitalocean/) writes `inventory/production/hosts.yml` for you (lab 10). Without Terraform, copy the example: + ```bash cp inventory/production/hosts.yml.example inventory/production/hosts.yml cp inventory/production/group_vars/servers/vault.yml.example inventory/production/group_vars/servers/vault.yml diff --git a/ansible/inventory/production/hosts.yml.example b/ansible/inventory/production/hosts.yml.example index 105ad42..94c069f 100644 --- a/ansible/inventory/production/hosts.yml.example +++ b/ansible/inventory/production/hosts.yml.example @@ -1,4 +1,5 @@ -# Copy to hosts.yml (git-ignored) and fill in your VPS. +# Copy to hosts.yml (git-ignored) and fill in your VPS, +# or let terraform/envs/digitalocean generate it (lab 10). servers: hosts: opsforge: diff --git a/labs/10-terraform/README.md b/labs/10-terraform/README.md index 7ae5bac..eaab109 100644 --- a/labs/10-terraform/README.md +++ b/labs/10-terraform/README.md @@ -1,26 +1,185 @@ # Lab 10 — Terraform: provision VPS, DNS, firewall - **Phase:** 4 — Infrastructure as Code -- **Status:** ⬜ todo +- **Status:** 🟨 in progress - **Started / finished:** — / — +- **Environment:** Docker on the Mac for practice (`envs/local`); DigitalOcean for the real VPS (`envs/digitalocean`) + +> 📖 **Learn first:** read [knowledge.md](knowledge.md) before starting the steps below. ## Goal -Create the cloud resources themselves from code. +Create the server itself from code: VPS, IP, firewall, and DNS. Then `terraform apply` + `ansible-playbook` takes you from nothing to the full OpsForge server, and `terraform destroy` cleans it all up. ## Done when -- [ ] VPS, firewall, and DNS records defined in Terraform +**Local (free)** +- [ ] `plan` → `apply` → Ansible → `destroy` completed with `envs/local` +- [ ] Explained why `plan` said "No changes" after Ansible changed the server +- [ ] Saw a change that `forces replacement`, and what that means for the server's configuration + +**DigitalOcean** +- [ ] VPS, reserved IP, firewall, and DNS records defined in Terraform - [ ] Remote state (not committed) - [ ] `terraform apply` + the Ansible playbook from lab 09 = full environment from zero -- [ ] `terraform destroy` cleans everything up +- [ ] `terraform destroy` cleans everything up (check the DigitalOcean console: nothing left, no more charges) + +## Measured while preparing this lab + +| Step | Result | +|---|---| +| `envs/local` plan | 5 to add (image, 2 volumes, container, inventory file) | +| `envs/local` apply | 5 added in 3 s; inventory generated | +| Ansible on it (`-i inventory/test/terraform.yml`) | ok=45, changed=35, failed=0, 91 s | +| `plan` after Ansible | **No changes** | +| `plan` with a different port | container **must be replaced** (`forces replacement`) | +| `destroy` | 5 destroyed; no containers, volumes, or inventory left | +| `envs/digitalocean` plan (dummy token, no account) | 9 to add: droplet, SSH key, reserved IP + assignment, firewall, domain, 2 records, inventory | + +Terraform 1.16.5; providers `kreuzwerker/docker` 4.6, `digitalocean/digitalocean` 2.104, `hashicorp/local` 2.9. + +## Files + +| File | What | +|---|---| +| [terraform/envs/local/main.tf](../../terraform/envs/local/main.tf) | Fake VPS: image, volumes, container, inventory | +| [terraform/envs/digitalocean/main.tf](../../terraform/envs/digitalocean/main.tf) | Droplet, reserved IP, firewall, DNS, inventory | +| [variables.tf](../../terraform/envs/digitalocean/variables.tf) / [outputs.tf](../../terraform/envs/digitalocean/outputs.tf) | Inputs (region, size, domain…) / results (IP, nameservers…) | +| [terraform.tfvars.example](../../terraform/envs/digitalocean/terraform.tfvars.example) | Your values (copy to `terraform.tfvars`, git-ignored) | +| [backend.tf.example](../../terraform/envs/digitalocean/backend.tf.example) | Remote state in DigitalOcean Spaces | +| [templates/ansible-inventory.yml.tftpl](../../terraform/templates/ansible-inventory.yml.tftpl) | Inventory both environments generate | +| [.github/workflows/terraform.yml](../../.github/workflows/terraform.yml) | CI: `fmt` + `validate` for both environments | ## Steps +### 0. Install Terraform + +Terraform is no longer in Homebrew's main repository (license change in 2023), so install it from HashiCorp's tap: + +```bash +brew tap hashicorp/tap +brew install hashicorp/tap/terraform +terraform version +``` + +Alternative: `brew install opentofu` (the open-source fork; same language, the command is `tofu`). + +### 1. Init and read the plan + +```bash +cd terraform/envs/local +terraform init # downloads providers, verifies them against .terraform.lock.hcl +terraform plan +``` + +Read every `+` line. Which 5 resources will be created? Which values are `(known after apply)`, and why can't Terraform know them yet? + +### 2. Apply + +```bash +terraform apply # shows the plan again, asks for "yes" +terraform output +terraform state list +cat ../../../ansible/inventory/test/terraform.yml +docker ps --filter name=opsforge-tf-vps +``` + +### 3. Configure it with Ansible + +```bash +ssh-keyscan -p 2201 -t ed25519 127.0.0.1 >> ~/.ssh/known_hosts # first-use trust of the new server +ssh -p 2201 root@127.0.0.1 hostname +cd ../../../ansible +ansible-playbook -i inventory/test/terraform.yml site.yml +cd ../terraform/envs/local +``` + +Terraform created the machine; Ansible made it an OpsForge server. + +### 4. Plan again: who owns what? + +```bash +terraform plan # "No changes". Why, when Ansible changed so much? +``` + +Then simulate a change Terraform can't make in place: + +```bash +terraform plan -var 'ports={"22"=2201,"2202"=2202,"80"=8080,"443"=9443}' +``` + +You'll see `must be replaced` / `forces replacement`. Applying that would give you a **new, unconfigured** server, so you'd run Ansible again. Don't apply it; just read it. + +Optional: drift. Delete the container behind Terraform's back (`docker rm -f opsforge-tf-vps`), then `terraform plan`. What does Terraform want to do? + +### 5. Destroy + +```bash +terraform destroy +docker ps -a --filter name=opsforge-tf-vps # nothing +ls ../../../ansible/inventory/test/ # terraform.yml is gone too +ssh-keygen -R "[127.0.0.1]:2201"; ssh-keygen -R "[127.0.0.1]:2202" +``` + +### 6. The real thing: DigitalOcean + +> 💸 This creates resources that cost money (~$24/month for the droplet, billed hourly) until you `terraform destroy`. + +**Prepare:** +1. A DigitalOcean account, then **API → Generate New Token** (read + write). Keep it in your password manager. +2. A domain. At your registrar, set its nameservers to `ns1.digitalocean.com`, `ns2.digitalocean.com`, `ns3.digitalocean.com`. Changes can take hours to spread. +3. Optional but recommended, **remote state**: create a Spaces bucket and a Spaces access key, then `cp backend.tf.example backend.tf` and fill in the bucket. + +**Create:** + +```bash +export DIGITALOCEAN_TOKEN=dop_v1_... +cd terraform/envs/digitalocean +cp terraform.tfvars.example terraform.tfvars # set domain (and admin_cidrs to your IP, if stable) +terraform init +terraform plan # read it: 9 to add +terraform apply +terraform output +``` + +**Configure** (lab 09, step 10). The inventory `ansible/inventory/production/hosts.yml` was just generated: + +```bash +ssh root@$(terraform output -raw ip) true # accept the host key (check the fingerprint in the droplet console) +cd ../../../ansible +ansible-playbook -i inventory/production site.yml --ask-vault-pass +dig +short api. # the reserved IP +curl -I https://api. # a real Let's Encrypt certificate +``` + +Make sure `ssh_port` in the Ansible inventory matches `ssh_port` in `terraform.tfvars` (both default to 2222). The cloud firewall only lets those ports through. + +**Destroy** when you're done practicing, then check the DigitalOcean console for leftovers: + +```bash +cd ../terraform/envs/digitalocean && terraform destroy +``` + ## Code +- [terraform/](../../terraform/) +- [.github/workflows/terraform.yml](../../.github/workflows/terraform.yml) + ## What broke +_Write down errors and fixes here as you go._ + +Notes from preparing this lab: +- `brew install terraform` no longer gives a current Terraform: use `hashicorp/tap/terraform` (or OpenTofu). +- The lock files were first created on macOS only. `terraform providers lock -platform=…` added Linux checksums, so CI and Linux machines can verify the providers too. +- A changed port list shows extra port "swaps" in the plan: diff noise from ordering. Read the `forces replacement` lines, not the noise. + ## Lessons learned ## References + +- [Terraform: get started](https://developer.hashicorp.com/terraform/tutorials) +- [Terraform language](https://developer.hashicorp.com/terraform/language) +- [DigitalOcean provider](https://registry.terraform.io/providers/digitalocean/digitalocean/latest/docs) +- [Docker provider](https://registry.terraform.io/providers/kreuzwerker/docker/latest/docs) +- [Backend: S3 (works with Spaces)](https://developer.hashicorp.com/terraform/language/backend/s3) diff --git a/labs/10-terraform/knowledge.md b/labs/10-terraform/knowledge.md new file mode 100644 index 0000000..16484ca --- /dev/null +++ b/labs/10-terraform/knowledge.md @@ -0,0 +1,154 @@ +# Lab 10 — Knowledge base + +Study this **before** the hands-on part in [README.md](README.md). Add your own notes as you learn. + +## Why this matters + +Lab 09 made the *inside* of a server reproducible. But someone still had to click "Create Droplet", pick a region, add DNS records, and set up the firewall in a web console. **Terraform** makes that part code too: + +- `terraform apply`: the infrastructure exists, exactly as described. +- `terraform destroy`: it's all gone, so nothing forgotten keeps costing money. +- Changes go through git and review, and `plan` shows the effect **before** anything happens. + +Together with Ansible, an empty cloud account becomes the full OpsForge server with two commands. + +## Core concepts + +### Terraform vs Ansible + +| | Terraform | Ansible | +|---|---|---| +| Manages | Infrastructure: servers, IPs, DNS, firewalls, buckets | What's on servers: packages, users, configs | +| Talks to | Cloud **APIs** | Servers over **SSH** | +| Knows the current state from | Its **state file** | Checking each server live, every run | +| Typical change | Create, update, or **replace** a resource | Change a file, restart a service | + +They meet at the **inventory**: Terraform knows the new server's IP, and writes the file Ansible reads. + +### Declarative, with a plan + +You write the **desired** infrastructure. Terraform works out the steps: + +``` +your .tf files ──┐ + ├──▶ terraform plan ──▶ the diff: + create ~ update - destroy -/+ replace +state + real API ─┘ │ + terraform apply (after "yes") +``` + +- **`plan` is read-only.** Always read it, above all `-/+` (replace) and `-` (destroy) lines. +- **`(known after apply)`**: values only the cloud can decide, like IDs and IPs. +- **Dependencies are automatic.** `digitalocean_record.apex` uses `digitalocean_reserved_ip.server.ip_address`, so Terraform creates the IP first. You rarely need `depends_on`. + +### State + +- `terraform.tfstate` maps your code to real resource IDs: "`digitalocean_droplet.server` = droplet 123456". +- Without it, Terraform doesn't know what it created, and would try to create everything again. +- **It contains sensitive data** (IPs, sometimes passwords and keys), so it's never committed. +- **Remote state** (S3/Spaces, HCP Terraform) is the norm for real infrastructure: it's backed up, shared, and **locked**, so two `apply`s can't run at once and corrupt it. +- `terraform state list` / `state show ` inspect it. Edit state only as a last resort. + +### Update in place vs replace + +Some attributes can be changed on a live resource (a droplet's tags). Others can't (its image, or a container's ports): the provider must **destroy and recreate** it. The plan marks this `forces replacement`. + +For a server, replacing means **new disk, fresh OS, everything inside gone**. That's fine *only* because: +- the configuration is in Ansible (lab 09), so run it again, and +- the data is backed up (lab 03), or kept on separate volumes. + +`lifecycle { prevent_destroy = true }` protects resources that must never be replaced by accident, such as a database volume. + +### Stable addresses: the reserved IP + +A new droplet gets a new IP. With a **reserved IP** assigned to it, DNS records and `known_hosts` keep pointing at the same address when the droplet is replaced: Terraform just moves the assignment. The IP has its own life cycle, separate from the server's. + +### Providers, versions, and the lock file + +- A **provider** is a plugin that speaks one API (`digitalocean`, `docker`, `local`…). +- `version = "~> 2.104"` allows 2.104.x and later 2.x minors, but not 3.0. +- **`.terraform.lock.hcl`** records the exact version and checksums chosen. **Commit it**, so everyone gets identical providers. `terraform init -upgrade` moves them forward deliberately. + +### Variables, outputs, and secrets + +- `variable` = input (region, size, domain). Set in `terraform.tfvars` (git-ignored), `-var`, or `TF_VAR_name` env vars. +- `output` = results to show or hand on (`terraform output -raw ip`). +- **Secrets stay out of files**: the provider reads `DIGITALOCEAN_TOKEN` from the environment. + +### DNS as code + +- `digitalocean_domain` creates the zone. Your registrar must **delegate** to DigitalOcean's nameservers, or the records are never seen. +- A **wildcard record** (`*` → IP) sends every subdomain to Traefik, so adding a service needs no DNS change, just a Traefik label. +- **TTL 300** means caches keep an answer up to 5 minutes. A low TTL makes changes visible quickly. + +### Cloud firewall + ufw + +The DigitalOcean firewall filters traffic **before it reaches the droplet**. ufw (lab 01) filters **on** the droplet. Two independent layers (defense in depth): a mistake in one doesn't expose the server. The cloud firewall also isn't bypassed by Docker's iptables rules (the Docker/ufw problem from lab 04). + +### Drift + +If someone changes infrastructure by hand (deletes a record in the console, resizes the droplet), the real world no longer matches the code. `terraform plan` refreshes state from the API, shows the difference, and offers to put things back. Rule: **after adopting Terraform, change infrastructure only through Terraform.** + +### Practicing for free with a local provider + +`envs/local` uses the Docker provider to create a "server" that is a container. The resources are different, but the **workflow is identical**: init, plan, apply, state, outputs, replacement, drift, destroy, and the hand-off to Ansible. Only the provider changes when you move to the cloud. + +### Terraform and OpenTofu + +HashiCorp changed Terraform's license to BSL in 2023, and the community forked it as **OpenTofu** (Linux Foundation). For this repo they're interchangeable: the same `.tf` files work with both. + +## Key terms + +| Term | Meaning | +|---|---| +| Provider | Plugin for one API (cloud, DNS, Docker…) | +| Resource | One piece of infrastructure Terraform manages | +| Data source | Read-only lookup of something that already exists | +| State | Terraform's record of what it manages, with real IDs | +| Backend | Where state is stored (local file, S3, HCP Terraform…) | +| Plan | The computed diff between code and reality | +| Replace (`-/+`) | Destroy and recreate, because the change can't be made in place | +| Drift | Reality changed outside Terraform | +| Lock file | Pinned provider versions and checksums | + +## Commands to know + +| Command | What it does | +|---|---| +| `terraform init` | Download providers, set up the backend | +| `terraform fmt -recursive` / `validate` | Format / check the code | +| `terraform plan` / `plan -out=x` | Show the diff / save it to apply exactly that | +| `terraform apply` / `apply x` | Make it so / apply a saved plan | +| `terraform destroy` | Delete everything in this state | +| `terraform output [-raw name]` | Show outputs | +| `terraform state list` / `state show ` | Inspect state | +| `terraform providers lock -platform=…` | Add checksums for other OSes | +| `terraform apply -replace=` | Force-recreate one resource | + +## Before you start, can you answer these? + +1. What's the difference between what Terraform manages and what Ansible manages? +2. What is the state file for, and why is it never committed? +3. What does `forces replacement` mean for a server? +4. Why does the DigitalOcean config use a reserved IP instead of the droplet's own IP? +5. Why must a domain's nameservers point to DigitalOcean before the DNS records work? + +## After the lab, check yourself + +1. List the resources each environment created, in the order Terraform created them. +2. Why did `plan` show "No changes" after Ansible? +3. What would you need to do after applying a change that replaces the server? +4. What happened when you deleted the container behind Terraform's back? +5. How would two people (or you and CI) share state safely? +6. After `terraform destroy` on DigitalOcean, how would you check that nothing is still billed? + +## My notes + +_Your own explanations, analogies, and "aha" moments._ + +## Further reading + +- [Terraform: core workflow](https://developer.hashicorp.com/terraform/intro/core-workflow) +- [Terraform: state](https://developer.hashicorp.com/terraform/language/state) +- [Terraform: dependency lock file](https://developer.hashicorp.com/terraform/language/files/dependency-lock) +- [DigitalOcean: reserved IPs](https://docs.digitalocean.com/products/networking/reserved-ips/) +- [OpenTofu](https://opentofu.org/) diff --git a/terraform/README.md b/terraform/README.md index a29b907..3538e06 100644 --- a/terraform/README.md +++ b/terraform/README.md @@ -1,3 +1,49 @@ # terraform -Cloud resources (VPS, DNS, firewall) as code. Built in lab 10. State is remote and never committed. +Creates the infrastructure: the server, its firewall, IP, and DNS (lab 10). [Ansible](../ansible/) then configures what runs on it. + +``` +terraform apply ──▶ server + firewall + DNS ──▶ ansible/inventory//…yml ──▶ ansible-playbook site.yml +``` + +| Environment | Creates | Cost | +|---|---|---| +| [envs/local](envs/local/) | The lab 09 fake VPS as a Docker container, for practice | free | +| [envs/digitalocean](envs/digitalocean/) | Droplet (sgp1, 2 vCPU / 4 GB), reserved IP, cloud firewall, DNS zone with `@` and `*` records | ~$24/month while it exists | + +Both write an Ansible inventory from the real resources, using [templates/ansible-inventory.yml.tftpl](templates/ansible-inventory.yml.tftpl). + +## Local practice + +```bash +cd terraform/envs/local +terraform init +terraform plan +terraform apply +ssh-keyscan -p 2201 -t ed25519 127.0.0.1 >> ~/.ssh/known_hosts # trust the new "server" +cd ../../../ansible && ansible-playbook -i inventory/test/terraform.yml site.yml +cd ../terraform/envs/local && terraform destroy +``` + +Don't run it at the same time as `ansible/test/fake-vps.sh`: both use ports 2201, 2202, 8080, and 8443. + +## DigitalOcean + +```bash +export DIGITALOCEAN_TOKEN=dop_v1_... # never in a file in git +cd terraform/envs/digitalocean +cp terraform.tfvars.example terraform.tfvars # set domain +terraform init +terraform plan +terraform apply +``` + +Then follow [lab 10, step 6](../labs/10-terraform/README.md#6-the-real-thing-digitalocean). + +## Rules + +- **State** (`terraform.tfstate`) holds every resource's details, including secrets. It's git-ignored. Use a remote backend for real infrastructure ([backend.tf.example](envs/digitalocean/backend.tf.example)). +- **Commit `.terraform.lock.hcl`**: it pins exact provider versions and checksums, like `go.sum`. +- **Secrets** come from environment variables (`DIGITALOCEAN_TOKEN`), not `.tf` or `.tfvars` files. +- **Always read the plan** before `apply`, especially lines with `must be replaced` or `destroy`. +- CI runs `fmt` and `validate` on every change ([workflow](../.github/workflows/terraform.yml)); it never applies. diff --git a/terraform/envs/digitalocean/.terraform.lock.hcl b/terraform/envs/digitalocean/.terraform.lock.hcl new file mode 100644 index 0000000..5362f5c --- /dev/null +++ b/terraform/envs/digitalocean/.terraform.lock.hcl @@ -0,0 +1,53 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/digitalocean/digitalocean" { + version = "2.104.0" + constraints = "~> 2.104" + hashes = [ + "h1:bp8CiGi4Rt/1PnffoOl/1PCVwB3Hl9RVYHSYkml8was=", + "h1:j7k/hga22SZVbsi7C0FEo9RCkmBYkRexTMYOI+jepAM=", + "h1:ogLP1sVEhdunYMZPvCrBA1aET6RVVuwcr0v/XQujVqg=", + "h1:td/CvxRmlzB6uflUOL3PfrGsDoU40F5tfIXLg5c4W1o=", + "zh:17e954cc81627fb61dea9205bf5867cd60a16dab3967bfd221966daf10fe78d5", + "zh:44cc4a4ab4c0abf2854738d4b66bd6f93aed70aedf66377539133547f316501b", + "zh:4e117a56c90be20aae03717e2b0e97e33b2021f5d181d8d6ecbbe368e0464c53", + "zh:50a95132afe091a5608f95a94744fc4e039e92ae70898579c1de5bc5b5eb954a", + "zh:569a94fee40d27ffa4ed09287f4f1f1d7e1f69bb18b3cc4555b2e26a92da046f", + "zh:61643b8cc0ea34cb9e58642f8a57163fe77c7432b44620878848ec8e185fc1aa", + "zh:6a5611f703cf7f2601fdc12ed172f91d81d7485127817d5084d5aeb6076fb5e1", + "zh:7ef4c8f379ba98bf59a2fd510e9b9b9bf2a4d7bd53a6c2e85e2adf490f7ad390", + "zh:9befabf1570aa8ab76ee7a867820e45d47c7f2164478b373b91ead026a2fad0a", + "zh:a54835312c3bda7ba5c9467ec21e002aac348df3b95bb1d117dc5d3b6ab7d736", + "zh:b90bffd6f6ce651337de225c64210cb0c8eba04cf1d4b094443348f6e5510dd0", + "zh:c5d96e7565bc75f5605748b8daf7aab2457ae4cfc291043854dbf23460e6c9bb", + "zh:c680e99b3f5c1d12b9c9979ad31dcecff8c10fe1c00f78b5959dba720d6e7b79", + "zh:d8b2399007e9f8ce00c21371f3c6fdf86347f911a9f7e4ece7c3db7bacd3cd12", + "zh:de8e918d9d20185eabd592303a51124c4bec96a6e15540f05a4c61fe5df1ee4d", + "zh:ef57682d84e9a4d9baba3949936727430c18623370aaec3acf3fe30decaf5ffd", + ] +} + +provider "registry.terraform.io/hashicorp/local" { + version = "2.9.1" + constraints = "~> 2.9" + hashes = [ + "h1:2mPBju3/AHaofZfLTvCXqsma1/AGC/8uls0kOxE/irw=", + "h1:OZGJN0LSSat5QIxZPxDXtVr4XpHb2oG7cVKJhSqBFIE=", + "h1:bek0XHcE8X713/1PR+sMKZFwLnamznJdYbORghbFRn0=", + "h1:qGLHCuYSus+uHNnoEL4SuJqOs5yrNOcB7gnuHoVqizo=", + "zh:25606c7a5e308144fb627f6e31611bb52ff72bb9ae2d27af39673ab1a6b3c1bf", + "zh:2568c4ef4dab31821f6f7040af0d1a2aa2b9455b8d9cc546598b791b8ada34cd", + "zh:25ac210f136042047975896e5e11fe6012425301c826d6b7ce22a49f96a1e0e8", + "zh:55d3a7bf01eced8e1f259b548020ef1221c5687e0fe6b5b30f81ad0b4121ff12", + "zh:6168d6934777b853c57815baa18a54101c604a6f1124ca47cd23d016c6f2f1e5", + "zh:63c0aa17c8373f761123376226debffd1f2259901ff259d4547c520def9a6787", + "zh:6d1f1572db6c85bd6d6b1b1deef247084e189816875f67e6bcc5d4736794e0a9", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:a8ea1c155bd5e0d695a28d3c0e2f542de8844e1791ea021f015a3f08e1c059e1", + "zh:ccd790f97269509ade0574ef63c955d3943cb1d812c59177bd85ce4c1d17ca0f", + "zh:dcfc17bb666e659a9daa9ff52fd8f6ec9ea519868726015e645955c372296332", + "zh:eb83abbfa7f52cba609b48cbb9a6a5cab07780a672b2020fc2ba190e699a2516", + "zh:eedffca70074ae153790fb23c4ace8d1637a60ceba4e3c42c8eaeda7c1f9d10f", + ] +} diff --git a/terraform/envs/digitalocean/backend.tf.example b/terraform/envs/digitalocean/backend.tf.example new file mode 100644 index 0000000..9911992 --- /dev/null +++ b/terraform/envs/digitalocean/backend.tf.example @@ -0,0 +1,21 @@ +# Remote state in a DigitalOcean Spaces bucket (S3-compatible). Copy to backend.tf, +# create the bucket and a Spaces access key first, then: +# export AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= +# terraform init -migrate-state +# Free alternative: HCP Terraform (cloud { organization = "...", workspaces { name = "opsforge" } }). +terraform { + backend "s3" { + endpoints = { + s3 = "https://sgp1.digitaloceanspaces.com" + } + bucket = "" + key = "opsforge/digitalocean.tfstate" + region = "us-east-1" # ignored by Spaces, required by the S3 backend + + skip_credentials_validation = true + skip_requesting_account_id = true + skip_metadata_api_check = true + skip_region_validation = true + skip_s3_checksum = true + } +} diff --git a/terraform/envs/digitalocean/main.tf b/terraform/envs/digitalocean/main.tf new file mode 100644 index 0000000..857a575 --- /dev/null +++ b/terraform/envs/digitalocean/main.tf @@ -0,0 +1,107 @@ +# Lab 10: the real server on DigitalOcean. +# Terraform creates the infrastructure; Ansible (lab 09) configures what runs on it. + +resource "digitalocean_ssh_key" "admin" { + name = "${var.name}-admin" + public_key = file(pathexpand(var.ssh_public_key_path)) +} + +resource "digitalocean_droplet" "server" { + name = var.name + region = var.region + size = var.size + image = var.image + ssh_keys = [digitalocean_ssh_key.admin.fingerprint] + ipv6 = true + monitoring = true # free CPU/RAM/disk graphs in the DigitalOcean console + tags = ["opsforge"] +} + +# A reserved IP survives rebuilding the droplet: DNS and known_hosts entries +# keep pointing at the same address when the server is replaced. +resource "digitalocean_reserved_ip" "server" { + region = var.region +} + +resource "digitalocean_reserved_ip_assignment" "server" { + ip_address = digitalocean_reserved_ip.server.ip_address + droplet_id = digitalocean_droplet.server.id +} + +# Cloud firewall, in front of ufw on the droplet (defense in depth, lab 01). +resource "digitalocean_firewall" "server" { + name = "${var.name}-fw" + droplet_ids = [digitalocean_droplet.server.id] + + # SSH from admin addresses only: 22 for the first (bootstrap) Ansible run, + # ssh_port afterwards. ufw on the droplet closes 22 once sshd has moved. + dynamic "inbound_rule" { + for_each = toset([22, var.ssh_port]) + content { + protocol = "tcp" + port_range = tostring(inbound_rule.value) + source_addresses = var.admin_cidrs + } + } + + dynamic "inbound_rule" { + for_each = toset([80, 443]) + content { + protocol = "tcp" + port_range = tostring(inbound_rule.value) + source_addresses = ["0.0.0.0/0", "::/0"] + } + } + + inbound_rule { + protocol = "icmp" + source_addresses = ["0.0.0.0/0", "::/0"] + } + + dynamic "outbound_rule" { + for_each = toset(["tcp", "udp"]) + content { + protocol = outbound_rule.value + port_range = "1-65535" + destination_addresses = ["0.0.0.0/0", "::/0"] + } + } + + outbound_rule { + protocol = "icmp" + destination_addresses = ["0.0.0.0/0", "::/0"] + } +} + +# DNS: the apex and a wildcard, so every *.domain subdomain (api., git., status.…) +# reaches Traefik without adding records per service. +resource "digitalocean_domain" "main" { + name = var.domain +} + +resource "digitalocean_record" "apex" { + domain = digitalocean_domain.main.id + type = "A" + name = "@" + value = digitalocean_reserved_ip.server.ip_address + ttl = 300 +} + +resource "digitalocean_record" "wildcard" { + domain = digitalocean_domain.main.id + type = "A" + name = "*" + value = digitalocean_reserved_ip.server.ip_address + ttl = 300 +} + +# The bridge to Ansible (git-ignored file). +resource "local_file" "ansible_inventory" { + filename = abspath("${path.module}/../../../ansible/inventory/production/hosts.yml") + file_permission = "0644" + content = templatefile("${path.module}/../../templates/ansible-inventory.yml.tftpl", { + env = "digitalocean" + host = var.name + address = digitalocean_reserved_ip.server.ip_address + }) +} diff --git a/terraform/envs/digitalocean/outputs.tf b/terraform/envs/digitalocean/outputs.tf new file mode 100644 index 0000000..c225123 --- /dev/null +++ b/terraform/envs/digitalocean/outputs.tf @@ -0,0 +1,19 @@ +output "ip" { + description = "Reserved (stable) public IP." + value = digitalocean_reserved_ip.server.ip_address +} + +output "ssh_bootstrap" { + description = "Log in to the fresh droplet (before Ansible)." + value = "ssh root@${digitalocean_reserved_ip.server.ip_address}" +} + +output "nameservers" { + description = "Set these at your domain registrar." + value = ["ns1.digitalocean.com", "ns2.digitalocean.com", "ns3.digitalocean.com"] +} + +output "ansible_command" { + description = "Configure the droplet (run in ansible/)." + value = "ansible-playbook -i inventory/production site.yml --ask-vault-pass" +} diff --git a/terraform/envs/digitalocean/terraform.tfvars.example b/terraform/envs/digitalocean/terraform.tfvars.example new file mode 100644 index 0000000..ab81da5 --- /dev/null +++ b/terraform/envs/digitalocean/terraform.tfvars.example @@ -0,0 +1,8 @@ +# Copy to terraform.tfvars (git-ignored) and adjust. +domain = "example.com" + +# Optional overrides: +# region = "sgp1" +# size = "s-2vcpu-4gb" +# ssh_port = 2222 # must match ansible ssh_port +# admin_cidrs = ["203.0.113.7/32"] # your IP only, if it is stable diff --git a/terraform/envs/digitalocean/variables.tf b/terraform/envs/digitalocean/variables.tf new file mode 100644 index 0000000..189ff04 --- /dev/null +++ b/terraform/envs/digitalocean/variables.tf @@ -0,0 +1,46 @@ +variable "name" { + description = "Droplet name (also its hostname)." + type = string + default = "opsforge" +} + +variable "region" { + description = "DigitalOcean region. sgp1 = Singapore, closest to Vietnam." + type = string + default = "sgp1" +} + +variable "size" { + description = "Droplet size slug. s-2vcpu-4gb fits every lab, including K3s." + type = string + default = "s-2vcpu-4gb" +} + +variable "image" { + description = "OS image. The Ansible playbook expects Ubuntu 24.04." + type = string + default = "ubuntu-24-04-x64" +} + +variable "ssh_public_key_path" { + description = "Public key installed for root on the new droplet." + type = string + default = "~/.ssh/id_ed25519.pub" +} + +variable "domain" { + description = "Domain to manage in DigitalOcean DNS (its nameservers must point to DigitalOcean)." + type = string +} + +variable "ssh_port" { + description = "Hardened SSH port; must match ssh_port in the Ansible inventory." + type = number + default = 2222 +} + +variable "admin_cidrs" { + description = "Who may reach SSH. Narrow this to your own IP (x.x.x.x/32) if it is stable." + type = list(string) + default = ["0.0.0.0/0", "::/0"] +} diff --git a/terraform/envs/digitalocean/versions.tf b/terraform/envs/digitalocean/versions.tf new file mode 100644 index 0000000..3ba6fab --- /dev/null +++ b/terraform/envs/digitalocean/versions.tf @@ -0,0 +1,18 @@ +terraform { + required_version = ">= 1.9" + + required_providers { + digitalocean = { + source = "digitalocean/digitalocean" + version = "~> 2.104" + } + local = { + source = "hashicorp/local" + version = "~> 2.9" + } + } +} + +# Token from the environment, never from a file in git: +# export DIGITALOCEAN_TOKEN=dop_v1_... +provider "digitalocean" {} diff --git a/terraform/envs/local/.terraform.lock.hcl b/terraform/envs/local/.terraform.lock.hcl new file mode 100644 index 0000000..f4a0e48 --- /dev/null +++ b/terraform/envs/local/.terraform.lock.hcl @@ -0,0 +1,49 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/local" { + version = "2.9.1" + constraints = "~> 2.9" + hashes = [ + "h1:2mPBju3/AHaofZfLTvCXqsma1/AGC/8uls0kOxE/irw=", + "h1:OZGJN0LSSat5QIxZPxDXtVr4XpHb2oG7cVKJhSqBFIE=", + "h1:bek0XHcE8X713/1PR+sMKZFwLnamznJdYbORghbFRn0=", + "h1:qGLHCuYSus+uHNnoEL4SuJqOs5yrNOcB7gnuHoVqizo=", + "zh:25606c7a5e308144fb627f6e31611bb52ff72bb9ae2d27af39673ab1a6b3c1bf", + "zh:2568c4ef4dab31821f6f7040af0d1a2aa2b9455b8d9cc546598b791b8ada34cd", + "zh:25ac210f136042047975896e5e11fe6012425301c826d6b7ce22a49f96a1e0e8", + "zh:55d3a7bf01eced8e1f259b548020ef1221c5687e0fe6b5b30f81ad0b4121ff12", + "zh:6168d6934777b853c57815baa18a54101c604a6f1124ca47cd23d016c6f2f1e5", + "zh:63c0aa17c8373f761123376226debffd1f2259901ff259d4547c520def9a6787", + "zh:6d1f1572db6c85bd6d6b1b1deef247084e189816875f67e6bcc5d4736794e0a9", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:a8ea1c155bd5e0d695a28d3c0e2f542de8844e1791ea021f015a3f08e1c059e1", + "zh:ccd790f97269509ade0574ef63c955d3943cb1d812c59177bd85ce4c1d17ca0f", + "zh:dcfc17bb666e659a9daa9ff52fd8f6ec9ea519868726015e645955c372296332", + "zh:eb83abbfa7f52cba609b48cbb9a6a5cab07780a672b2020fc2ba190e699a2516", + "zh:eedffca70074ae153790fb23c4ace8d1637a60ceba4e3c42c8eaeda7c1f9d10f", + ] +} + +provider "registry.terraform.io/kreuzwerker/docker" { + version = "4.6.0" + constraints = "~> 4.6" + hashes = [ + "h1:1jD5QVpeLkdxKEWWJpTzNdxtR0WNNFq28Po4yq9w+VM=", + "h1:OrYslEQqlrStPyKh+BFZvY4kCoPrtTfJ3NQeT/PneBs=", + "h1:kJn6TaGTDA7iIbPWth3hUFYEk2xtKkmq2aNKnt1mHEM=", + "h1:q3mwfVVYLcYlniI59CoMhIuRBegmcY4tOiif8at9MZw=", + "zh:085dbfb1ca54d4517e8cd325670e8c966f747b7757bfbb161055ab924b785a27", + "zh:520a0d8c55d0119ea70a9e72740d6fbb53d044ddfd5c7acfa1439a9f4f22946f", + "zh:7315bd14ab303d3b4c69c405df694da3fb0e2ab1dc442a136def1d7297ae664b", + "zh:866046fa4938ac1ed5cc58b3c4d36e27433dac1aae50256911e6f28184dcf82e", + "zh:aa49adcfe63f03ef70ad4619dc211c54892dd16ff2e0c1437627616972bf1221", + "zh:afa3d476821cd6de28f62b117b1f68413a38acaa417ed89a299cb5a77cdfc5f1", + "zh:bad00d59d082dcad2be2582660aae6f7683ffb3bba226cc627fb910008f27411", + "zh:c0dca05a77b08db040791e9ff73924ed96f06837dce53951c2589d55c6e122f1", + "zh:c14b8d122bd12cc84aed5eb62cc0a965b5a9807b5a2257a51be5460d2f69b437", + "zh:c9cfe529d87f6fbb83ec7064c15a1fb6d756f759141f147e0fb644cb0354f05c", + "zh:d60fc2d42da1d567f28b6267dde57af03d7333033d8246c6880944f24d641556", + "zh:e4f8d82f2a5171dd5cb9d5813a49d7d7221d09c10482c2c819d6b2a899e30c5f", + ] +} diff --git a/terraform/envs/local/main.tf b/terraform/envs/local/main.tf new file mode 100644 index 0000000..f713240 --- /dev/null +++ b/terraform/envs/local/main.tf @@ -0,0 +1,90 @@ +# Lab 10, local practice: Terraform creates the "fake VPS" from lab 09 as a +# Docker container, and writes the Ansible inventory for it. +# Same workflow as the real cloud (envs/digitalocean): plan, apply, Ansible, destroy. + +provider "docker" { + host = var.docker_host +} + +locals { + repo_root = abspath("${path.module}/../../..") +} + +# Like a cloud "image": built from the lab 09 Dockerfile. +resource "docker_image" "vps" { + name = "opsforge-fake-vps:24.04" + keep_locally = false + + build { + context = "${local.repo_root}/ansible/test" + } + + # Rebuild when the Dockerfile changes. + triggers = { + dockerfile = filesha256("${local.repo_root}/ansible/test/Dockerfile") + } +} + +# Like attached disks: Docker-in-Docker needs real volumes (overlay can't nest). +resource "docker_volume" "docker" { + name = "${var.name}-docker" +} + +resource "docker_volume" "containerd" { + name = "${var.name}-containerd" +} + +# Like the server itself. +resource "docker_container" "vps" { + name = var.name + hostname = "tf-vps" + image = docker_image.vps.image_id + privileged = true + cgroupns_mode = "host" + + tmpfs = { + "/run" = "" + "/run/lock" = "" + } + + volumes { + host_path = "/sys/fs/cgroup" + container_path = "/sys/fs/cgroup" + } + + volumes { + volume_name = docker_volume.docker.name + container_path = "/var/lib/docker" + } + + volumes { + volume_name = docker_volume.containerd.name + container_path = "/var/lib/containerd" + } + + dynamic "ports" { + for_each = var.ports + content { + internal = ports.key + external = ports.value + ip = "127.0.0.1" + } + } + + # Like a cloud provider's "SSH keys" setting: root gets our public key. + upload { + file = "/root/.ssh/authorized_keys" + content = file(pathexpand(var.ssh_public_key_path)) + } +} + +# The bridge to Ansible: the inventory is generated from real resources. +resource "local_file" "ansible_inventory" { + filename = "${local.repo_root}/ansible/inventory/test/terraform.yml" + file_permission = "0644" + content = templatefile("${path.module}/../../templates/ansible-inventory.yml.tftpl", { + env = "local" + host = docker_container.vps.hostname + address = "127.0.0.1" + }) +} diff --git a/terraform/envs/local/outputs.tf b/terraform/envs/local/outputs.tf new file mode 100644 index 0000000..c624e45 --- /dev/null +++ b/terraform/envs/local/outputs.tf @@ -0,0 +1,13 @@ +output "ssh_bootstrap" { + description = "Log in to the fresh server (before Ansible)." + value = "ssh -p ${var.ports[22]} root@127.0.0.1" +} + +output "ansible_command" { + description = "Configure it (run in ansible/)." + value = "ansible-playbook -i inventory/test/terraform.yml site.yml" +} + +output "container_id" { + value = docker_container.vps.id +} diff --git a/terraform/envs/local/variables.tf b/terraform/envs/local/variables.tf new file mode 100644 index 0000000..08269b0 --- /dev/null +++ b/terraform/envs/local/variables.tf @@ -0,0 +1,30 @@ +variable "docker_host" { + description = "Docker daemon to use. null = DOCKER_HOST or the default socket." + type = string + default = null +} + +variable "ssh_public_key_path" { + description = "Public key installed for root, like a cloud provider does." + type = string + default = "~/.ssh/id_ed25519.pub" +} + +variable "name" { + description = "Container (\"server\") name." + type = string + default = "opsforge-tf-vps" +} + +# Same ports as ansible/test/fake-vps.sh, so inventory/test's settings apply. +# Don't run both fake VPSs at once. +variable "ports" { + description = "Container port => port on 127.0.0.1." + type = map(number) + default = { + 22 = 2201 # sshd before hardening + 2202 = 2202 # hardened sshd (ssh_port in inventory/test) + 80 = 8080 + 443 = 8443 + } +} diff --git a/terraform/envs/local/versions.tf b/terraform/envs/local/versions.tf new file mode 100644 index 0000000..beae1ee --- /dev/null +++ b/terraform/envs/local/versions.tf @@ -0,0 +1,14 @@ +terraform { + required_version = ">= 1.9" + + required_providers { + docker = { + source = "kreuzwerker/docker" + version = "~> 4.6" + } + local = { + source = "hashicorp/local" + version = "~> 2.9" + } + } +} diff --git a/terraform/templates/ansible-inventory.yml.tftpl b/terraform/templates/ansible-inventory.yml.tftpl new file mode 100644 index 0000000..5ce52d6 --- /dev/null +++ b/terraform/templates/ansible-inventory.yml.tftpl @@ -0,0 +1,5 @@ +# Generated by Terraform (terraform/envs/${env}). Do not edit: run `terraform apply`. +servers: + hosts: + ${host}: + ansible_host: ${address}