diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index c7cfd4a..173b90b 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -416,7 +416,7 @@ Phases execute in numeric order: 24 → 25 → 26 → 27 → 28 **Plans**: TBD (proposed slices) -- (a) Bootstrap module: state storage account + GitHub OIDC app registration, never destroyed +- (a) Bootstrap module: state storage account + GitHub OIDC managed identity, never destroyed (DONE, 84bc8a8; managed identity because the university tenant denied app-registration writes) - (b) Root module: resource group, Container App, Postgres Flexible Server, budget alert; applied locally - (c) Smoke-test script: `/health` + `/ready`, then POST /watchlist and re-GET - (d) CI: `terraform fmt`/`validate`, tflint, config scan, plan comment on PRs diff --git a/.planning/STATE.md b/.planning/STATE.md index ae7e6a9..ad61ef4 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -515,6 +515,7 @@ _Closed 2026-09-05: Phase 16 gap G-16-1 (n1-boot guard-adoption skip) — quick | 261011-gq7 | Amend Phase 26 CONTEXT and UI-SPEC with grill decisions Q1–Q10 (derived selection, outcome-only bulk responses + refresh, ordered-lock bulk tag attach, single-statement remove/preferences, pending-alert disclosure) and add withdraw-pending-events todo | 2026-10-11 | 6408d28 | [261011-gq7-amend-phase-26-docs-with-grill-decisions](./quick/261011-gq7-amend-phase-26-docs-with-grill-decisions/) | | 261011-moj | Amend Phase 26 CONTEXT and UI-SPEC with UI review U1–U4 (route-level confirm + close→clear→focus order, scroll-padding focus clearance, one-row phone bar, remove-tag Undo) | 2026-10-11 | 9ac478c | [261011-moj-amend-phase-26-ui-spec-and-context-with-](./quick/261011-moj-amend-phase-26-ui-spec-and-context-with-/) | | 261019-jkv | Refactor phase 26 bulk UI: shared preference options, popover and outcome-messages modules, useBulkSelection hook | 2026-10-19 | 66e1990 | [261019-jkv-refactor-phase-26-bulk-ui-preferenceopti](./quick/261019-jkv-refactor-phase-26-bulk-ui-preferenceopti/) | +| 261025-i7g | Phase 999.1 slice (a): Terraform bootstrap module (Azure remote state + GitHub OIDC managed identity), applied and state migrated | 2026-10-30 | 84bc8a8 | [261025-i7g-bootstrap-terraform-module-for-azure-rem](./quick/261025-i7g-bootstrap-terraform-module-for-azure-rem/) | | 38 | Fix trivy-fs: bump transitive web deps body-parser, systeminformation, undici for HIGH CVEs | 2026-10-31 | f45762a | — | ### Roadmap Evolution diff --git a/.planning/notes/2026-10-17-terraform-azure-ephemeral-deploy.md b/.planning/notes/2026-10-17-terraform-azure-ephemeral-deploy.md index 6dac754..309f7e2 100644 --- a/.planning/notes/2026-10-17-terraform-azure-ephemeral-deploy.md +++ b/.planning/notes/2026-10-17-terraform-azure-ephemeral-deploy.md @@ -24,3 +24,13 @@ context: /gsd-explore session; $100 Azure student credit, goal is Terraform/IaC - Flexible Server B1ms ~ $0.02/hr and "stopped server auto-restarts after 7 days" - unverifiable: prior knowledge, no primary source consulted. - Total credit runway estimates (~100+ sessions) - derived from the unverified hourly figure above. + +## Update 2026-10-30 (slice a done, 84bc8a8) + +- **Learning mode:** the user wants to learn Terraform. Claude writes boilerplate; the user writes core resources and predicts `plan` output; quiz via the grilling skill. Weak spot to revisit: the three-way compare (config vs state vs real Azure). +- **Region:** the student subscription's policy allows only southcentralus, canadacentral, mexicocentral, eastus2, brazilsouth. Use `eastus2`. +- **Free tier** (expires 2027-02-06): Postgres Flexible B1MS 750 h + 32 GB storage and backup. Free-tier B-series VMs are restricted in the allowed regions, so the app runs on Container Apps. +- **Identity:** a user-assigned managed identity with a federated credential replaced the app registration. The tenant returned 403 on service-principal and credential writes, and the app had been created with no owner. An orphan app `droptracker-github-actions` remains in the unal.edu.co tenant. +- **OIDC subject** uses immutable IDs (`gh api repos/{owner}/{repo}/actions/oidc/customization/sub`). Unverified until the first real login; Azure prints the received subject on mismatch. +- **Providers** `Microsoft.App`, `Microsoft.DBforPostgreSQL`, `Microsoft.OperationalInsights` were NotRegistered; slice (b) must register them (azurerm registers a core set automatically). +- Backend `key` for the root module should be `root/terraform.tfstate`; auth defaults to the `az login` key lookup (Owner role), not Entra data-plane auth. diff --git a/.planning/quick/261025-i7g-bootstrap-terraform-module-for-azure-rem/261025-i7g-PLAN.md b/.planning/quick/261025-i7g-bootstrap-terraform-module-for-azure-rem/261025-i7g-PLAN.md new file mode 100644 index 0000000..9bfcdcb --- /dev/null +++ b/.planning/quick/261025-i7g-bootstrap-terraform-module-for-azure-rem/261025-i7g-PLAN.md @@ -0,0 +1,42 @@ +--- +quick_id: 261025-i7g +phase: 999.1 slice (a) +mode: learning (pair-executed, no executor subagent) +--- + +# Terraform bootstrap module (infra/bootstrap) + +Remote-state storage + GitHub OIDC identity, applied once locally and never destroyed. +Source: ROADMAP Phase 999.1 slice (a); notes `2026-10-17-terraform-azure-ephemeral-deploy.md`. + +## Constraints + +- Region `eastus2` (policy allows only southcentralus, canadacentral, mexicocentral, eastus2, brazilsouth). +- Verified 2026-10-25: tenant allows app registrations; user is subscription Owner. +- Providers `Microsoft.Storage` and others may need `az provider register` first. +- Learning mode: Claude writes boilerplate; the user writes the core resources and predicts `terraform plan` output before each run. +- Commits on a feature branch (not main), per Definition of Done; no AI attribution. + +## Division of labor + +| File | Author | Concept taught | +|---|---|---| +| `versions.tf`, `providers.tf`, `variables.tf`, `outputs.tf`, `infra/.gitignore` | Claude | providers vs state, lock file | +| `main.tf` (resource group, storage account, container) | User | resources, references, plan output | +| `github_oidc.tf` (app, service principal, federated credential, role) | User, guided | OIDC, why no stored secrets | +| backend migration (`backend "azurerm"` + `init -migrate-state`) | User | state migration, bootstrap's own state | + +## Tasks + +1. **Scaffold (Claude):** boilerplate files above; `terraform fmt` + `validate`. Grill: providers vs state. +2. **State storage (User):** write `main.tf`; predict plan (resource count, which attributes are "known after apply"); apply. +3. **OIDC identity (User, guided):** write `github_oidc.tf`; predict plan; apply. Grill: why federated credentials beat secrets. +4. **Migrate state (User):** add backend block, `init -migrate-state`, confirm blob exists. Grill: what if bootstrap state is lost. +5. **Wrap-up:** README note, DoD gates for any non-infra files touched, commit on branch. + +## Done when + +- `terraform plan` on `infra/bootstrap` reports no changes after apply. +- State blob exists in the new storage container; local `terraform.tfstate` is not committed. +- Storage account has versioning enabled and public blob access disabled. +- Federated credential subject matches this repo. diff --git a/.planning/quick/261025-i7g-bootstrap-terraform-module-for-azure-rem/261025-i7g-SUMMARY.md b/.planning/quick/261025-i7g-bootstrap-terraform-module-for-azure-rem/261025-i7g-SUMMARY.md new file mode 100644 index 0000000..2551bec --- /dev/null +++ b/.planning/quick/261025-i7g-bootstrap-terraform-module-for-azure-rem/261025-i7g-SUMMARY.md @@ -0,0 +1,26 @@ +--- +quick_id: 261025-i7g +status: complete +commit: 84bc8a8 +--- + +# Summary: Terraform bootstrap module (Phase 999.1 slice a) + +Built `infra/bootstrap` pair-style: Claude wrote the scaffold and role assignments, the user wrote the storage resources, the federated credential and the backend block. + +## Delivered + +- Resource group, versioned private storage account, `tfstate` container (all `prevent_destroy`). +- User-assigned managed identity + federated credential for GitHub Actions on `main`, with Contributor (subscription) and Blob Data Contributor (state account) roles. +- Bootstrap state migrated to the `azurerm` backend (`bootstrap/terraform.tfstate`); `plan` reports no changes. + +## Deviations and findings + +- **Managed identity, not an app registration.** The tenant returned 403 on service-principal and federated-credential writes. The app was created with no owner, so the cause may have been ownership rather than a tenant policy; untested because the managed identity avoids directory permissions entirely. +- **Orphan app registration** `droptracker-github-actions` remains in the unal.edu.co tenant (no owner, undeletable by the user, no credentials). Removed from state with `terraform state rm`. +- **OIDC subject uses immutable IDs** (`repo:danielrpof@127344630/drop-tracker@1323351282:...`) per the repo's OIDC customization; confirm on the first real login (Azure prints the received subject on mismatch). +- Free-tier B-series VMs are unavailable in the allowed regions; plan the app on Container Apps. + +## Next + +Slice (b): root module (Container App, Postgres Flexible Server B1ms, budget alert) using the `root/terraform.tfstate` key. diff --git a/infra/.gitignore b/infra/.gitignore new file mode 100644 index 0000000..c897eb8 --- /dev/null +++ b/infra/.gitignore @@ -0,0 +1,15 @@ +.terraform/ +*.tfstate +*.tfstate.* +*.tfvars +!*.tfvars.example +crash.log +crash.*.log +tfplan +*.tfplan +override.tf +override.tf.json +*_override.tf +*_override.tf.json +.terraformrc +terraform.rc diff --git a/infra/bootstrap/.terraform.lock.hcl b/infra/bootstrap/.terraform.lock.hcl new file mode 100644 index 0000000..487dc66 --- /dev/null +++ b/infra/bootstrap/.terraform.lock.hcl @@ -0,0 +1,22 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/azurerm" { + version = "4.81.0" + constraints = "~> 4.0" + hashes = [ + "h1:4BEgtU/crlkbMduT7tm8Sp3+C+1RBfrpUIlTBM/wtMc=", + "zh:0732e7b74264ddfa2b90ba69d01c283d3cbae9f72ed3e506c6ac92529fed7fd3", + "zh:12afb524e232fe4e3d6161927724af5dfa4831d71edd9c174917ca9b7377bfae", + "zh:169d619ae202c4145e02fb706fb7c3679445ab3e3ff722edbf89597517a8c92e", + "zh:6beb95a3ef2f2d9c76abaa48e5450e90686a3fb6a47f1cb0ff7c5e94b6960151", + "zh:705e075fb5ffc4bf66fd7cbabf1a65007a41621e80030a2c158a4c83b6046216", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:79a8d17fefe647040fcb9ee8821a4f09f395427c4fd49493489b9a93a9a1038e", + "zh:8cc3f900b3774c0ae37ae42365c4579a199cf9e5edf88e476fdf5ab1048f84ea", + "zh:dec373b9390fa95e257291acd018ed65a7d512b428645d35e22cdbe8b245a08b", + "zh:e60f1e9fb45df6defade2855ed6e68547409ea75d30655c556adb0c08579749b", + "zh:f901d12ec82f3f8b5880a27b5cbcd7bd0d97e60c9367a2d7ed82fdd1157b39ff", + "zh:facf68ea5bf0f2b8ba720e7fba5f86492e1d4c591100460bb91c3f79f391f4b6", + ] +} diff --git a/infra/bootstrap/README.md b/infra/bootstrap/README.md new file mode 100644 index 0000000..3e39387 --- /dev/null +++ b/infra/bootstrap/README.md @@ -0,0 +1,27 @@ +# Terraform bootstrap + +Remote-state storage and the GitHub OIDC identity. Applied once by hand and never destroyed: every `prevent_destroy` here protects the state that other modules depend on. + +| Resource | Purpose | +|---|---| +| Resource group, storage account, `tfstate` container | Remote state (blob versioning on, no public access) | +| User-assigned managed identity + federated credential | Lets GitHub Actions on `main` log in with no stored secret | +| Role assignments | Contributor on the subscription; Blob Data Contributor on the state account | + +## Apply + +``` +az login +cp terraform.tfvars.example terraform.tfvars # set subscription_id +terraform init +terraform plan -out=tfplan && terraform apply tfplan +``` + +State lives in blob `bootstrap/terraform.tfstate`; other modules use their own `key`. + +## Notes + +- Region is `eastus2`: the student subscription's policy only allows five regions. +- A managed identity replaces an app registration because the university tenant denied service-principal and credential writes. +- The OIDC subject uses GitHub's immutable-ID prefix (`gh api repos/{owner}/{repo}/actions/oidc/customization/sub`). If a login fails, Azure prints the subject it received. +- State holds secrets (storage keys). Never commit `*.tfstate*`, `tfplan` or `*.tfvars`. diff --git a/infra/bootstrap/backend.tf b/infra/bootstrap/backend.tf new file mode 100644 index 0000000..9793522 --- /dev/null +++ b/infra/bootstrap/backend.tf @@ -0,0 +1,8 @@ +terraform { + backend "azurerm" { + resource_group_name = "rg-droptracker-tfstate" + storage_account_name = "storagedroptrackerazure" + container_name = "tfstate" + key = "bootstrap/terraform.tfstate" + } +} \ No newline at end of file diff --git a/infra/bootstrap/github_oidc.tf b/infra/bootstrap/github_oidc.tf new file mode 100644 index 0000000..d472eb1 --- /dev/null +++ b/infra/bootstrap/github_oidc.tf @@ -0,0 +1,30 @@ +data "azurerm_subscription" "current" {} + +# A managed identity instead of an app registration: the student tenant blocks service principals. +resource "azurerm_user_assigned_identity" "github" { + name = "id-droptracker-github" + resource_group_name = azurerm_resource_group.tfstate.name + location = azurerm_resource_group.tfstate.location +} + +resource "azurerm_federated_identity_credential" "drop-tracker" { + name = "drop-tracker-cred" + parent_id = azurerm_user_assigned_identity.github.id + audience = ["api://AzureADTokenExchange"] + issuer = "https://token.actions.githubusercontent.com" + subject = "${var.github_sub_prefix}:ref:refs/heads/main" +} + +# Contributor creates and destroys the root module's resources; it cannot grant roles. +resource "azurerm_role_assignment" "github_contributor" { + scope = data.azurerm_subscription.current.id + role_definition_name = "Contributor" + principal_id = azurerm_user_assigned_identity.github.principal_id +} + +# Lets the pipeline read and write state through Entra auth instead of storage keys. +resource "azurerm_role_assignment" "github_state" { + scope = azurerm_storage_account.tfstate.id + role_definition_name = "Storage Blob Data Contributor" + principal_id = azurerm_user_assigned_identity.github.principal_id +} diff --git a/infra/bootstrap/main.tf b/infra/bootstrap/main.tf new file mode 100644 index 0000000..006deb1 --- /dev/null +++ b/infra/bootstrap/main.tf @@ -0,0 +1,35 @@ +resource "azurerm_resource_group" "tfstate" { + name = "rg-droptracker-tfstate" + location = var.location + + lifecycle { + prevent_destroy = true + } +} + +resource "azurerm_storage_account" "tfstate" { + name = "storagedroptrackerazure" + resource_group_name = azurerm_resource_group.tfstate.name + location = azurerm_resource_group.tfstate.location + account_tier = "Standard" + account_replication_type = "LRS" + allow_nested_items_to_be_public = false + + blob_properties { + versioning_enabled = true + } + + lifecycle { + prevent_destroy = true + } +} + +resource "azurerm_storage_container" "tfstate" { + name = "tfstate" + storage_account_id = azurerm_storage_account.tfstate.id + container_access_type = "private" + + lifecycle { + prevent_destroy = true + } +} diff --git a/infra/bootstrap/providers.tf b/infra/bootstrap/providers.tf new file mode 100644 index 0000000..97b9493 --- /dev/null +++ b/infra/bootstrap/providers.tf @@ -0,0 +1,4 @@ +provider "azurerm" { + features {} + subscription_id = var.subscription_id +} diff --git a/infra/bootstrap/terraform.tfvars.example b/infra/bootstrap/terraform.tfvars.example new file mode 100644 index 0000000..546c7ba --- /dev/null +++ b/infra/bootstrap/terraform.tfvars.example @@ -0,0 +1 @@ +subscription_id = "00000000-0000-0000-0000-000000000000" diff --git a/infra/bootstrap/variables.tf b/infra/bootstrap/variables.tf new file mode 100644 index 0000000..01c8154 --- /dev/null +++ b/infra/bootstrap/variables.tf @@ -0,0 +1,16 @@ +variable "subscription_id" { + type = string + description = "Azure subscription that owns the state storage." +} + +variable "location" { + type = string + description = "Azure region; the student policy only allows a handful." + default = "eastus2" +} + +variable "github_sub_prefix" { + type = string + description = "Start of GitHub's OIDC subject for this repo; see `gh api repos/{owner}/{repo}/actions/oidc/customization/sub`." + default = "repo:danielrpof@127344630/drop-tracker@1323351282" +} diff --git a/infra/bootstrap/versions.tf b/infra/bootstrap/versions.tf new file mode 100644 index 0000000..f197efd --- /dev/null +++ b/infra/bootstrap/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.9" + + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "~> 4.0" + } + } +}