diff --git a/CLAUDE.md b/CLAUDE.md index 2b37230..e0721c4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,3 +70,17 @@ CI/CD via GitHub Actions: - **`pr-validation.yml`**: on PRs, runs `dotnet build` + `dotnet test --filter "Category!=Integration"` The Dockerfile is a standard multi-stage build (SDK → publish → mcr.microsoft.com/dotnet/runtime). + +## Agent skills + +### Issue tracker + +Issues are tracked in GitHub Issues for this repository (`cgrpa/TheSexy6BotWorker`) via `gh`. See `docs/agents/issue-tracker.md`. + +### Triage labels + +Triage uses the default five-label vocabulary (`needs-triage`, `needs-info`, `ready-for-agent`, `ready-for-human`, `wontfix`). See `docs/agents/triage-labels.md`. + +### Domain docs + +Domain docs are configured as multi-context (`CONTEXT-MAP.md` + per-context `CONTEXT.md` and ADRs). See `docs/agents/domain.md`. diff --git a/docs/agents/domain.md b/docs/agents/domain.md new file mode 100644 index 0000000..eaa8113 --- /dev/null +++ b/docs/agents/domain.md @@ -0,0 +1,53 @@ +# Domain Docs + +Configured layout for this repo: **multi-context**. + +How the engineering skills should consume this repo's domain documentation when exploring the codebase. + +## Before exploring, read these + +- **`CONTEXT.md`** at the repo root, or +- **`CONTEXT-MAP.md`** at the repo root if it exists - it points at one `CONTEXT.md` per context. Read each one relevant to the topic. +- **`docs/adr/`** - read ADRs that touch the area you're about to work in. In multi-context repos, also check `src//docs/adr/` for context-scoped decisions. + +If any of these files don't exist, **proceed silently**. Don't flag their absence; don't suggest creating them upfront. The producer skill (`/grill-with-docs`) creates them lazily when terms or decisions actually get resolved. + +## File structure + +Single-context repo (most repos): + +``` +/ +├── CONTEXT.md +├── docs/adr/ +│ ├── 0001-event-sourced-orders.md +│ └── 0002-postgres-for-write-model.md +└── src/ +``` + +Multi-context repo (presence of `CONTEXT-MAP.md` at the root): + +``` +/ +├── CONTEXT-MAP.md +├── docs/adr/ ← system-wide decisions +└── src/ + ├── ordering/ + │ ├── CONTEXT.md + │ └── docs/adr/ ← context-specific decisions + └── billing/ + ├── CONTEXT.md + └── docs/adr/ +``` + +## Use the glossary's vocabulary + +When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in `CONTEXT.md`. Don't drift to synonyms the glossary explicitly avoids. + +If the concept you need isn't in the glossary yet, that's a signal - either you're inventing language the project doesn't use (reconsider) or there's a real gap (note it for `/grill-with-docs`). + +## Flag ADR conflicts + +If your output contradicts an existing ADR, surface it explicitly rather than silently overriding: + +> _Contradicts ADR-0007 (event-sourced orders) - but worth reopening because..._ diff --git a/docs/agents/issue-tracker.md b/docs/agents/issue-tracker.md new file mode 100644 index 0000000..2396408 --- /dev/null +++ b/docs/agents/issue-tracker.md @@ -0,0 +1,22 @@ +# Issue tracker: GitHub + +Issues and PRDs for this repo live as GitHub issues. Use the `gh` CLI for all operations. + +## Conventions + +- **Create an issue**: `gh issue create --title "..." --body "..."`. Use a heredoc for multi-line bodies. +- **Read an issue**: `gh issue view --comments`, filtering comments by `jq` and also fetching labels. +- **List issues**: `gh issue list --state open --json number,title,body,labels,comments --jq '[.[] | {number, title, body, labels: [.labels[].name], comments: [.comments[].body]}]'` with appropriate `--label` and `--state` filters. +- **Comment on an issue**: `gh issue comment --body "..."` +- **Apply / remove labels**: `gh issue edit --add-label "..."` / `--remove-label "..."` +- **Close**: `gh issue close --comment "..."` + +Infer the repo from `git remote -v` - `gh` does this automatically when run inside a clone. + +## When a skill says "publish to the issue tracker" + +Create a GitHub issue. + +## When a skill says "fetch the relevant ticket" + +Run `gh issue view --comments`. diff --git a/docs/agents/triage-labels.md b/docs/agents/triage-labels.md new file mode 100644 index 0000000..b716855 --- /dev/null +++ b/docs/agents/triage-labels.md @@ -0,0 +1,15 @@ +# Triage Labels + +The skills speak in terms of five canonical triage roles. This file maps those roles to the actual label strings used in this repo's issue tracker. + +| Label in mattpocock/skills | Label in our tracker | Meaning | +| -------------------------- | -------------------- | ---------------------------------------- | +| `needs-triage` | `needs-triage` | Maintainer needs to evaluate this issue | +| `needs-info` | `needs-info` | Waiting on reporter for more information | +| `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent | +| `ready-for-human` | `ready-for-human` | Requires human implementation | +| `wontfix` | `wontfix` | Will not be actioned | + +When a skill mentions a role (e.g. "apply the AFK-ready triage label"), use the corresponding label string from this table. + +Edit the right-hand column to match whatever vocabulary you actually use. diff --git a/src/dotnet/TheSexy6BotWorker/DiscordWorker.cs b/src/dotnet/TheSexy6BotWorker/DiscordWorker.cs index 4a34f75..54047a9 100644 --- a/src/dotnet/TheSexy6BotWorker/DiscordWorker.cs +++ b/src/dotnet/TheSexy6BotWorker/DiscordWorker.cs @@ -23,11 +23,17 @@ public class DiscordWorker : BackgroundService { private readonly ILogger _logger; private readonly IConfiguration _configuration; + private readonly IHostEnvironment _hostEnvironment; private DiscordClient _client; - public DiscordWorker(ILogger logger, IConfiguration configuration) + + public DiscordWorker( + ILogger logger, + IConfiguration configuration, + IHostEnvironment hostEnvironment) { _logger = logger; _configuration = configuration; + _hostEnvironment = hostEnvironment; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) @@ -42,7 +48,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) builder.ConfigureServices(services => { // Determine environment prefix for bot commands - var environmentPrefix = Environment.GetEnvironmentVariable("Environment") == "Development" ? "test-" : ""; + var environmentPrefix = _hostEnvironment.IsDevelopment() ? "test-" : ""; // Register bot registry and configurations services.AddSingleton(sp => diff --git a/src/dotnet/TheSexy6BotWorker/Program.cs b/src/dotnet/TheSexy6BotWorker/Program.cs index cef59ff..9ddb8ca 100644 --- a/src/dotnet/TheSexy6BotWorker/Program.cs +++ b/src/dotnet/TheSexy6BotWorker/Program.cs @@ -15,7 +15,10 @@ public static int Main(string[] args) .ToArray(); var builder = Host.CreateApplicationBuilder(hostArgs); - builder.Configuration.AddUserSecrets(); + if (builder.Environment.IsDevelopment()) + { + builder.Configuration.AddUserSecrets(); + } if (!isSmokeTest) { diff --git a/src/terraform/README.md b/src/terraform/README.md index 1594850..8e37a93 100644 --- a/src/terraform/README.md +++ b/src/terraform/README.md @@ -1,11 +1,78 @@ +# Terraform: Azure Key Vault Remote Secret Contract +## Local Terraform Plan +1. Set subscription context: +`export ARM_SUBSCRIPTION_ID=""` +2. Initialize: +`terraform init -backend-config=local.tfbackend` +3. Plan: +`terraform plan` -Before running tf plan: -1. Set environment subscription -run `export ARM_SUBSCRIPTION_ID=your-subscription-id` +## Secret Contract -2. `terraform init -backend-config=local.tfbackend` +- Required runtime keys are: + - `DiscordToken` + - `GeminiKey` + - `GrokKey` + - `PerplexityApiKey` +- Terraform maps those keys to Container App secret aliases: + - `DiscordToken -> discord-token` + - `GeminiKey -> gemini-key` + - `GrokKey -> grok-key` + - `PerplexityApiKey -> perplexity-api-key` +- Remote runtime uses Key Vault references only (no secret values in Terraform config/state). +## Enforcement Controls + +- `required_secret_names`: + - Defaults to the four runtime keys above. + - Must exactly match the alias-map keys (parity check enforced by precondition). +- `enforce_required_secret_presence`: + - Defaults to `true`. + - When `true`, Terraform fails fast if required secrets are missing/disabled in Key Vault. + - When `false`, Terraform allows bootstrap mode and only wires currently present+enabled secrets. + +## Bootstrap / Rotation Script + +Use `scripts/upsert-required-secrets.sh` to set all required secrets in Key Vault. + +Example: + +```bash +KEY_VAULT_NAME="stg-uks-discordbot-kv" \ +DISCORD_TOKEN="..." \ +GEMINI_KEY="..." \ +GROK_KEY="..." \ +PERPLEXITY_API_KEY="..." \ +./scripts/upsert-required-secrets.sh +``` + +Notes: + +- The script defaults to all-or-nothing updates. +- Missing values are prompted securely when interactive. +- Use `--non-interactive` in CI/automation. +- Use `--allow-partial` only for explicit recovery workflows. + +## Post-Rotation Refresh (Deterministic) + +Container Apps can take time to pick up rotated values automatically. For deterministic cutover, restart active revisions: + +```bash +APP_NAME="" +RESOURCE_GROUP="" + +for REVISION in $(az containerapp revision list \ + --name "$APP_NAME" \ + --resource-group "$RESOURCE_GROUP" \ + --query "[?properties.active].name" \ + --output tsv); do + az containerapp revision restart \ + --name "$APP_NAME" \ + --resource-group "$RESOURCE_GROUP" \ + --revision "$REVISION" +done +``` diff --git a/src/terraform/container_app.tf b/src/terraform/container_app.tf index 4ff22bd..3cd209d 100644 --- a/src/terraform/container_app.tf +++ b/src/terraform/container_app.tf @@ -19,6 +19,16 @@ resource "azurerm_container_app" "this" { identity = "System" } + dynamic "secret" { + for_each = local.container_app_secret_aliases + + content { + name = secret.value + identity = "System" + key_vault_secret_id = format("%ssecrets/%s", azurerm_key_vault.this.vault_uri, secret.key) + } + } + // bootstrap - pipeline owns image template { container { @@ -26,6 +36,15 @@ resource "azurerm_container_app" "this" { image = "mcr.microsoft.com/k8se/quickstart:latest" cpu = 0.25 memory = "0.5Gi" + + dynamic "env" { + for_each = local.container_app_secret_aliases + + content { + name = env.key + secret_name = env.value + } + } } } @@ -33,6 +52,21 @@ resource "azurerm_container_app" "this" { ignore_changes = [ template[0].container[0].image, ] + + precondition { + condition = length(setsubtract(local.required_secret_keys, local.alias_map_keys)) == 0 && length(setsubtract(local.alias_map_keys, local.required_secret_keys)) == 0 + error_message = "required_secret_names must match alias-map keys exactly. Expected keys: ${join(", ", sort(keys(local.required_secret_alias_map)))}." + } + + precondition { + condition = !var.enforce_required_secret_presence || length(local.missing_required_secret_names) == 0 + error_message = "Required Key Vault secrets are missing: ${join(", ", local.missing_required_secret_names)}." + } + + precondition { + condition = !var.enforce_required_secret_presence || length(local.disabled_required_secret_names) == 0 + error_message = "Required Key Vault secrets are disabled: ${join(", ", local.disabled_required_secret_names)}." + } } identity { diff --git a/src/terraform/container_registry.tf b/src/terraform/container_registry.tf index 3c9a4f5..67531ca 100644 --- a/src/terraform/container_registry.tf +++ b/src/terraform/container_registry.tf @@ -1,25 +1,25 @@ resource "azurerm_container_registry" "this" { - name = "${local.name_prefix_no_dash}acr" - resource_group_name = azurerm_resource_group.this.name - location = var.location - sku = "Basic" - admin_enabled = false - tags = local.common_tags - - identity { - type = "SystemAssigned" - } + name = "${local.name_prefix_no_dash}acr" + resource_group_name = azurerm_resource_group.this.name + location = var.location + sku = "Basic" + admin_enabled = false + tags = local.common_tags + + identity { + type = "SystemAssigned" + } } resource "azurerm_role_assignment" "acr_push" { - scope = azurerm_container_registry.this.id - role_definition_name = "AcrPush" - principal_id = data.azurerm_client_config.this.object_id + scope = azurerm_container_registry.this.id + role_definition_name = "AcrPush" + principal_id = data.azurerm_client_config.this.object_id } resource "azurerm_role_assignment" "acr_pull" { - scope = azurerm_container_registry.this.id - role_definition_name = "AcrPull" - principal_id = azurerm_container_app.this.identity[0].principal_id + scope = azurerm_container_registry.this.id + role_definition_name = "AcrPull" + principal_id = azurerm_container_app.this.identity[0].principal_id } diff --git a/src/terraform/data.tf b/src/terraform/data.tf index 012126a..761e5eb 100644 --- a/src/terraform/data.tf +++ b/src/terraform/data.tf @@ -1 +1,5 @@ -data "azurerm_client_config" "this" {} \ No newline at end of file +data "azurerm_client_config" "this" {} + +data "azurerm_key_vault_secrets" "required" { + key_vault_id = azurerm_key_vault.this.id +} diff --git a/src/terraform/environment.tfvars b/src/terraform/environment.tfvars index f9b95fb..03ae795 100644 --- a/src/terraform/environment.tfvars +++ b/src/terraform/environment.tfvars @@ -1,7 +1,7 @@ -application = "discordbot" -location = "uksouth" +application = "discordbot" +location = "uksouth" location_short = "uks" -environment = "__ENVIRONMENT__" +environment = "__ENVIRONMENT__" #these populate: # name_prefix in format "${var.environment}-${var.application}" diff --git a/src/terraform/key_vault.tf b/src/terraform/key_vault.tf index 904edd6..cb8978d 100644 --- a/src/terraform/key_vault.tf +++ b/src/terraform/key_vault.tf @@ -1,17 +1,23 @@ resource "azurerm_key_vault" "this" { - name = "${local.name_prefix}-kv" - resource_group_name = azurerm_resource_group.this.name - location = var.location - tenant_id = data.azurerm_client_config.this.tenant_id - purge_protection_enabled = false - sku_name = "standard" - enable_rbac_authorization = true + name = "${local.name_prefix}-kv" + resource_group_name = azurerm_resource_group.this.name + location = var.location + tenant_id = data.azurerm_client_config.this.tenant_id + purge_protection_enabled = false + sku_name = "standard" + enable_rbac_authorization = true - tags = local.common_tags + tags = local.common_tags } resource "azurerm_role_assignment" "kv_pipeline_access" { - scope = azurerm_key_vault.this.id - role_definition_name = "Key Vault Secrets User" - principal_id = data.azurerm_client_config.this.object_id -} \ No newline at end of file + scope = azurerm_key_vault.this.id + role_definition_name = "Key Vault Secrets User" + principal_id = data.azurerm_client_config.this.object_id +} + +resource "azurerm_role_assignment" "kv_container_app_access" { + scope = azurerm_key_vault.this.id + role_definition_name = "Key Vault Secrets User" + principal_id = azurerm_container_app.this.identity[0].principal_id +} diff --git a/src/terraform/law.tf b/src/terraform/law.tf index 23edc9c..8ada931 100644 --- a/src/terraform/law.tf +++ b/src/terraform/law.tf @@ -4,7 +4,7 @@ resource "azurerm_log_analytics_workspace" "this" { resource_group_name = azurerm_resource_group.this.name sku = "PerGB2018" retention_in_days = 30 - daily_quota_gb = 1 + daily_quota_gb = 1 tags = local.common_tags } \ No newline at end of file diff --git a/src/terraform/locals.tf b/src/terraform/locals.tf index 17f1154..5b2e60f 100644 --- a/src/terraform/locals.tf +++ b/src/terraform/locals.tf @@ -1,9 +1,43 @@ locals { - common_tags = { - Environment = var.environment - Application = var.application - Location = var.location - } - name_prefix = "${var.environment}-${var.location_short}-${var.application}" - name_prefix_no_dash = "${var.environment}${var.location_short}${var.application}" -} \ No newline at end of file + common_tags = { + Environment = var.environment + Application = var.application + Location = var.location + } + name_prefix = "${var.environment}-${var.location_short}-${var.application}" + name_prefix_no_dash = "${var.environment}${var.location_short}${var.application}" + + required_secret_alias_map = { + DiscordToken = "discord-token" + GeminiKey = "gemini-key" + GrokKey = "grok-key" + PerplexityApiKey = "perplexity-api-key" + } + + required_secret_keys = toset(var.required_secret_names) + alias_map_keys = toset(keys(local.required_secret_alias_map)) + + required_secret_aliases = { + for key in var.required_secret_names : key => local.required_secret_alias_map[key] + if contains(keys(local.required_secret_alias_map), key) + } + + existing_key_vault_secrets_by_name = { + for secret in data.azurerm_key_vault_secrets.required.secrets : secret.name => secret + } + + missing_required_secret_names = [ + for secret_name in var.required_secret_names : secret_name + if !contains(keys(local.existing_key_vault_secrets_by_name), secret_name) + ] + + disabled_required_secret_names = [ + for secret_name in var.required_secret_names : secret_name + if contains(keys(local.existing_key_vault_secrets_by_name), secret_name) && !local.existing_key_vault_secrets_by_name[secret_name].enabled + ] + + container_app_secret_aliases = var.enforce_required_secret_presence ? local.required_secret_aliases : { + for secret_name, secret_alias in local.required_secret_aliases : secret_name => secret_alias + if contains(keys(local.existing_key_vault_secrets_by_name), secret_name) && local.existing_key_vault_secrets_by_name[secret_name].enabled + } +} diff --git a/src/terraform/main.tf b/src/terraform/main.tf index 90317a4..b4e53e5 100644 --- a/src/terraform/main.tf +++ b/src/terraform/main.tf @@ -1,5 +1,5 @@ resource "azurerm_resource_group" "this" { - name = "${local.name_prefix}-rg" - location = var.location - tags = local.common_tags + name = "${local.name_prefix}-rg" + location = var.location + tags = local.common_tags } \ No newline at end of file diff --git a/src/terraform/providers.tf b/src/terraform/providers.tf index 111af33..d5e6f4c 100644 --- a/src/terraform/providers.tf +++ b/src/terraform/providers.tf @@ -1,17 +1,17 @@ terraform { required_providers { azurerm = { - source = "hashicorp/azurerm" + source = "hashicorp/azurerm" version = "~> 4.1.0" - } } + } backend "azurerm" { use_azuread_auth = true } } provider "azurerm" { - use_oidc = true - storage_use_azuread = true - features {} + use_oidc = true + storage_use_azuread = true + features {} } \ No newline at end of file diff --git a/src/terraform/scripts/upsert-required-secrets.sh b/src/terraform/scripts/upsert-required-secrets.sh new file mode 100755 index 0000000..9070b92 --- /dev/null +++ b/src/terraform/scripts/upsert-required-secrets.sh @@ -0,0 +1,166 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: + upsert-required-secrets.sh [options] + +Options: + --vault-name Key Vault name (or set KEY_VAULT_NAME) + --discord-token Discord token (or set DISCORD_TOKEN) + --gemini-key Gemini API key (or set GEMINI_KEY) + --grok-key Grok API key (or set GROK_KEY) + --perplexity-key Perplexity API key (or set PERPLEXITY_API_KEY) + --allow-partial Allow partial updates (default is all-or-nothing) + --non-interactive Fail on missing inputs instead of prompting + -h, --help Show this help +EOF +} + +require_command() { + local command_name="$1" + if ! command -v "$command_name" >/dev/null 2>&1; then + echo "Required command is missing: $command_name" >&2 + exit 1 + fi +} + +read_secret_if_missing() { + local label="$1" + local current_value="$2" + + if [[ -n "$current_value" ]]; then + printf '%s' "$current_value" + return + fi + + if [[ "$NON_INTERACTIVE" == "true" ]]; then + printf '' + return + fi + + local entered_value + read -r -s -p "$label: " entered_value + echo >&2 + printf '%s' "$entered_value" +} + +KEY_VAULT_NAME="${KEY_VAULT_NAME:-}" +DISCORD_TOKEN="${DISCORD_TOKEN:-}" +GEMINI_KEY="${GEMINI_KEY:-}" +GROK_KEY="${GROK_KEY:-}" +PERPLEXITY_API_KEY="${PERPLEXITY_API_KEY:-}" + +ALLOW_PARTIAL="false" +NON_INTERACTIVE="false" + +while [[ $# -gt 0 ]]; do + case "$1" in + --vault-name) + KEY_VAULT_NAME="$2" + shift 2 + ;; + --discord-token) + DISCORD_TOKEN="$2" + shift 2 + ;; + --gemini-key) + GEMINI_KEY="$2" + shift 2 + ;; + --grok-key) + GROK_KEY="$2" + shift 2 + ;; + --perplexity-key) + PERPLEXITY_API_KEY="$2" + shift 2 + ;; + --allow-partial) + ALLOW_PARTIAL="true" + shift + ;; + --non-interactive) + NON_INTERACTIVE="true" + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +if [[ -z "$KEY_VAULT_NAME" ]]; then + echo "Missing Key Vault name. Set KEY_VAULT_NAME or pass --vault-name." >&2 + exit 1 +fi + +require_command az + +if ! az account show >/dev/null 2>&1; then + echo "Azure CLI is not logged in. Run: az login" >&2 + exit 1 +fi + +if ! az keyvault show --name "$KEY_VAULT_NAME" --query id --output tsv >/dev/null; then + echo "Unable to access Key Vault '$KEY_VAULT_NAME'." >&2 + exit 1 +fi + +DISCORD_TOKEN="$(read_secret_if_missing "DiscordToken" "$DISCORD_TOKEN")" +GEMINI_KEY="$(read_secret_if_missing "GeminiKey" "$GEMINI_KEY")" +GROK_KEY="$(read_secret_if_missing "GrokKey" "$GROK_KEY")" +PERPLEXITY_API_KEY="$(read_secret_if_missing "PerplexityApiKey" "$PERPLEXITY_API_KEY")" + +missing_keys=() +keys_to_update=() +values_to_update=() + +append_secret_if_present() { + local key="$1" + local value="$2" + + if [[ -z "$value" ]]; then + missing_keys+=("$key") + else + keys_to_update+=("$key") + values_to_update+=("$value") + fi +} + +append_secret_if_present "DiscordToken" "$DISCORD_TOKEN" +append_secret_if_present "GeminiKey" "$GEMINI_KEY" +append_secret_if_present "GrokKey" "$GROK_KEY" +append_secret_if_present "PerplexityApiKey" "$PERPLEXITY_API_KEY" + +if [[ "$ALLOW_PARTIAL" != "true" && ${#missing_keys[@]} -gt 0 ]]; then + echo "Refusing partial update. Missing values for: ${missing_keys[*]}" >&2 + echo "Provide all values or pass --allow-partial intentionally." >&2 + exit 1 +fi + +if [[ ${#keys_to_update[@]} -eq 0 ]]; then + echo "No values provided to update." >&2 + exit 1 +fi + +for i in "${!keys_to_update[@]}"; do + key="${keys_to_update[$i]}" + value="${values_to_update[$i]}" + + az keyvault secret set \ + --vault-name "$KEY_VAULT_NAME" \ + --name "$key" \ + --value "$value" \ + --only-show-errors \ + --output none +done + +echo "Updated secrets in '$KEY_VAULT_NAME': ${keys_to_update[*]}" diff --git a/src/terraform/storage.tf b/src/terraform/storage.tf index dc0db2a..9294963 100644 --- a/src/terraform/storage.tf +++ b/src/terraform/storage.tf @@ -1,11 +1,11 @@ resource "azurerm_storage_account" "this" { - name = "${local.name_prefix_no_dash}sa" - resource_group_name = azurerm_resource_group.this.name - location = var.location - account_tier = "Standard" - account_replication_type = "LRS" - tags = local.common_tags - shared_access_key_enabled = false - https_traffic_only_enabled = true + name = "${local.name_prefix_no_dash}sa" + resource_group_name = azurerm_resource_group.this.name + location = var.location + account_tier = "Standard" + account_replication_type = "LRS" + tags = local.common_tags + shared_access_key_enabled = false + https_traffic_only_enabled = true } \ No newline at end of file diff --git a/src/terraform/terraform.tfvars b/src/terraform/terraform.tfvars index d8e04f9..1bc22c4 100644 --- a/src/terraform/terraform.tfvars +++ b/src/terraform/terraform.tfvars @@ -1,6 +1,8 @@ -application = "discordbot" -location = "uksouth" +application = "discordbot" +location = "uksouth" location_short = "uks" -environment = "stg" +environment = "stg" -# populate these for local tf plan \ No newline at end of file +# populate these for local tf plan +# required_secret_names = ["DiscordToken", "GeminiKey", "GrokKey", "PerplexityApiKey"] +# enforce_required_secret_presence = false diff --git a/src/terraform/variables.tf b/src/terraform/variables.tf index 666effa..8829da0 100644 --- a/src/terraform/variables.tf +++ b/src/terraform/variables.tf @@ -16,4 +16,31 @@ variable "location" { variable "environment" { type = string description = "Environment (staging / prd)" -} \ No newline at end of file +} + +variable "required_secret_names" { + type = list(string) + description = "Required runtime secret keys expected in Key Vault and mapped into the Container App." + default = ["DiscordToken", "GeminiKey", "GrokKey", "PerplexityApiKey"] + + validation { + condition = length(var.required_secret_names) > 0 + error_message = "required_secret_names must contain at least one key." + } + + validation { + condition = length(distinct(var.required_secret_names)) == length(var.required_secret_names) + error_message = "required_secret_names must not contain duplicate keys." + } + + validation { + condition = alltrue([for secret_name in var.required_secret_names : trimspace(secret_name) != ""]) + error_message = "required_secret_names must not contain empty keys." + } +} + +variable "enforce_required_secret_presence" { + type = bool + description = "When true, Terraform fails fast if required Key Vault secrets are missing or disabled." + default = true +}