diff --git a/docs/plans/2026-02-22-infra-design.md b/docs/plans/2026-02-22-infra-design.md new file mode 100644 index 0000000..52722c5 --- /dev/null +++ b/docs/plans/2026-02-22-infra-design.md @@ -0,0 +1,121 @@ +# infra/ Design + +## Goal + +Automate provisioning of a per-client Azure VM and backup storage container using +Terraform, with a clear provider boundary so switching cloud providers requires only +adding a new provider directory. + +## Architecture + +Terraform with provider-prefixed directories. The `azure/` directory is the Azure +implementation; `variables.tf` and `outputs.tf` define the contract any future +provider implementation must satisfy. Terraform is provider-agnostic at the tool +level; the directory structure makes the implementation boundary explicit. + +No abstraction layer across providers — that would be misleading. Real isolation +means: "I know exactly what to change and where to add it." + +## Tech Stack + +- Terraform (HCL) +- azurerm provider +- State: local (`terraform.tfstate`, gitignored) + +## Decisions + +**DNS:** Manual. Terraform outputs the public IP; the operator sets the A record by hand. +This matches the existing RUNBOOK flow. + +**Backup storage:** One shared storage account (`tillerbackups`) created manually once +during platform setup. Terraform adds a per-client blob container to it. The shared +account is referenced via a data source, not managed by per-client Terraform state. + +**State backend:** Local file. Solo operator, no collaboration needed now. Migrate to +Azure Blob Storage backend if/when a second operator is added. + +## Directory Structure + +``` +infra/ +├── README.md # contract documentation +├── azure/ +│ ├── providers.tf # azurerm provider + version constraints +│ ├── variables.tf # input contract +│ ├── outputs.tf # output contract +│ ├── main.tf # resource group + module composition +│ ├── modules/ +│ │ ├── vm/ # VM, VNet, NSG, managed identity, role assignment +│ │ │ ├── main.tf +│ │ │ ├── variables.tf +│ │ │ └── outputs.tf +│ │ └── backup-container/ # blob container in shared storage account +│ │ ├── main.tf +│ │ ├── variables.tf +│ │ └── outputs.tf +│ └── clients/ +│ └── edmonton.tfvars # per-client values (committed, no secrets) +└── (future: aws/, gcp/) +``` + +## Input Contract (`variables.tf`) + +| Variable | Default | Notes | +|---|---|---| +| `client_name` | — | Short identifier, e.g. `edmonton` | +| `location` | `canadacentral` | Azure region | +| `vm_size` | `Standard_B2ms` | | +| `admin_username` | `tiller` | SSH user on the VM | +| `admin_ssh_public_key` | — | Public key content | +| `backup_storage_account_name` | — | Pre-existing shared account, e.g. `tillerbackups` | +| `backup_storage_resource_group` | — | Resource group of the shared storage account | + +## Output Contract (`outputs.tf`) + +| Output | Purpose | +|---|---| +| `public_ip_address` | Set DNS A record | +| `resource_group_name` | `az` CLI operations | +| `backup_container_name` | `AZURE_CONTAINER` in `breedbase-client.env` | + +## Azure Resources + +### `modules/vm/` + +- `azurerm_resource_group` — `tiller-{client_name}` +- `azurerm_virtual_network` + `azurerm_subnet` +- `azurerm_public_ip` (static) +- `azurerm_network_security_group` — inbound: SSH (22), HTTP (80), HTTPS (443) +- `azurerm_network_interface` +- `azurerm_user_assigned_identity` +- `azurerm_linux_virtual_machine` — B2ms, Ubuntu 22.04 LTS, identity attached +- `azurerm_role_assignment` — Storage Blob Data Contributor on shared backup account + (enables `az storage blob upload --auth-mode login` without stored credentials) + +### `modules/backup-container/` + +- `data.azurerm_storage_account` — references pre-existing shared account +- `azurerm_storage_container` — `{client_name}-backups` + +## Deployment Workflow + +```bash +cd infra/azure +terraform init +terraform plan -var-file=clients/edmonton.tfvars +terraform apply -var-file=clients/edmonton.tfvars + +# Consume outputs in RUNBOOK: +terraform output public_ip_address # → set DNS A record +terraform output backup_container_name # → AZURE_CONTAINER in breedbase-client.env +``` + +## Provider Isolation Seam + +A future provider (e.g. `infra/aws/`) would: +1. Accept the same variables defined in the input contract +2. Produce the same outputs defined in the output contract +3. Contain AWS-specific resources (EC2, Security Groups, IAM role, S3 bucket/prefix) + +The `infra/README.md` documents the contract abstractly. The provider directories +implement it concretely. diff --git a/docs/plans/2026-02-22-infra.md b/docs/plans/2026-02-22-infra.md new file mode 100644 index 0000000..8504303 --- /dev/null +++ b/docs/plans/2026-02-22-infra.md @@ -0,0 +1,600 @@ +# infra/ Terraform Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Build Terraform configuration in `infra/azure/` that provisions a per-client Azure VM, networking, managed identity, and backup blob container. + +**Architecture:** Provider-prefixed directory structure (`infra/azure/`) with two child modules (`vm/` and `backup-container/`). The root `variables.tf`/`outputs.tf` define the provider-agnostic contract; the modules contain Azure-specific resources. State is local (gitignored). See `docs/plans/2026-02-22-infra-design.md` for full design rationale. + +**Tech Stack:** Terraform >= 1.9, azurerm ~> 4.0 + +--- + +### Task 1: Bootstrap infra/azure/ with provider config + +**Files:** +- Create: `infra/azure/.gitignore` +- Create: `infra/azure/providers.tf` + +**Step 1: Create the gitignore** + +``` +# infra/azure/.gitignore +.terraform/ +terraform.tfstate +terraform.tfstate.backup +*.tfplan +``` + +Note: `.terraform.lock.hcl` is intentionally NOT ignored — it pins provider versions and should be committed (like a lockfile). + +**Step 2: Create providers.tf** + +```hcl +# infra/azure/providers.tf +terraform { + required_version = ">= 1.9" + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "~> 4.0" + } + } +} + +provider "azurerm" { + features {} + # subscription_id is read from ARM_SUBSCRIPTION_ID environment variable. + # Set it before running terraform: export ARM_SUBSCRIPTION_ID="" +} +``` + +**Step 3: Run terraform init** + +```bash +cd infra/azure +terraform init +``` + +Expected: "Terraform has been successfully initialized!" and `.terraform.lock.hcl` is created. + +**Step 4: Commit** + +```bash +git add infra/azure/.gitignore infra/azure/providers.tf infra/azure/.terraform.lock.hcl +git commit -m "feat: bootstrap infra/azure with terraform provider config" +``` + +--- + +### Task 2: Define the contract — variables and outputs + +These files define what goes in and what comes out. Write them before the implementation so you know exactly what you're building toward. + +**Files:** +- Create: `infra/azure/variables.tf` +- Create: `infra/azure/outputs.tf` + +**Step 1: Create variables.tf** + +```hcl +# infra/azure/variables.tf + +variable "client_name" { + description = "Short client identifier, e.g. edmonton. Used in resource names." + type = string +} + +variable "location" { + description = "Azure region." + type = string + default = "canadacentral" +} + +variable "vm_size" { + description = "VM SKU." + type = string + default = "Standard_B2ms" +} + +variable "admin_username" { + description = "SSH username on the VM." + type = string + default = "tiller" +} + +variable "admin_ssh_public_key" { + description = "SSH public key content (full string, e.g. 'ssh-ed25519 AAAA...')." + type = string +} + +variable "backup_storage_account_name" { + description = "Name of the pre-existing shared Azure Storage account, e.g. tillerbackups." + type = string +} + +variable "backup_storage_resource_group" { + description = "Resource group of the pre-existing shared backup storage account." + type = string +} +``` + +**Step 2: Create outputs.tf** + +```hcl +# infra/azure/outputs.tf + +output "public_ip_address" { + description = "Public IP of the VM. Set the DNS A record for CLIENT_HOSTNAME to this value." + value = module.vm.public_ip_address +} + +output "resource_group_name" { + description = "Azure resource group containing all client resources." + value = module.vm.resource_group_name +} + +output "backup_container_name" { + description = "Blob container name for backups. Use as AZURE_CONTAINER in breedbase-client.env." + value = module.backup_container.container_name +} +``` + +**Step 3: Verify validate fails (expected — main.tf doesn't exist yet)** + +```bash +cd infra/azure +terraform validate +``` + +Expected: error about missing `module.vm` and `module.backup_container` references. This confirms the outputs are wired correctly. + +**Step 4: Commit** + +```bash +git add infra/azure/variables.tf infra/azure/outputs.tf +git commit -m "feat: define infra input/output contract" +``` + +--- + +### Task 3: Create modules/vm/ + +This module creates: resource group, managed identity, VNet/subnet, public IP, NSG, NIC, and the Linux VM. It also assigns the Storage Blob Data Contributor role on the shared backup account to the managed identity — this is what lets `backup.sh` authenticate with `--auth-mode login` without stored credentials. + +**Files:** +- Create: `infra/azure/modules/vm/variables.tf` +- Create: `infra/azure/modules/vm/outputs.tf` +- Create: `infra/azure/modules/vm/main.tf` + +**Step 1: Create modules/vm/variables.tf** + +```hcl +# infra/azure/modules/vm/variables.tf + +variable "client_name" { + description = "Short client identifier." + type = string +} + +variable "location" { + description = "Azure region." + type = string +} + +variable "vm_size" { + description = "VM SKU." + type = string +} + +variable "admin_username" { + description = "SSH username on the VM." + type = string +} + +variable "admin_ssh_public_key" { + description = "SSH public key content." + type = string +} + +variable "backup_storage_account_name" { + description = "Name of the pre-existing shared backup storage account." + type = string +} + +variable "backup_storage_resource_group" { + description = "Resource group of the shared backup storage account." + type = string +} +``` + +**Step 2: Create modules/vm/outputs.tf** + +```hcl +# infra/azure/modules/vm/outputs.tf + +output "public_ip_address" { + description = "Public IP address of the VM." + value = azurerm_public_ip.main.ip_address +} + +output "resource_group_name" { + description = "Resource group name." + value = azurerm_resource_group.main.name +} +``` + +**Step 3: Create modules/vm/main.tf** + +```hcl +# infra/azure/modules/vm/main.tf + +resource "azurerm_resource_group" "main" { + name = "tiller-${var.client_name}" + location = var.location +} + +resource "azurerm_user_assigned_identity" "main" { + name = "tiller-${var.client_name}-identity" + resource_group_name = azurerm_resource_group.main.name + location = azurerm_resource_group.main.location +} + +# Grant the VM's managed identity permission to upload blobs to the shared backup account. +# This is what allows backup.sh to use --auth-mode login without stored credentials. +data "azurerm_storage_account" "backup" { + name = var.backup_storage_account_name + resource_group_name = var.backup_storage_resource_group +} + +resource "azurerm_role_assignment" "backup_contributor" { + scope = data.azurerm_storage_account.backup.id + role_definition_name = "Storage Blob Data Contributor" + principal_id = azurerm_user_assigned_identity.main.principal_id +} + +resource "azurerm_virtual_network" "main" { + name = "tiller-${var.client_name}-vnet" + resource_group_name = azurerm_resource_group.main.name + location = azurerm_resource_group.main.location + address_space = ["10.0.0.0/16"] +} + +resource "azurerm_subnet" "main" { + name = "main" + resource_group_name = azurerm_resource_group.main.name + virtual_network_name = azurerm_virtual_network.main.name + address_prefixes = ["10.0.1.0/24"] +} + +resource "azurerm_public_ip" "main" { + name = "tiller-${var.client_name}-pip" + resource_group_name = azurerm_resource_group.main.name + location = azurerm_resource_group.main.location + allocation_method = "Static" + sku = "Standard" +} + +resource "azurerm_network_security_group" "main" { + name = "tiller-${var.client_name}-nsg" + resource_group_name = azurerm_resource_group.main.name + location = azurerm_resource_group.main.location + + security_rule { + name = "SSH" + priority = 100 + direction = "Inbound" + access = "Allow" + protocol = "Tcp" + source_port_range = "*" + destination_port_range = "22" + source_address_prefix = "*" + destination_address_prefix = "*" + } + + security_rule { + name = "HTTP" + priority = 110 + direction = "Inbound" + access = "Allow" + protocol = "Tcp" + source_port_range = "*" + destination_port_range = "80" + source_address_prefix = "*" + destination_address_prefix = "*" + } + + security_rule { + name = "HTTPS" + priority = 120 + direction = "Inbound" + access = "Allow" + protocol = "Tcp" + source_port_range = "*" + destination_port_range = "443" + source_address_prefix = "*" + destination_address_prefix = "*" + } +} + +resource "azurerm_network_interface" "main" { + name = "tiller-${var.client_name}-nic" + resource_group_name = azurerm_resource_group.main.name + location = azurerm_resource_group.main.location + + ip_configuration { + name = "main" + subnet_id = azurerm_subnet.main.id + private_ip_address_allocation = "Dynamic" + public_ip_address_id = azurerm_public_ip.main.id + } +} + +resource "azurerm_network_interface_security_group_association" "main" { + network_interface_id = azurerm_network_interface.main.id + network_security_group_id = azurerm_network_security_group.main.id +} + +resource "azurerm_linux_virtual_machine" "main" { + name = "tiller-${var.client_name}" + resource_group_name = azurerm_resource_group.main.name + location = azurerm_resource_group.main.location + size = var.vm_size + + admin_username = var.admin_username + disable_password_authentication = true + + admin_ssh_key { + username = var.admin_username + public_key = var.admin_ssh_public_key + } + + network_interface_ids = [azurerm_network_interface.main.id] + + identity { + type = "UserAssigned" + identity_ids = [azurerm_user_assigned_identity.main.id] + } + + os_disk { + caching = "ReadWrite" + storage_account_type = "Standard_LRS" + } + + source_image_reference { + publisher = "Canonical" + offer = "0001-com-ubuntu-server-jammy" + sku = "22_04-lts-gen2" + version = "latest" + } +} +``` + +**Step 4: Commit** + +```bash +git add infra/azure/modules/vm/ +git commit -m "feat: add vm module (VM, networking, managed identity, role assignment)" +``` + +--- + +### Task 4: Create modules/backup-container/ + +This module creates the per-client blob container inside the pre-existing shared storage account. It does NOT create the storage account — that exists already. + +**Files:** +- Create: `infra/azure/modules/backup-container/variables.tf` +- Create: `infra/azure/modules/backup-container/outputs.tf` +- Create: `infra/azure/modules/backup-container/main.tf` + +**Step 1: Create modules/backup-container/variables.tf** + +```hcl +# infra/azure/modules/backup-container/variables.tf + +variable "client_name" { + description = "Short client identifier." + type = string +} + +variable "backup_storage_account_name" { + description = "Name of the pre-existing shared backup storage account." + type = string +} + +variable "backup_storage_resource_group" { + description = "Resource group of the shared backup storage account." + type = string +} +``` + +**Step 2: Create modules/backup-container/outputs.tf** + +```hcl +# infra/azure/modules/backup-container/outputs.tf + +output "container_name" { + description = "Blob container name for client backups." + value = azurerm_storage_container.main.name +} +``` + +**Step 3: Create modules/backup-container/main.tf** + +```hcl +# infra/azure/modules/backup-container/main.tf + +data "azurerm_storage_account" "backup" { + name = var.backup_storage_account_name + resource_group_name = var.backup_storage_resource_group +} + +resource "azurerm_storage_container" "main" { + name = "${var.client_name}-backups" + storage_account_id = data.azurerm_storage_account.backup.id +} +``` + +**Step 4: Commit** + +```bash +git add infra/azure/modules/backup-container/ +git commit -m "feat: add backup-container module" +``` + +--- + +### Task 5: Root main.tf and per-client tfvars + +**Files:** +- Create: `infra/azure/main.tf` +- Create: `infra/azure/clients/edmonton.tfvars` + +**Step 1: Create main.tf** + +```hcl +# infra/azure/main.tf + +module "vm" { + source = "./modules/vm" + + client_name = var.client_name + location = var.location + vm_size = var.vm_size + admin_username = var.admin_username + admin_ssh_public_key = var.admin_ssh_public_key + backup_storage_account_name = var.backup_storage_account_name + backup_storage_resource_group = var.backup_storage_resource_group +} + +module "backup_container" { + source = "./modules/backup-container" + + client_name = var.client_name + backup_storage_account_name = var.backup_storage_account_name + backup_storage_resource_group = var.backup_storage_resource_group +} +``` + +**Step 2: Create clients/edmonton.tfvars** + +```hcl +# infra/azure/clients/edmonton.tfvars +# Per-client values for the Edmonton deployment. +# No secrets here — admin_ssh_public_key is a public key. + +client_name = "edmonton" +location = "canadacentral" +vm_size = "Standard_B2ms" +admin_username = "tiller" +admin_ssh_public_key = "ssh-ed25519 REPLACE_WITH_ACTUAL_PUBLIC_KEY operator@host" +backup_storage_account_name = "tillerbackups" +backup_storage_resource_group = "tiller-platform" +``` + +Replace `REPLACE_WITH_ACTUAL_PUBLIC_KEY` with the actual SSH public key before running Terraform. + +**Step 3: Commit** + +```bash +git add infra/azure/main.tf infra/azure/clients/edmonton.tfvars +git commit -m "feat: add root main.tf and edmonton client tfvars" +``` + +--- + +### Task 6: Validate and update infra/README.md + +**Files:** +- Modify: `infra/README.md` + +**Step 1: Run terraform validate** + +```bash +cd infra/azure +terraform validate +``` + +Expected: `Success! The configuration is valid.` + +If you see errors: they will be HCL syntax errors or type mismatches — read the error, fix the specific file, and re-run. + +**Step 2: Rewrite infra/README.md** + +Replace the existing contents (which describe a Bicep approach) with: + +```markdown +# infra + +Terraform configuration for provisioning Azure infrastructure per client deployment. + +Each client gets an isolated resource group containing a VM, networking, managed identity, +and a blob container in the shared backup storage account. + +## Provider isolation + +The `azure/` directory is the Azure-specific implementation. The `variables.tf` and +`outputs.tf` in that directory define the contract: what goes in, what comes out. A future +provider implementation (`aws/`, `gcp/`) would accept the same variables and produce the +same outputs using provider-specific resources. + +## Prerequisites + +- Terraform >= 1.9 installed +- Azure CLI authenticated: `az login` +- `ARM_SUBSCRIPTION_ID` environment variable set +- Shared backup storage account pre-created (once, manually): + ```bash + az group create --name tiller-platform --location canadacentral + az storage account create \ + --name tillerbackups \ + --resource-group tiller-platform \ + --location canadacentral \ + --sku Standard_LRS \ + --kind StorageV2 + ``` + +## Deploying a new client + +```bash +cd infra/azure + +# 1. Edit clients/.tfvars with correct values (copy from edmonton.tfvars) +# 2. Provision +export ARM_SUBSCRIPTION_ID="" +terraform init +terraform plan -var-file=clients/.tfvars +terraform apply -var-file=clients/.tfvars + +# 3. Note outputs for use in the RUNBOOK +terraform output public_ip_address # → set DNS A record +terraform output backup_container_name # → AZURE_CONTAINER in breedbase-client.env +``` + +## Contents + +- `providers.tf` — Terraform and azurerm provider version constraints +- `variables.tf` — input contract +- `outputs.tf` — output contract +- `main.tf` — composes modules +- `modules/vm/` — VM, VNet, NSG, managed identity, Storage Blob Data Contributor role +- `modules/backup-container/` — blob container in shared storage account +- `clients/` — per-client `.tfvars` files (committed, no secrets) +``` + +**Step 3: Verify validate still passes** + +```bash +cd infra/azure +terraform validate +``` + +Expected: `Success! The configuration is valid.` + +**Step 4: Final commit** + +```bash +git add infra/README.md +git commit -m "docs: update infra README for Terraform approach" +``` diff --git a/infra/README.md b/infra/README.md index 4e6a089..19794f8 100644 --- a/infra/README.md +++ b/infra/README.md @@ -1,12 +1,56 @@ # infra -Bicep/ARM templates for provisioning Azure infrastructure per client deployment. +Terraform configuration for provisioning Azure infrastructure per client deployment. -Each client gets an isolated resource group containing a VM, networking, and storage. +Each client gets an isolated resource group containing a VM, networking, managed identity, +and a blob container in the shared backup storage account. -## Contents (to be added) +## Provider isolation -- `main.bicep` — top-level Bicep template -- `vm.bicep` — VM + networking module -- `storage.bicep` — Azure Blob Storage for backups -- `parameters/` — per-client parameter files +The `azure/` directory is the Azure-specific implementation. The `variables.tf` and +`outputs.tf` in that directory define the contract: what goes in, what comes out. A future +provider implementation (`aws/`, `gcp/`) would accept the same variables and produce the +same outputs using provider-specific resources. + +## Prerequisites + +- Terraform >= 1.9 installed +- Azure CLI authenticated: `az login` +- `ARM_SUBSCRIPTION_ID` environment variable set +- Shared backup storage account pre-created (once, manually): + ```bash + az group create --name tiller-platform --location canadacentral + az storage account create \ + --name tillerbackups \ + --resource-group tiller-platform \ + --location canadacentral \ + --sku Standard_LRS \ + --kind StorageV2 + ``` + +## Deploying a new client + +```bash +cd infra/azure + +# 1. Edit clients/.tfvars with correct values (copy from edmonton.tfvars) +# 2. Provision +export ARM_SUBSCRIPTION_ID="" +terraform init +terraform plan -var-file=clients/.tfvars +terraform apply -var-file=clients/.tfvars + +# 3. Note outputs for use in the RUNBOOK +terraform output public_ip_address # → set DNS A record +terraform output backup_container_name # → AZURE_CONTAINER in breedbase-client.env +``` + +## Contents + +- `providers.tf` — Terraform and azurerm provider version constraints +- `variables.tf` — input contract +- `outputs.tf` — output contract +- `main.tf` — composes modules +- `modules/vm/` — VM, VNet, NSG, managed identity, Storage Blob Data Contributor role +- `modules/backup-container/` — blob container in shared storage account +- `clients/` — per-client `.tfvars` files (committed, no secrets) diff --git a/infra/azure/.gitignore b/infra/azure/.gitignore new file mode 100644 index 0000000..040b593 --- /dev/null +++ b/infra/azure/.gitignore @@ -0,0 +1,4 @@ +.terraform/ +terraform.tfstate +terraform.tfstate.backup +*.tfplan diff --git a/infra/azure/clients/edmonton.tfvars b/infra/azure/clients/edmonton.tfvars new file mode 100644 index 0000000..01351a1 --- /dev/null +++ b/infra/azure/clients/edmonton.tfvars @@ -0,0 +1,11 @@ +# infra/azure/clients/edmonton.tfvars +# Per-client values for the Edmonton deployment. +# No secrets here — admin_ssh_public_key is a public key. + +client_name = "edmonton" +location = "canadacentral" +vm_size = "Standard_B2ms" +admin_username = "tiller" +admin_ssh_public_key = "ssh-ed25519 REPLACE_WITH_ACTUAL_PUBLIC_KEY operator@host" +backup_storage_account_name = "tillerbackups" +backup_storage_resource_group = "tiller-platform" diff --git a/infra/azure/main.tf b/infra/azure/main.tf new file mode 100644 index 0000000..515aff3 --- /dev/null +++ b/infra/azure/main.tf @@ -0,0 +1,21 @@ +# infra/azure/main.tf + +module "vm" { + source = "./modules/vm" + + client_name = var.client_name + location = var.location + vm_size = var.vm_size + admin_username = var.admin_username + admin_ssh_public_key = var.admin_ssh_public_key + backup_storage_account_name = var.backup_storage_account_name + backup_storage_resource_group = var.backup_storage_resource_group +} + +module "backup_container" { + source = "./modules/backup-container" + + client_name = var.client_name + backup_storage_account_name = var.backup_storage_account_name + backup_storage_resource_group = var.backup_storage_resource_group +} diff --git a/infra/azure/modules/backup-container/main.tf b/infra/azure/modules/backup-container/main.tf new file mode 100644 index 0000000..4949962 --- /dev/null +++ b/infra/azure/modules/backup-container/main.tf @@ -0,0 +1,11 @@ +# infra/azure/modules/backup-container/main.tf + +data "azurerm_storage_account" "backup" { + name = var.backup_storage_account_name + resource_group_name = var.backup_storage_resource_group +} + +resource "azurerm_storage_container" "main" { + name = "${var.client_name}-backups" + storage_account_id = data.azurerm_storage_account.backup.id +} diff --git a/infra/azure/modules/backup-container/outputs.tf b/infra/azure/modules/backup-container/outputs.tf new file mode 100644 index 0000000..623d4fd --- /dev/null +++ b/infra/azure/modules/backup-container/outputs.tf @@ -0,0 +1,6 @@ +# infra/azure/modules/backup-container/outputs.tf + +output "container_name" { + description = "Blob container name for client backups." + value = azurerm_storage_container.main.name +} diff --git a/infra/azure/modules/backup-container/variables.tf b/infra/azure/modules/backup-container/variables.tf new file mode 100644 index 0000000..90c8284 --- /dev/null +++ b/infra/azure/modules/backup-container/variables.tf @@ -0,0 +1,16 @@ +# infra/azure/modules/backup-container/variables.tf + +variable "client_name" { + description = "Short client identifier." + type = string +} + +variable "backup_storage_account_name" { + description = "Name of the pre-existing shared backup storage account." + type = string +} + +variable "backup_storage_resource_group" { + description = "Resource group of the shared backup storage account." + type = string +} diff --git a/infra/azure/modules/vm/main.tf b/infra/azure/modules/vm/main.tf new file mode 100644 index 0000000..d3fb023 --- /dev/null +++ b/infra/azure/modules/vm/main.tf @@ -0,0 +1,141 @@ +# infra/azure/modules/vm/main.tf + +resource "azurerm_resource_group" "main" { + name = "tiller-${var.client_name}" + location = var.location +} + +resource "azurerm_user_assigned_identity" "main" { + name = "tiller-${var.client_name}-identity" + resource_group_name = azurerm_resource_group.main.name + location = azurerm_resource_group.main.location +} + +# Grant the VM's managed identity permission to upload blobs to the shared backup account. +# This is what allows backup.sh to use --auth-mode login without stored credentials. +data "azurerm_storage_account" "backup" { + name = var.backup_storage_account_name + resource_group_name = var.backup_storage_resource_group +} + +resource "azurerm_role_assignment" "backup_contributor" { + scope = format("%s/blobServices/default/containers/%s", data.azurerm_storage_account.backup.id, var.client_name) + role_definition_name = "Storage Blob Data Contributor" + principal_id = azurerm_user_assigned_identity.main.principal_id +} + +resource "azurerm_virtual_network" "main" { + name = "tiller-${var.client_name}-vnet" + resource_group_name = azurerm_resource_group.main.name + location = azurerm_resource_group.main.location + address_space = ["10.0.0.0/16"] +} + +resource "azurerm_subnet" "main" { + name = "main" + resource_group_name = azurerm_resource_group.main.name + virtual_network_name = azurerm_virtual_network.main.name + address_prefixes = ["10.0.1.0/24"] +} + +resource "azurerm_public_ip" "main" { + name = "tiller-${var.client_name}-pip" + resource_group_name = azurerm_resource_group.main.name + location = azurerm_resource_group.main.location + allocation_method = "Static" + sku = "Standard" +} + +resource "azurerm_network_security_group" "main" { + name = "tiller-${var.client_name}-nsg" + resource_group_name = azurerm_resource_group.main.name + location = azurerm_resource_group.main.location + + security_rule { + name = "SSH" + priority = 100 + direction = "Inbound" + access = "Allow" + protocol = "Tcp" + source_port_range = "*" + destination_port_range = "22" + source_address_prefix = "*" + destination_address_prefix = "*" + } + + security_rule { + name = "HTTP" + priority = 110 + direction = "Inbound" + access = "Allow" + protocol = "Tcp" + source_port_range = "*" + destination_port_range = "80" + source_address_prefix = "*" + destination_address_prefix = "*" + } + + security_rule { + name = "HTTPS" + priority = 120 + direction = "Inbound" + access = "Allow" + protocol = "Tcp" + source_port_range = "*" + destination_port_range = "443" + source_address_prefix = "*" + destination_address_prefix = "*" + } +} + +resource "azurerm_network_interface" "main" { + name = "tiller-${var.client_name}-nic" + resource_group_name = azurerm_resource_group.main.name + location = azurerm_resource_group.main.location + + ip_configuration { + name = "main" + subnet_id = azurerm_subnet.main.id + private_ip_address_allocation = "Dynamic" + public_ip_address_id = azurerm_public_ip.main.id + } +} + +resource "azurerm_network_interface_security_group_association" "main" { + network_interface_id = azurerm_network_interface.main.id + network_security_group_id = azurerm_network_security_group.main.id +} + +resource "azurerm_linux_virtual_machine" "main" { + name = "tiller-${var.client_name}" + resource_group_name = azurerm_resource_group.main.name + location = azurerm_resource_group.main.location + size = var.vm_size + + admin_username = var.admin_username + disable_password_authentication = true + + admin_ssh_key { + username = var.admin_username + public_key = var.admin_ssh_public_key + } + + network_interface_ids = [azurerm_network_interface.main.id] + + identity { + type = "UserAssigned" + identity_ids = [azurerm_user_assigned_identity.main.id] + } + + os_disk { + caching = "ReadWrite" + storage_account_type = "Standard_LRS" + } + + source_image_reference { + publisher = "Canonical" + offer = "0001-com-ubuntu-server-jammy" + sku = "22_04-lts-gen2" + version = "latest" + } +} diff --git a/infra/azure/modules/vm/outputs.tf b/infra/azure/modules/vm/outputs.tf new file mode 100644 index 0000000..0294a8a --- /dev/null +++ b/infra/azure/modules/vm/outputs.tf @@ -0,0 +1,11 @@ +# infra/azure/modules/vm/outputs.tf + +output "public_ip_address" { + description = "Public IP address of the VM." + value = azurerm_public_ip.main.ip_address +} + +output "resource_group_name" { + description = "Resource group name." + value = azurerm_resource_group.main.name +} diff --git a/infra/azure/modules/vm/variables.tf b/infra/azure/modules/vm/variables.tf new file mode 100644 index 0000000..11a3f1c --- /dev/null +++ b/infra/azure/modules/vm/variables.tf @@ -0,0 +1,36 @@ +# infra/azure/modules/vm/variables.tf + +variable "client_name" { + description = "Short client identifier." + type = string +} + +variable "location" { + description = "Azure region." + type = string +} + +variable "vm_size" { + description = "VM SKU." + type = string +} + +variable "admin_username" { + description = "SSH username on the VM." + type = string +} + +variable "admin_ssh_public_key" { + description = "SSH public key content." + type = string +} + +variable "backup_storage_account_name" { + description = "Name of the pre-existing shared backup storage account." + type = string +} + +variable "backup_storage_resource_group" { + description = "Resource group of the shared backup storage account." + type = string +} diff --git a/infra/azure/outputs.tf b/infra/azure/outputs.tf new file mode 100644 index 0000000..f784785 --- /dev/null +++ b/infra/azure/outputs.tf @@ -0,0 +1,16 @@ +# infra/azure/outputs.tf + +output "public_ip_address" { + description = "Public IP of the VM. Set the DNS A record for CLIENT_HOSTNAME to this value." + value = module.vm.public_ip_address +} + +output "resource_group_name" { + description = "Azure resource group containing all client resources." + value = module.vm.resource_group_name +} + +output "backup_container_name" { + description = "Blob container name for backups. Use as AZURE_CONTAINER in breedbase-client.env." + value = module.backup_container.container_name +} diff --git a/infra/azure/providers.tf b/infra/azure/providers.tf new file mode 100644 index 0000000..dcbdac1 --- /dev/null +++ b/infra/azure/providers.tf @@ -0,0 +1,16 @@ +# infra/azure/providers.tf +terraform { + required_version = ">= 1.9" + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "~> 4.0" + } + } +} + +provider "azurerm" { + features {} + # subscription_id is read from ARM_SUBSCRIPTION_ID environment variable. + # Set it before running terraform: export ARM_SUBSCRIPTION_ID="" +} diff --git a/infra/azure/variables.tf b/infra/azure/variables.tf new file mode 100644 index 0000000..e8bb140 --- /dev/null +++ b/infra/azure/variables.tf @@ -0,0 +1,44 @@ +# infra/azure/variables.tf + +variable "client_name" { + description = "Short client identifier, e.g. edmonton. Used in resource names." + type = string + + validation { + condition = can(regex("^[a-z0-9-]+$", var.client_name)) && length(var.client_name) >= 3 && length(var.client_name) <= 40 + error_message = "client_name must be 3-40 characters long and contain only lowercase letters, digits, and hyphens to satisfy Azure naming constraints." + } +} + +variable "location" { + description = "Azure region." + type = string + default = "canadacentral" +} + +variable "vm_size" { + description = "VM SKU." + type = string + default = "Standard_B2ms" +} + +variable "admin_username" { + description = "SSH username on the VM." + type = string + default = "tiller" +} + +variable "admin_ssh_public_key" { + description = "SSH public key content (full string, e.g. 'ssh-ed25519 AAAA...')." + type = string +} + +variable "backup_storage_account_name" { + description = "Name of the pre-existing shared Azure Storage account, e.g. tillerbackups." + type = string +} + +variable "backup_storage_resource_group" { + description = "Resource group of the pre-existing shared backup storage account." + type = string +}