Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .planning/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions .planning/STATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions .planning/notes/2026-10-17-terraform-azure-ephemeral-deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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.
15 changes: 15 additions & 0 deletions infra/.gitignore
Original file line number Diff line number Diff line change
@@ -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
22 changes: 22 additions & 0 deletions infra/bootstrap/.terraform.lock.hcl

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

27 changes: 27 additions & 0 deletions infra/bootstrap/README.md
Original file line number Diff line number Diff line change
@@ -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`.
8 changes: 8 additions & 0 deletions infra/bootstrap/backend.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
terraform {
backend "azurerm" {
resource_group_name = "rg-droptracker-tfstate"
storage_account_name = "storagedroptrackerazure"
container_name = "tfstate"
key = "bootstrap/terraform.tfstate"
}
}
30 changes: 30 additions & 0 deletions infra/bootstrap/github_oidc.tf
Original file line number Diff line number Diff line change
@@ -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
}
35 changes: 35 additions & 0 deletions infra/bootstrap/main.tf
Original file line number Diff line number Diff line change
@@ -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
}
}
4 changes: 4 additions & 0 deletions infra/bootstrap/providers.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
provider "azurerm" {
features {}
subscription_id = var.subscription_id
}
1 change: 1 addition & 0 deletions infra/bootstrap/terraform.tfvars.example
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
subscription_id = "00000000-0000-0000-0000-000000000000"
16 changes: 16 additions & 0 deletions infra/bootstrap/variables.tf
Original file line number Diff line number Diff line change
@@ -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"
}
10 changes: 10 additions & 0 deletions infra/bootstrap/versions.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
terraform {
required_version = ">= 1.9"

required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
}
Loading