diff --git a/.gitignore b/.gitignore index 11f635a7..8a3f6a36 100644 --- a/.gitignore +++ b/.gitignore @@ -148,4 +148,4 @@ frontend/deployment/tests/integration/volume/ testing/docker/certs/ # Claude Code -.claude/ +.claude/ \ No newline at end of file diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 00000000..f16f79e8 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,673 @@ +# Frontend Deployment Module + +This module provides infrastructure-as-code for deploying static frontend applications across multiple cloud providers. It uses a **layered architecture** that separates concerns and enables mix-and-match combinations of providers, DNS solutions, and CDN/hosting platforms. + +## Table of Contents + +- [Architecture Overview](#architecture-overview) +- [Layer System](#layer-system) +- [Variable Naming Conventions](#variable-naming-conventions) +- [Cross-Layer Communication](#cross-layer-communication) +- [Adding New Layer Implementations](#adding-new-layer-implementations) +- [Setup Script Patterns](#setup-script-patterns) +- [Testing](#testing) +- [Quick Reference](#quick-reference) + +--- + +## Architecture Overview + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ WORKFLOW ENGINE │ +│ (workflows/initial.yaml, workflows/delete.yaml) │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ LAYER COMPOSITION │ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────┐ │ +│ │ PROVIDER │ │ NETWORK │ │ DISTRIBUTION │ │ +│ │ LAYER │──▶ LAYER │──▶ LAYER │ │ +│ └──────────────┘ └──────────────┘ └──────────────────────┘ │ +│ │ +│ Implementations: Implementations: Implementations: │ +│ • aws • route53 • cloudfront │ +│ • azure • azure_dns • blob-cdn │ +│ • gcp • cloud_dns • amplify │ +│ • firebase │ +│ • gcs-cdn │ +│ • static-web-apps │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ TERRAFORM/OPENTOFU │ +│ (composed modules from all active layers) │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Layer Flow + +1. **Provider Layer**: Configures cloud credentials, state backend, and resource tags +2. **Network Layer**: Sets up DNS zones and records, calculates domains +3. **Distribution Layer**: Deploys CDN/hosting with references to network outputs + +--- + +## Layer System + +Each layer consists of two components: + +### 1. Setup Script (`setup`) + +A bash script that: +- Validates required inputs (environment variables, context) +- Fetches external data (cloud APIs, nullplatform API) +- Updates `TOFU_VARIABLES` with layer-specific configuration +- Registers the module directory in `MODULES_TO_USE` + +### 2. Modules Directory (`modules/`) + +Terraform/OpenTofu files: +- `main.tf` - Resource definitions +- `variables.tf` - Input variable declarations +- `locals.tf` - Computed values and cross-layer references +- `outputs.tf` - Exported values for other layers +- `test_locals.tf` - Test-only stubs (skipped during composition) + +### Directory Structure + +``` +frontend/deployment/ +├── provider/ +│ └── {cloud}/ +│ ├── setup # Validation & module registration +│ └── modules/ +│ ├── provider.tf # Backend & provider config +│ └── variables.tf +│ +├── network/ +│ └── {dns_provider}/ +│ ├── setup +│ └── modules/ +│ ├── main.tf +│ ├── variables.tf +│ ├── locals.tf +│ ├── outputs.tf +│ └── test_locals.tf +│ +├── distribution/ +│ └── {cdn_provider}/ +│ ├── setup +│ └── modules/ +│ ├── main.tf +│ ├── variables.tf +│ ├── locals.tf +│ ├── outputs.tf +│ └── test_locals.tf +│ +├── scripts/ # Shared helper scripts +├── workflows/ # Workflow definitions +└── tests/ # Unit and integration tests +``` + +--- + +## Variable Naming Conventions + +**All variables MUST use layer-prefixed naming** for clarity and to avoid conflicts: + +| Layer | Prefix | Examples | +|-------|--------|----------| +| **Provider** | `{cloud}_provider` | `azure_provider`, `aws_provider`, `gcp_provider` | +| **Provider** | `provider_*` | `provider_resource_tags_json` | +| **Network** | `network_*` | `network_domain`, `network_subdomain`, `network_full_domain`, `network_dns_zone_name` | +| **Distribution** | `distribution_*` | `distribution_storage_account`, `distribution_app_name`, `distribution_blob_prefix` | + +### Provider Object Structure + +Each cloud provider uses an object variable: + +```hcl +# Azure +variable "azure_provider" { + type = object({ + subscription_id = string + resource_group = string + storage_account = string # For Terraform state + container = string # For Terraform state + }) +} + +# AWS +variable "aws_provider" { + type = object({ + region = string + state_bucket = string + lock_table = string + }) +} + +# GCP +variable "gcp_provider" { + type = object({ + project = string + region = string + bucket = string + }) +} +``` + +### Cross-Layer Shared Variables + +These variables are used by multiple layers and MUST use consistent naming across implementations: + +| Variable | Set By | Used By | Description | +|----------|--------|---------|-------------| +| `network_full_domain` | Network | Distribution | Full domain (e.g., `app.example.com`) | +| `network_domain` | Network | Distribution | Base domain (e.g., `example.com`) | +| `network_subdomain` | Network | Distribution | Subdomain part (e.g., `app`) | +| `distribution_target_domain` | Distribution | Network | CDN endpoint hostname for DNS record | +| `distribution_record_type` | Distribution | Network | DNS record type (`CNAME` or `A`) | + +--- + +## Cross-Layer Communication + +Layers communicate through **locals** that are merged when modules are composed together. + +### How It Works + +1. Each layer defines locals in `locals.tf` +2. When modules are composed, all locals are merged into a single namespace +3. Layers can reference each other's locals directly + +### Example: Network → Distribution + +**Network layer exports** (`network/azure_dns/modules/locals.tf`): +```hcl +locals { + network_full_domain = "${var.network_subdomain}.${var.network_domain}" + network_domain = var.network_domain +} +``` + +**Distribution layer consumes** (`distribution/blob-cdn/modules/locals.tf`): +```hcl +locals { + # References network layer's local directly + distribution_has_custom_domain = local.network_full_domain != "" + distribution_full_domain = local.network_full_domain +} +``` + +### Test Locals (`test_locals.tf`) + +For unit testing modules in isolation, use `test_locals.tf` to stub cross-layer dependencies: + +```hcl +# File: test_locals.tf +# NOTE: Files matching test_*.tf are skipped by compose_modules + +variable "network_full_domain" { + description = "Test-only: Simulates network layer output" + default = "" +} + +locals { + network_full_domain = var.network_full_domain +} +``` + +--- + +## Adding New Layer Implementations + +### Quick Start with Boilerplate Script + +Use the provided script to generate the folder structure: + +```bash +# Create a new network layer implementation +./scripts/setup-layer --type network --name cloudflare + +# Create a new distribution layer implementation +./scripts/setup-layer --type distribution --name netlify + +# Create a new provider layer implementation +./scripts/setup-layer --type provider --name digitalocean +``` + +This creates: +``` +frontend/deployment/{type}/{name}/ +├── setup # Boilerplate setup script +└── modules/ + ├── main.tf # Empty, ready for resources + ├── variables.tf # Layer-prefixed variables + ├── locals.tf # Cross-layer locals + ├── outputs.tf # Layer outputs + └── test_locals.tf # Test stubs +``` + +### Manual Steps After Generation + +1. **Edit `setup` script**: Add validation logic and TOFU_VARIABLES updates +2. **Edit `modules/main.tf`**: Add Terraform resources +3. **Edit `modules/variables.tf`**: Define required inputs +4. **Update `modules/locals.tf`**: Add cross-layer references +5. **Add tests**: Create `tests/{type}/{name}/` with `.tftest.hcl` files + +--- + +## Setup Script Patterns + +### Required Structure + +Every setup script must: + +```bash +#!/bin/bash +# ============================================================================= +# {Layer Type}: {Implementation Name} +# +# Brief description of what this layer does. +# ============================================================================= + +set -euo pipefail + +# 1. VALIDATION PHASE +echo "🔍 Validating {Implementation} configuration..." +echo "" + +# Validate required variables +if [ -z "${REQUIRED_VAR:-}" ]; then + echo " ❌ REQUIRED_VAR is missing" + echo "" + echo " 💡 Possible causes:" + echo " • Variable not set in environment" + echo "" + echo " 🔧 How to fix:" + echo " • Set REQUIRED_VAR in your environment" + exit 1 +fi +echo " ✅ REQUIRED_VAR=$REQUIRED_VAR" + +# 2. EXTERNAL DATA FETCHING (if needed) +echo "" +echo " 📡 Fetching {resource}..." +# Call APIs, validate responses + +# 3. UPDATE TOFU_VARIABLES +TOFU_VARIABLES=$(echo "$TOFU_VARIABLES" | jq \ + --arg var_name "$var_value" \ + '. + { + layer_variable_name: $var_name + }') + +# 4. REGISTER MODULE +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +module_name="${script_dir}/modules" + +if [[ -n ${MODULES_TO_USE:-} ]]; then + MODULES_TO_USE="$MODULES_TO_USE,$module_name" +else + MODULES_TO_USE="$module_name" +fi + +echo "" +echo "✨ {Implementation} configured successfully" +echo "" +``` + +### Logging Conventions + +| Icon | Usage | +|------|-------| +| `🔍` | Starting validation phase | +| `✅` | Successful validation | +| `❌` | Failed validation | +| `📡` | Fetching external data | +| `📝` | Performing an action | +| `💡` | Possible causes of error | +| `🔧` | How to fix instructions | +| `📋` | Debug information | +| `✨` | Success summary | + +### Error Handling Pattern + +```bash +if [ $? -ne 0 ]; then + echo " ❌ Failed to {action}" + echo "" + + # Classify error type + if echo "$output" | grep -q "NotFound"; then + echo " 🔎 Error: Resource not found" + elif echo "$output" | grep -q "Forbidden\|403"; then + echo " 🔒 Error: Permission denied" + else + echo " ⚠️ Error: Unknown error" + fi + + echo "" + echo " 💡 Possible causes:" + echo " • Cause 1" + echo " • Cause 2" + echo "" + echo " 🔧 How to fix:" + echo " 1. Step 1" + echo " 2. Step 2" + echo "" + echo " 📋 Error details:" + echo "$output" | sed 's/^/ /' + + exit 1 +fi +``` + +--- + +## Testing + +We use **three types of tests** to ensure quality at different levels: + +| Test Type | What it Tests | Location | Command | +|-----------|---------------|----------|---------| +| **Unit Tests (BATS)** | Bash setup scripts | `tests/{layer_type}/{name}/` | `make test-unit` | +| **Tofu Tests** | Terraform modules | `{layer_type}/{name}/modules/*.tftest.hcl` | `make test-tofu` | +| **Integration Tests** | Full workflow execution | `tests/integration/test_cases/` | `make test-integration` | + +### 1. Unit Tests (BATS) + +Test bash setup scripts in isolation using mocked commands. + +**Location:** `frontend/deployment/tests/{layer_type}/{name}/setup_test.bats` + +**Run:** `make test-unit` or `make test-unit MODULE=frontend` + +**Example files:** +- Provider: [`tests/provider/azure/setup_test.bats`](deployment/tests/provider/azure/setup_test.bats) +- Network: [`tests/network/azure_dns/setup_test.bats`](deployment/tests/network/azure_dns/setup_test.bats) +- Distribution: [`tests/distribution/blob-cdn/setup_test.bats`](deployment/tests/distribution/blob-cdn/setup_test.bats) + +**Structure:** +```bash +#!/usr/bin/env bats + +setup() { + # Mock external commands (jq, az, aws, np, etc.) + # Set required environment variables + export CONTEXT='{"key": "value"}' + export TOFU_VARIABLES='{}' +} + +@test "validates required environment variable" { + unset REQUIRED_VAR + run source_setup + assert_failure + assert_output --partial "REQUIRED_VAR is missing" +} + +@test "sets TOFU_VARIABLES correctly" { + export REQUIRED_VAR="test-value" + run source_setup + assert_success + # Check TOFU_VARIABLES was updated correctly +} +``` + +### 2. Tofu Tests (OpenTofu/Terraform) + +Test Terraform modules using `tofu test` with mock providers. + +**Location:** `frontend/deployment/{layer_type}/{name}/modules/{name}.tftest.hcl` + +**Run:** `make test-tofu` or `make test-tofu MODULE=frontend` + +**Example files:** +- Provider: [`provider/azure/modules/provider.tftest.hcl`](deployment/provider/azure/modules/provider.tftest.hcl) +- Network: [`network/azure_dns/modules/azure_dns.tftest.hcl`](deployment/network/azure_dns/modules/azure_dns.tftest.hcl) +- Distribution: [`distribution/blob-cdn/modules/blob-cdn.tftest.hcl`](deployment/distribution/blob-cdn/modules/blob-cdn.tftest.hcl) + +**Structure:** +```hcl +# ============================================================================= +# Mock Providers +# ============================================================================= +mock_provider "azurerm" {} + +# ============================================================================= +# Test Variables +# ============================================================================= +variables { + network_domain = "example.com" + network_subdomain = "app" +} + +# ============================================================================= +# Tests +# ============================================================================= +run "test_dns_record_created" { + command = plan + + assert { + condition = azurerm_dns_cname_record.main[0].name == "app" + error_message = "CNAME record name should be 'app'" + } +} + +run "test_full_domain_output" { + command = plan + + assert { + condition = output.network_full_domain == "app.example.com" + error_message = "Full domain should be 'app.example.com'" + } +} +``` + +### 3. Integration Tests (BATS) + +Test complete workflows with mocked external dependencies (LocalStack, Azure Mock, Smocker). + +**Location:** `frontend/deployment/tests/integration/test_cases/{scenario}/lifecycle_test.bats` + +**Run:** `make test-integration` or `make test-integration MODULE=frontend` + +**Example file:** [`tests/integration/test_cases/azure_blobcdn_azuredns/lifecycle_test.bats`](deployment/tests/integration/test_cases/azure_blobcdn_azuredns/lifecycle_test.bats) + +**What's mocked:** +- **LocalStack**: AWS services (S3, Route53, STS, IAM, DynamoDB, ACM) +- **Moto**: CloudFront (not in LocalStack free tier) +- **Azure Mock**: Azure ARM APIs (CDN, DNS, Storage) + Blob Storage +- **Smocker**: nullplatform API + +**Structure:** +```bash +#!/usr/bin/env bats + +# Test constants derived from context +TEST_DISTRIBUTION_APP_NAME="my-app" +TEST_NETWORK_DOMAIN="example.com" + +setup_file() { + source "${PROJECT_ROOT}/testing/integration_helpers.sh" + integration_setup --cloud-provider azure # or: aws + # Pre-create required resources in mocks +} + +teardown_file() { + integration_teardown +} + +setup() { + source "${PROJECT_ROOT}/testing/integration_helpers.sh" + load_context "frontend/deployment/tests/resources/context.json" + + # Configure layer selection + export NETWORK_LAYER="azure_dns" + export DISTRIBUTION_LAYER="blob-cdn" + export TOFU_PROVIDER="azure" + + # Setup API mocks + mock_request "GET" "/provider" "mocks/provider.json" +} + +@test "create infrastructure deploys resources" { + run_workflow "frontend/deployment/workflows/initial.yaml" + + assert_azure_cdn_configured "$TEST_DISTRIBUTION_APP_NAME" ... + assert_azure_dns_configured "$TEST_NETWORK_DOMAIN" ... +} + +@test "destroy infrastructure removes resources" { + run_workflow "frontend/deployment/workflows/delete.yaml" + + assert_azure_cdn_not_configured ... + assert_azure_dns_not_configured ... +} +``` + +### Running Tests + +```bash +# Run all tests +make test-all + +# Run specific test types +make test-unit # BATS unit tests for bash scripts +make test-tofu # OpenTofu module tests +make test-integration # Full workflow integration tests + +# Run tests for specific module +make test-unit MODULE=frontend +make test-tofu MODULE=frontend +make test-integration MODULE=frontend + +# Run with verbose output (integration only) +make test-integration VERBOSE=1 +``` + +--- + +## Quick Reference + +### Environment Variables by Provider + +#### AWS +```bash +export TOFU_PROVIDER=aws +export AWS_REGION=us-east-1 +export TOFU_PROVIDER_BUCKET=my-state-bucket +export TOFU_LOCK_TABLE=my-lock-table +``` + +#### Azure +```bash +export TOFU_PROVIDER=azure +export AZURE_SUBSCRIPTION_ID=xxx +export AZURE_RESOURCE_GROUP=my-rg +export TOFU_PROVIDER_STORAGE_ACCOUNT=mystateaccount +export TOFU_PROVIDER_CONTAINER=tfstate +``` + +#### GCP +```bash +export TOFU_PROVIDER=gcp +export GOOGLE_PROJECT=my-project +export GOOGLE_REGION=us-central1 +export TOFU_PROVIDER_BUCKET=my-state-bucket +``` + +### Layer Selection + +```bash +export NETWORK_LAYER=route53 # or: azure_dns, cloud_dns +export DISTRIBUTION_LAYER=cloudfront # or: blob-cdn, amplify, firebase, etc. +``` + +--- + +## AI Assistant Prompt for Implementing New Layers + +When asking an AI assistant to help implement a new layer, just paste this prompt: + +```` +I need to implement a new layer in the frontend deployment module. + +**IMPORTANT:** Before starting: + +1. Read `frontend/README.md` to understand: + - The layer system architecture and how layers interact + - Variable naming conventions (layer prefixes) + - Cross-layer communication via locals + - Setup script patterns and logging conventions + - Testing requirements (unit, tofu, integration) + +2. Ask me for the following information: + - Layer type (provider, network, or distribution) + - Provider/service name (e.g., Cloudflare, Netlify, DigitalOcean) + - Required environment variables or context values to validate + - External APIs to call (if any) + - Terraform resources to create + - Cross-layer dependencies and exports + +3. After gathering requirements, generate: + - Setup script with validation and TOFU_VARIABLES + - Terraform module (main.tf, variables.tf, locals.tf, outputs.tf, test_locals.tf) + - Unit tests (BATS) for the setup script + - Tofu tests for the Terraform module + - Integration test additions (if applicable) + +**Reference Files by Layer Type:** + +For PROVIDER layers, reference: +- Setup script: `frontend/deployment/provider/azure/setup` +- Terraform module: `frontend/deployment/provider/azure/modules/` +- Unit test (BATS): `frontend/deployment/tests/provider/azure/setup_test.bats` +- Tofu test: `frontend/deployment/provider/azure/modules/provider.tftest.hcl` + +For NETWORK layers, reference: +- Setup script: `frontend/deployment/network/azure_dns/setup` +- Terraform module: `frontend/deployment/network/azure_dns/modules/` +- Unit test (BATS): `frontend/deployment/tests/network/azure_dns/setup_test.bats` +- Tofu test: `frontend/deployment/network/azure_dns/modules/azure_dns.tftest.hcl` + +For DISTRIBUTION layers, reference: +- Setup script: `frontend/deployment/distribution/blob-cdn/setup` +- Terraform module: `frontend/deployment/distribution/blob-cdn/modules/` +- Unit test (BATS): `frontend/deployment/tests/distribution/blob-cdn/setup_test.bats` +- Tofu test: `frontend/deployment/distribution/blob-cdn/modules/blob-cdn.tftest.hcl` + +For INTEGRATION tests, reference: +- `frontend/deployment/tests/integration/test_cases/azure_blobcdn_azuredns/lifecycle_test.bats` +```` + +--- + +## Troubleshooting + +### Common Issues + +| Issue | Cause | Solution | +|-------|-------|----------| +| "Variable not found" | Missing setup script execution | Ensure workflow runs all setup scripts in order | +| "Local not found" | Missing cross-layer local | Add to `locals.tf` or check `test_locals.tf` for unit tests | +| "Module not composed" | `MODULES_TO_USE` not updated | Verify setup script appends to `MODULES_TO_USE` | +| "Backend not configured" | Missing provider setup | Run provider layer setup first | + +### Debug Commands + +```bash +# Check composed modules +echo $MODULES_TO_USE + +# Check TOFU_VARIABLES +echo $TOFU_VARIABLES | jq . + +# Validate terraform +cd /path/to/composed/modules && tofu validate +``` diff --git a/frontend/deployment/.gitignore b/frontend/deployment/.gitignore new file mode 100644 index 00000000..343012d3 --- /dev/null +++ b/frontend/deployment/.gitignore @@ -0,0 +1,11 @@ +# Terraform/OpenTofu +.terraform/ +.terraform.lock.hcl +*.tfstate +*.tfstate.* +crash.log +crash.*.log +.terraformrc +terraform.rc + + diff --git a/frontend/deployment/distribution/amplify/modules/main.tf b/frontend/deployment/distribution/amplify/modules/main.tf new file mode 100644 index 00000000..6a45c61f --- /dev/null +++ b/frontend/deployment/distribution/amplify/modules/main.tf @@ -0,0 +1,205 @@ +# AWS Amplify Hosting +# Resources for AWS Amplify static frontend hosting + +variable "distribution_app_name" { + description = "Application name" + type = string +} + +variable "distribution_environment" { + description = "Environment (dev, staging, prod)" + type = string + default = "prod" +} + +variable "distribution_repository_url" { + description = "Git repository URL" + type = string +} + +variable "distribution_branch_name" { + description = "Branch to deploy" + type = string + default = "main" +} + +variable "distribution_github_access_token" { + description = "GitHub access token" + type = string + sensitive = true + default = null +} + +variable "distribution_custom_domain" { + description = "Custom domain (e.g., app.example.com)" + type = string + default = null +} + +variable "distribution_environment_variables" { + description = "Environment variables for the application" + type = map(string) + default = {} +} + +variable "distribution_build_spec" { + description = "Build specification in YAML format" + type = string + default = <<-EOT + version: 1 + frontend: + phases: + preBuild: + commands: + - npm ci + build: + commands: + - npm run build + artifacts: + baseDirectory: dist + files: + - '**/*' + cache: + paths: + - node_modules/**/* + EOT +} + +variable "distribution_framework" { + description = "Application framework (React, Vue, Angular, etc.)" + type = string + default = "React" +} + +variable "distribution_resource_tags_json" { + description = "Resource tags as JSON object" + type = map(string) + default = {} +} + +locals { + distribution_default_tags = merge(var.distribution_resource_tags_json, { + Application = var.distribution_app_name + Environment = var.distribution_environment + ManagedBy = "terraform" + Module = "hosting/amplify" + }) + + distribution_env_vars = merge({ + ENVIRONMENT = var.distribution_environment + APP_NAME = var.distribution_app_name + }, var.distribution_environment_variables) +} + +resource "aws_amplify_app" "main" { + name = "${var.distribution_app_name}-${var.distribution_environment}" + repository = var.distribution_repository_url + + access_token = var.distribution_github_access_token + build_spec = var.distribution_build_spec + environment_variables = local.distribution_env_vars + + custom_rule { + source = "" + status = "200" + target = "/index.html" + } + + enable_auto_branch_creation = false + enable_branch_auto_build = true + enable_branch_auto_deletion = false + platform = "WEB" + + tags = local.distribution_default_tags +} + +resource "aws_amplify_branch" "main" { + app_id = aws_amplify_app.main.id + branch_name = var.distribution_branch_name + + framework = var.distribution_framework + stage = var.distribution_environment == "prod" ? "PRODUCTION" : "DEVELOPMENT" + enable_auto_build = true + + environment_variables = { + BRANCH = var.distribution_branch_name + } + + tags = local.distribution_default_tags +} + +resource "aws_amplify_domain_association" "main" { + count = var.distribution_custom_domain != null ? 1 : 0 + + app_id = aws_amplify_app.main.id + domain_name = var.distribution_custom_domain + + sub_domain { + branch_name = aws_amplify_branch.main.branch_name + prefix = "" + } + + sub_domain { + branch_name = aws_amplify_branch.main.branch_name + prefix = "www" + } + + wait_for_verification = false +} + +resource "aws_amplify_webhook" "main" { + app_id = aws_amplify_app.main.id + branch_name = aws_amplify_branch.main.branch_name + description = "Webhook for manual build triggers" +} + +# Locals for cross-module references (consumed by network/route53) +locals { + # Amplify default domain for DNS pointing + distribution_target_domain = "${aws_amplify_branch.main.branch_name}.${aws_amplify_app.main.id}.amplifyapp.com" + # Amplify uses CNAME records, not alias - so no hosted zone ID needed + distribution_target_zone_id = null + # Amplify requires CNAME records + distribution_record_type = "CNAME" +} + +output "distribution_app_id" { + description = "Amplify application ID" + value = aws_amplify_app.main.id +} + +output "distribution_app_arn" { + description = "Amplify application ARN" + value = aws_amplify_app.main.arn +} + +output "distribution_default_domain" { + description = "Amplify default domain" + value = "https://${local.distribution_target_domain}" +} + +output "distribution_target_domain" { + description = "Target domain for DNS records" + value = local.distribution_target_domain +} + +output "distribution_target_zone_id" { + description = "Hosted zone ID for alias records (null for Amplify/CNAME)" + value = local.distribution_target_zone_id +} + +output "distribution_record_type" { + description = "DNS record type to use (CNAME for Amplify)" + value = local.distribution_record_type +} + +output "distribution_website_url" { + description = "Website URL" + value = var.distribution_custom_domain != null ? "https://${var.distribution_custom_domain}" : "https://${local.distribution_target_domain}" +} + +output "distribution_webhook_url" { + description = "Webhook URL for manual triggers" + value = aws_amplify_webhook.main.url + sensitive = true +} diff --git a/frontend/deployment/distribution/amplify/setup b/frontend/deployment/distribution/amplify/setup new file mode 100755 index 00000000..90a3fc2f --- /dev/null +++ b/frontend/deployment/distribution/amplify/setup @@ -0,0 +1,23 @@ +#!/bin/bash + +# Amplify Hosting Setup +# Adds amplify-specific variables to TOFU_VARIABLES + +distribution_app_name=$(echo "$CONTEXT" | jq -r .application.slug) +distribution_repository_url=$(echo "$CONTEXT" | jq -r .application.repository_url) +distribution_environment=$(echo "$CONTEXT" | jq -r .scope.slug) + +TOFU_VARIABLES=$(echo "$TOFU_VARIABLES" | jq \ + --arg app_name "$distribution_app_name" \ + --arg repository_url "$distribution_repository_url" \ + --arg environment "$distribution_environment" \ + '. + { + distribution_app_name: $app_name, + distribution_repository_url: $repository_url, + distribution_environment: $environment + }') + +# Add module to composition list +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +module_name="${script_dir#*deployment/}" +MODULES_TO_USE="${MODULES_TO_USE:+$MODULES_TO_USE,}$module_name" diff --git a/frontend/deployment/distribution/blob-cdn/modules/blob-cdn.tftest.hcl b/frontend/deployment/distribution/blob-cdn/modules/blob-cdn.tftest.hcl new file mode 100644 index 00000000..8ae74d95 --- /dev/null +++ b/frontend/deployment/distribution/blob-cdn/modules/blob-cdn.tftest.hcl @@ -0,0 +1,373 @@ +# ============================================================================= +# Unit tests for distribution/blob-cdn module +# +# Run: tofu test +# ============================================================================= + +mock_provider "azurerm" { + mock_data "azurerm_storage_account" { + defaults = { + primary_web_host = "mystaticstorage.z13.web.core.windows.net" + } + } + + mock_resource "azurerm_cdn_endpoint" { + defaults = { + id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/my-resource-group/providers/Microsoft.Cdn/profiles/my-app-prod-cdn/endpoints/my-app-prod" + fqdn = "my-app-prod.azureedge.net" + } + } +} + +variables { + distribution_storage_account = "mystaticstorage" + distribution_container_name = "$web" + distribution_blob_prefix = "app/scope-1" + distribution_app_name = "my-app-prod" + network_full_domain = "" + network_domain = "" + distribution_resource_tags_json = { + Environment = "production" + Application = "my-app" + } + azure_provider = { + subscription_id = "00000000-0000-0000-0000-000000000000" + resource_group = "my-resource-group" + storage_account = "mytfstatestorage" + container = "tfstate" + } + provider_resource_tags_json = { + Team = "platform" + } +} + +# ============================================================================= +# Test: CDN Profile is created +# ============================================================================= +run "creates_cdn_profile" { + command = plan + + assert { + condition = azurerm_cdn_profile.static.name == "my-app-prod-cdn" + error_message = "CDN profile name should be 'my-app-prod-cdn'" + } + + assert { + condition = azurerm_cdn_profile.static.sku == "Standard_Microsoft" + error_message = "CDN profile SKU should be 'Standard_Microsoft'" + } + + assert { + condition = azurerm_cdn_profile.static.resource_group_name == "my-resource-group" + error_message = "CDN profile should be in 'my-resource-group'" + } +} + +# ============================================================================= +# Test: CDN Endpoint is created +# ============================================================================= +run "creates_cdn_endpoint" { + command = plan + + assert { + condition = azurerm_cdn_endpoint.static.name == "my-app-prod" + error_message = "CDN endpoint name should be 'my-app-prod'" + } + + assert { + condition = azurerm_cdn_endpoint.static.profile_name == "my-app-prod-cdn" + error_message = "CDN endpoint profile should be 'my-app-prod-cdn'" + } +} + +# ============================================================================= +# Test: CDN Endpoint origin configuration +# ============================================================================= +run "cdn_endpoint_origin_configuration" { + command = plan + + assert { + condition = azurerm_cdn_endpoint.static.origin_host_header == "mystaticstorage.z13.web.core.windows.net" + error_message = "Origin host header should be storage account primary web host" + } + + assert { + condition = one(azurerm_cdn_endpoint.static.origin).name == "blob-origin" + error_message = "Origin name should be 'blob-origin'" + } + + assert { + condition = one(azurerm_cdn_endpoint.static.origin).host_name == "mystaticstorage.z13.web.core.windows.net" + error_message = "Origin host name should be storage account primary web host" + } +} + +# ============================================================================= +# Test: No custom domain without network_full_domain +# ============================================================================= +run "no_custom_domain_without_network_domain" { + command = plan + + assert { + condition = local.distribution_has_custom_domain == false + error_message = "Should not have custom domain when network_full_domain is empty" + } + + assert { + condition = length(azurerm_cdn_endpoint_custom_domain.static) == 0 + error_message = "Should not create custom domain resource when network_full_domain is empty" + } +} + +# ============================================================================= +# Test: Custom domain with network_full_domain +# ============================================================================= +run "has_custom_domain_with_network_domain" { + command = plan + + variables { + network_full_domain = "cdn.example.com" + } + + assert { + condition = local.distribution_has_custom_domain == true + error_message = "Should have custom domain when network_full_domain is set" + } + + assert { + condition = local.distribution_full_domain == "cdn.example.com" + error_message = "Full domain should be 'cdn.example.com'" + } + + assert { + condition = length(azurerm_cdn_endpoint_custom_domain.static) == 1 + error_message = "Should create custom domain resource when network_full_domain is set" + } + + assert { + condition = azurerm_cdn_endpoint_custom_domain.static[0].host_name == "cdn.example.com" + error_message = "Custom domain host name should be 'cdn.example.com'" + } +} + +# ============================================================================= +# Test: Origin path normalization - removes double slashes +# ============================================================================= +run "origin_path_normalizes_leading_slash" { + command = plan + + variables { + distribution_blob_prefix = "/app" + } + + assert { + condition = local.distribution_origin_path == "/app" + error_message = "Origin path should be '/app' not '//app'" + } +} + +# ============================================================================= +# Test: Origin path normalization - adds leading slash if missing +# ============================================================================= +run "origin_path_adds_leading_slash" { + command = plan + + variables { + distribution_blob_prefix = "app" + } + + assert { + condition = local.distribution_origin_path == "/app" + error_message = "Origin path should add leading slash" + } +} + +# ============================================================================= +# Test: Origin path normalization - handles empty prefix +# ============================================================================= +run "origin_path_handles_empty" { + command = plan + + variables { + distribution_blob_prefix = "" + } + + assert { + condition = local.distribution_origin_path == "" + error_message = "Origin path should be empty when prefix is empty" + } +} + +# ============================================================================= +# Test: Origin path normalization - trims trailing slashes +# ============================================================================= +run "origin_path_trims_trailing_slash" { + command = plan + + variables { + distribution_blob_prefix = "/app/subfolder/" + } + + assert { + condition = local.distribution_origin_path == "/app/subfolder" + error_message = "Origin path should trim trailing slashes" + } +} + +# ============================================================================= +# Test: Cross-module locals for DNS integration +# ============================================================================= +run "cross_module_locals_for_dns" { + command = plan + + assert { + condition = local.distribution_record_type == "CNAME" + error_message = "Record type should be 'CNAME' for Azure CDN records" + } +} + +# ============================================================================= +# Test: Outputs from data source +# ============================================================================= +run "outputs_from_data_source" { + command = plan + + assert { + condition = output.distribution_storage_account == "mystaticstorage" + error_message = "distribution_storage_account should be 'mystaticstorage'" + } +} + +# ============================================================================= +# Test: Outputs from variables +# ============================================================================= +run "outputs_from_variables" { + command = plan + + assert { + condition = output.distribution_blob_prefix == "app/scope-1" + error_message = "distribution_blob_prefix should be 'app/scope-1'" + } + + assert { + condition = output.distribution_container_name == "$web" + error_message = "distribution_container_name should be '$web'" + } +} + +# ============================================================================= +# Test: DNS-related outputs +# ============================================================================= +run "dns_related_outputs" { + command = plan + + assert { + condition = output.distribution_record_type == "CNAME" + error_message = "distribution_record_type should be 'CNAME'" + } +} + +# ============================================================================= +# Test: Website URL without network domain +# ============================================================================= +run "website_url_without_network_domain" { + command = plan + + assert { + condition = startswith(output.distribution_website_url, "https://") + error_message = "distribution_website_url should start with 'https://'" + } +} + +# ============================================================================= +# Test: Website URL with network domain +# ============================================================================= +run "website_url_with_network_domain" { + command = plan + + variables { + network_full_domain = "cdn.example.com" + } + + assert { + condition = output.distribution_website_url == "https://cdn.example.com" + error_message = "distribution_website_url should be 'https://cdn.example.com'" + } +} + +# ============================================================================= +# Test: CDN endpoint has SPA routing delivery rule +# ============================================================================= +run "cdn_endpoint_has_spa_routing" { + command = plan + + assert { + condition = azurerm_cdn_endpoint.static.delivery_rule[0].name == "sparouting" + error_message = "Should have sparouting delivery rule" + } + + assert { + condition = azurerm_cdn_endpoint.static.delivery_rule[0].order == 1 + error_message = "SPA routing rule should have order 1" + } +} + +# ============================================================================= +# Test: CDN endpoint has static cache delivery rule +# ============================================================================= +run "cdn_endpoint_has_static_cache" { + command = plan + + assert { + condition = azurerm_cdn_endpoint.static.delivery_rule[1].name == "staticcache" + error_message = "Should have staticcache delivery rule" + } + + assert { + condition = azurerm_cdn_endpoint.static.delivery_rule[1].order == 2 + error_message = "Static cache rule should have order 2" + } +} + +# ============================================================================= +# Test: CDN endpoint has HTTPS redirect delivery rule +# ============================================================================= +run "cdn_endpoint_has_https_redirect" { + command = plan + + assert { + condition = azurerm_cdn_endpoint.static.delivery_rule[2].name == "httpsredirect" + error_message = "Should have httpsredirect delivery rule" + } + + assert { + condition = azurerm_cdn_endpoint.static.delivery_rule[2].order == 3 + error_message = "HTTPS redirect rule should have order 3" + } +} + +# ============================================================================= +# Test: Custom domain has managed HTTPS +# ============================================================================= +run "custom_domain_has_managed_https" { + command = plan + + variables { + network_full_domain = "cdn.example.com" + } + + assert { + condition = azurerm_cdn_endpoint_custom_domain.static[0].cdn_managed_https[0].certificate_type == "Dedicated" + error_message = "Custom domain should use dedicated certificate" + } + + assert { + condition = azurerm_cdn_endpoint_custom_domain.static[0].cdn_managed_https[0].protocol_type == "ServerNameIndication" + error_message = "Custom domain should use SNI protocol" + } + + assert { + condition = azurerm_cdn_endpoint_custom_domain.static[0].cdn_managed_https[0].tls_version == "TLS12" + error_message = "Custom domain should use TLS 1.2" + } +} diff --git a/frontend/deployment/distribution/blob-cdn/modules/locals.tf b/frontend/deployment/distribution/blob-cdn/modules/locals.tf new file mode 100644 index 00000000..580c7228 --- /dev/null +++ b/frontend/deployment/distribution/blob-cdn/modules/locals.tf @@ -0,0 +1,22 @@ +# ============================================================================= +# Locals for Azure CDN Distribution +# ============================================================================= + +locals { + # Use network_full_domain from network layer (provided via cross-module locals when composed) + distribution_has_custom_domain = local.network_full_domain != "" + distribution_full_domain = local.network_full_domain + + # Normalize blob_prefix: trim leading/trailing slashes, then add single leading slash if non-empty + distribution_blob_prefix_trimmed = trim(var.distribution_blob_prefix, "/") + distribution_origin_path = local.distribution_blob_prefix_trimmed != "" ? "/${local.distribution_blob_prefix_trimmed}" : "" + + distribution_tags = merge(var.distribution_resource_tags_json, { + ManagedBy = "terraform" + Module = "distribution/blob-cdn" + }) + + # Cross-module references (consumed by network/azure_dns) + distribution_target_domain = azurerm_cdn_endpoint.static.fqdn + distribution_record_type = "CNAME" +} diff --git a/frontend/deployment/distribution/blob-cdn/modules/main.tf b/frontend/deployment/distribution/blob-cdn/modules/main.tf new file mode 100644 index 00000000..775e8178 --- /dev/null +++ b/frontend/deployment/distribution/blob-cdn/modules/main.tf @@ -0,0 +1,103 @@ +# ============================================================================= +# Azure CDN Distribution +# +# Creates an Azure CDN profile and endpoint for static website hosting +# using Azure Blob Storage as the origin. +# ============================================================================= + +# Get storage account details +data "azurerm_storage_account" "static" { + name = var.distribution_storage_account + resource_group_name = var.azure_provider.resource_group +} + +# CDN Profile +resource "azurerm_cdn_profile" "static" { + name = "${var.distribution_app_name}-cdn" + location = "global" + resource_group_name = var.azure_provider.resource_group + sku = "Standard_Microsoft" + + tags = local.distribution_tags +} + +# CDN Endpoint +resource "azurerm_cdn_endpoint" "static" { + name = var.distribution_app_name + profile_name = azurerm_cdn_profile.static.name + location = "global" + resource_group_name = var.azure_provider.resource_group + + origin_host_header = data.azurerm_storage_account.static.primary_web_host + + origin { + name = "blob-origin" + host_name = data.azurerm_storage_account.static.primary_web_host + } + + # SPA routing - redirect 404s to index.html + delivery_rule { + name = "sparouting" + order = 1 + + url_file_extension_condition { + operator = "LessThan" + match_values = ["1"] + } + + url_rewrite_action { + destination = "/index.html" + preserve_unmatched_path = false + source_pattern = "/" + } + } + + # Cache configuration + delivery_rule { + name = "staticcache" + order = 2 + + url_path_condition { + operator = "BeginsWith" + match_values = ["/static/"] + } + + cache_expiration_action { + behavior = "Override" + duration = "7.00:00:00" + } + } + + # HTTPS redirect + delivery_rule { + name = "httpsredirect" + order = 3 + + request_scheme_condition { + operator = "Equal" + match_values = ["HTTP"] + } + + url_redirect_action { + redirect_type = "Found" + protocol = "Https" + } + } + + tags = local.distribution_tags +} + +# Custom domain configuration (when network layer provides domain) +resource "azurerm_cdn_endpoint_custom_domain" "static" { + count = local.distribution_has_custom_domain ? 1 : 0 + + name = "custom-domain" + cdn_endpoint_id = azurerm_cdn_endpoint.static.id + host_name = local.distribution_full_domain + + cdn_managed_https { + certificate_type = "Dedicated" + protocol_type = "ServerNameIndication" + tls_version = "TLS12" + } +} diff --git a/frontend/deployment/distribution/blob-cdn/modules/outputs.tf b/frontend/deployment/distribution/blob-cdn/modules/outputs.tf new file mode 100644 index 00000000..d5a61e5a --- /dev/null +++ b/frontend/deployment/distribution/blob-cdn/modules/outputs.tf @@ -0,0 +1,44 @@ +output "distribution_storage_account" { + description = "Azure Storage account name" + value = var.distribution_storage_account +} + +output "distribution_container_name" { + description = "Azure Storage container name" + value = var.distribution_container_name +} + +output "distribution_blob_prefix" { + description = "Blob prefix path for this scope" + value = var.distribution_blob_prefix +} + +output "distribution_cdn_profile_name" { + description = "Azure CDN profile name" + value = azurerm_cdn_profile.static.name +} + +output "distribution_cdn_endpoint_name" { + description = "Azure CDN endpoint name" + value = azurerm_cdn_endpoint.static.name +} + +output "distribution_cdn_endpoint_hostname" { + description = "Azure CDN endpoint hostname" + value = azurerm_cdn_endpoint.static.fqdn +} + +output "distribution_target_domain" { + description = "Target domain for DNS records (CDN endpoint hostname)" + value = local.distribution_target_domain +} + +output "distribution_record_type" { + description = "DNS record type (CNAME for Azure CDN)" + value = local.distribution_record_type +} + +output "distribution_website_url" { + description = "Website URL" + value = local.distribution_has_custom_domain ? "https://${local.distribution_full_domain}" : "https://${azurerm_cdn_endpoint.static.fqdn}" +} diff --git a/frontend/deployment/distribution/blob-cdn/modules/test_locals.tf b/frontend/deployment/distribution/blob-cdn/modules/test_locals.tf new file mode 100644 index 00000000..1826c78d --- /dev/null +++ b/frontend/deployment/distribution/blob-cdn/modules/test_locals.tf @@ -0,0 +1,51 @@ +# ============================================================================= +# Test-only locals +# +# This file provides the network_* locals that are normally defined by the +# network layer (Azure DNS, etc.) when modules are composed. +# This file is only used for running isolated unit tests. +# +# NOTE: Files matching test_*.tf are skipped by compose_modules +# ============================================================================= + +# Test-only variables to allow tests to control the network values +variable "network_full_domain" { + description = "Test-only: Full domain from network layer (e.g., app.example.com)" + type = string + default = "" +} + +variable "network_domain" { + description = "Test-only: Root domain from network layer (e.g., example.com)" + type = string + default = "" +} + +variable "network_dns_zone_name" { + description = "Azure DNS zone name" + type = string + default = "" +} + +variable "network_subdomain" { + description = "Subdomain for the distribution" + type = string + default = "" +} + +variable "azure_provider" { + description = "Azure provider configuration" + type = object({ + subscription_id = string + resource_group = string + storage_account = string + container = string + }) +} + +locals { + # These locals are normally provided by network modules (e.g., Azure DNS) + # For testing, we bridge from variables to locals + network_full_domain = var.network_full_domain + network_domain = var.network_domain +} diff --git a/frontend/deployment/distribution/blob-cdn/modules/variables.tf b/frontend/deployment/distribution/blob-cdn/modules/variables.tf new file mode 100644 index 00000000..39bd4ccf --- /dev/null +++ b/frontend/deployment/distribution/blob-cdn/modules/variables.tf @@ -0,0 +1,27 @@ +variable "distribution_storage_account" { + description = "Azure Storage account name for static website distribution" + type = string +} + +variable "distribution_container_name" { + description = "Azure Storage container name (defaults to $web for static websites)" + type = string + default = "$web" +} + +variable "distribution_blob_prefix" { + description = "Blob path prefix for this scope's files (e.g., '/app-name/scope-id')" + type = string + default = "/" +} + +variable "distribution_app_name" { + description = "Application name (used for resource naming)" + type = string +} + +variable "distribution_resource_tags_json" { + description = "Resource tags as JSON object" + type = map(string) + default = {} +} \ No newline at end of file diff --git a/frontend/deployment/distribution/blob-cdn/setup b/frontend/deployment/distribution/blob-cdn/setup new file mode 100755 index 00000000..c6841497 --- /dev/null +++ b/frontend/deployment/distribution/blob-cdn/setup @@ -0,0 +1,127 @@ +#!/bin/bash + +echo "🔍 Validating Azure CDN distribution configuration..." + +application_slug=$(echo "$CONTEXT" | jq -r .application.slug) +scope_slug=$(echo "$CONTEXT" | jq -r .scope.slug) +scope_id=$(echo "$CONTEXT" | jq -r .scope.id) +asset_url=$(echo "$CONTEXT" | jq -r .asset.url) + +distribution_app_name="$application_slug-$scope_slug-$scope_id" +echo " ✅ app_name=$distribution_app_name" + +echo "" +echo " 📡 Fetching assets-repository provider..." + +nrn=$(echo "$CONTEXT" | jq -r .scope.nrn) + +asset_repository=$(np provider list --nrn "$nrn" --categories assets-repository --format json 2>&1) +np_exit_code=$? + +echo $asset_repository | jq . + +if [ $np_exit_code -ne 0 ]; then + echo "" + echo " ❌ Failed to fetch assets-repository provider" + echo "" + + if echo "$asset_repository" | grep -q "unauthorized\|forbidden\|401\|403"; then + echo " 🔒 Error: Permission denied" + echo "" + echo " 💡 Possible causes:" + echo " • The nullplatform API Key doesn't have 'Ops' permissions at nrn: $nrn" + echo "" + echo " 🔧 How to fix:" + echo " 1. Ensure the API Key has 'Ops' permissions at the correct NRN hierarchy level" + + else + echo " 📋 Error details:" + echo "$asset_repository" | sed 's/^/ /' + fi + + echo "" + exit 1 +fi + +# Look for Azure Blob Storage provider (storage_account and container) +distribution_storage_account=$(echo "$asset_repository" | jq -r ' + [.results[] | select(.attributes.storage_account.name != null)] | first | .attributes.storage_account.name // empty +') + +distribution_container_name=$(echo "$asset_repository" | jq -r ' + [.results[] | select(.attributes.container.name != null)] | first | .attributes.container.name // empty +') + +if [ -z "$distribution_storage_account" ] || [ "$distribution_storage_account" = "null" ]; then + echo "" + echo " ❌ No assets-repository provider of type Azure Blob Storage at nrn: $nrn" + echo "" + + provider_count=$(echo "$asset_repository" | jq '.results | length') + + if [ "$provider_count" -gt 0 ]; then + echo " 🤔 Found $provider_count asset-repository provider(s), but none are configured for Azure Blob Storage." + echo "" + echo " 📋 Verify the existing providers with the nullplatform CLI:" + echo "$asset_repository" | jq -r '.results[] | " • np provider read --id \(.id) --format json"' 2>/dev/null + echo "" + fi + + echo " 🔧 How to fix:" + echo " 1. Ensure there is an asset-repository provider of type Azure Blob Storage configured at the correct NRN hierarchy level" + echo "" + exit 1 +fi + +echo " ✅ storage_account=$distribution_storage_account" +echo " ✅ container=${distribution_container_name:-"\$web"}" + +# Set default container to $web if not specified (Azure static website container) +distribution_container_name=${distribution_container_name:-"\$web"} + +# Blob path prefix for multi-scope storage support +# Extract path from asset_url (supports https:// format) +# Removes protocol and storage account/container prefix, keeps the path with leading slash +if [[ "$asset_url" == https://* ]]; then + # Extract path after the container name + distribution_blob_prefix="/${asset_url#https://*/*/}" +else + distribution_blob_prefix="/" +fi + +# Normalize empty prefix to just "/" +if [ "$distribution_blob_prefix" = "/" ] || [ -z "$distribution_blob_prefix" ]; then + distribution_blob_prefix="/" +fi + +echo " ✅ blob_prefix=${distribution_blob_prefix:-"(root)"}" + +RESOURCE_TAGS_JSON=${RESOURCE_TAGS_JSON:-"{}"} + +TOFU_VARIABLES=$(echo "$TOFU_VARIABLES" | jq \ + --arg storage_account "$distribution_storage_account" \ + --arg container_name "$distribution_container_name" \ + --arg app_name "$distribution_app_name" \ + --argjson resource_tags_json "$RESOURCE_TAGS_JSON" \ + --arg blob_prefix "$distribution_blob_prefix" \ + '. + { + distribution_storage_account: $storage_account, + distribution_container_name: $container_name, + distribution_app_name: $app_name, + distribution_blob_prefix: $blob_prefix, + distribution_resource_tags_json: $resource_tags_json + }') + +echo "" +echo "✨ Azure CDN distribution configured successfully" +echo "" + +# Add module to composition list +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +module_name="${script_dir}/modules" + +if [[ -n $MODULES_TO_USE ]]; then + MODULES_TO_USE="$MODULES_TO_USE,$module_name" +else + MODULES_TO_USE="$module_name" +fi diff --git a/frontend/deployment/distribution/cloudfront/modules/cloudfront.tftest.hcl b/frontend/deployment/distribution/cloudfront/modules/cloudfront.tftest.hcl new file mode 100644 index 00000000..491d7c6f --- /dev/null +++ b/frontend/deployment/distribution/cloudfront/modules/cloudfront.tftest.hcl @@ -0,0 +1,466 @@ +# ============================================================================= +# Unit tests for distribution/cloudfront module +# +# Run: tofu test +# ============================================================================= + +mock_provider "aws" { + mock_data "aws_s3_bucket" { + defaults = { + id = "my-static-bucket" + arn = "arn:aws:s3:::my-static-bucket" + bucket_regional_domain_name = "my-static-bucket.s3.us-east-1.amazonaws.com" + } + } + + mock_data "aws_caller_identity" { + defaults = { + account_id = "123456789012" + arn = "arn:aws:iam::123456789012:root" + user_id = "123456789012" + } + } + + mock_data "aws_acm_certificate" { + defaults = { + arn = "arn:aws:acm:us-east-1:123456789012:certificate/12345678-1234-1234-1234-123456789012" + id = "arn:aws:acm:us-east-1:123456789012:certificate/12345678-1234-1234-1234-123456789012" + } + } +} + +variables { + distribution_bucket_name = "my-static-bucket" + distribution_s3_prefix = "app/scope-1" + distribution_app_name = "my-app-prod" + network_full_domain = "" + network_domain = "" + distribution_resource_tags_json = { + Environment = "production" + Application = "my-app" + } +} + +# ============================================================================= +# Test: Origin Access Control is created +# ============================================================================= +run "creates_origin_access_control" { + command = plan + + assert { + condition = aws_cloudfront_origin_access_control.static.name == "my-app-prod-oac" + error_message = "OAC name should be 'my-app-prod-oac'" + } + + assert { + condition = aws_cloudfront_origin_access_control.static.origin_access_control_origin_type == "s3" + error_message = "OAC origin type should be 's3'" + } + + assert { + condition = aws_cloudfront_origin_access_control.static.signing_behavior == "always" + error_message = "OAC signing behavior should be 'always'" + } +} + +# ============================================================================= +# Test: CloudFront distribution basic configuration +# ============================================================================= +run "distribution_basic_configuration" { + command = plan + + assert { + condition = aws_cloudfront_distribution.static.enabled == true + error_message = "Distribution should be enabled" + } + + assert { + condition = aws_cloudfront_distribution.static.is_ipv6_enabled == true + error_message = "IPv6 should be enabled" + } + + assert { + condition = aws_cloudfront_distribution.static.default_root_object == "index.html" + error_message = "Default root object should be 'index.html'" + } + + assert { + condition = aws_cloudfront_distribution.static.price_class == "PriceClass_100" + error_message = "Price class should be 'PriceClass_100'" + } +} + +# ============================================================================= +# Test: Distribution has no aliases when network_full_domain is empty +# ============================================================================= +run "no_aliases_without_network_domain" { + command = plan + + assert { + condition = length(local.distribution_aliases) == 0 + error_message = "Should have no aliases when network_full_domain is empty" + } +} + +# ============================================================================= +# Test: Distribution has alias when network_full_domain is set +# ============================================================================= +run "has_alias_with_network_domain" { + command = plan + + variables { + network_full_domain = "cdn.example.com" + } + + assert { + condition = length(local.distribution_aliases) == 1 + error_message = "Should have one alias when network_full_domain is set" + } + + assert { + condition = local.distribution_aliases[0] == "cdn.example.com" + error_message = "Alias should be 'cdn.example.com'" + } +} + +# ============================================================================= +# Test: Origin ID is computed correctly +# ============================================================================= +run "origin_id_format" { + command = plan + + assert { + condition = local.distribution_origin_id == "S3-my-static-bucket" + error_message = "Origin ID should be 'S3-my-static-bucket'" + } +} + +# ============================================================================= +# Test: Origin path normalization - removes double slashes +# ============================================================================= +run "origin_path_normalizes_leading_slash" { + command = plan + + variables { + distribution_s3_prefix = "/app" + } + + assert { + condition = local.distribution_origin_path == "/app" + error_message = "Origin path should be '/app' not '//app'" + } +} + +# ============================================================================= +# Test: Origin path normalization - adds leading slash if missing +# ============================================================================= +run "origin_path_adds_leading_slash" { + command = plan + + variables { + distribution_s3_prefix = "app" + } + + assert { + condition = local.distribution_origin_path == "/app" + error_message = "Origin path should add leading slash" + } +} + +# ============================================================================= +# Test: Origin path normalization - handles empty prefix +# ============================================================================= +run "origin_path_handles_empty" { + command = plan + + variables { + distribution_s3_prefix = "" + } + + assert { + condition = local.distribution_origin_path == "" + error_message = "Origin path should be empty when prefix is empty" + } +} + +# ============================================================================= +# Test: Origin path normalization - trims trailing slashes +# ============================================================================= +run "origin_path_trims_trailing_slash" { + command = plan + + variables { + distribution_s3_prefix = "/app/subfolder/" + } + + assert { + condition = local.distribution_origin_path == "/app/subfolder" + error_message = "Origin path should trim trailing slashes" + } +} + +# ============================================================================= +# Test: Default tags include module tag +# ============================================================================= +run "default_tags_include_module" { + command = plan + + assert { + condition = local.distribution_default_tags["ManagedBy"] == "terraform" + error_message = "Tags should include ManagedBy=terraform" + } + + assert { + condition = local.distribution_default_tags["Module"] == "distribution/cloudfront" + error_message = "Tags should include Module=distribution/cloudfront" + } + + assert { + condition = local.distribution_default_tags["Environment"] == "production" + error_message = "Tags should preserve input Environment tag" + } +} + +# ============================================================================= +# Test: Cross-module locals for DNS integration +# ============================================================================= +run "cross_module_locals_for_dns" { + command = plan + + assert { + condition = local.distribution_record_type == "A" + error_message = "Record type should be 'A' for CloudFront alias records" + } +} + +# ============================================================================= +# Test: Cache behaviors +# ============================================================================= +run "cache_behaviors_configured" { + command = plan + + # Default cache behavior + assert { + condition = aws_cloudfront_distribution.static.default_cache_behavior[0].viewer_protocol_policy == "redirect-to-https" + error_message = "Default cache should redirect to HTTPS" + } + + assert { + condition = aws_cloudfront_distribution.static.default_cache_behavior[0].compress == true + error_message = "Default cache should enable compression" + } +} + +# ============================================================================= +# Test: Custom error responses for SPA +# ============================================================================= +run "spa_error_responses" { + command = plan + + # Check that 404 and 403 errors redirect to index.html (SPA behavior) + assert { + condition = length(aws_cloudfront_distribution.static.custom_error_response) == 2 + error_message = "Should have 2 custom error responses" + } +} + +# ============================================================================= +# Test: Outputs from data source +# ============================================================================= +run "outputs_from_data_source" { + command = plan + + assert { + condition = output.distribution_bucket_name == "my-static-bucket" + error_message = "distribution_bucket_name should be 'my-static-bucket'" + } + + assert { + condition = output.distribution_bucket_arn == "arn:aws:s3:::my-static-bucket" + error_message = "distribution_bucket_arn should be 'arn:aws:s3:::my-static-bucket'" + } +} + +# ============================================================================= +# Test: Outputs from variables +# ============================================================================= +run "outputs_from_variables" { + command = plan + + assert { + condition = output.distribution_s3_prefix == "app/scope-1" + error_message = "distribution_s3_prefix should be 'app/scope-1'" + } +} + +# ============================================================================= +# Test: DNS-related outputs +# ============================================================================= +run "dns_related_outputs" { + command = plan + + assert { + condition = output.distribution_record_type == "A" + error_message = "distribution_record_type should be 'A'" + } +} + +# ============================================================================= +# Test: Website URL without network domain +# ============================================================================= +run "website_url_without_network_domain" { + command = plan + + # Without network domain, URL should use CloudFront domain (known after apply) + # We can only check it starts with https:// + assert { + condition = startswith(output.distribution_website_url, "https://") + error_message = "distribution_website_url should start with 'https://'" + } +} + +# ============================================================================= +# Test: Website URL with network domain +# ============================================================================= +run "website_url_with_network_domain" { + command = plan + + variables { + network_full_domain = "cdn.example.com" + } + + assert { + condition = output.distribution_website_url == "https://cdn.example.com" + error_message = "distribution_website_url should be 'https://cdn.example.com'" + } +} + +# ============================================================================= +# Test: S3 bucket policy is created for CloudFront OAC +# ============================================================================= +run "creates_s3_bucket_policy" { + command = plan + + assert { + condition = aws_s3_bucket_policy.static.bucket == "my-static-bucket" + error_message = "Bucket policy should be attached to 'my-static-bucket'" + } +} + +# ============================================================================= +# Test: S3 bucket policy allows CloudFront service principal +# ============================================================================= +run "bucket_policy_allows_cloudfront" { + command = plan + + assert { + condition = can(jsondecode(aws_s3_bucket_policy.static.policy)) + error_message = "Bucket policy should be valid JSON" + } + + assert { + condition = jsondecode(aws_s3_bucket_policy.static.policy).Statement[0].Principal.Service == "cloudfront.amazonaws.com" + error_message = "Bucket policy should allow cloudfront.amazonaws.com service principal" + } + + assert { + condition = jsondecode(aws_s3_bucket_policy.static.policy).Statement[0].Action == "s3:GetObject" + error_message = "Bucket policy should allow s3:GetObject action" + } + + assert { + condition = jsondecode(aws_s3_bucket_policy.static.policy).Statement[0].Effect == "Allow" + error_message = "Bucket policy should have Allow effect" + } +} + +# ============================================================================= +# Test: S3 bucket policy resource scope +# ============================================================================= +run "bucket_policy_resource_scope" { + command = plan + + assert { + condition = jsondecode(aws_s3_bucket_policy.static.policy).Statement[0].Resource == "arn:aws:s3:::my-static-bucket/*" + error_message = "Bucket policy resource should be 'arn:aws:s3:::my-static-bucket/*'" + } +} + +# ============================================================================= +# Test: S3 bucket policy has distribution condition +# ============================================================================= +run "bucket_policy_has_distribution_condition" { + command = plan + + assert { + condition = can(jsondecode(aws_s3_bucket_policy.static.policy).Statement[0].Condition.StringEquals["AWS:SourceArn"]) + error_message = "Bucket policy should have AWS:SourceArn condition" + } + + assert { + condition = startswith(jsondecode(aws_s3_bucket_policy.static.policy).Statement[0].Condition.StringEquals["AWS:SourceArn"], "arn:aws:cloudfront::123456789012:distribution/") + error_message = "Bucket policy condition should reference the CloudFront distribution ARN with account 123456789012" + } +} + +# ============================================================================= +# Test: ACM certificate domain derivation +# ============================================================================= +run "acm_certificate_domain_derived_from_network_domain" { + command = plan + + variables { + network_domain = "example.com" + } + + assert { + condition = local.distribution_acm_certificate_domain == "*.example.com" + error_message = "ACM certificate domain should be '*.example.com'" + } +} + +# ============================================================================= +# Test: No ACM certificate lookup when network_domain is empty +# ============================================================================= +run "no_acm_lookup_without_network_domain" { + command = plan + + assert { + condition = local.distribution_acm_certificate_domain == "" + error_message = "ACM certificate domain should be empty when network_domain is empty" + } + + assert { + condition = local.distribution_has_acm_certificate == false + error_message = "Should not have ACM certificate when network_domain is empty" + } +} + +# ============================================================================= +# Test: Uses ACM certificate when network_domain is set +# ============================================================================= +run "uses_acm_certificate_with_network_domain" { + command = plan + + variables { + network_domain = "example.com" + network_full_domain = "app.example.com" + } + + assert { + condition = local.distribution_has_acm_certificate == true + error_message = "Should have ACM certificate when network_domain is set" + } +} + +# ============================================================================= +# Test: Uses default certificate without network_domain +# ============================================================================= +run "uses_default_certificate_without_network_domain" { + command = plan + + assert { + condition = local.distribution_has_acm_certificate == false + error_message = "Should use default certificate when network_domain is empty" + } +} diff --git a/frontend/deployment/distribution/cloudfront/modules/data.tf b/frontend/deployment/distribution/cloudfront/modules/data.tf new file mode 100644 index 00000000..075a9425 --- /dev/null +++ b/frontend/deployment/distribution/cloudfront/modules/data.tf @@ -0,0 +1,17 @@ +data "aws_s3_bucket" "static" { + bucket = var.distribution_bucket_name +} + +data "aws_caller_identity" "current" {} + +# Look up ACM certificate for custom domain (must be in us-east-1 for CloudFront) +# Uses wildcard pattern: *.parent-domain.tld +# Note: PENDING_VALIDATION is included for LocalStack compatibility in integration tests +data "aws_acm_certificate" "custom_domain" { + count = local.distribution_acm_certificate_domain != "" ? 1 : 0 + + provider = aws + domain = local.distribution_acm_certificate_domain + statuses = ["ISSUED", "PENDING_VALIDATION"] + most_recent = true +} diff --git a/frontend/deployment/distribution/cloudfront/modules/locals.tf b/frontend/deployment/distribution/cloudfront/modules/locals.tf new file mode 100644 index 00000000..efcadf36 --- /dev/null +++ b/frontend/deployment/distribution/cloudfront/modules/locals.tf @@ -0,0 +1,26 @@ +locals { + distribution_origin_id = "S3-${var.distribution_bucket_name}" + distribution_aws_endpoint_url_param = var.distribution_cloudfront_endpoint_url != "" ? "--endpoint-url ${var.distribution_cloudfront_endpoint_url}" : "" + + # Use network_full_domain from network layer (provided via cross-module locals when composed) + distribution_aliases = local.network_full_domain != "" ? [local.network_full_domain] : [] + + # Normalize s3_prefix: trim leading/trailing slashes, then add single leading slash if non-empty + distribution_s3_prefix_trimmed = trim(var.distribution_s3_prefix, "/") + distribution_origin_path = local.distribution_s3_prefix_trimmed != "" ? "/${local.distribution_s3_prefix_trimmed}" : "" + + # ACM certificate domain: derive wildcard from network_domain + # e.g., "example.com" -> "*.example.com" + distribution_acm_certificate_domain = local.network_domain != "" ? "*.${local.network_domain}" : "" + distribution_has_acm_certificate = length(data.aws_acm_certificate.custom_domain) > 0 + + distribution_default_tags = merge(var.distribution_resource_tags_json, { + ManagedBy = "terraform" + Module = "distribution/cloudfront" + }) + + # Cross-module references (consumed by network/route53) + distribution_target_domain = aws_cloudfront_distribution.static.domain_name + distribution_target_zone_id = aws_cloudfront_distribution.static.hosted_zone_id + distribution_record_type = "A" +} diff --git a/frontend/deployment/distribution/cloudfront/modules/main.tf b/frontend/deployment/distribution/cloudfront/modules/main.tf new file mode 100644 index 00000000..53b6a67d --- /dev/null +++ b/frontend/deployment/distribution/cloudfront/modules/main.tf @@ -0,0 +1,139 @@ +resource "aws_cloudfront_origin_access_control" "static" { + name = "${var.distribution_app_name}-oac" + description = "OAC for ${var.distribution_app_name}" + origin_access_control_origin_type = "s3" + signing_behavior = "always" + signing_protocol = "sigv4" +} + +resource "aws_s3_bucket_policy" "static" { + bucket = data.aws_s3_bucket.static.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "AllowCloudFrontServicePrincipalReadOnly" + Effect = "Allow" + Principal = { + Service = "cloudfront.amazonaws.com" + } + Action = "s3:GetObject" + Resource = "${data.aws_s3_bucket.static.arn}/*" + Condition = { + StringEquals = { + "AWS:SourceArn" = "arn:aws:cloudfront::${data.aws_caller_identity.current.account_id}:distribution/${aws_cloudfront_distribution.static.id}" + } + } + } + ] + }) +} + +resource "aws_cloudfront_distribution" "static" { + enabled = true + is_ipv6_enabled = true + default_root_object = "index.html" + aliases = local.distribution_aliases + price_class = "PriceClass_100" + comment = "Distribution for ${var.distribution_app_name}" + + origin { + domain_name = data.aws_s3_bucket.static.bucket_regional_domain_name + origin_id = local.distribution_origin_id + origin_access_control_id = aws_cloudfront_origin_access_control.static.id + + origin_path = local.distribution_origin_path + } + + default_cache_behavior { + allowed_methods = ["GET", "HEAD", "OPTIONS"] + cached_methods = ["GET", "HEAD"] + target_origin_id = local.distribution_origin_id + + forwarded_values { + query_string = false + cookies { + forward = "none" + } + } + + viewer_protocol_policy = "redirect-to-https" + min_ttl = 0 + default_ttl = 3600 + max_ttl = 86400 + compress = true + } + + ordered_cache_behavior { + path_pattern = "/static/*" + allowed_methods = ["GET", "HEAD"] + cached_methods = ["GET", "HEAD"] + target_origin_id = local.distribution_origin_id + + forwarded_values { + query_string = false + cookies { + forward = "none" + } + } + + viewer_protocol_policy = "redirect-to-https" + min_ttl = 86400 + default_ttl = 604800 + max_ttl = 31536000 + compress = true + } + + custom_error_response { + error_code = 404 + response_code = 200 + response_page_path = "/index.html" + } + + custom_error_response { + error_code = 403 + response_code = 200 + response_page_path = "/index.html" + } + + restrictions { + geo_restriction { + restriction_type = "none" + } + } + + # Use ACM certificate if available for custom domain, otherwise use default CloudFront certificate + dynamic "viewer_certificate" { + for_each = local.distribution_has_acm_certificate ? [1] : [] + content { + acm_certificate_arn = data.aws_acm_certificate.custom_domain[0].arn + ssl_support_method = "sni-only" + minimum_protocol_version = "TLSv1.2_2021" + } + } + + dynamic "viewer_certificate" { + for_each = local.distribution_has_acm_certificate ? [] : [1] + content { + cloudfront_default_certificate = true + minimum_protocol_version = "TLSv1.2_2021" + } + } + + tags = local.distribution_default_tags +} + +# Invalidate CloudFront cache on every deployment (when origin path changes) +resource "terraform_data" "cloudfront_invalidation" { + # Trigger invalidation whenever the origin path changes + triggers_replace = [ + local.distribution_origin_path + ] + + provisioner "local-exec" { + command = "aws cloudfront create-invalidation ${local.distribution_aws_endpoint_url_param} --distribution-id ${aws_cloudfront_distribution.static.id} --paths '/*'" + } + + depends_on = [aws_cloudfront_distribution.static] +} diff --git a/frontend/deployment/distribution/cloudfront/modules/outputs.tf b/frontend/deployment/distribution/cloudfront/modules/outputs.tf new file mode 100644 index 00000000..dd5cbba9 --- /dev/null +++ b/frontend/deployment/distribution/cloudfront/modules/outputs.tf @@ -0,0 +1,44 @@ +output "distribution_bucket_name" { + description = "S3 bucket name" + value = data.aws_s3_bucket.static.id +} + +output "distribution_bucket_arn" { + description = "S3 bucket ARN" + value = data.aws_s3_bucket.static.arn +} + +output "distribution_s3_prefix" { + description = "S3 prefix path for this scope" + value = var.distribution_s3_prefix +} + +output "distribution_cloudfront_distribution_id" { + description = "CloudFront distribution ID" + value = aws_cloudfront_distribution.static.id +} + +output "distribution_cloudfront_domain_name" { + description = "CloudFront domain name" + value = aws_cloudfront_distribution.static.domain_name +} + +output "distribution_target_domain" { + description = "Target domain for DNS records (CloudFront domain)" + value = local.distribution_target_domain +} + +output "distribution_target_zone_id" { + description = "Hosted zone ID for Route 53 alias records" + value = local.distribution_target_zone_id +} + +output "distribution_record_type" { + description = "DNS record type (A for CloudFront alias)" + value = local.distribution_record_type +} + +output "distribution_website_url" { + description = "Website URL" + value = local.network_full_domain != "" ? "https://${local.network_full_domain}" : "https://${aws_cloudfront_distribution.static.domain_name}" +} diff --git a/frontend/deployment/distribution/cloudfront/modules/test_locals.tf b/frontend/deployment/distribution/cloudfront/modules/test_locals.tf new file mode 100644 index 00000000..652d45a8 --- /dev/null +++ b/frontend/deployment/distribution/cloudfront/modules/test_locals.tf @@ -0,0 +1,29 @@ +# ============================================================================= +# Test-only locals +# +# This file provides the network_* locals that are normally defined by the +# network layer (Route53, etc.) when modules are composed. +# This file is only used for running isolated unit tests. +# +# NOTE: Files matching test_*.tf are skipped by compose_modules +# ============================================================================= + +# Test-only variables to allow tests to control the network values +variable "network_full_domain" { + description = "Test-only: Full domain from network layer (e.g., app.example.com)" + type = string + default = "" +} + +variable "network_domain" { + description = "Test-only: Root domain from network layer (e.g., example.com)" + type = string + default = "" +} + +locals { + # These locals are normally provided by network modules (e.g., Route53) + # For testing, we bridge from variables to locals + network_full_domain = var.network_full_domain + network_domain = var.network_domain +} diff --git a/frontend/deployment/distribution/cloudfront/modules/variables.tf b/frontend/deployment/distribution/cloudfront/modules/variables.tf new file mode 100644 index 00000000..a55c81d3 --- /dev/null +++ b/frontend/deployment/distribution/cloudfront/modules/variables.tf @@ -0,0 +1,26 @@ +variable "distribution_bucket_name" { + description = "Existing S3 bucket name for static website distribution" + type = string +} + +variable "distribution_s3_prefix" { + description = "S3 prefix/path for this scope's files (e.g., 'app-name/scope-id')" + type = string +} + +variable "distribution_app_name" { + description = "Application name (used for resource naming)" + type = string +} + +variable "distribution_resource_tags_json" { + description = "Resource tags as JSON object" + type = map(string) + default = {} +} + +variable "distribution_cloudfront_endpoint_url" { + description = "Custom CloudFront endpoint URL for AWS CLI (used for testing with moto)" + type = string + default = "" +} diff --git a/frontend/deployment/distribution/cloudfront/setup b/frontend/deployment/distribution/cloudfront/setup new file mode 100755 index 00000000..01605b88 --- /dev/null +++ b/frontend/deployment/distribution/cloudfront/setup @@ -0,0 +1,108 @@ +#!/bin/bash + +echo "🔍 Validating CloudFront distribution configuration..." + +application_slug=$(echo "$CONTEXT" | jq -r .application.slug) +scope_slug=$(echo "$CONTEXT" | jq -r .scope.slug) +scope_id=$(echo "$CONTEXT" | jq -r .scope.id) +asset_url=$(echo "$CONTEXT" | jq -r .asset.url) + +distribution_app_name="$application_slug-$scope_slug-$scope_id" +echo " ✅ app_name=$distribution_app_name" + +echo "" +echo " 📡 Fetching assets-repository provider..." + +nrn=$(echo "$CONTEXT" | jq -r .scope.nrn) + +asset_repository=$(np provider list --nrn "$nrn" --categories assets-repository --format json 2>&1) +np_exit_code=$? + +if [ $np_exit_code -ne 0 ]; then + echo "" + echo " ❌ Failed to fetch assets-repository provider" + echo "" + + if echo "$asset_repository" | grep -q "unauthorized\|forbidden\|401\|403"; then + echo " 🔒 Error: Permission denied" + echo "" + echo " 💡 Possible causes:" + echo " • The nullplatform API Key doesn't have 'Ops' permissions at nrn: $nrn" + echo "" + echo " 🔧 How to fix:" + echo " 1. Ensure the API Key has 'Ops' permissions at the correct NRN hierarchy level" + + else + echo " 📋 Error details:" + echo "$asset_repository" | sed 's/^/ /' + fi + + echo "" + exit 1 +fi + +distribution_bucket_name=$(echo "$asset_repository" | jq -r ' + [.results[] | select(.attributes.bucket.name != null)] | first | .attributes.bucket.name // empty +') + +if [ -z "$distribution_bucket_name" ] || [ "$distribution_bucket_name" = "null" ]; then + echo "" + echo " ❌ No assets-repository provider of type AWS S3 at nrn: $nrn" + echo "" + + provider_count=$(echo "$asset_repository" | jq '.results | length') + + if [ "$provider_count" -gt 0 ]; then + echo " 🤔 Found $provider_count asset-repository provider(s), but none are configured for S3." + echo "" + echo " 📋 Verify the existing providers with the nullplatform CLI:" + echo "$asset_repository" | jq -r '.results[] | " • np provider read --id \(.id) --format json"' 2>/dev/null + echo "" + fi + + echo " 🔧 How to fix:" + echo " 1. Ensure there is an asset-repository provider of type S3 configured at the correct NRN hierarchy level" + echo "" + exit 1 +fi + +echo " ✅ bucket_name=$distribution_bucket_name" + +# S3 prefix for multi-scope bucket support +# Extract path from asset_url (supports both s3:// and https:// formats) +# Removes protocol and bucket/domain prefix, keeps the path with leading slash +if [[ "$asset_url" == s3://* ]]; then + distribution_s3_prefix="/${asset_url#s3://*/}" +else + distribution_s3_prefix="/${asset_url#https://*/}" +fi + +echo " ✅ s3_prefix=${distribution_s3_prefix:-"(root)"}" + +RESOURCE_TAGS_JSON=${RESOURCE_TAGS_JSON:-"{}"} + +TOFU_VARIABLES=$(echo "$TOFU_VARIABLES" | jq \ + --arg bucket_name "$distribution_bucket_name" \ + --arg app_name "$distribution_app_name" \ + --argjson resource_tags_json "$RESOURCE_TAGS_JSON" \ + --arg s3_prefix "$distribution_s3_prefix" \ + '. + { + distribution_bucket_name: $bucket_name, + distribution_app_name: $app_name, + distribution_s3_prefix: $s3_prefix, + distribution_resource_tags_json: $resource_tags_json + }') + +echo "" +echo "✨ CloudFront distribution configured successfully" +echo "" + +# Add module to composition list +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +module_name="${script_dir}/modules" + +if [[ -n $MODULES_TO_USE ]]; then + MODULES_TO_USE="$MODULES_TO_USE,$module_name" +else + MODULES_TO_USE="$module_name" +fi diff --git a/frontend/deployment/distribution/firebase/modules/main.tf b/frontend/deployment/distribution/firebase/modules/main.tf new file mode 100644 index 00000000..c99c1a0b --- /dev/null +++ b/frontend/deployment/distribution/firebase/modules/main.tf @@ -0,0 +1,87 @@ +# Firebase Hosting +# Resources for Firebase static hosting + +variable "distribution_project_id" { + description = "GCP/Firebase project ID" + type = string +} + +variable "distribution_app_name" { + description = "Application name" + type = string +} + +variable "distribution_environment" { + description = "Environment (dev, staging, prod)" + type = string + default = "prod" +} + +variable "distribution_custom_domains" { + description = "List of custom domains" + type = list(string) + default = [] +} + +variable "distribution_labels" { + description = "Resource labels" + type = map(string) + default = {} +} + +locals { + distribution_site_id = "${var.distribution_app_name}-${var.distribution_environment}" + + distribution_default_labels = merge(var.distribution_labels, { + application = replace(var.distribution_app_name, "-", "_") + environment = var.distribution_environment + managed_by = "terraform" + }) +} + +resource "google_firebase_project" "default" { + provider = google-beta + project = var.distribution_project_id +} + +resource "google_firebase_distribution_site" "default" { + provider = google-beta + project = google_firebase_project.default.project + site_id = local.distribution_site_id +} + +resource "google_firebase_distribution_custom_domain" "domains" { + for_each = toset(var.distribution_custom_domains) + + provider = google-beta + project = google_firebase_project.default.project + site_id = google_firebase_distribution_site.default.site_id + custom_domain = each.value + + wait_dns_verification = false +} + +output "distribution_project_id" { + description = "Firebase project ID" + value = google_firebase_project.default.project +} + +output "distribution_site_id" { + description = "Firebase Hosting site ID" + value = google_firebase_distribution_site.default.site_id +} + +output "distribution_default_url" { + description = "Firebase Hosting default URL" + value = "https://${google_firebase_distribution_site.default.site_id}.web.app" +} + +output "distribution_firebaseapp_url" { + description = "Firebase alternative URL" + value = "https://${google_firebase_distribution_site.default.site_id}.firebaseapp.com" +} + +output "distribution_website_url" { + description = "Website URL" + value = length(var.distribution_custom_domains) > 0 ? "https://${var.distribution_custom_domains[0]}" : "https://${google_firebase_distribution_site.default.site_id}.web.app" +} diff --git a/frontend/deployment/distribution/firebase/setup b/frontend/deployment/distribution/firebase/setup new file mode 100755 index 00000000..cfe2181a --- /dev/null +++ b/frontend/deployment/distribution/firebase/setup @@ -0,0 +1,27 @@ +#!/bin/bash + +# Firebase Hosting Setup + +distribution_app_name=$(echo "$CONTEXT" | jq -r .application.slug) +distribution_environment=$(echo "$CONTEXT" | jq -r .scope.slug) +distribution_project_id="must fill this value" + +if [ -z "$distribution_project_id" ]; then + echo "✗ GCP project not configured. Run provider/gcp setup first." + exit 1 +fi + +TOFU_VARIABLES=$(echo "$TOFU_VARIABLES" | jq \ + --arg app_name "$distribution_app_name" \ + --arg environment "$distribution_environment" \ + --arg project_id "$distribution_project_id" \ + '. + { + distribution_app_name: $app_name, + distribution_environment: $environment, + distribution_project_id: $project_id + }') + +# Add module to composition list +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +module_name="${script_dir#*deployment/}" +MODULES_TO_USE="${MODULES_TO_USE:+$MODULES_TO_USE,}$module_name" diff --git a/frontend/deployment/distribution/gcs-cdn/modules/main.tf b/frontend/deployment/distribution/gcs-cdn/modules/main.tf new file mode 100644 index 00000000..5182dd48 --- /dev/null +++ b/frontend/deployment/distribution/gcs-cdn/modules/main.tf @@ -0,0 +1,193 @@ +# GCP Cloud Storage + Cloud CDN Hosting +# Resources for GCS static hosting with Cloud CDN + +variable "distribution_project_id" { + description = "GCP project ID" + type = string +} + +variable "distribution_app_name" { + description = "Application name" + type = string +} + +variable "distribution_environment" { + description = "Environment (dev, staging, prod)" + type = string + default = "prod" +} + +variable "distribution_region" { + description = "GCP region" + type = string + default = "us-central1" +} + +variable "distribution_custom_domain" { + description = "Custom domain (e.g., app.example.com)" + type = string + default = null +} + +variable "distribution_labels" { + description = "Resource labels" + type = map(string) + default = {} +} + +locals { + distribution_bucket_name = "${var.distribution_app_name}-${var.distribution_environment}-static-${var.distribution_project_id}" + + distribution_default_labels = merge(var.distribution_labels, { + application = var.distribution_app_name + environment = var.distribution_environment + managed_by = "terraform" + }) +} + +resource "google_storage_bucket" "static" { + name = local.distribution_bucket_name + project = var.distribution_project_id + location = var.distribution_region + force_destroy = false + + website { + main_page_suffix = "index.html" + not_found_page = "index.html" + } + + versioning { + enabled = true + } + + cors { + origin = ["*"] + method = ["GET", "HEAD", "OPTIONS"] + response_header = ["*"] + max_age_seconds = 3600 + } + + uniform_bucket_level_access = true + + labels = local.distribution_default_labels +} + +resource "google_storage_bucket_iam_member" "public_read" { + bucket = google_storage_bucket.static.name + role = "roles/storage.objectViewer" + member = "allUsers" +} + +resource "google_compute_backend_bucket" "static" { + name = "${var.distribution_app_name}-${var.distribution_environment}-backend" + project = var.distribution_project_id + bucket_name = google_storage_bucket.static.name + + enable_cdn = true + + cdn_policy { + cache_mode = "CACHE_ALL_STATIC" + client_ttl = 3600 + default_ttl = 3600 + max_ttl = 86400 + negative_caching = true + serve_while_stale = 86400 + + negative_caching_policy { + code = 404 + ttl = 60 + } + } +} + +resource "google_compute_url_map" "static" { + name = "${var.distribution_app_name}-${var.distribution_environment}-urlmap" + project = var.distribution_project_id + default_service = google_compute_backend_bucket.static.id +} + +resource "google_compute_managed_ssl_certificate" "static" { + count = var.distribution_custom_domain != null ? 1 : 0 + name = "${var.distribution_app_name}-${var.distribution_environment}-cert" + project = var.distribution_project_id + + managed { + domains = [var.distribution_custom_domain] + } +} + +resource "google_compute_target_https_proxy" "static" { + count = var.distribution_custom_domain != null ? 1 : 0 + name = "${var.distribution_app_name}-${var.distribution_environment}-https-proxy" + project = var.distribution_project_id + url_map = google_compute_url_map.static.id + ssl_certificates = [google_compute_managed_ssl_certificate.static[0].id] +} + +resource "google_compute_target_http_proxy" "static" { + name = "${var.distribution_app_name}-${var.distribution_environment}-http-proxy" + project = var.distribution_project_id + url_map = google_compute_url_map.http_redirect.id +} + +resource "google_compute_url_map" "http_redirect" { + name = "${var.distribution_app_name}-${var.distribution_environment}-http-redirect" + project = var.distribution_project_id + + default_url_redirect { + https_redirect = true + redirect_response_code = "MOVED_PERMANENTLY_DEFAULT" + strip_query = false + } +} + +resource "google_compute_global_address" "static" { + name = "${var.distribution_app_name}-${var.distribution_environment}-ip" + project = var.distribution_project_id +} + +resource "google_compute_global_forwarding_rule" "https" { + count = var.distribution_custom_domain != null ? 1 : 0 + name = "${var.distribution_app_name}-${var.distribution_environment}-https-rule" + project = var.distribution_project_id + ip_address = google_compute_global_address.static.address + ip_protocol = "TCP" + port_range = "443" + target = google_compute_target_https_proxy.static[0].id + load_balancing_scheme = "EXTERNAL_MANAGED" +} + +resource "google_compute_global_forwarding_rule" "http" { + name = "${var.distribution_app_name}-${var.distribution_environment}-http-rule" + project = var.distribution_project_id + ip_address = google_compute_global_address.static.address + ip_protocol = "TCP" + port_range = "80" + target = google_compute_target_http_proxy.static.id + load_balancing_scheme = "EXTERNAL_MANAGED" +} + +output "distribution_bucket_name" { + description = "GCS bucket name" + value = google_storage_bucket.static.name +} + +output "distribution_bucket_url" { + description = "GCS bucket URL" + value = google_storage_bucket.static.url +} + +output "distribution_load_balancer_ip" { + description = "Load Balancer IP" + value = google_compute_global_address.static.address +} + +output "distribution_website_url" { + description = "Website URL" + value = var.distribution_custom_domain != null ? "https://${var.distribution_custom_domain}" : "http://${google_compute_global_address.static.address}" +} + +output "distribution_upload_command" { + description = "Command to upload files" + value = "gsutil -m rsync -r ./dist gs://${google_storage_bucket.static.name}" +} diff --git a/frontend/deployment/distribution/gcs-cdn/setup b/frontend/deployment/distribution/gcs-cdn/setup new file mode 100755 index 00000000..5637f51d --- /dev/null +++ b/frontend/deployment/distribution/gcs-cdn/setup @@ -0,0 +1,30 @@ +#!/bin/bash + +# GCS + Cloud CDN Hosting Setup + +distribution_app_name=$(echo "$CONTEXT" | jq -r .application.slug) +distribution_environment=$(echo "$CONTEXT" | jq -r .scope.slug) +distribution_project_id="must fill this value" +distribution_region="must fill this value" + +if [ -z "$distribution_project_id" ]; then + echo "✗ GCP project not configured. Run provider/gcp setup first." + exit 1 +fi + +TOFU_VARIABLES=$(echo "$TOFU_VARIABLES" | jq \ + --arg app_name "$distribution_app_name" \ + --arg environment "$distribution_environment" \ + --arg project_id "$distribution_project_id" \ + --arg region "$distribution_region" \ + '. + { + distribution_app_name: $app_name, + distribution_environment: $environment, + distribution_project_id: $project_id, + distribution_region: $region + }') + +# Add module to composition list +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +module_name="${script_dir#*deployment/}" +MODULES_TO_USE="${MODULES_TO_USE:+$MODULES_TO_USE,}$module_name" diff --git a/frontend/deployment/distribution/static-web-apps/modules/main.tf b/frontend/deployment/distribution/static-web-apps/modules/main.tf new file mode 100644 index 00000000..a0ac20f0 --- /dev/null +++ b/frontend/deployment/distribution/static-web-apps/modules/main.tf @@ -0,0 +1,107 @@ +# Azure Static Web Apps Hosting +# Resources for Azure Static Web Apps + +variable "distribution_app_name" { + description = "Application name" + type = string +} + +variable "distribution_environment" { + description = "Environment (dev, staging, prod)" + type = string + default = "prod" +} + +variable "distribution_location" { + description = "Azure region" + type = string + default = "eastus2" +} + +variable "distribution_sku_tier" { + description = "SKU tier (Free or Standard)" + type = string + default = "Free" +} + +variable "distribution_sku_size" { + description = "SKU size" + type = string + default = "Free" +} + +variable "distribution_custom_domains" { + description = "List of custom domains" + type = list(string) + default = [] +} + +variable "distribution_tags" { + description = "Resource tags" + type = map(string) + default = {} +} + +locals { + distribution_default_tags = merge(var.distribution_tags, { + Application = var.distribution_app_name + Environment = var.distribution_environment + ManagedBy = "terraform" + }) +} + +resource "azurerm_resource_group" "main" { + name = "rg-${var.distribution_app_name}-${var.distribution_environment}" + location = var.distribution_location + tags = local.distribution_default_tags +} + +resource "azurerm_static_web_app" "main" { + name = "swa-${var.distribution_app_name}-${var.distribution_environment}" + resource_group_name = azurerm_resource_group.main.name + location = var.distribution_location + + sku_tier = var.distribution_sku_tier + sku_size = var.distribution_sku_size + + tags = local.distribution_default_tags +} + +resource "azurerm_static_web_app_custom_domain" "main" { + for_each = toset(var.distribution_custom_domains) + + static_web_app_id = azurerm_static_web_app.main.id + domain_name = each.value + validation_type = "cname-delegation" +} + +output "distribution_resource_group_name" { + description = "Resource Group name" + value = azurerm_resource_group.main.name +} + +output "distribution_static_web_app_id" { + description = "Static Web App ID" + value = azurerm_static_web_app.main.id +} + +output "distribution_static_web_app_name" { + description = "Static Web App name" + value = azurerm_static_web_app.main.name +} + +output "distribution_default_hostname" { + description = "Default hostname" + value = azurerm_static_web_app.main.default_host_name +} + +output "distribution_website_url" { + description = "Website URL" + value = length(var.distribution_custom_domains) > 0 ? "https://${var.distribution_custom_domains[0]}" : "https://${azurerm_static_web_app.main.default_host_name}" +} + +output "distribution_api_key" { + description = "API key for deployments" + value = azurerm_static_web_app.main.api_key + sensitive = true +} diff --git a/frontend/deployment/distribution/static-web-apps/setup b/frontend/deployment/distribution/static-web-apps/setup new file mode 100755 index 00000000..c51d9f6f --- /dev/null +++ b/frontend/deployment/distribution/static-web-apps/setup @@ -0,0 +1,19 @@ +#!/bin/bash + +# Azure Static Web Apps Hosting Setup + +distribution_app_name=$(echo "$CONTEXT" | jq -r .application.slug) +distribution_environment=$(echo "$CONTEXT" | jq -r .scope.slug) + +TOFU_VARIABLES=$(echo "$TOFU_VARIABLES" | jq \ + --arg app_name "$distribution_app_name" \ + --arg environment "$distribution_environment" \ + '. + { + distribution_app_name: $app_name, + distribution_environment: $environment + }') + +# Add module to composition list +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +module_name="${script_dir#*deployment/}" +MODULES_TO_USE="${MODULES_TO_USE:+$MODULES_TO_USE,}$module_name" diff --git a/frontend/deployment/network/azure_dns/modules/azure_dns.tftest.hcl b/frontend/deployment/network/azure_dns/modules/azure_dns.tftest.hcl new file mode 100644 index 00000000..b5dd0088 --- /dev/null +++ b/frontend/deployment/network/azure_dns/modules/azure_dns.tftest.hcl @@ -0,0 +1,197 @@ +# ============================================================================= +# Unit tests for network/azure_dns module +# +# Run: tofu test +# ============================================================================= + +mock_provider "azurerm" { + mock_data "azurerm_dns_zone" { + defaults = { + id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/my-resource-group/providers/Microsoft.Network/dnszones/example.com" + } + } +} + +variables { + network_dns_zone_name = "example.com" + network_domain = "example.com" + network_subdomain = "app" + + azure_provider = { + subscription_id = "00000000-0000-0000-0000-000000000000" + resource_group = "my-resource-group" + storage_account = "mytfstatestorage" + container = "tfstate" + } + + # These come from the distribution module (e.g., blob-cdn) + distribution_target_domain = "myapp.azureedge.net" + distribution_record_type = "CNAME" +} + +# ============================================================================= +# Test: Full domain is computed correctly with subdomain +# ============================================================================= +run "full_domain_with_subdomain" { + command = plan + + assert { + condition = local.network_full_domain == "app.example.com" + error_message = "Full domain should be 'app.example.com', got '${local.network_full_domain}'" + } +} + +# ============================================================================= +# Test: Full domain is computed correctly without subdomain (apex) +# ============================================================================= +run "full_domain_apex" { + command = plan + + variables { + network_subdomain = "" + } + + assert { + condition = local.network_full_domain == "example.com" + error_message = "Full domain should be 'example.com' for apex, got '${local.network_full_domain}'" + } +} + +# ============================================================================= +# Test: CNAME record is created for CNAME type +# ============================================================================= +run "creates_cname_record_for_type_cname" { + command = plan + + variables { + distribution_record_type = "CNAME" + } + + assert { + condition = length(azurerm_dns_cname_record.main) == 1 + error_message = "Should create one CNAME record" + } + + assert { + condition = length(azurerm_dns_a_record.main) == 0 + error_message = "Should not create A record when type is CNAME" + } +} + +# ============================================================================= +# Test: A record is created for A type +# ============================================================================= +run "creates_a_record_for_type_a" { + command = plan + + variables { + distribution_record_type = "A" + } + + assert { + condition = length(azurerm_dns_a_record.main) == 1 + error_message = "Should create one A record" + } + + assert { + condition = length(azurerm_dns_cname_record.main) == 0 + error_message = "Should not create CNAME record when type is A" + } +} + +# ============================================================================= +# Test: CNAME record configuration +# ============================================================================= +run "cname_record_configuration" { + command = plan + + variables { + distribution_record_type = "CNAME" + } + + assert { + condition = azurerm_dns_cname_record.main[0].zone_name == "example.com" + error_message = "CNAME record should use the correct DNS zone" + } + + assert { + condition = azurerm_dns_cname_record.main[0].name == "app" + error_message = "CNAME record name should be the subdomain" + } + + assert { + condition = azurerm_dns_cname_record.main[0].ttl == 300 + error_message = "CNAME TTL should be 300" + } + + assert { + condition = azurerm_dns_cname_record.main[0].record == "myapp.azureedge.net" + error_message = "CNAME record should point to distribution target domain" + } + + assert { + condition = azurerm_dns_cname_record.main[0].resource_group_name == "my-resource-group" + error_message = "CNAME record should be in the correct resource group" + } +} + +# ============================================================================= +# Test: A record configuration +# ============================================================================= +run "a_record_configuration" { + command = plan + + variables { + distribution_record_type = "A" + distribution_target_domain = "10.0.0.1" + } + + assert { + condition = azurerm_dns_a_record.main[0].zone_name == "example.com" + error_message = "A record should use the correct DNS zone" + } + + assert { + condition = azurerm_dns_a_record.main[0].name == "app" + error_message = "A record name should be the subdomain" + } + + assert { + condition = azurerm_dns_a_record.main[0].ttl == 300 + error_message = "A record TTL should be 300" + } + + assert { + condition = azurerm_dns_a_record.main[0].resource_group_name == "my-resource-group" + error_message = "A record should be in the correct resource group" + } +} + +# ============================================================================= +# Test: Outputs +# ============================================================================= +run "outputs_are_correct" { + command = plan + + assert { + condition = output.network_full_domain == "app.example.com" + error_message = "network_full_domain output should be 'app.example.com'" + } + + assert { + condition = output.network_website_url == "https://app.example.com" + error_message = "network_website_url output should be 'https://app.example.com'" + } +} + +# ============================================================================= +# Test: DNS zone variable is correctly passed +# ============================================================================= +run "dns_zone_variable_configuration" { + command = plan + + assert { + condition = var.network_dns_zone_name == "example.com" + error_message = "DNS zone name variable should be 'example.com'" + } +} diff --git a/frontend/deployment/network/azure_dns/modules/locals.tf b/frontend/deployment/network/azure_dns/modules/locals.tf new file mode 100644 index 00000000..b2cba907 --- /dev/null +++ b/frontend/deployment/network/azure_dns/modules/locals.tf @@ -0,0 +1,7 @@ +locals { + # Expose network_domain for cross-module use (e.g., certificate lookup) + network_domain = var.network_domain + + # Compute full domain from domain + subdomain + network_full_domain = var.network_subdomain != "" ? "${var.network_subdomain}.${var.network_domain}" : var.network_domain +} diff --git a/frontend/deployment/network/azure_dns/modules/main.tf b/frontend/deployment/network/azure_dns/modules/main.tf new file mode 100644 index 00000000..59380599 --- /dev/null +++ b/frontend/deployment/network/azure_dns/modules/main.tf @@ -0,0 +1,34 @@ +# ============================================================================= +# Azure DNS Network +# +# Creates DNS records in Azure DNS zone for the distribution endpoint. +# Supports both CNAME records (for CDN endpoints) and A records (for static IPs). +# ============================================================================= + +# Get DNS zone details +data "azurerm_dns_zone" "main" { + name = var.network_dns_zone_name + resource_group_name = var.azure_provider.resource_group +} + +# CNAME record for CDN endpoints +resource "azurerm_dns_cname_record" "main" { + count = local.distribution_record_type == "CNAME" ? 1 : 0 + + name = var.network_subdomain + zone_name = var.network_dns_zone_name + resource_group_name = var.azure_provider.resource_group + ttl = 300 + record = local.distribution_target_domain +} + +# A record for static IPs (if needed in the future) +resource "azurerm_dns_a_record" "main" { + count = local.distribution_record_type == "A" ? 1 : 0 + + name = var.network_subdomain + zone_name = var.network_dns_zone_name + resource_group_name = var.azure_provider.resource_group + ttl = 300 + records = [local.distribution_target_domain] +} diff --git a/frontend/deployment/network/azure_dns/modules/outputs.tf b/frontend/deployment/network/azure_dns/modules/outputs.tf new file mode 100644 index 00000000..5b339bea --- /dev/null +++ b/frontend/deployment/network/azure_dns/modules/outputs.tf @@ -0,0 +1,14 @@ +output "network_full_domain" { + description = "Full domain name (subdomain.domain or just domain)" + value = local.network_full_domain +} + +output "network_fqdn" { + description = "Fully qualified domain name" + value = local.distribution_record_type == "CNAME" ? azurerm_dns_cname_record.main[0].fqdn : azurerm_dns_a_record.main[0].fqdn +} + +output "network_website_url" { + description = "Website URL" + value = "https://${local.network_full_domain}" +} diff --git a/frontend/deployment/network/azure_dns/modules/test_locals.tf b/frontend/deployment/network/azure_dns/modules/test_locals.tf new file mode 100644 index 00000000..1d4d7d47 --- /dev/null +++ b/frontend/deployment/network/azure_dns/modules/test_locals.tf @@ -0,0 +1,39 @@ +# ============================================================================= +# Test-only locals +# +# This file provides the distribution_* locals that are normally defined by the +# distribution layer (blob-cdn, etc.) when modules are composed. +# This file is only used for running isolated unit tests. +# +# NOTE: Files matching test_*.tf are skipped by compose_modules +# ============================================================================= + +# Test-only variables to allow tests to control the distribution values +variable "distribution_target_domain" { + description = "Test-only: Target domain from distribution provider" + type = string + default = "myapp.azureedge.net" +} + +variable "distribution_record_type" { + description = "Test-only: DNS record type (A or CNAME)" + type = string + default = "CNAME" +} + +variable "azure_provider" { + description = "Azure provider configuration" + type = object({ + subscription_id = string + resource_group = string + storage_account = string + container = string + }) +} + +locals { + # These locals are normally provided by distribution modules (e.g., blob-cdn) + # For testing, we bridge from variables to locals + distribution_target_domain = var.distribution_target_domain + distribution_record_type = var.distribution_record_type +} diff --git a/frontend/deployment/network/azure_dns/modules/variables.tf b/frontend/deployment/network/azure_dns/modules/variables.tf new file mode 100644 index 00000000..2c0c0fbd --- /dev/null +++ b/frontend/deployment/network/azure_dns/modules/variables.tf @@ -0,0 +1,15 @@ +variable "network_dns_zone_name" { + description = "Azure DNS zone name" + type = string +} + +variable "network_domain" { + description = "Root domain name (e.g., example.com)" + type = string +} + +variable "network_subdomain" { + description = "Subdomain prefix (e.g., 'app' for app.example.com, empty string for apex)" + type = string + default = "" +} \ No newline at end of file diff --git a/frontend/deployment/network/azure_dns/setup b/frontend/deployment/network/azure_dns/setup new file mode 100755 index 00000000..10b201e2 --- /dev/null +++ b/frontend/deployment/network/azure_dns/setup @@ -0,0 +1,174 @@ +#!/bin/bash + +echo "🔍 Validating Azure DNS network configuration..." + +public_dns_zone_name=$(echo "$CONTEXT" | jq -r '.providers["cloud-providers"].networking.public_dns_zone_name // empty') + +if [ -z "$public_dns_zone_name" ]; then + echo "" + echo " ❌ public_dns_zone_name is not set in context" + echo "" + echo " 🔧 How to fix:" + echo " • Ensure there is an Azure cloud-provider configured at the correct NRN hierarchy level" + echo " • Set the 'public_dns_zone_name' field with the Azure DNS zone name" + echo "" + exit 1 +fi +echo " ✅ public_dns_zone_name=$public_dns_zone_name" + +application_slug=$(echo "$CONTEXT" | jq -r .application.slug) +scope_slug=$(echo "$CONTEXT" | jq -r .scope.slug) +scope_id=$(echo "$CONTEXT" | jq -r .scope.id) +public_dns_zone_resource_group_name=$(echo "$CONTEXT" | jq -r '.providers["cloud-providers"].networking.public_dns_zone_resource_group_name // empty') + +if [ -z "$public_dns_zone_resource_group_name" ]; then + echo "" + echo " ❌ public_dns_zone_resource_group_name is not set in context" + echo "" + echo " 🔧 How to fix:" + echo " • Ensure the Azure cloud-provider has 'public_dns_zone_resource_group_name' configured" + echo "" + exit 1 +fi +echo " ✅ public_dns_zone_resource_group_name=$public_dns_zone_resource_group_name" + +echo "" +echo " 📡 Verifying Azure DNS zone..." + +stderr_file=$(mktemp) +az_output=$(az network dns zone show --name "$public_dns_zone_name" --resource-group "$public_dns_zone_resource_group_name" 2>"$stderr_file") +az_exit_code=$? +az_stderr=$(cat "$stderr_file") +rm -f "$stderr_file" + +if [ $az_exit_code -ne 0 ]; then + echo "" + echo " ❌ Failed to fetch Azure DNS zone information" + echo "" + + if echo "$az_stderr" | grep -q "ResourceNotFound\|NotFound"; then + echo " 🔎 Error: DNS zone '$public_dns_zone_name' does not exist in resource group '$public_dns_zone_resource_group_name'" + echo "" + echo " 💡 Possible causes:" + echo " • The DNS zone name is incorrect or has a typo" + echo " • The DNS zone was deleted" + echo " • The resource group is incorrect" + echo "" + echo " 🔧 How to fix:" + echo " • Verify the DNS zone exists: az network dns zone list --resource-group $public_dns_zone_resource_group_name" + echo " • Update 'public_dns_zone_name' in the Azure cloud-provider configuration" + + elif echo "$az_stderr" | grep -q "AuthorizationFailed\|Forbidden\|403"; then + echo " 🔒 Error: Permission denied when accessing Azure DNS" + echo "" + echo " 💡 Possible causes:" + echo " • The Azure credentials don't have DNS Zone read permissions" + echo " • The service principal is missing the 'DNS Zone Contributor' role" + echo "" + echo " 🔧 How to fix:" + echo " • Check the Azure credentials are configured correctly" + echo " • Ensure the service principal has 'DNS Zone Reader' or 'DNS Zone Contributor' role" + + elif echo "$az_stderr" | grep -q "InvalidSubscriptionId\|SubscriptionNotFound"; then + echo " ⚠️ Error: Invalid subscription" + echo "" + echo " 🔧 How to fix:" + echo " • Verify the Azure subscription is correct" + echo " • Check the service principal has access to the subscription" + + elif echo "$az_stderr" | grep -q "AADSTS\|InvalidAuthenticationToken\|ExpiredAuthenticationToken"; then + echo " 🔑 Error: Azure credentials issue" + echo "" + echo " 💡 Possible causes:" + echo " • The nullplatform agent is not configured with Azure credentials" + echo " • The service principal credentials have expired" + echo "" + echo " 🔧 How to fix:" + echo " • Configure Azure credentials in the nullplatform agent" + echo " • Verify the service principal credentials are valid" + + else + echo " 📋 Error details:" + echo "$az_stderr" | sed 's/^/ /' + fi + + echo "" + exit 1 +fi + +network_domain=$(echo "$az_output" | jq -r '.name') + +if [ -z "$network_domain" ] || [ "$network_domain" = "null" ]; then + echo "" + echo " ❌ Failed to extract domain name from DNS zone response" + echo "" + echo " 💡 Possible causes:" + echo " • The DNS zone does not have a valid domain name configured" + echo "" + echo " 🔧 How to fix:" + echo " • Verify the DNS zone has a valid domain: az network dns zone show --name $public_dns_zone_name --resource-group $public_dns_zone_resource_group_name" + echo "" + exit 1 +fi + +echo " ✅ domain=$network_domain" + +network_subdomain="$application_slug-$scope_slug" +echo " ✅ subdomain=$network_subdomain" + +TOFU_VARIABLES=$(echo "$TOFU_VARIABLES" | jq \ + --arg dns_zone_name "$public_dns_zone_name" \ + --arg domain "$network_domain" \ + --arg subdomain "$network_subdomain" \ + '. + { + network_dns_zone_name: $dns_zone_name, + network_domain: $domain, + network_subdomain: $subdomain + }') + +scope_domain="$network_subdomain.$network_domain" + +echo "" +echo " 📝 Setting scope domain to '$scope_domain'..." + +np_output=$(np scope patch --id "$scope_id" --body "{\"domain\":\"$scope_domain\"}" --format json 2>&1) +np_exit_code=$? + +if [ $np_exit_code -ne 0 ]; then + echo "" + echo " ❌ Failed to update scope domain" + echo "" + + if echo "$np_output" | grep -q "unauthorized\|forbidden\|401\|403"; then + echo " 🔒 Error: Permission denied" + echo "" + echo " 💡 Possible causes:" + echo " • The nullplatform API Key doesn't have 'Developer' permissions" + echo "" + echo " 🔧 How to fix:" + echo " • Ensure the API Key has 'Developer' permissions at the correct NRN hierarchy level" + + else + echo " 📋 Error details:" + echo "$np_output" | sed 's/^/ /' + fi + + echo "" + exit 1 +fi + +echo " ✅ Scope domain set successfully" + +echo "" +echo "✨ Azure DNS network configured successfully" +echo "" + +# Add module to composition list +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +module_name="${script_dir}/modules" + +if [[ -n $MODULES_TO_USE ]]; then + MODULES_TO_USE="$MODULES_TO_USE,$module_name" +else + MODULES_TO_USE="$module_name" +fi \ No newline at end of file diff --git a/frontend/deployment/network/cloud_dns/modules/main.tf b/frontend/deployment/network/cloud_dns/modules/main.tf new file mode 100644 index 00000000..897803e9 --- /dev/null +++ b/frontend/deployment/network/cloud_dns/modules/main.tf @@ -0,0 +1,105 @@ +# GCP Cloud DNS Configuration +# Creates DNS records pointing to hosting resources (Load Balancer, Firebase, etc.) + +variable "network_project_id" { + description = "GCP project ID" + type = string +} + +variable "network_managed_zone" { + description = "Cloud DNS managed zone name" + type = string +} + +variable "network_domain" { + description = "Domain name for the application" + type = string +} + +variable "network_target_ip" { + description = "Target IP address (for A records)" + type = string + default = null +} + +variable "network_target_domain" { + description = "Target domain (for CNAME records)" + type = string + default = null +} + +variable "network_record_type" { + description = "DNS record type (A or CNAME)" + type = string + default = "A" +} + +variable "network_ttl" { + description = "DNS record TTL in seconds" + type = number + default = 300 +} + +variable "network_create_www" { + description = "Create www subdomain record as well" + type = bool + default = true +} + +# A record +resource "google_dns_record_set" "main_a" { + count = var.network_record_type == "A" && var.network_target_ip != null ? 1 : 0 + + name = "${var.network_domain}." + project = var.network_project_id + type = "A" + ttl = var.network_ttl + managed_zone = var.network_managed_zone + rrdatas = [var.network_target_ip] +} + +# CNAME record +resource "google_dns_record_set" "main_cname" { + count = var.network_record_type == "CNAME" && var.network_target_domain != null ? 1 : 0 + + name = "${var.network_domain}." + project = var.network_project_id + type = "CNAME" + ttl = var.network_ttl + managed_zone = var.network_managed_zone + rrdatas = ["${var.network_target_domain}."] +} + +# WWW subdomain (A record) +resource "google_dns_record_set" "www_a" { + count = var.network_create_www && var.network_record_type == "A" && var.network_target_ip != null ? 1 : 0 + + name = "www.${var.network_domain}." + project = var.network_project_id + type = "A" + ttl = var.network_ttl + managed_zone = var.network_managed_zone + rrdatas = [var.network_target_ip] +} + +# WWW subdomain (CNAME record) +resource "google_dns_record_set" "www_cname" { + count = var.network_create_www && var.network_record_type == "CNAME" && var.network_target_domain != null ? 1 : 0 + + name = "www.${var.network_domain}." + project = var.network_project_id + type = "CNAME" + ttl = var.network_ttl + managed_zone = var.network_managed_zone + rrdatas = ["${var.network_target_domain}."] +} + +output "network_domain" { + description = "Configured domain" + value = var.network_domain +} + +output "network_website_url" { + description = "Website URL" + value = "https://${var.network_domain}" +} diff --git a/frontend/deployment/network/cloud_dns/setup b/frontend/deployment/network/cloud_dns/setup new file mode 100755 index 00000000..9ae7b088 --- /dev/null +++ b/frontend/deployment/network/cloud_dns/setup @@ -0,0 +1,43 @@ +#!/bin/bash + +# Cloud DNS Setup +# Configures DNS variables based on hosting output + +network_project_id="must fill this value" + +if [ -z "$network_project_id" ]; then + echo "✗ GCP project not configured. Run provider/gcp setup first." + exit 1 +fi + +managed_zone=$(echo "$CONTEXT" | jq -r '.providers["cloud-providers"].networking.managed_zone // empty') +if [ -z "$managed_zone" ]; then + echo "✗ managed_zone is not set in context" + exit 1 +fi + +# Get domain from scope or context +network_domain=$(echo "$TOFU_VARIABLES" | jq -r '.scope_domain // empty') +if [ -z "$network_domain" ]; then + network_domain=$(echo "$CONTEXT" | jq -r '.scope.domain // empty') +fi + +# Get target from hosting outputs (Load Balancer IP, etc.) +network_target_ip=$(echo "$TOFU_VARIABLES" | jq -r '.distribution_load_balancer_ip // empty') + +TOFU_VARIABLES=$(echo "$TOFU_VARIABLES" | jq \ + --arg project_id "$network_project_id" \ + --arg managed_zone "$managed_zone" \ + --arg domain "$network_domain" \ + --arg target_ip "$network_target_ip" \ + '. + { + network_project_id: $project_id, + network_managed_zone: $managed_zone, + network_domain: $domain, + network_target_ip: $target_ip + }') + +# Add module to composition list +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +module_name="${script_dir#*deployment/}" +MODULES_TO_USE="${MODULES_TO_USE:+$MODULES_TO_USE,}$module_name" diff --git a/frontend/deployment/network/route53/modules/locals.tf b/frontend/deployment/network/route53/modules/locals.tf new file mode 100644 index 00000000..daf83ed9 --- /dev/null +++ b/frontend/deployment/network/route53/modules/locals.tf @@ -0,0 +1,7 @@ +locals { + # Expose network_domain for cross-module use (e.g., ACM certificate lookup) + network_domain = var.network_domain + + # Compute full domain from domain + subdomain + network_full_domain = var.network_subdomain != "" ? "${var.network_subdomain}.${var.network_domain}" : var.network_domain +} \ No newline at end of file diff --git a/frontend/deployment/network/route53/modules/main.tf b/frontend/deployment/network/route53/modules/main.tf new file mode 100644 index 00000000..70ed2180 --- /dev/null +++ b/frontend/deployment/network/route53/modules/main.tf @@ -0,0 +1,23 @@ +resource "aws_route53_record" "main_alias" { + count = local.distribution_record_type == "A" ? 1 : 0 + + zone_id = var.network_hosted_zone_id + name = local.network_full_domain + type = "A" + + alias { + name = local.distribution_target_domain + zone_id = local.distribution_target_zone_id + evaluate_target_health = false + } +} + +resource "aws_route53_record" "main_cname" { + count = local.distribution_record_type == "CNAME" ? 1 : 0 + + zone_id = var.network_hosted_zone_id + name = local.network_full_domain + type = "CNAME" + ttl = 300 + records = [local.distribution_target_domain] +} diff --git a/frontend/deployment/network/route53/modules/outputs.tf b/frontend/deployment/network/route53/modules/outputs.tf new file mode 100644 index 00000000..385a1f19 --- /dev/null +++ b/frontend/deployment/network/route53/modules/outputs.tf @@ -0,0 +1,14 @@ +output "network_full_domain" { + description = "Full domain name (subdomain.domain or just domain)" + value = local.network_full_domain +} + +output "network_fqdn" { + description = "Fully qualified domain name" + value = local.distribution_record_type == "A" ? aws_route53_record.main_alias[0].fqdn : aws_route53_record.main_cname[0].fqdn +} + +output "network_website_url" { + description = "Website URL" + value = "https://${local.network_full_domain}" +} \ No newline at end of file diff --git a/frontend/deployment/network/route53/modules/route53.tftest.hcl b/frontend/deployment/network/route53/modules/route53.tftest.hcl new file mode 100644 index 00000000..b43cd7eb --- /dev/null +++ b/frontend/deployment/network/route53/modules/route53.tftest.hcl @@ -0,0 +1,157 @@ +# ============================================================================= +# Unit tests for network/route53 module +# +# Run: tofu test +# ============================================================================= + +mock_provider "aws" {} + +variables { + network_hosted_zone_id = "Z1234567890ABC" + network_domain = "example.com" + network_subdomain = "app" + + # These come from the distribution module (e.g., cloudfront) + distribution_target_domain = "d1234567890.cloudfront.net" + distribution_target_zone_id = "Z2FDTNDATAQYW2" + distribution_record_type = "A" +} + +# ============================================================================= +# Test: Full domain is computed correctly with subdomain +# ============================================================================= +run "full_domain_with_subdomain" { + command = plan + + assert { + condition = local.network_full_domain == "app.example.com" + error_message = "Full domain should be 'app.example.com', got '${local.network_full_domain}'" + } +} + +# ============================================================================= +# Test: Full domain is computed correctly without subdomain (apex) +# ============================================================================= +run "full_domain_apex" { + command = plan + + variables { + network_subdomain = "" + } + + assert { + condition = local.network_full_domain == "example.com" + error_message = "Full domain should be 'example.com' for apex, got '${local.network_full_domain}'" + } +} + +# ============================================================================= +# Test: A record is created for alias type +# ============================================================================= +run "creates_alias_record_for_type_a" { + command = plan + + variables { + distribution_record_type = "A" + } + + assert { + condition = length(aws_route53_record.main_alias) == 1 + error_message = "Should create one A alias record" + } + + assert { + condition = length(aws_route53_record.main_cname) == 0 + error_message = "Should not create CNAME record when type is A" + } +} + +# ============================================================================= +# Test: CNAME record is created for CNAME type +# ============================================================================= +run "creates_cname_record_for_type_cname" { + command = plan + + variables { + distribution_record_type = "CNAME" + } + + assert { + condition = length(aws_route53_record.main_cname) == 1 + error_message = "Should create one CNAME record" + } + + assert { + condition = length(aws_route53_record.main_alias) == 0 + error_message = "Should not create A alias record when type is CNAME" + } +} + +# ============================================================================= +# Test: A record configuration +# ============================================================================= +run "alias_record_configuration" { + command = plan + + variables { + distribution_record_type = "A" + } + + assert { + condition = aws_route53_record.main_alias[0].zone_id == "Z1234567890ABC" + error_message = "Record should use the correct hosted zone ID" + } + + assert { + condition = aws_route53_record.main_alias[0].type == "A" + error_message = "Record type should be A" + } + + assert { + condition = aws_route53_record.main_alias[0].name == "app.example.com" + error_message = "Record name should be the full domain" + } +} + +# ============================================================================= +# Test: CNAME record configuration +# ============================================================================= +run "cname_record_configuration" { + command = plan + + variables { + distribution_record_type = "CNAME" + } + + assert { + condition = aws_route53_record.main_cname[0].zone_id == "Z1234567890ABC" + error_message = "Record should use the correct hosted zone ID" + } + + assert { + condition = aws_route53_record.main_cname[0].type == "CNAME" + error_message = "Record type should be CNAME" + } + + assert { + condition = aws_route53_record.main_cname[0].ttl == 300 + error_message = "CNAME TTL should be 300" + } +} + +# ============================================================================= +# Test: Outputs +# ============================================================================= +run "outputs_are_correct" { + command = plan + + assert { + condition = output.network_full_domain == "app.example.com" + error_message = "network_full_domain output should be 'app.example.com'" + } + + assert { + condition = output.network_website_url == "https://app.example.com" + error_message = "network_website_url output should be 'https://app.example.com'" + } +} diff --git a/frontend/deployment/network/route53/modules/test_locals.tf b/frontend/deployment/network/route53/modules/test_locals.tf new file mode 100644 index 00000000..5120b86e --- /dev/null +++ b/frontend/deployment/network/route53/modules/test_locals.tf @@ -0,0 +1,36 @@ +# ============================================================================= +# Test-only locals +# +# This file provides the distribution_* locals that are normally defined by the +# distribution layer (CloudFront, Amplify, etc.) when modules are composed. +# This file is only used for running isolated unit tests. +# +# NOTE: Files matching test_*.tf are skipped by compose_modules +# ============================================================================= + +# Test-only variables to allow tests to control the hosting values +variable "distribution_target_domain" { + description = "Test-only: Target domain from hosting provider" + type = string + default = "d1234567890.cloudfront.net" +} + +variable "distribution_target_zone_id" { + description = "Test-only: Hosted zone ID from hosting provider" + type = string + default = "Z2FDTNDATAQYW2" +} + +variable "distribution_record_type" { + description = "Test-only: DNS record type (A or CNAME)" + type = string + default = "A" +} + +locals { + # These locals are normally provided by distribution modules (e.g., CloudFront) + # For testing, we bridge from variables to locals + distribution_target_domain = var.distribution_target_domain + distribution_target_zone_id = var.distribution_target_zone_id + distribution_record_type = var.distribution_record_type +} diff --git a/frontend/deployment/network/route53/modules/variables.tf b/frontend/deployment/network/route53/modules/variables.tf new file mode 100644 index 00000000..9f67cf99 --- /dev/null +++ b/frontend/deployment/network/route53/modules/variables.tf @@ -0,0 +1,15 @@ +variable "network_hosted_zone_id" { + description = "Route53 hosted zone ID" + type = string +} + +variable "network_domain" { + description = "Root domain name (e.g., example.com)" + type = string +} + +variable "network_subdomain" { + description = "Subdomain prefix (e.g., 'app' for app.example.com, empty string for apex)" + type = string + default = "" +} \ No newline at end of file diff --git a/frontend/deployment/network/route53/setup b/frontend/deployment/network/route53/setup new file mode 100755 index 00000000..300bad50 --- /dev/null +++ b/frontend/deployment/network/route53/setup @@ -0,0 +1,166 @@ +#!/bin/bash + +echo "🔍 Validating Route53 network configuration..." + +hosted_zone_id=$(echo "$CONTEXT" | jq -r '.providers["cloud-providers"].networking.hosted_public_zone_id // empty') + +if [ -z "$hosted_zone_id" ]; then + echo "" + echo " ❌ hosted_public_zone_id is not set in context" + echo "" + echo " 🔧 How to fix:" + echo " 1. Ensure there is an AWS cloud-provider configured at the correct NRN hierarchy level" + echo " 2. Set the 'hosted_public_zone_id' field with the Route 53 hosted zone ID" + echo "" + exit 1 +fi +echo " ✅ hosted_zone_id=$hosted_zone_id" + +application_slug=$(echo "$CONTEXT" | jq -r .application.slug) +scope_slug=$(echo "$CONTEXT" | jq -r .scope.slug) +scope_id=$(echo "$CONTEXT" | jq -r .scope.id) + +echo "" +echo " 📡 Fetching domain from Route 53 hosted zone..." + +aws_output=$(aws route53 get-hosted-zone --id "$hosted_zone_id" 2>&1) +aws_exit_code=$? + +if [ $aws_exit_code -ne 0 ]; then + echo "" + echo " ❌ Failed to fetch Route 53 hosted zone information" + echo "" + + if echo "$aws_output" | grep -q "NoSuchHostedZone"; then + echo " 🔎 Error: Hosted zone '$hosted_zone_id' does not exist" + echo "" + echo " 💡 Possible causes:" + echo " • The hosted zone ID is incorrect or has a typo" + echo " • The hosted zone was deleted" + echo " • The hosted zone ID format is wrong (should be like 'Z1234567890ABC' or '/hostedzone/Z1234567890ABC')" + echo "" + echo " 🔧 How to fix:" + echo " 1. Verify the hosted zone exists: aws route53 list-hosted-zones" + echo " 2. Update 'hosted_public_zone_id' in the AWS cloud-provider configuration" + + elif echo "$aws_output" | grep -q "AccessDenied\|not authorized"; then + echo " 🔒 Error: Permission denied when accessing Route 53" + echo "" + echo " 💡 Possible causes:" + echo " • The AWS credentials don't have Route 53 read permissions" + echo " • The IAM role/user is missing the 'route53:GetHostedZone' permission" + echo "" + echo " 🔧 How to fix:" + echo " 1. Check the AWS credentials are configured correctly" + echo " 2. Ensure the IAM policy includes:" + echo " {" + echo " \"Effect\": \"Allow\"," + echo " \"Action\": \"route53:GetHostedZone\"," + echo " \"Resource\": \"arn:aws:route53:::hostedzone/$hosted_zone_id\"" + echo " }" + + elif echo "$aws_output" | grep -q "InvalidInput"; then + echo " ⚠️ Error: Invalid hosted zone ID format" + echo "" + echo " The hosted zone ID '$hosted_zone_id' is not valid." + echo "" + echo " 🔧 How to fix:" + echo " • Use the format 'Z1234567890ABC' or '/hostedzone/Z1234567890ABC'" + echo " • Find valid zone IDs with: aws route53 list-hosted-zones" + + elif echo "$aws_output" | grep -q "Unable to locate credentials\|ExpiredToken\|InvalidClientTokenId"; then + echo " 🔑 Error: AWS credentials issue" + echo "" + echo " 💡 Possible causes:" + echo " • The nullplatform agent is not configured with AWS credentials" + echo " • The IAM role associated with the service account does not exist" + echo "" + echo " 🔧 How to fix:" + echo " 1. Configure a service account in the nullplatform agent Helm installation" + echo " 2. Verify the IAM role associated with the service account exists and has the required permissions" + + else + echo " 📋 Error details:" + echo "$aws_output" | sed 's/^/ /' + fi + + echo "" + exit 1 +fi + +network_domain=$(echo "$aws_output" | jq -r '.HostedZone.Name' | sed 's/\.$//') + +if [ -z "$network_domain" ] || [ "$network_domain" = "null" ]; then + echo "" + echo " ❌ Failed to extract domain name from hosted zone response" + echo "" + echo " 💡 Possible causes:" + echo " • The hosted zone does not have a valid domain name configured" + echo "" + echo " 🔧 How to fix:" + echo " 1. Verify the hosted zone has a valid domain: aws route53 get-hosted-zone --id $hosted_zone_id" + echo "" + exit 1 +fi + +echo " ✅ domain=$network_domain" + +network_subdomain="$application_slug-$scope_slug" +echo " ✅ subdomain=$network_subdomain" + +TOFU_VARIABLES=$(echo "$TOFU_VARIABLES" | jq \ + --arg hosted_zone_id "$hosted_zone_id" \ + --arg domain "$network_domain" \ + --arg subdomain "$network_subdomain" \ + '. + { + network_hosted_zone_id: $hosted_zone_id, + network_domain: $domain, + network_subdomain: $subdomain + }') + +scope_domain="$network_subdomain.$network_domain" + +echo "" +echo " 📝 Setting scope domain to '$scope_domain'..." + +np_output=$(np scope patch --id "$scope_id" --body "{\"domain\":\"$scope_domain\"}" --format json 2>&1) +np_exit_code=$? + +if [ $np_exit_code -ne 0 ]; then + echo "" + echo " ❌ Failed to update scope domain" + echo "" + + if echo "$np_output" | grep -q "unauthorized\|forbidden\|401\|403"; then + echo " 🔒 Error: Permission denied" + echo "" + echo " 💡 Possible causes:" + echo " • The nullplatform API Key doesn't have 'Developer' permissions" + echo "" + echo " 🔧 How to fix:" + echo " 1. Ensure the API Key has 'Developer' permissions at the correct NRN hierarchy level" + + else + echo " 📋 Error details:" + echo "$np_output" | sed 's/^/ /' + fi + + echo "" + exit 1 +fi + +echo " ✅ Scope domain set successfully" + +echo "" +echo "✨ Route53 network configured successfully" +echo "" + +# Add module to composition list +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +module_name="${script_dir}/modules" + +if [[ -n $MODULES_TO_USE ]]; then + MODULES_TO_USE="$MODULES_TO_USE,$module_name" +else + MODULES_TO_USE="$module_name" +fi diff --git a/frontend/deployment/provider/aws/modules/provider.tf b/frontend/deployment/provider/aws/modules/provider.tf new file mode 100644 index 00000000..c6ef3b81 --- /dev/null +++ b/frontend/deployment/provider/aws/modules/provider.tf @@ -0,0 +1,20 @@ +terraform { + required_version = ">= 1.4.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.0" + } + } + + backend "s3" {} +} + +provider "aws" { + region = var.aws_provider.region + + default_tags { + tags = var.provider_resource_tags_json + } +} \ No newline at end of file diff --git a/frontend/deployment/provider/aws/modules/provider.tftest.hcl b/frontend/deployment/provider/aws/modules/provider.tftest.hcl new file mode 100644 index 00000000..ff6d8b68 --- /dev/null +++ b/frontend/deployment/provider/aws/modules/provider.tftest.hcl @@ -0,0 +1,82 @@ +# ============================================================================= +# Unit tests for provider/aws module +# +# Run: tofu test +# ============================================================================= + +mock_provider "aws" {} + +variables { + aws_provider = { + region = "us-east-1" + state_bucket = "my-terraform-state" + lock_table = "terraform-locks" + } + + provider_resource_tags_json = { + Environment = "test" + Project = "frontend" + ManagedBy = "terraform" + } +} + +# ============================================================================= +# Test: Provider configuration is valid +# ============================================================================= +run "provider_configuration_is_valid" { + command = plan + + assert { + condition = var.aws_provider.region == "us-east-1" + error_message = "AWS region should be us-east-1" + } + + assert { + condition = var.aws_provider.state_bucket == "my-terraform-state" + error_message = "State bucket should be my-terraform-state" + } + + assert { + condition = var.aws_provider.lock_table == "terraform-locks" + error_message = "Lock table should be terraform-locks" + } +} + +# ============================================================================= +# Test: Default tags are configured +# ============================================================================= +run "default_tags_are_configured" { + command = plan + + assert { + condition = var.provider_resource_tags_json["Environment"] == "test" + error_message = "Environment tag should be 'test'" + } + + assert { + condition = var.provider_resource_tags_json["ManagedBy"] == "terraform" + error_message = "ManagedBy tag should be 'terraform'" + } +} + +# ============================================================================= +# Test: Required variables validation +# ============================================================================= +run "aws_provider_requires_region" { + command = plan + + variables { + aws_provider = { + region = "" + state_bucket = "bucket" + lock_table = "table" + } + } + + # Empty region should still be syntactically valid but semantically wrong + # This tests that the variable structure is enforced + assert { + condition = var.aws_provider.region == "" + error_message = "Empty region should be accepted by variable type" + } +} diff --git a/frontend/deployment/provider/aws/modules/variables.tf b/frontend/deployment/provider/aws/modules/variables.tf new file mode 100644 index 00000000..27d2535b --- /dev/null +++ b/frontend/deployment/provider/aws/modules/variables.tf @@ -0,0 +1,14 @@ +variable "aws_provider" { + description = "AWS provider configuration" + type = object({ + region = string + state_bucket = string + lock_table = string + }) +} + +variable "provider_resource_tags_json" { + description = "Resource tags as JSON object - applied as default tags to all AWS resources" + type = map(string) + default = {} +} \ No newline at end of file diff --git a/frontend/deployment/provider/aws/setup b/frontend/deployment/provider/aws/setup new file mode 100755 index 00000000..69052daf --- /dev/null +++ b/frontend/deployment/provider/aws/setup @@ -0,0 +1,57 @@ +#!/bin/bash + +echo "🔍 Validating AWS provider configuration..." + +missing_vars=() + +function validate_env_var() { + local variable_name=$1 + local variable_value="${!variable_name}" + + if [ -z "$variable_value" ]; then + echo " ❌ $variable_name is missing" + missing_vars+=("$variable_name") + else + echo " ✅ $variable_name=$variable_value" + fi +} + +validate_env_var AWS_REGION +validate_env_var TOFU_PROVIDER_BUCKET +validate_env_var TOFU_LOCK_TABLE + +if [ ${#missing_vars[@]} -gt 0 ]; then + echo "" + echo " 🔧 How to fix:" + echo " Set the missing variable(s) in the nullplatform agent Helm installation:" + for var in "${missing_vars[@]}"; do + echo " • $var" + done + echo "" + exit 1 +fi + +echo "✨ AWS provider configured successfully" +echo "" + +RESOURCE_TAGS_JSON=${RESOURCE_TAGS_JSON:-"{}"} + +TOFU_VARIABLES=$(echo "$TOFU_VARIABLES" | jq \ + --arg aws_region "$AWS_REGION" \ + --arg tf_state_bucket "$TOFU_PROVIDER_BUCKET" \ + --argjson resource_tags_json "$RESOURCE_TAGS_JSON" \ + --arg tf_lock_table "$TOFU_LOCK_TABLE" \ + '. + {aws_provider: {region: $aws_region, state_bucket: $tf_state_bucket, lock_table: $tf_lock_table}, provider_resource_tags_json: $resource_tags_json}') + +TOFU_INIT_VARIABLES="$TOFU_INIT_VARIABLES -backend-config=bucket=$TOFU_PROVIDER_BUCKET" +TOFU_INIT_VARIABLES="$TOFU_INIT_VARIABLES -backend-config=region=$AWS_REGION" +TOFU_INIT_VARIABLES="$TOFU_INIT_VARIABLES -backend-config=dynamodb_table=$TOFU_LOCK_TABLE" + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +module_name="${script_dir}/modules" + +if [[ -n $MODULES_TO_USE ]]; then + MODULES_TO_USE="$MODULES_TO_USE,$module_name" +else + MODULES_TO_USE="$module_name" +fi \ No newline at end of file diff --git a/frontend/deployment/provider/azure/modules/provider.tf b/frontend/deployment/provider/azure/modules/provider.tf new file mode 100644 index 00000000..e31e8d27 --- /dev/null +++ b/frontend/deployment/provider/azure/modules/provider.tf @@ -0,0 +1,32 @@ +# ============================================================================= +# Azure Provider Configuration +# ============================================================================= + +terraform { + required_version = ">= 1.4.0" + + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "~> 3.0" + } + } + + backend "azurerm" { + # Backend configuration is provided via -backend-config flags: + # - storage_account_name + # - container_name + # - resource_group_name + # - key (provided by build_context) + } +} + +provider "azurerm" { + features {} + + subscription_id = var.azure_provider.subscription_id + + default_tags { + tags = var.provider_resource_tags_json + } +} diff --git a/frontend/deployment/provider/azure/modules/provider.tftest.hcl b/frontend/deployment/provider/azure/modules/provider.tftest.hcl new file mode 100644 index 00000000..19bc2bc1 --- /dev/null +++ b/frontend/deployment/provider/azure/modules/provider.tftest.hcl @@ -0,0 +1,89 @@ +# ============================================================================= +# Unit tests for provider/azure module +# +# Run: tofu test +# ============================================================================= + +mock_provider "azurerm" {} + +variables { + azure_provider = { + subscription_id = "00000000-0000-0000-0000-000000000000" + resource_group = "my-resource-group" + storage_account = "mytfstatestorage" + container = "tfstate" + } + + provider_resource_tags_json = { + Environment = "test" + Project = "frontend" + ManagedBy = "terraform" + } +} + +# ============================================================================= +# Test: Provider configuration is valid +# ============================================================================= +run "provider_configuration_is_valid" { + command = plan + + assert { + condition = var.azure_provider.subscription_id == "00000000-0000-0000-0000-000000000000" + error_message = "Azure subscription ID should match" + } + + assert { + condition = var.azure_provider.resource_group == "my-resource-group" + error_message = "Resource group should be my-resource-group" + } + + assert { + condition = var.azure_provider.storage_account == "mytfstatestorage" + error_message = "Storage account should be mytfstatestorage" + } + + assert { + condition = var.azure_provider.container == "tfstate" + error_message = "Container should be tfstate" + } +} + +# ============================================================================= +# Test: Default tags are configured +# ============================================================================= +run "default_tags_are_configured" { + command = plan + + assert { + condition = var.provider_resource_tags_json["Environment"] == "test" + error_message = "Environment tag should be 'test'" + } + + assert { + condition = var.provider_resource_tags_json["ManagedBy"] == "terraform" + error_message = "ManagedBy tag should be 'terraform'" + } +} + +# ============================================================================= +# Test: Required variables validation +# ============================================================================= +run "azure_provider_requires_subscription_id" { + command = plan + + variables { + azure_provider = { + subscription_id = "" + resource_group = "rg" + storage_account = "storage" + container = "container" + } + } + + # Empty subscription_id should still be syntactically valid but semantically wrong + # This tests that the variable structure is enforced + assert { + condition = var.azure_provider.subscription_id == "" + error_message = "Empty subscription_id should be accepted by variable type" + } +} diff --git a/frontend/deployment/provider/azure/modules/variables.tf b/frontend/deployment/provider/azure/modules/variables.tf new file mode 100644 index 00000000..78a78a8b --- /dev/null +++ b/frontend/deployment/provider/azure/modules/variables.tf @@ -0,0 +1,15 @@ +variable "azure_provider" { + description = "Azure provider configuration" + type = object({ + subscription_id = string + resource_group = string + storage_account = string + container = string + }) +} + +variable "provider_resource_tags_json" { + description = "Resource tags as JSON object - applied as default tags to all Azure resources" + type = map(string) + default = {} +} diff --git a/frontend/deployment/provider/azure/setup b/frontend/deployment/provider/azure/setup new file mode 100755 index 00000000..be430fbe --- /dev/null +++ b/frontend/deployment/provider/azure/setup @@ -0,0 +1,59 @@ +#!/bin/bash + +echo "🔍 Validating Azure provider configuration..." + +missing_vars=() + +function validate_env_var() { + local variable_name=$1 + local variable_value="${!variable_name}" + + if [ -z "$variable_value" ]; then + echo " ❌ $variable_name is missing" + missing_vars+=("$variable_name") + else + echo " ✅ $variable_name=$variable_value" + fi +} + +validate_env_var AZURE_SUBSCRIPTION_ID +validate_env_var AZURE_RESOURCE_GROUP +validate_env_var TOFU_PROVIDER_STORAGE_ACCOUNT +validate_env_var TOFU_PROVIDER_CONTAINER + +if [ ${#missing_vars[@]} -gt 0 ]; then + echo "" + echo " 🔧 How to fix:" + echo " Set the missing variable(s) in the nullplatform agent Helm installation:" + for var in "${missing_vars[@]}"; do + echo " • $var" + done + echo "" + exit 1 +fi + +echo "✨ Azure provider configured successfully" +echo "" + +RESOURCE_TAGS_JSON=${RESOURCE_TAGS_JSON:-"{}"} + +TOFU_VARIABLES=$(echo "$TOFU_VARIABLES" | jq \ + --arg subscription_id "$AZURE_SUBSCRIPTION_ID" \ + --arg resource_group "$AZURE_RESOURCE_GROUP" \ + --arg storage_account "$TOFU_PROVIDER_STORAGE_ACCOUNT" \ + --arg container "$TOFU_PROVIDER_CONTAINER" \ + --argjson resource_tags_json "$RESOURCE_TAGS_JSON" \ + '. + {azure_provider: {subscription_id: $subscription_id, resource_group: $resource_group, storage_account: $storage_account, container: $container}, provider_resource_tags_json: $resource_tags_json}') + +TOFU_INIT_VARIABLES="$TOFU_INIT_VARIABLES -backend-config=storage_account_name=$TOFU_PROVIDER_STORAGE_ACCOUNT" +TOFU_INIT_VARIABLES="$TOFU_INIT_VARIABLES -backend-config=container_name=$TOFU_PROVIDER_CONTAINER" +TOFU_INIT_VARIABLES="$TOFU_INIT_VARIABLES -backend-config=resource_group_name=$AZURE_RESOURCE_GROUP" + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +module_name="${script_dir}/modules" + +if [[ -n $MODULES_TO_USE ]]; then + MODULES_TO_USE="$MODULES_TO_USE,$module_name" +else + MODULES_TO_USE="$module_name" +fi diff --git a/frontend/deployment/provider/gcp/modules/provider.tf b/frontend/deployment/provider/gcp/modules/provider.tf new file mode 100644 index 00000000..25db4e39 --- /dev/null +++ b/frontend/deployment/provider/gcp/modules/provider.tf @@ -0,0 +1,26 @@ +terraform { + required_version = ">= 1.4.0" + + required_providers { + google = { + source = "hashicorp/google" + version = "~> 5.0" + } + google-beta = { + source = "hashicorp/google-beta" + version = "~> 5.0" + } + } + + backend "gcs" {} +} + +provider "google" { + project = var.gcp_provider.project + region = var.gcp_provider.region +} + +provider "google-beta" { + project = var.gcp_provider.project + region = var.gcp_provider.region +} diff --git a/frontend/deployment/provider/gcp/modules/variables.tf b/frontend/deployment/provider/gcp/modules/variables.tf new file mode 100644 index 00000000..12a6b20d --- /dev/null +++ b/frontend/deployment/provider/gcp/modules/variables.tf @@ -0,0 +1,8 @@ +variable "gcp_provider" { + description = "GCP provider configuration" + type = object({ + project = string + region = string + bucket = string + }) +} diff --git a/frontend/deployment/provider/gcp/setup b/frontend/deployment/provider/gcp/setup new file mode 100755 index 00000000..a91cd7c6 --- /dev/null +++ b/frontend/deployment/provider/gcp/setup @@ -0,0 +1,37 @@ +#!/bin/bash + +if [ -z "${GCP_PROJECT:-}" ]; then + echo "✗ GCP_PROJECT is not set" + exit 1 +fi + +if [ -z "${GCP_REGION:-}" ]; then + echo "✗ GCP_REGION is not set" + exit 1 +fi + +if [ -z "${TOFU_PROVIDER_BUCKET:-}" ]; then + echo "✗ TOFU_PROVIDER_BUCKET is not set" + exit 1 +fi + +TOFU_VARIABLES=$(echo "$TOFU_VARIABLES" | jq \ + --arg project "$GCP_PROJECT" \ + --arg region "$GCP_REGION" \ + --arg bucket "$TOFU_PROVIDER_BUCKET" \ + '. + {gcp_provider: { + project: $project, + region: $region, + bucket: $bucket + }}') + +TOFU_INIT_VARIABLES="$TOFU_INIT_VARIABLES -backend-config=\"bucket=$TOFU_PROVIDER_BUCKET\"" + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +module_name="${script_dir}/modules" + +if [[ -n $MODULES_TO_USE ]]; then + MODULES_TO_USE="$module_name" +else + MODULES_TO_USE="$MODULES_TO_USE,$module_name" +fi \ No newline at end of file diff --git a/frontend/deployment/scripts/build_context b/frontend/deployment/scripts/build_context new file mode 100644 index 00000000..09fbecc6 --- /dev/null +++ b/frontend/deployment/scripts/build_context @@ -0,0 +1,51 @@ +#!/bin/bash + +application_slug=$(echo "$CONTEXT" | jq -r .application.slug) +scope_slug=$(echo "$CONTEXT" | jq -r .scope.slug) +namespace_slug=$(echo "$CONTEXT" | jq -r .namespace.slug) +scope_id=$(echo "$CONTEXT" | jq -r .scope.id) +application_version="$(echo "$CONTEXT" | jq -r .release.semver)" +env_vars_json=$(echo "$CONTEXT" | jq '(.parameters.results // []) | map({(.variable): .values[0].value}) | add // {}') + +RESOURCE_TAGS_JSON=$(echo "$CONTEXT" | jq \ + '{ + nullplatform: "true", + account: .account.slug, + account_id: .account.id, + namespace: .namespace.slug, + namespace_id: .namespace.id, + application: .application.slug, + application_id: .application.id, + scope: .scope.slug, + scope_id: .scope.id, + deployment_id: .deployment.id + }') + +TOFU_VARIABLES={} + +tf_state_key="frontend/$namespace_slug/$application_slug/$scope_slug-$scope_id" + +TOFU_INIT_VARIABLES="${TOFU_INIT_VARIABLES:-} -backend-config=key=$tf_state_key" + +TOFU_MODULE_DIR="$SERVICE_PATH/output/$scope_id" +if [ -n "${NP_OUTPUT_DIR:-}" ]; then + TOFU_MODULE_DIR="$NP_OUTPUT_DIR/output/$scope_id" +fi + +mkdir -p "$TOFU_MODULE_DIR" + +# ============================================================================= +# Modules to compose (comma-separated list) +# Initialized from CUSTOM_MODULES, extended by setup scripts +# Available modules: +# - state/aws : AWS S3 backend for terraform state +# - network/route_53 : AWS Route53 DNS configuration +# - hosting/amplify : AWS Amplify hosting (coming soon) +# ============================================================================= +MODULES_TO_USE="${CUSTOM_TOFU_MODULES:-}" + +export TOFU_VARIABLES +export TOFU_INIT_VARIABLES +export TOFU_MODULE_DIR +export MODULES_TO_USE +export RESOURCE_TAGS_JSON \ No newline at end of file diff --git a/frontend/deployment/scripts/compose_modules b/frontend/deployment/scripts/compose_modules new file mode 100755 index 00000000..06d9c2d9 --- /dev/null +++ b/frontend/deployment/scripts/compose_modules @@ -0,0 +1,102 @@ +#!/bin/bash + +# Compose Terraform modules dynamically +# Usage: source compose_modules +# +# Required environment variables: +# MODULES_TO_USE - Comma-separated list of modules (e.g., "state/aws,network/route_53,hosting/amplify") +# TOFU_MODULE_DIR - Target directory where .tf files will be copied +# +# Each module can have: +# - *.tf files: Copied to TOFU_MODULE_DIR (Terraform auto-merges all .tf files) +# - setup script: Sourced to configure TOFU_VARIABLES and TOFU_INIT_VARIABLES + +script_dir="$(dirname "${BASH_SOURCE[0]}")" +modules_dir="$script_dir" + +echo "🔍 Validating module composition configuration..." + +if [ -z "${MODULES_TO_USE:-}" ]; then + echo "" + echo " ❌ MODULES_TO_USE is not set" + echo "" + echo " 🔧 How to fix:" + echo " • Ensure MODULES_TO_USE is set before calling compose_modules" + echo " • This is typically done by the setup scripts (provider, network, distribution)" + echo "" + exit 1 +fi + +if [ -z "${TOFU_MODULE_DIR:-}" ]; then + echo "" + echo " ❌ TOFU_MODULE_DIR is not set" + echo "" + echo " 🔧 How to fix:" + echo " • Ensure TOFU_MODULE_DIR is set before calling compose_modules" + echo " • This is typically done by the build_context script" + echo "" + exit 1 +fi + +mkdir -p "$TOFU_MODULE_DIR" + +echo " ✅ modules=$MODULES_TO_USE" +echo " ✅ target=$TOFU_MODULE_DIR" +echo "" + +IFS=',' read -ra modules <<< "$MODULES_TO_USE" +for module in "${modules[@]}"; do + module=$(echo "$module" | xargs) # trim whitespace + + if [ ! -d "$module" ]; then + echo " ❌ Module directory not found: $module" + echo "" + echo " 🔧 How to fix:" + echo " • Verify the module path is correct and the directory exists" + echo "" + exit 1 + fi + + echo "📦 $module" + + # Copy .tf files if they exist (with module prefix to avoid conflicts) + if ls "$module"/*.tf 1> /dev/null 2>&1; then + # Extract last two path components for prefix (e.g., "/path/to/state/aws" -> "state_aws_") + parent=$(basename "$(dirname "$module")") + leaf=$(basename "$module") + prefix="${parent}_${leaf}_" + + copied_files=() + for tf_file in "$module"/*.tf; do + filename=$(basename "$tf_file") + # Skip test-only files (test_*.tf) + if [[ "$filename" == test_*.tf ]]; then + continue + fi + cp "$tf_file" "$TOFU_MODULE_DIR/${prefix}${filename}" + copied_files+=("$filename") + done + + for file in "${copied_files[@]}"; do + echo " $file" + done + echo " ✅ Copied ${#copied_files[@]} file(s) with prefix: $prefix" + fi + + # Source setup script if it exists + if [ -f "$module/setup" ]; then + echo " 📡 Running setup script..." + source "$module/setup" + if [ $? -ne 0 ]; then + echo "" + echo " ❌ Setup script failed for module: $module" + echo "" + exit 1 + fi + echo " ✅ Setup completed" + fi + + echo "" +done + +echo "✨ All modules composed successfully" \ No newline at end of file diff --git a/frontend/deployment/scripts/do_tofu b/frontend/deployment/scripts/do_tofu new file mode 100644 index 00000000..4d65f044 --- /dev/null +++ b/frontend/deployment/scripts/do_tofu @@ -0,0 +1,13 @@ +#!/bin/bash + +set -eou pipefail + +TOFU_VAR_FILE="$TOFU_MODULE_DIR/.tfvars.json" +echo "$TOFU_VARIABLES" > "$TOFU_VAR_FILE" + +CURRENT_DIR=$(dirname "${BASH_SOURCE[0]}") + +cd "$CURRENT_DIR" + +tofu -chdir="$TOFU_MODULE_DIR" init -input=false $TOFU_INIT_VARIABLES +tofu -chdir="$TOFU_MODULE_DIR" "$TOFU_ACTION" -auto-approve -var-file="$TOFU_VAR_FILE" diff --git a/frontend/deployment/scripts/setup-layer b/frontend/deployment/scripts/setup-layer new file mode 100755 index 00000000..85ef3f0a --- /dev/null +++ b/frontend/deployment/scripts/setup-layer @@ -0,0 +1,642 @@ +#!/bin/bash +# ============================================================================= +# Layer Boilerplate Generator +# +# Creates the folder structure and template files for a new layer implementation. +# +# Usage: +# ./setup-layer --type network --name cloudflare +# ./setup-layer --type distribution --name netlify +# ./setup-layer --type provider --name digitalocean +# +# This will create: +# frontend/deployment/{type}/{name}/ +# ├── setup +# └── modules/ +# ├── main.tf +# ├── variables.tf +# ├── locals.tf +# ├── outputs.tf +# └── test_locals.tf +# ============================================================================= + +set -euo pipefail + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +NC='\033[0m' + +# Script location +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DEPLOYMENT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +# Parse arguments +LAYER_TYPE="" +LAYER_NAME="" + +while [[ $# -gt 0 ]]; do + case $1 in + --type|-t) + LAYER_TYPE="$2" + shift 2 + ;; + --name|-n) + LAYER_NAME="$2" + shift 2 + ;; + --help|-h) + echo "Usage: $0 --type --name " + echo "" + echo "Examples:" + echo " $0 --type network --name cloudflare" + echo " $0 --type distribution --name netlify" + echo " $0 --type provider --name digitalocean" + exit 0 + ;; + *) + echo -e "${RED}Unknown option: $1${NC}" + exit 1 + ;; + esac +done + +# Validate arguments +if [[ -z "$LAYER_TYPE" ]]; then + echo -e "${RED}Error: --type is required${NC}" + echo "Valid types: provider, network, distribution" + exit 1 +fi + +if [[ -z "$LAYER_NAME" ]]; then + echo -e "${RED}Error: --name is required${NC}" + exit 1 +fi + +if [[ ! "$LAYER_TYPE" =~ ^(provider|network|distribution)$ ]]; then + echo -e "${RED}Error: Invalid layer type '$LAYER_TYPE'${NC}" + echo "Valid types: provider, network, distribution" + exit 1 +fi + +# Sanitize name (lowercase, replace spaces with underscores) +LAYER_NAME=$(echo "$LAYER_NAME" | tr '[:upper:]' '[:lower:]' | tr ' ' '_' | tr '-' '_') + +# Target directory +TARGET_DIR="$DEPLOYMENT_DIR/$LAYER_TYPE/$LAYER_NAME" + +if [[ -d "$TARGET_DIR" ]]; then + echo -e "${RED}Error: Directory already exists: $TARGET_DIR${NC}" + exit 1 +fi + +echo "" +echo -e "${CYAN}Creating $LAYER_TYPE layer: $LAYER_NAME${NC}" +echo "" + +# Create directory structure +mkdir -p "$TARGET_DIR/modules" + +# Determine variable prefix based on layer type +case $LAYER_TYPE in + provider) + VAR_PREFIX="${LAYER_NAME}_provider" + ;; + network) + VAR_PREFIX="network" + ;; + distribution) + VAR_PREFIX="distribution" + ;; +esac + +# ============================================================================= +# Generate setup script +# ============================================================================= + +cat > "$TARGET_DIR/setup" << 'SETUP_HEADER' +#!/bin/bash +# ============================================================================= +SETUP_HEADER + +# Capitalize first letter of layer type and name +LAYER_TYPE_CAP="$(echo "${LAYER_TYPE:0:1}" | tr '[:lower:]' '[:upper:]')${LAYER_TYPE:1}" +LAYER_NAME_CAP="$(echo "${LAYER_NAME:0:1}" | tr '[:lower:]' '[:upper:]')${LAYER_NAME:1}" + +cat >> "$TARGET_DIR/setup" << SETUP_META +# ${LAYER_TYPE_CAP}: ${LAYER_NAME} +# +# TODO: Add description of what this layer does. +# +# Required environment variables: +# - TODO: List required env vars +# +# Required context values: +# - TODO: List required context paths +# ============================================================================= + +set -euo pipefail + +SETUP_META + +cat >> "$TARGET_DIR/setup" << 'SETUP_BODY' +echo "🔍 Validating configuration..." +echo "" + +# ============================================================================= +# Input Validation +# ============================================================================= + +# TODO: Add validation for required environment variables +# Example: +# if [ -z "${REQUIRED_VAR:-}" ]; then +# echo " ❌ REQUIRED_VAR is missing" +# echo "" +# echo " 💡 Possible causes:" +# echo " • Variable not set in environment" +# echo "" +# echo " 🔧 How to fix:" +# echo " • export REQUIRED_VAR=value" +# exit 1 +# fi +# echo " ✅ REQUIRED_VAR=$REQUIRED_VAR" + +# TODO: Add validation for context values +# Example: +# config_value=$(echo "$CONTEXT" | jq -r '.path.to.value // empty') +# if [ -z "$config_value" ]; then +# echo " ❌ config_value not found in context" +# exit 1 +# fi +# echo " ✅ config_value=$config_value" + +# ============================================================================= +# External Data Fetching (if needed) +# ============================================================================= + +# TODO: Add API calls to fetch external data +# Example: +# echo "" +# echo " 📡 Fetching resource..." +# api_response=$(curl -s "https://api.example.com/resource") +# if [ $? -ne 0 ]; then +# echo " ❌ Failed to fetch resource" +# exit 1 +# fi + +# ============================================================================= +# Update TOFU_VARIABLES +# ============================================================================= + +# TODO: Add layer-specific variables to TOFU_VARIABLES +# Use the appropriate prefix for your layer type: +# - provider: {cloud}_provider object + provider_* vars +# - network: network_* vars +# - distribution: distribution_* vars +# +# Example: +# TOFU_VARIABLES=$(echo "$TOFU_VARIABLES" | jq \ +# --arg var1 "$value1" \ +# --arg var2 "$value2" \ +# '. + { +# layer_variable1: $var1, +# layer_variable2: $var2 +# }') + +# ============================================================================= +# Register Module +# ============================================================================= + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +module_name="${script_dir}/modules" + +if [[ -n ${MODULES_TO_USE:-} ]]; then + MODULES_TO_USE="$MODULES_TO_USE,$module_name" +else + MODULES_TO_USE="$module_name" +fi + +echo "" +echo "✨ Configuration completed successfully" +echo "" +SETUP_BODY + +chmod +x "$TARGET_DIR/setup" +echo -e " ${GREEN}✓${NC} Created setup script" + +# ============================================================================= +# Generate main.tf +# ============================================================================= + +cat > "$TARGET_DIR/modules/main.tf" << MAIN_TF +# ============================================================================= +# ${LAYER_TYPE_CAP}: ${LAYER_NAME} +# +# TODO: Add description of resources managed by this module. +# ============================================================================= + +# TODO: Add terraform resources +# +# Example for network layer: +# resource "cloudflare_record" "main" { +# zone_id = data.cloudflare_zone.main.id +# name = var.network_subdomain +# value = local.distribution_target_domain +# type = local.distribution_record_type +# ttl = 300 +# proxied = true +# } +# +# Example for distribution layer: +# resource "netlify_site" "main" { +# name = var.distribution_app_name +# custom_domain = local.network_full_domain +# } +MAIN_TF + +echo -e " ${GREEN}✓${NC} Created modules/main.tf" + +# ============================================================================= +# Generate variables.tf +# ============================================================================= + +case $LAYER_TYPE in + provider) + cat > "$TARGET_DIR/modules/variables.tf" << VARIABLES_TF +# ============================================================================= +# Provider Layer Variables +# ============================================================================= + +variable "${LAYER_NAME}_provider" { + description = "${LAYER_NAME_CAP} provider configuration" + type = object({ + # TODO: Define provider-specific fields + # Example: + # api_token = string + # account_id = string + }) +} + +variable "provider_resource_tags_json" { + description = "Resource tags to apply to all resources" + type = map(string) + default = {} +} +VARIABLES_TF + ;; + + network) + cat > "$TARGET_DIR/modules/variables.tf" << 'VARIABLES_TF' +# ============================================================================= +# Network Layer Variables +# +# NOTE: Use consistent naming with other network implementations: +# - network_domain: Base domain (e.g., "example.com") +# - network_subdomain: Subdomain part (e.g., "app") +# - network_dns_zone_name: DNS zone identifier +# ============================================================================= + +variable "network_domain" { + description = "Base domain name (e.g., example.com)" + type = string +} + +variable "network_subdomain" { + description = "Subdomain for the application (e.g., app)" + type = string +} + +variable "network_dns_zone_name" { + description = "DNS zone name/identifier" + type = string +} + +# TODO: Add provider-specific variables with network_ prefix +# Example: +# variable "network_zone_id" { +# description = "Cloudflare zone ID" +# type = string +# } +VARIABLES_TF + ;; + + distribution) + cat > "$TARGET_DIR/modules/variables.tf" << 'VARIABLES_TF' +# ============================================================================= +# Distribution Layer Variables +# +# NOTE: Use consistent naming with other distribution implementations: +# - distribution_app_name: Application/site name +# - distribution_storage_account / distribution_bucket: Asset storage +# - distribution_container / distribution_prefix: Asset path +# ============================================================================= + +variable "distribution_app_name" { + description = "Application name for the CDN/hosting" + type = string +} + +# TODO: Add provider-specific variables with distribution_ prefix +# Example for storage-backed CDN: +# variable "distribution_bucket" { +# description = "S3/GCS bucket name for assets" +# type = string +# } +# +# variable "distribution_prefix" { +# description = "Path prefix within the bucket" +# type = string +# default = "" +# } +VARIABLES_TF + ;; +esac + +echo -e " ${GREEN}✓${NC} Created modules/variables.tf" + +# ============================================================================= +# Generate locals.tf +# ============================================================================= + +case $LAYER_TYPE in + provider) + cat > "$TARGET_DIR/modules/locals.tf" << 'LOCALS_TF' +# ============================================================================= +# Provider Layer Locals +# ============================================================================= + +locals { + # Provider layers typically don't need many locals + # Add any computed values here +} +LOCALS_TF + ;; + + network) + cat > "$TARGET_DIR/modules/locals.tf" << 'LOCALS_TF' +# ============================================================================= +# Network Layer Locals +# +# These locals are used by the distribution layer for cross-module integration. +# ============================================================================= + +locals { + # Computed full domain - REQUIRED for distribution layer + network_full_domain = "${var.network_subdomain}.${var.network_domain}" + + # Expose base domain for cross-module use + network_domain = var.network_domain +} +LOCALS_TF + ;; + + distribution) + cat > "$TARGET_DIR/modules/locals.tf" << 'LOCALS_TF' +# ============================================================================= +# Distribution Layer Locals +# +# Cross-layer integration with network layer. +# ============================================================================= + +locals { + # Check if custom domain is configured (from network layer) + distribution_has_custom_domain = local.network_full_domain != "" + + # Full domain from network layer + distribution_full_domain = local.network_full_domain + + # TODO: Set these based on your CDN/hosting provider + # These are consumed by the network layer for DNS record creation + # + # distribution_target_domain: The CDN endpoint hostname (e.g., "d123.cloudfront.net") + # distribution_record_type: DNS record type ("CNAME" or "A" for alias) + # + # Example: + # distribution_target_domain = netlify_site.main.ssl_url + # distribution_record_type = "CNAME" +} +LOCALS_TF + ;; +esac + +echo -e " ${GREEN}✓${NC} Created modules/locals.tf" + +# ============================================================================= +# Generate outputs.tf +# ============================================================================= + +case $LAYER_TYPE in + provider) + cat > "$TARGET_DIR/modules/outputs.tf" << 'OUTPUTS_TF' +# ============================================================================= +# Provider Layer Outputs +# ============================================================================= + +# Provider layers typically don't have outputs +# The provider configuration is used implicitly by other resources +OUTPUTS_TF + ;; + + network) + cat > "$TARGET_DIR/modules/outputs.tf" << 'OUTPUTS_TF' +# ============================================================================= +# Network Layer Outputs +# ============================================================================= + +output "network_full_domain" { + description = "Full domain name (subdomain.domain)" + value = local.network_full_domain +} + +output "network_website_url" { + description = "Full website URL with protocol" + value = "https://${local.network_full_domain}" +} + +# TODO: Add provider-specific outputs +# Example: +# output "network_fqdn" { +# description = "Fully qualified domain name with trailing dot" +# value = "${local.network_full_domain}." +# } +OUTPUTS_TF + ;; + + distribution) + cat > "$TARGET_DIR/modules/outputs.tf" << 'OUTPUTS_TF' +# ============================================================================= +# Distribution Layer Outputs +# ============================================================================= + +output "distribution_cdn_endpoint_hostname" { + description = "CDN endpoint hostname" + value = local.distribution_target_domain +} + +output "distribution_website_url" { + description = "Website URL (custom domain if configured, otherwise CDN URL)" + value = local.distribution_has_custom_domain ? "https://${local.distribution_full_domain}" : "https://${local.distribution_target_domain}" +} + +# TODO: Add provider-specific outputs +# Example: +# output "distribution_site_id" { +# description = "Netlify site ID" +# value = netlify_site.main.id +# } +OUTPUTS_TF + ;; +esac + +echo -e " ${GREEN}✓${NC} Created modules/outputs.tf" + +# ============================================================================= +# Generate test_locals.tf +# ============================================================================= + +case $LAYER_TYPE in + provider) + cat > "$TARGET_DIR/modules/test_locals.tf" << 'TEST_LOCALS_TF' +# ============================================================================= +# Test-Only Locals +# +# NOTE: Files matching test_*.tf are skipped by compose_modules. +# This file provides stubs for unit testing in isolation. +# ============================================================================= + +# Provider layers typically don't need test locals +TEST_LOCALS_TF + ;; + + network) + cat > "$TARGET_DIR/modules/test_locals.tf" << 'TEST_LOCALS_TF' +# ============================================================================= +# Test-Only Locals +# +# NOTE: Files matching test_*.tf are skipped by compose_modules. +# This file provides stubs for unit testing in isolation. +# ============================================================================= + +# Network layer needs distribution layer outputs for DNS records +variable "distribution_target_domain" { + description = "Test-only: CDN endpoint hostname from distribution layer" + type = string + default = "test-cdn.example.net" +} + +variable "distribution_record_type" { + description = "Test-only: DNS record type from distribution layer" + type = string + default = "CNAME" +} + +locals { + distribution_target_domain = var.distribution_target_domain + distribution_record_type = var.distribution_record_type +} +TEST_LOCALS_TF + ;; + + distribution) + cat > "$TARGET_DIR/modules/test_locals.tf" << 'TEST_LOCALS_TF' +# ============================================================================= +# Test-Only Locals +# +# NOTE: Files matching test_*.tf are skipped by compose_modules. +# This file provides stubs for unit testing in isolation. +# ============================================================================= + +# Distribution layer needs network layer outputs for custom domain +variable "network_full_domain" { + description = "Test-only: Full domain from network layer" + type = string + default = "" +} + +variable "network_domain" { + description = "Test-only: Base domain from network layer" + type = string + default = "example.com" +} + +locals { + network_full_domain = var.network_full_domain + network_domain = var.network_domain +} +TEST_LOCALS_TF + ;; +esac + +echo -e " ${GREEN}✓${NC} Created modules/test_locals.tf" + +# ============================================================================= +# Create test directory structure +# ============================================================================= + +TEST_DIR="$DEPLOYMENT_DIR/tests/$LAYER_TYPE/$LAYER_NAME" +mkdir -p "$TEST_DIR" + +cat > "$TEST_DIR/${LAYER_NAME}.tftest.hcl" << TEST_HCL +# ============================================================================= +# Unit Tests: ${LAYER_TYPE}/${LAYER_NAME} +# ============================================================================= + +mock_provider "${LAYER_NAME}" {} + +# ============================================================================= +# Test Variables +# ============================================================================= + +variables { + # TODO: Add test variable values +} + +# ============================================================================= +# Tests +# ============================================================================= + +run "test_basic_configuration" { + command = plan + + # TODO: Add assertions + # assert { + # condition = resource.type.name != null + # error_message = "Resource should be created" + # } +} +TEST_HCL + +echo -e " ${GREEN}✓${NC} Created tests/$LAYER_TYPE/$LAYER_NAME/${LAYER_NAME}.tftest.hcl" + +# ============================================================================= +# Summary +# ============================================================================= + +echo "" +echo -e "${GREEN}Layer created successfully!${NC}" +echo "" +echo "Created structure:" +echo " $TARGET_DIR/" +echo " ├── setup" +echo " └── modules/" +echo " ├── main.tf" +echo " ├── variables.tf" +echo " ├── locals.tf" +echo " ├── outputs.tf" +echo " └── test_locals.tf" +echo "" +echo " $TEST_DIR/" +echo " └── ${LAYER_NAME}.tftest.hcl" +echo "" +echo -e "${YELLOW}Next steps:${NC}" +echo " 1. Edit the setup script to add validation and TOFU_VARIABLES" +echo " 2. Edit modules/main.tf to add Terraform resources" +echo " 3. Update modules/variables.tf with required inputs" +echo " 4. Update modules/locals.tf with cross-layer references" +echo " 5. Add unit tests to tests/$LAYER_TYPE/$LAYER_NAME/" +echo "" +echo -e "${CYAN}Tip:${NC} See frontend/README.md for the Claude prompt template" +echo " to help implement the setup script and Terraform files." +echo "" diff --git a/frontend/deployment/tests/distribution/blob-cdn/setup_test.bats b/frontend/deployment/tests/distribution/blob-cdn/setup_test.bats new file mode 100644 index 00000000..61cc16cb --- /dev/null +++ b/frontend/deployment/tests/distribution/blob-cdn/setup_test.bats @@ -0,0 +1,202 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for distribution/blob-cdn/setup script +# +# Requirements: +# - bats-core: brew install bats-core +# - jq: brew install jq +# +# Run tests: +# bats tests/distribution/blob-cdn/setup_test.bats +# ============================================================================= + +# Setup - runs before each test +setup() { + TEST_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")" && pwd)" + PROJECT_DIR="$(cd "$TEST_DIR/../../.." && pwd)" + PROJECT_ROOT="$(cd "$PROJECT_DIR/../.." && pwd)" + SCRIPT_PATH="$PROJECT_DIR/distribution/blob-cdn/setup" + RESOURCES_DIR="$PROJECT_DIR/tests/resources" + MOCKS_DIR="$RESOURCES_DIR/np_mocks" + + # Load shared test utilities + source "$PROJECT_ROOT/testing/assertions.sh" + + # Add mock np to PATH (must be first) + export PATH="$MOCKS_DIR:$PATH" + + # Load context with Azure-specific asset URL + export CONTEXT='{ + "application": {"slug": "automation"}, + "scope": {"slug": "development-tools", "id": "7", "nrn": "organization=1:account=2:namespace=3:application=4:scope=7"}, + "asset": {"url": "https://mystaticstorage.blob.core.windows.net/$web/tools/automation/v1.0.0"} + }' + + # Initialize TOFU_VARIABLES with required fields + export TOFU_VARIABLES='{ + "application_slug": "automation", + "scope_slug": "development-tools", + "scope_id": "7" + }' + + export MODULES_TO_USE="" +} + +# ============================================================================= +# Helper functions +# ============================================================================= +run_blob_cdn_setup() { + source "$SCRIPT_PATH" +} + +# ============================================================================= +# Test: Auth error case +# ============================================================================= +@test "Should handle permission denied error fetching the asset-repository-provider" { + set_np_mock "$MOCKS_DIR/asset_repository/auth_error.json" 1 + + run source "$SCRIPT_PATH" + + assert_equal "$status" "1" + assert_contains "$output" " ❌ Failed to fetch assets-repository provider" + assert_contains "$output" " 🔒 Error: Permission denied" + assert_contains "$output" " 💡 Possible causes:" + assert_contains "$output" " • The nullplatform API Key doesn't have 'Ops' permissions at nrn: organization=1:account=2:namespace=3:application=4:scope=7" + + assert_contains "$output" " 🔧 How to fix:" + assert_contains "$output" " 1. Ensure the API Key has 'Ops' permissions at the correct NRN hierarchy level" +} + +# ============================================================================= +# Test: Unknown error case +# ============================================================================= +@test "Should handle unknown error fetching the asset-repository-provider" { + set_np_mock "$MOCKS_DIR/asset_repository/unknown_error.json" 1 + + run source "$SCRIPT_PATH" + + assert_equal "$status" "1" + assert_contains "$output" " ❌ Failed to fetch assets-repository provider" + assert_contains "$output" " 📋 Error details:" + assert_contains "$output" "Unknown error fetching provider" +} + +# ============================================================================= +# Test: Empty results case +# ============================================================================= +@test "Should fail if no asset-repository found" { + set_np_mock "$MOCKS_DIR/asset_repository/no_data.json" + + run source "$SCRIPT_PATH" + + assert_equal "$status" "1" + assert_contains "$output" " ❌ No assets-repository provider of type Azure Blob Storage at nrn: organization=1:account=2:namespace=3:application=4:scope=7" + + assert_contains "$output" " 🔧 How to fix:" + assert_contains "$output" " 1. Ensure there is an asset-repository provider of type Azure Blob Storage configured at the correct NRN hierarchy level" +} + +# ============================================================================= +# Test: No providers found case +# ============================================================================= +@test "Should fail when no asset provider is of type Azure Blob Storage" { + set_np_mock "$MOCKS_DIR/asset_repository_azure/no_storage_account_data.json" + + run source "$SCRIPT_PATH" + + assert_equal "$status" "1" + assert_contains "$output" " ❌ No assets-repository provider of type Azure Blob Storage at nrn: organization=1:account=2:namespace=3:application=4:scope=7" + assert_contains "$output" " 🤔 Found 1 asset-repository provider(s), but none are configured for Azure Blob Storage." + + assert_contains "$output" " 📋 Verify the existing providers with the nullplatform CLI:" + assert_contains "$output" " • np provider read --id d397e46b-89b8-419d-ac14-2b483ace511c --format json" + + assert_contains "$output" " 🔧 How to fix:" + assert_contains "$output" " 1. Ensure there is an asset-repository provider of type Azure Blob Storage configured at the correct NRN hierarchy level" +} + +# ============================================================================= +# Test: Blob prefix extraction from asset URL +# ============================================================================= +@test "Should extract blob_prefix from asset.url with https format" { + set_np_mock "$MOCKS_DIR/asset_repository_azure/success.json" + + run_blob_cdn_setup + + local blob_prefix=$(echo "$TOFU_VARIABLES" | jq -r '.distribution_blob_prefix') + assert_equal "$blob_prefix" "/tools/automation/v1.0.0" +} + +@test "Should use root prefix when asset.url has no path" { + set_np_mock "$MOCKS_DIR/asset_repository_azure/success.json" + + # Override asset.url in context with no path + export CONTEXT=$(echo "$CONTEXT" | jq '.asset.url = "other://bucket"') + + run_blob_cdn_setup + + local blob_prefix=$(echo "$TOFU_VARIABLES" | jq -r '.distribution_blob_prefix') + assert_equal "$blob_prefix" "/" +} + +# ============================================================================= +# Test: TOFU_VARIABLES - verifies the entire JSON structure +# ============================================================================= +@test "Should add distribution variables to TOFU_VARIABLES" { + set_np_mock "$MOCKS_DIR/asset_repository_azure/success.json" + + run_blob_cdn_setup + + local expected='{ + "application_slug": "automation", + "scope_slug": "development-tools", + "scope_id": "7", + "distribution_storage_account": "mystaticstorage", + "distribution_container_name": "$web", + "distribution_app_name": "automation-development-tools-7", + "distribution_blob_prefix": "/tools/automation/v1.0.0", + "distribution_resource_tags_json": {} +}' + + assert_json_equal "$TOFU_VARIABLES" "$expected" "TOFU_VARIABLES" +} + +@test "Should add distribution_resource_tags_json to TOFU_VARIABLES" { + set_np_mock "$MOCKS_DIR/asset_repository_azure/success.json" + export RESOURCE_TAGS_JSON='{"Environment": "production", "Team": "platform"}' + + run_blob_cdn_setup + + local expected='{ + "application_slug": "automation", + "scope_slug": "development-tools", + "scope_id": "7", + "distribution_storage_account": "mystaticstorage", + "distribution_container_name": "$web", + "distribution_app_name": "automation-development-tools-7", + "distribution_blob_prefix": "/tools/automation/v1.0.0", + "distribution_resource_tags_json": {"Environment": "production", "Team": "platform"} +}' + + assert_json_equal "$TOFU_VARIABLES" "$expected" "TOFU_VARIABLES" +} + +# ============================================================================= +# Test: MODULES_TO_USE +# ============================================================================= +@test "Should register the provider in the MODULES_TO_USE variable when it's empty" { + set_np_mock "$MOCKS_DIR/asset_repository_azure/success.json" + + run_blob_cdn_setup + + assert_equal "$MODULES_TO_USE" "$PROJECT_DIR/distribution/blob-cdn/modules" +} + +@test "Should append the provider in the MODULES_TO_USE variable when it's not empty" { + set_np_mock "$MOCKS_DIR/asset_repository_azure/success.json" + export MODULES_TO_USE="existing/module" + + run_blob_cdn_setup + + assert_equal "$MODULES_TO_USE" "existing/module,$PROJECT_DIR/distribution/blob-cdn/modules" +} diff --git a/frontend/deployment/tests/distribution/cloudfront/setup_test.bats b/frontend/deployment/tests/distribution/cloudfront/setup_test.bats new file mode 100644 index 00000000..84310f80 --- /dev/null +++ b/frontend/deployment/tests/distribution/cloudfront/setup_test.bats @@ -0,0 +1,197 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for distribution/cloudfront/setup script +# +# Requirements: +# - bats-core: brew install bats-core +# - jq: brew install jq +# +# Run tests: +# bats tests/distribution/cloudfront/setup_test.bats +# ============================================================================= + +# Setup - runs before each test +setup() { + TEST_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")" && pwd)" + PROJECT_DIR="$(cd "$TEST_DIR/../../.." && pwd)" + PROJECT_ROOT="$(cd "$PROJECT_DIR/../.." && pwd)" + SCRIPT_PATH="$PROJECT_DIR/distribution/cloudfront/setup" + RESOURCES_DIR="$PROJECT_DIR/tests/resources" + MOCKS_DIR="$RESOURCES_DIR/np_mocks" + + # Load shared test utilities + source "$PROJECT_ROOT/testing/assertions.sh" + + # Add mock np to PATH (must be first) + export PATH="$MOCKS_DIR:$PATH" + + # Load context + export CONTEXT=$(cat "$RESOURCES_DIR/context.json") + + # Initialize TOFU_VARIABLES with required fields + export TOFU_VARIABLES='{ + "application_slug": "automation", + "scope_slug": "development-tools", + "scope_id": "7" + }' + + export MODULES_TO_USE="" +} + +# ============================================================================= +# Helper functions +# ============================================================================= +run_cloudfront_setup() { + source "$SCRIPT_PATH" +} + +# ============================================================================= +# Test: Auth error case +# ============================================================================= +@test "Should handle permission denied error fetching the asset-repository-provider" { + set_np_mock "$MOCKS_DIR/asset_repository/auth_error.json" 1 + + run source "$SCRIPT_PATH" + + assert_equal "$status" "1" + assert_contains "$output" " ❌ Failed to fetch assets-repository provider" + assert_contains "$output" " 🔒 Error: Permission denied" + assert_contains "$output" " 💡 Possible causes:" + assert_contains "$output" " • The nullplatform API Key doesn't have 'Ops' permissions at nrn: organization=1:account=2:namespace=3:application=4:scope=7" + + assert_contains "$output" " 🔧 How to fix:" + assert_contains "$output" " 1. Ensure the API Key has 'Ops' permissions at the correct NRN hierarchy level" +} + +# ============================================================================= +# Test: Unknown error case +# ============================================================================= +@test "Should handle unknown error fetching the asset-repository-provider" { + set_np_mock "$MOCKS_DIR/asset_repository/unknown_error.json" 1 + + run source "$SCRIPT_PATH" + + assert_equal "$status" "1" + assert_contains "$output" " ❌ Failed to fetch assets-repository provider" + assert_contains "$output" " 📋 Error details:" + assert_contains "$output" "Unknown error fetching provider" +} + +# ============================================================================= +# Test: Empty results case +# ============================================================================= +@test "Should fail if no asset-repository found" { + set_np_mock "$MOCKS_DIR/asset_repository/no_data.json" + + run source "$SCRIPT_PATH" + + assert_equal "$status" "1" + assert_contains "$output" " ❌ No assets-repository provider of type AWS S3 at nrn: organization=1:account=2:namespace=3:application=4:scope=7" + + assert_contains "$output" " 🔧 How to fix:" + assert_contains "$output" " 1. Ensure there is an asset-repository provider of type S3 configured at the correct NRN hierarchy level" +} + +# ============================================================================= +# Test: No providers found case +# ============================================================================= +@test "Should fail when no asset provider is of type s3" { + set_np_mock "$MOCKS_DIR/asset_repository/no_bucket_data.json" + + run source "$SCRIPT_PATH" + + assert_equal "$status" "1" + assert_contains "$output" " ❌ No assets-repository provider of type AWS S3 at nrn: organization=1:account=2:namespace=3:application=4:scope=7" + assert_contains "$output" " 🤔 Found 1 asset-repository provider(s), but none are configured for S3." + + assert_contains "$output" " 📋 Verify the existing providers with the nullplatform CLI:" + assert_contains "$output" " • np provider read --id d397e46b-89b8-419d-ac14-2b483ace511c --format json" + + assert_contains "$output" " 🔧 How to fix:" + assert_contains "$output" " 1. Ensure there is an asset-repository provider of type S3 configured at the correct NRN hierarchy level" + assert_equal "$status" "1" +} + +# ============================================================================= +# Test: S3 prefix extraction from asset URL +# ============================================================================= +@test "Should extracts s3_prefix from asset.url with s3 format" { + set_np_mock "$MOCKS_DIR/asset_repository/success.json" + + run_cloudfront_setup + + local s3_prefix=$(echo "$TOFU_VARIABLES" | jq -r '.distribution_s3_prefix') + assert_equal "$s3_prefix" "/tools/automation/v1.0.0" +} + +@test "Should extracts s3_prefix from asset.url with http format" { + set_np_mock "$MOCKS_DIR/asset_repository/success.json" + + # Override asset.url in context with https format + export CONTEXT=$(echo "$CONTEXT" | jq '.asset.url = "https://my-asset-bucket/tools/automation/v1.0.0"') + + run_cloudfront_setup + + local s3_prefix=$(echo "$TOFU_VARIABLES" | jq -r '.distribution_s3_prefix') + assert_equal "$s3_prefix" "/tools/automation/v1.0.0" +} + +# ============================================================================= +# Test: TOFU_VARIABLES - verifies the entire JSON structure +# ============================================================================= +@test "Should add distribution variables to TOFU_VARIABLES" { + set_np_mock "$MOCKS_DIR/asset_repository/success.json" + + run_cloudfront_setup + + local expected='{ + "application_slug": "automation", + "scope_slug": "development-tools", + "scope_id": "7", + "distribution_bucket_name": "assets-bucket", + "distribution_app_name": "automation-development-tools-7", + "distribution_resource_tags_json": {}, + "distribution_s3_prefix": "/tools/automation/v1.0.0" +}' + + assert_json_equal "$TOFU_VARIABLES" "$expected" "TOFU_VARIABLES" +} + +@test "Should add distribution_resource_tags_json to TOFU_VARIABLES" { + set_np_mock "$MOCKS_DIR/asset_repository/success.json" + export RESOURCE_TAGS_JSON='{"Environment": "production", "Team": "platform"}' + + run_cloudfront_setup + + local expected='{ + "application_slug": "automation", + "scope_slug": "development-tools", + "scope_id": "7", + "distribution_bucket_name": "assets-bucket", + "distribution_app_name": "automation-development-tools-7", + "distribution_resource_tags_json": {"Environment": "production", "Team": "platform"}, + "distribution_s3_prefix": "/tools/automation/v1.0.0" +}' + + assert_json_equal "$TOFU_VARIABLES" "$expected" "TOFU_VARIABLES" +} + +# ============================================================================= +# Test: MODULES_TO_USE +# ============================================================================= +@test "Should register the provider in the MODULES_TO_USE variable when it's empty" { + set_np_mock "$MOCKS_DIR/asset_repository/success.json" + + run_cloudfront_setup + + assert_equal "$MODULES_TO_USE" "$PROJECT_DIR/distribution/cloudfront/modules" +} + +@test "Should append the provider in the MODULES_TO_USE variable when it's not empty" { + set_np_mock "$MOCKS_DIR/asset_repository/success.json" + export MODULES_TO_USE="existing/module" + + run_cloudfront_setup + + assert_equal "$MODULES_TO_USE" "existing/module,$PROJECT_DIR/distribution/cloudfront/modules" +} \ No newline at end of file diff --git a/frontend/deployment/tests/integration/mocks/asset_repository/category.json b/frontend/deployment/tests/integration/mocks/asset_repository/category.json new file mode 100644 index 00000000..3dd986f4 --- /dev/null +++ b/frontend/deployment/tests/integration/mocks/asset_repository/category.json @@ -0,0 +1,12 @@ +{ + "status": 200, + "body": { + "results": [ + { + "id": "assets-repository-id", + "slug": "assets-repository", + "name": "Assets Repository" + } + ] + } +} diff --git a/frontend/deployment/tests/integration/mocks/asset_repository/get_provider.json b/frontend/deployment/tests/integration/mocks/asset_repository/get_provider.json new file mode 100644 index 00000000..6cacf827 --- /dev/null +++ b/frontend/deployment/tests/integration/mocks/asset_repository/get_provider.json @@ -0,0 +1,13 @@ +{ + "status": 200, + "body": { + "id": "s3-asset-repository-id", + "specification_id": "s3-asset-repository-spec-id", + "category": "assets-repository-id", + "attributes": { + "bucket": { + "name": "assets-bucket" + } + } + } +} diff --git a/frontend/deployment/tests/integration/mocks/asset_repository/list_provider.json b/frontend/deployment/tests/integration/mocks/asset_repository/list_provider.json new file mode 100644 index 00000000..e24ca793 --- /dev/null +++ b/frontend/deployment/tests/integration/mocks/asset_repository/list_provider.json @@ -0,0 +1,18 @@ +{ + "status": 200, + "body": { + "results": [ + { + "category": "assets-repository-id", + "created_at": "2026-01-07T16:28:17.036Z", + "dimensions": {}, + "groups": [], + "id": "s3-asset-repository-id", + "nrn": "organization=1:account=2", + "specification_id": "s3-asset-repository-spec-id", + "tags": [], + "updated_at": "2026-01-07T16:28:17.036Z" + } + ] + } +} \ No newline at end of file diff --git a/frontend/deployment/tests/integration/mocks/asset_repository/list_provider_spec.json b/frontend/deployment/tests/integration/mocks/asset_repository/list_provider_spec.json new file mode 100644 index 00000000..e723a0fd --- /dev/null +++ b/frontend/deployment/tests/integration/mocks/asset_repository/list_provider_spec.json @@ -0,0 +1,21 @@ +{ + "status": 200, + "body": { + "results": [ + { + "id": "s3-asset-repository-spec-id", + "slug": "s3-assets", + "categories": [ + {"slug": "assets-repository"} + ] + }, + { + "id": "azure-asset-repository-spec-id", + "slug": "azure-assets", + "categories": [ + {"slug": "assets-repository"} + ] + } + ] + } +} diff --git a/frontend/deployment/tests/integration/mocks/azure_asset_repository/get_provider.json b/frontend/deployment/tests/integration/mocks/azure_asset_repository/get_provider.json new file mode 100644 index 00000000..a15383a0 --- /dev/null +++ b/frontend/deployment/tests/integration/mocks/azure_asset_repository/get_provider.json @@ -0,0 +1,17 @@ +{ + "status": 200, + "body": { + "id": "azure-blob-asset-repository-id", + "specification_id": "azure-asset-repository-spec-id", + "category": "assets-repository-id", + "attributes": { + "storage_account": { + "name": "assetsaccount", + "resource_group": "test-resource-group" + }, + "container": { + "name": "assets" + } + } + } +} diff --git a/frontend/deployment/tests/integration/mocks/azure_asset_repository/list_provider.json b/frontend/deployment/tests/integration/mocks/azure_asset_repository/list_provider.json new file mode 100644 index 00000000..80e2e608 --- /dev/null +++ b/frontend/deployment/tests/integration/mocks/azure_asset_repository/list_provider.json @@ -0,0 +1,18 @@ +{ + "status": 200, + "body": { + "results": [ + { + "category": "assets-repository-id", + "created_at": "2026-01-07T16:28:17.036Z", + "dimensions": {}, + "groups": [], + "id": "azure-blob-asset-repository-id", + "nrn": "organization=1:account=2", + "specification_id": "azure-asset-repository-spec-id", + "tags": [], + "updated_at": "2026-01-07T16:28:17.036Z" + } + ] + } +} diff --git a/frontend/deployment/tests/integration/mocks/azure_asset_repository/list_provider_spec.json b/frontend/deployment/tests/integration/mocks/azure_asset_repository/list_provider_spec.json new file mode 100644 index 00000000..910b1572 --- /dev/null +++ b/frontend/deployment/tests/integration/mocks/azure_asset_repository/list_provider_spec.json @@ -0,0 +1,14 @@ +{ + "status": 200, + "body": { + "results": [ + { + "id": "azure-asset-repository-spec-id", + "slug": "azure-assets", + "categories": [ + {"slug": "assets-repository"} + ] + } + ] + } +} diff --git a/frontend/deployment/tests/integration/mocks/scope/patch.json b/frontend/deployment/tests/integration/mocks/scope/patch.json new file mode 100644 index 00000000..28e7be11 --- /dev/null +++ b/frontend/deployment/tests/integration/mocks/scope/patch.json @@ -0,0 +1,3 @@ +{ + "success": true +} \ No newline at end of file diff --git a/frontend/deployment/tests/integration/scripts/disable_cloudfront.sh b/frontend/deployment/tests/integration/scripts/disable_cloudfront.sh new file mode 100755 index 00000000..be45014f --- /dev/null +++ b/frontend/deployment/tests/integration/scripts/disable_cloudfront.sh @@ -0,0 +1,59 @@ +#!/bin/bash +# Disable a CloudFront distribution by comment +# Usage: disable_cloudfront.sh "Distribution comment" + +set -e + +COMMENT="$1" +MOTO_ENDPOINT="${MOTO_ENDPOINT:-http://localhost:5555}" + +if [ -z "$COMMENT" ]; then + echo "Usage: $0 " + exit 1 +fi + +echo "Looking for CloudFront distribution with comment: $COMMENT" + +# Get distribution ID by comment +DIST_ID=$(aws --endpoint-url="$MOTO_ENDPOINT" cloudfront list-distributions \ + --query "DistributionList.Items[?Comment=='$COMMENT'].Id" \ + --output text 2>/dev/null) + +if [ -z "$DIST_ID" ] || [ "$DIST_ID" = "None" ]; then + echo "No distribution found with comment: $COMMENT" + exit 0 +fi + +echo "Found distribution: $DIST_ID" + +# Get current ETag +ETAG=$(aws --endpoint-url="$MOTO_ENDPOINT" cloudfront get-distribution \ + --id "$DIST_ID" \ + --query "ETag" \ + --output text) + +echo "Current ETag: $ETAG" + +# Get current config and save to temp file +TEMP_CONFIG=$(mktemp) +aws --endpoint-url="$MOTO_ENDPOINT" cloudfront get-distribution-config \ + --id "$DIST_ID" \ + --query "DistributionConfig" \ + > "$TEMP_CONFIG" + +# Set Enabled to false +jq '.Enabled = false' "$TEMP_CONFIG" > "${TEMP_CONFIG}.new" +mv "${TEMP_CONFIG}.new" "$TEMP_CONFIG" + +echo "Disabling distribution..." + +# Update distribution to disabled +aws --endpoint-url="$MOTO_ENDPOINT" cloudfront update-distribution \ + --id "$DIST_ID" \ + --distribution-config "file://$TEMP_CONFIG" \ + --if-match "$ETAG" \ + > /dev/null + +rm -f "$TEMP_CONFIG" + +echo "Distribution $DIST_ID disabled successfully" diff --git a/frontend/deployment/tests/integration/test_cases/aws_cloudfront_route53/cloudfront_assertions.bash b/frontend/deployment/tests/integration/test_cases/aws_cloudfront_route53/cloudfront_assertions.bash new file mode 100644 index 00000000..53fc2575 --- /dev/null +++ b/frontend/deployment/tests/integration/test_cases/aws_cloudfront_route53/cloudfront_assertions.bash @@ -0,0 +1,126 @@ +#!/bin/bash +# ============================================================================= +# CloudFront Assertion Functions +# +# Provides assertion functions for validating CloudFront distribution +# configuration in integration tests. +# +# Variables validated (from distribution/cloudfront/modules/variables.tf): +# - distribution_bucket_name -> Origin domain +# - distribution_s3_prefix -> Origin path +# - distribution_app_name -> Distribution comment +# - distribution_resource_tags_json -> (skipped - Moto limitation) +# +# Usage: +# source "cloudfront_assertions.bash" +# assert_cloudfront_configured "comment" "domain" "bucket" "/prefix" +# +# Note: Some CloudFront fields are not fully supported by Moto and are skipped: +# - DefaultRootObject, PriceClass, IsIPV6Enabled +# - OriginAccessControlId, Compress, CachedMethods +# - CustomErrorResponses, Tags +# ============================================================================= + +# ============================================================================= +# CloudFront Configured Assertion +# ============================================================================= +# +----------------------------------+----------------------------------------+ +# | Assertion | Expected Value | +# +----------------------------------+----------------------------------------+ +# | Distribution exists | Non-empty ID | +# | Distribution enabled | true | +# | Distribution comment | expected comment (exact match) | +# | Custom domain alias | expected_domain (exact match) | +# | Origin domain | Contains expected_bucket | +# | Origin path (S3 prefix) | expected_origin_path (exact match) | +# | Viewer protocol policy | redirect-to-https | +# | Allowed methods | Contains GET, HEAD | +# +----------------------------------+----------------------------------------+ +assert_cloudfront_configured() { + local comment="$1" + local expected_domain="$2" + local expected_bucket="$3" + local expected_origin_path="$4" + + # Get CloudFront distribution by comment + local distribution_json + distribution_json=$(aws_moto cloudfront list-distributions \ + --query "DistributionList.Items[?Comment=='$comment']" \ + --output json 2>/dev/null | jq '.[0]') + + # Distribution exists + assert_not_empty "$distribution_json" "CloudFront distribution" + + local distribution_id + distribution_id=$(echo "$distribution_json" | jq -r '.Id') + assert_not_empty "$distribution_id" "CloudFront distribution ID" + + # Distribution enabled + local distribution_enabled + distribution_enabled=$(echo "$distribution_json" | jq -r '.Enabled') + assert_true "$distribution_enabled" "CloudFront distribution enabled" + + # Distribution comment (validates distribution_app_name) + local actual_comment + actual_comment=$(echo "$distribution_json" | jq -r '.Comment') + assert_equal "$actual_comment" "$comment" + + # Custom domain alias (exact match - only one alias expected) + local alias + alias=$(echo "$distribution_json" | jq -r '.Aliases.Items[0] // empty') + if [[ -n "$alias" && "$alias" != "null" ]]; then + assert_equal "$alias" "$expected_domain" + fi + + # Origin domain (validates distribution_bucket_name) + local origin_domain + origin_domain=$(echo "$distribution_json" | jq -r '.Origins.Items[0].DomainName // empty') + assert_not_empty "$origin_domain" "Origin domain" + assert_contains "$origin_domain" "$expected_bucket" + + # Origin path (validates distribution_s3_prefix) + local origin_path + origin_path=$(echo "$distribution_json" | jq -r '.Origins.Items[0].OriginPath // empty') + assert_equal "$origin_path" "$expected_origin_path" + + # Default cache behavior - viewer protocol policy + local viewer_protocol_policy + viewer_protocol_policy=$(echo "$distribution_json" | jq -r '.DefaultCacheBehavior.ViewerProtocolPolicy // empty') + if [[ -n "$viewer_protocol_policy" && "$viewer_protocol_policy" != "null" ]]; then + assert_equal "$viewer_protocol_policy" "redirect-to-https" + fi + + # Default cache behavior - allowed methods (check GET and HEAD are present) + local allowed_methods + allowed_methods=$(echo "$distribution_json" | jq -r '.DefaultCacheBehavior.AllowedMethods.Items // [] | join(",")') + if [[ -n "$allowed_methods" ]]; then + assert_contains "$allowed_methods" "GET" + assert_contains "$allowed_methods" "HEAD" + fi +} + +# ============================================================================= +# CloudFront Not Configured Assertion +# ============================================================================= +# +----------------------------------+----------------------------------------+ +# | Assertion | Expected Value | +# +----------------------------------+----------------------------------------+ +# | Distribution exists | null/empty (deleted) | +# +----------------------------------+----------------------------------------+ +assert_cloudfront_not_configured() { + local comment="$1" + + local distribution_json + distribution_json=$(aws_moto cloudfront list-distributions \ + --query "DistributionList.Items[?Comment=='$comment']" \ + --output json 2>/dev/null | jq '.[0]') + + # jq returns "null" when array is empty, treat as deleted + if [[ -z "$distribution_json" || "$distribution_json" == "null" ]]; then + return 0 + fi + + echo "Expected CloudFront distribution to be deleted" + echo "Actual: '$distribution_json'" + return 1 +} diff --git a/frontend/deployment/tests/integration/test_cases/aws_cloudfront_route53/lifecycle_test.bats b/frontend/deployment/tests/integration/test_cases/aws_cloudfront_route53/lifecycle_test.bats new file mode 100644 index 00000000..ce2f2ea6 --- /dev/null +++ b/frontend/deployment/tests/integration/test_cases/aws_cloudfront_route53/lifecycle_test.bats @@ -0,0 +1,130 @@ +#!/usr/bin/env bats +# ============================================================================= +# Integration test: CloudFront + Route53 Lifecycle +# +# Tests the full lifecycle of a static frontend deployment: +# 1. Create infrastructure (CloudFront distribution + Route53 record) +# 2. Verify all resources are configured correctly +# 3. Destroy infrastructure +# 4. Verify all resources are removed +# ============================================================================= + +# ============================================================================= +# Test Constants +# ============================================================================= +# Expected values derived from context.json and terraform variables + +# CloudFront variables (distribution/cloudfront/modules/variables.tf) +TEST_DISTRIBUTION_BUCKET="assets-bucket" # distribution_bucket_name +TEST_DISTRIBUTION_S3_PREFIX="/tools/automation/v1.0.0" # distribution_s3_prefix +TEST_DISTRIBUTION_APP_NAME="automation-development-tools-7" # distribution_app_name +TEST_DISTRIBUTION_COMMENT="Distribution for automation-development-tools-7" # derived from app_name + +# Route53 variables (network/route53/modules/variables.tf) +TEST_NETWORK_DOMAIN="frontend.publicdomain.com" # network_domain +TEST_NETWORK_SUBDOMAIN="automation-development-tools" # network_subdomain +TEST_NETWORK_FULL_DOMAIN="automation-development-tools.frontend.publicdomain.com" # computed + +# ============================================================================= +# Test Setup +# ============================================================================= + +setup_file() { + source "${PROJECT_ROOT}/testing/integration_helpers.sh" + source "${PROJECT_ROOT}/testing/assertions.sh" + integration_setup --cloud-provider aws + + clear_mocks + + # Create AWS prerequisites + echo "Creating test prerequisites..." + aws_local s3api create-bucket --bucket assets-bucket >/dev/null 2>&1 || true + aws_local s3api create-bucket --bucket tofu-state-bucket >/dev/null 2>&1 || true + aws_local dynamodb create-table \ + --table-name tofu-locks \ + --attribute-definitions AttributeName=LockID,AttributeType=S \ + --key-schema AttributeName=LockID,KeyType=HASH \ + --billing-mode PAY_PER_REQUEST >/dev/null 2>&1 || true + aws_local route53 create-hosted-zone \ + --name "$TEST_NETWORK_DOMAIN" \ + --caller-reference "test-$(date +%s)" >/dev/null 2>&1 || true + + # Create ACM certificate for the test domain + aws_local acm request-certificate \ + --domain-name "*.$TEST_NETWORK_DOMAIN" \ + --validation-method DNS >/dev/null 2>&1 || true + + # Get hosted zone ID for context override + HOSTED_ZONE_ID=$(aws_local route53 list-hosted-zones --query 'HostedZones[0].Id' --output text | sed 's|/hostedzone/||') + export HOSTED_ZONE_ID +} + +teardown_file() { + source "${PROJECT_ROOT}/testing/integration_helpers.sh" + clear_mocks + integration_teardown +} + +setup() { + source "${PROJECT_ROOT}/testing/integration_helpers.sh" + source "${PROJECT_ROOT}/testing/assertions.sh" + source "${BATS_TEST_DIRNAME}/cloudfront_assertions.bash" + source "${BATS_TEST_DIRNAME}/route53_assertions.bash" + + clear_mocks + load_context "frontend/deployment/tests/resources/context.json" + override_context "providers.cloud-providers.networking.hosted_public_zone_id" "$HOSTED_ZONE_ID" + + # Export environment variables + export NETWORK_LAYER="route53" + export DISTRIBUTION_LAYER="cloudfront" + export TOFU_PROVIDER="aws" + export TOFU_PROVIDER_BUCKET="tofu-state-bucket" + export TOFU_LOCK_TABLE="tofu-locks" + export AWS_REGION="us-east-1" + export SERVICE_PATH="$INTEGRATION_MODULE_ROOT/frontend" + export CUSTOM_TOFU_MODULES="$INTEGRATION_MODULE_ROOT/testing/localstack-provider" + + # Setup API mocks for np CLI calls + local mocks_dir="frontend/deployment/tests/integration/mocks/" + mock_request "GET" "/category" "$mocks_dir/asset_repository/category.json" + mock_request "GET" "/provider_specification" "$mocks_dir/asset_repository/list_provider_spec.json" + mock_request "GET" "/provider" "$mocks_dir/asset_repository/list_provider.json" + mock_request "GET" "/provider/s3-asset-repository-id" "$mocks_dir/asset_repository/get_provider.json" + mock_request "PATCH" "/scope/7" "$mocks_dir/scope/patch.json" +} + +# ============================================================================= +# Test: Create Infrastructure +# ============================================================================= + +@test "create infrastructure deploys CloudFront and Route53 resources" { + run_workflow "frontend/deployment/workflows/initial.yaml" + + assert_cloudfront_configured \ + "$TEST_DISTRIBUTION_COMMENT" \ + "$TEST_NETWORK_FULL_DOMAIN" \ + "$TEST_DISTRIBUTION_BUCKET" \ + "$TEST_DISTRIBUTION_S3_PREFIX" + + assert_route53_configured \ + "$TEST_NETWORK_FULL_DOMAIN" \ + "A" \ + "$HOSTED_ZONE_ID" +} + +# ============================================================================= +# Test: Destroy Infrastructure +# ============================================================================= + +@test "destroy infrastructure removes CloudFront and Route53 resources" { + # Disable CloudFront before deletion (required by AWS) + if [[ -f "$BATS_TEST_DIRNAME/../../scripts/disable_cloudfront.sh" ]]; then + "$BATS_TEST_DIRNAME/../../scripts/disable_cloudfront.sh" "$TEST_DISTRIBUTION_COMMENT" + fi + + run_workflow "frontend/deployment/workflows/delete.yaml" + + assert_cloudfront_not_configured "$TEST_DISTRIBUTION_COMMENT" + assert_route53_not_configured "$TEST_NETWORK_FULL_DOMAIN" "A" "$HOSTED_ZONE_ID" +} diff --git a/frontend/deployment/tests/integration/test_cases/aws_cloudfront_route53/route53_assertions.bash b/frontend/deployment/tests/integration/test_cases/aws_cloudfront_route53/route53_assertions.bash new file mode 100644 index 00000000..9b1e8720 --- /dev/null +++ b/frontend/deployment/tests/integration/test_cases/aws_cloudfront_route53/route53_assertions.bash @@ -0,0 +1,133 @@ +#!/bin/bash +# ============================================================================= +# Route53 Assertion Functions +# +# Provides assertion functions for validating Route53 record configuration +# in integration tests. +# +# Variables validated (from network/route53/modules/variables.tf): +# - network_hosted_zone_id -> Record is in the correct hosted zone +# - network_domain -> Part of full domain name +# - network_subdomain -> Part of full domain name (subdomain.domain) +# +# Usage: +# source "route53_assertions.bash" +# assert_route53_configured "full.domain.com" "A" "hosted-zone-id" +# ============================================================================= + +# ============================================================================= +# Route53 Configured Assertion +# ============================================================================= +# +----------------------------------+----------------------------------------+ +# | Assertion | Expected Value | +# +----------------------------------+----------------------------------------+ +# | Record exists | Non-empty record | +# | Record in correct hosted zone | expected_hosted_zone_id | +# | Record name | domain. (with trailing dot) | +# | Record type | A (exact match) | +# | Alias target hosted zone ID | Z2FDTNDATAQYW2 (CloudFront zone) | +# | Alias target DNS name | Contains cloudfront.net | +# | Evaluate target health | false | +# +----------------------------------+----------------------------------------+ +assert_route53_configured() { + local full_domain="$1" + local record_type="${2:-A}" + local expected_hosted_zone_id="$3" + + # Verify we're querying the correct hosted zone (network_hosted_zone_id) + local zone_id + if [[ -n "$expected_hosted_zone_id" ]]; then + zone_id="$expected_hosted_zone_id" + else + # Fallback to first hosted zone if not specified + zone_id=$(aws_local route53 list-hosted-zones \ + --query "HostedZones[0].Id" \ + --output text 2>/dev/null | sed 's|/hostedzone/||') + fi + + assert_not_empty "$zone_id" "Route53 hosted zone ID" + + # Verify the hosted zone exists + local zone_info + zone_info=$(aws_local route53 get-hosted-zone \ + --id "$zone_id" \ + --output json 2>/dev/null) + assert_not_empty "$zone_info" "Route53 hosted zone info" + + # Get Route53 record details + local record_name="${full_domain}." + local record_json + record_json=$(aws_local route53 list-resource-record-sets \ + --hosted-zone-id "$zone_id" \ + --query "ResourceRecordSets[?Name=='$record_name' && Type=='$record_type']" \ + --output json 2>/dev/null | jq '.[0]') + + # Record exists + assert_not_empty "$record_json" "Route53 $record_type record" + + # Record name (with trailing dot) - validates network_domain + network_subdomain + local actual_name + actual_name=$(echo "$record_json" | jq -r '.Name') + assert_equal "$actual_name" "$record_name" + + # Record type + local actual_type + actual_type=$(echo "$record_json" | jq -r '.Type') + assert_equal "$actual_type" "$record_type" + + # Alias target hosted zone ID (CloudFront's global hosted zone) + local alias_hosted_zone_id + alias_hosted_zone_id=$(echo "$record_json" | jq -r '.AliasTarget.HostedZoneId // empty') + assert_equal "$alias_hosted_zone_id" "Z2FDTNDATAQYW2" + + # Alias target DNS name (CloudFront distribution domain) + # Note: LocalStack may return domain with or without trailing dot + local alias_target + alias_target=$(echo "$record_json" | jq -r '.AliasTarget.DNSName // empty') + assert_not_empty "$alias_target" "Route53 alias target" + assert_contains "$alias_target" "cloudfront.net" + + # Evaluate target health is false (CloudFront doesn't support health checks) + local evaluate_target_health + evaluate_target_health=$(echo "$record_json" | jq -r '.AliasTarget.EvaluateTargetHealth') + assert_false "$evaluate_target_health" "Route53 evaluate target health" +} + +# ============================================================================= +# Route53 Not Configured Assertion +# ============================================================================= +# +----------------------------------+----------------------------------------+ +# | Assertion | Expected Value | +# +----------------------------------+----------------------------------------+ +# | Record exists | null/empty (deleted) | +# +----------------------------------+----------------------------------------+ +assert_route53_not_configured() { + local full_domain="$1" + local record_type="${2:-A}" + local expected_hosted_zone_id="$3" + + local zone_id + if [[ -n "$expected_hosted_zone_id" ]]; then + zone_id="$expected_hosted_zone_id" + else + zone_id=$(aws_local route53 list-hosted-zones \ + --query "HostedZones[0].Id" \ + --output text 2>/dev/null | sed 's|/hostedzone/||') + fi + + local record_name="${full_domain}." + local record_json + record_json=$(aws_local route53 list-resource-record-sets \ + --hosted-zone-id "$zone_id" \ + --query "ResourceRecordSets[?Name=='$record_name' && Type=='$record_type']" \ + --output json 2>/dev/null | jq '.[0]') + + # jq returns "null" when array is empty, treat as deleted + if [[ -z "$record_json" || "$record_json" == "null" ]]; then + return 0 + fi + + echo "Expected Route53 $record_type record to be deleted" + echo "Actual: '$record_json'" + return 1 +} diff --git a/frontend/deployment/tests/integration/test_cases/azure_blobcdn_azuredns/cdn_assertions.bash b/frontend/deployment/tests/integration/test_cases/azure_blobcdn_azuredns/cdn_assertions.bash new file mode 100644 index 00000000..e7149095 --- /dev/null +++ b/frontend/deployment/tests/integration/test_cases/azure_blobcdn_azuredns/cdn_assertions.bash @@ -0,0 +1,139 @@ +#!/bin/bash +# ============================================================================= +# Azure CDN Assertion Functions +# +# Provides assertion functions for validating Azure CDN endpoint +# configuration in integration tests using the Azure Mock API server. +# +# Variables validated (from distribution/azure_blob_cdn/modules/variables.tf): +# - distribution_storage_account -> Origin host +# - distribution_app_name -> CDN profile/endpoint name +# +# Usage: +# source "cdn_assertions.bash" +# assert_azure_cdn_configured "app-name" "storage-account" "sub-id" "rg" +# +# Note: Uses azure_mock() helper from integration_helpers.sh +# ============================================================================= + +# ============================================================================= +# Azure CDN Configured Assertion +# ============================================================================= +# +----------------------------------+----------------------------------------+ +# | Assertion | Expected Value | +# +----------------------------------+----------------------------------------+ +# | CDN Profile exists | Non-empty ID | +# | CDN Profile provisioning state | Succeeded | +# | CDN Endpoint exists | Non-empty ID | +# | CDN Endpoint provisioning state | Succeeded | +# | CDN Endpoint hostname | Contains azureedge.net | +# | Origin host contains | storage account name | +# +----------------------------------+----------------------------------------+ +assert_azure_cdn_configured() { + local app_name="$1" + local storage_account="$2" + local subscription_id="$3" + local resource_group="$4" + + # Derive CDN profile and endpoint names from app_name + # The terraform module uses: "${var.distribution_app_name}-cdn" for profile + # and "${var.distribution_app_name}" for endpoint + local profile_name="${app_name}-cdn" + local endpoint_name="${app_name}" + + # Get CDN Profile + local profile_path="/subscriptions/${subscription_id}/resourceGroups/${resource_group}/providers/Microsoft.Cdn/profiles/${profile_name}" + local profile_json + profile_json=$(azure_mock "$profile_path") + + # Profile exists + local profile_id + profile_id=$(echo "$profile_json" | jq -r '.id // empty') + assert_not_empty "$profile_id" "Azure CDN Profile ID" + + # Profile provisioning state + local profile_state + profile_state=$(echo "$profile_json" | jq -r '.properties.provisioningState // empty') + assert_equal "$profile_state" "Succeeded" + + # Get CDN Endpoint + local endpoint_path="/subscriptions/${subscription_id}/resourceGroups/${resource_group}/providers/Microsoft.Cdn/profiles/${profile_name}/endpoints/${endpoint_name}" + local endpoint_json + endpoint_json=$(azure_mock "$endpoint_path") + + # Endpoint exists + local endpoint_id + endpoint_id=$(echo "$endpoint_json" | jq -r '.id // empty') + assert_not_empty "$endpoint_id" "Azure CDN Endpoint ID" + + # Endpoint provisioning state + local endpoint_state + endpoint_state=$(echo "$endpoint_json" | jq -r '.properties.provisioningState // empty') + assert_equal "$endpoint_state" "Succeeded" + + # Endpoint hostname contains azureedge.net + local hostname + hostname=$(echo "$endpoint_json" | jq -r '.properties.hostName // empty') + assert_not_empty "$hostname" "Azure CDN Endpoint hostname" + assert_contains "$hostname" "azureedge.net" + + # Origin host contains storage account name + local origin_host + origin_host=$(echo "$endpoint_json" | jq -r '.properties.origins[0].properties.hostName // empty') + assert_not_empty "$origin_host" "Azure CDN Origin host" + assert_contains "$origin_host" "$storage_account" +} + +# ============================================================================= +# Azure CDN Not Configured Assertion +# ============================================================================= +# +----------------------------------+----------------------------------------+ +# | Assertion | Expected Value | +# +----------------------------------+----------------------------------------+ +# | CDN Profile exists | null/empty (deleted) | +# | CDN Endpoint exists | null/empty (deleted) | +# +----------------------------------+----------------------------------------+ +assert_azure_cdn_not_configured() { + local app_name="$1" + local subscription_id="$2" + local resource_group="$3" + + local profile_name="${app_name}-cdn" + local endpoint_name="${app_name}" + + # Check CDN Profile is deleted + local profile_path="/subscriptions/${subscription_id}/resourceGroups/${resource_group}/providers/Microsoft.Cdn/profiles/${profile_name}" + local profile_json + profile_json=$(azure_mock "$profile_path") + + local profile_error + profile_error=$(echo "$profile_json" | jq -r '.error.code // empty') + if [[ "$profile_error" != "ResourceNotFound" ]]; then + local profile_id + profile_id=$(echo "$profile_json" | jq -r '.id // empty') + if [[ -n "$profile_id" && "$profile_id" != "null" ]]; then + echo "Expected Azure CDN Profile to be deleted" + echo "Actual: '$profile_json'" + return 1 + fi + fi + + # Check CDN Endpoint is deleted + local endpoint_path="/subscriptions/${subscription_id}/resourceGroups/${resource_group}/providers/Microsoft.Cdn/profiles/${profile_name}/endpoints/${endpoint_name}" + local endpoint_json + endpoint_json=$(azure_mock "$endpoint_path") + + local endpoint_error + endpoint_error=$(echo "$endpoint_json" | jq -r '.error.code // empty') + if [[ "$endpoint_error" != "ResourceNotFound" ]]; then + local endpoint_id + endpoint_id=$(echo "$endpoint_json" | jq -r '.id // empty') + if [[ -n "$endpoint_id" && "$endpoint_id" != "null" ]]; then + echo "Expected Azure CDN Endpoint to be deleted" + echo "Actual: '$endpoint_json'" + return 1 + fi + fi + + return 0 +} diff --git a/frontend/deployment/tests/integration/test_cases/azure_blobcdn_azuredns/dns_assertions.bash b/frontend/deployment/tests/integration/test_cases/azure_blobcdn_azuredns/dns_assertions.bash new file mode 100644 index 00000000..c970fc2a --- /dev/null +++ b/frontend/deployment/tests/integration/test_cases/azure_blobcdn_azuredns/dns_assertions.bash @@ -0,0 +1,105 @@ +#!/bin/bash +# ============================================================================= +# Azure DNS Assertion Functions +# +# Provides assertion functions for validating Azure DNS CNAME record +# configuration in integration tests using the Azure Mock API server. +# +# Variables validated (from network/azure_dns/modules/variables.tf): +# - network_domain -> DNS zone name +# - network_subdomain -> CNAME record name +# +# Usage: +# source "dns_assertions.bash" +# assert_azure_dns_configured "subdomain" "domain.com" "sub-id" "rg" +# +# Note: Uses azure_mock() helper from integration_helpers.sh +# ============================================================================= + +# ============================================================================= +# Azure DNS Configured Assertion +# ============================================================================= +# +----------------------------------+----------------------------------------+ +# | Assertion | Expected Value | +# +----------------------------------+----------------------------------------+ +# | CNAME Record exists | Non-empty ID | +# | Record name | expected subdomain | +# | CNAME target | Non-empty (points to CDN) | +# | TTL | > 0 | +# +----------------------------------+----------------------------------------+ +assert_azure_dns_configured() { + local subdomain="$1" + local zone_name="$2" + local subscription_id="$3" + local resource_group="$4" + + # Get DNS CNAME Record + local record_path="/subscriptions/${subscription_id}/resourceGroups/${resource_group}/providers/Microsoft.Network/dnszones/${zone_name}/CNAME/${subdomain}" + local record_json + record_json=$(azure_mock "$record_path") + + # Record exists + local record_id + record_id=$(echo "$record_json" | jq -r '.id // empty') + assert_not_empty "$record_id" "Azure DNS CNAME Record ID" + + # Record name + local record_name + record_name=$(echo "$record_json" | jq -r '.name // empty') + assert_equal "$record_name" "$subdomain" + + # CNAME target (should point to CDN endpoint) + local cname_target + cname_target=$(echo "$record_json" | jq -r '.properties.CNAMERecord.cname // empty') + assert_not_empty "$cname_target" "Azure DNS CNAME target" + + # The CNAME should point to the Azure CDN endpoint (azureedge.net) + assert_contains "$cname_target" "azureedge.net" + + # TTL should be positive + local ttl + ttl=$(echo "$record_json" | jq -r '.properties.TTL // 0') + if [[ "$ttl" -le 0 ]]; then + echo "Expected TTL > 0, got $ttl" + return 1 + fi + + # FQDN should be set correctly + local fqdn + fqdn=$(echo "$record_json" | jq -r '.properties.fqdn // empty') + assert_contains "$fqdn" "${subdomain}.${zone_name}" +} + +# ============================================================================= +# Azure DNS Not Configured Assertion +# ============================================================================= +# +----------------------------------+----------------------------------------+ +# | Assertion | Expected Value | +# +----------------------------------+----------------------------------------+ +# | CNAME Record exists | null/empty (deleted) | +# +----------------------------------+----------------------------------------+ +assert_azure_dns_not_configured() { + local subdomain="$1" + local zone_name="$2" + local subscription_id="$3" + local resource_group="$4" + + # Check CNAME Record is deleted + local record_path="/subscriptions/${subscription_id}/resourceGroups/${resource_group}/providers/Microsoft.Network/dnszones/${zone_name}/CNAME/${subdomain}" + local record_json + record_json=$(azure_mock "$record_path") + + local record_error + record_error=$(echo "$record_json" | jq -r '.error.code // empty') + if [[ "$record_error" != "ResourceNotFound" ]]; then + local record_id + record_id=$(echo "$record_json" | jq -r '.id // empty') + if [[ -n "$record_id" && "$record_id" != "null" ]]; then + echo "Expected Azure DNS CNAME Record to be deleted" + echo "Actual: '$record_json'" + return 1 + fi + fi + + return 0 +} diff --git a/frontend/deployment/tests/integration/test_cases/azure_blobcdn_azuredns/lifecycle_test.bats b/frontend/deployment/tests/integration/test_cases/azure_blobcdn_azuredns/lifecycle_test.bats new file mode 100644 index 00000000..78622454 --- /dev/null +++ b/frontend/deployment/tests/integration/test_cases/azure_blobcdn_azuredns/lifecycle_test.bats @@ -0,0 +1,155 @@ +#!/usr/bin/env bats +# ============================================================================= +# Integration test: Azure BlobCDN + Azure DNS Lifecycle +# +# Tests the full lifecycle of a static frontend deployment on Azure: +# 1. Create infrastructure (CDN endpoint + DNS CNAME record) +# 2. Verify all resources are configured correctly +# 3. Destroy infrastructure +# 4. Verify all resources are removed +# ============================================================================= + +# ============================================================================= +# Test Constants +# ============================================================================= +# Expected values derived from context_azure.json and terraform variables + +# CDN variables (distribution/azure_blob_cdn/modules/variables.tf) +TEST_DISTRIBUTION_STORAGE_ACCOUNT="assetsaccount" # distribution_storage_account +TEST_DISTRIBUTION_CONTAINER="assets" # distribution_container +TEST_DISTRIBUTION_S3_PREFIX="/tools/automation/v1.0.0" # distribution_s3_prefix +TEST_DISTRIBUTION_APP_NAME="automation-development-tools-7" # distribution_app_name + +# DNS variables (network/azure_dns/modules/variables.tf) +TEST_NETWORK_DOMAIN="frontend.publicdomain.com" # network_domain +TEST_NETWORK_SUBDOMAIN="automation-development-tools" # network_subdomain +TEST_NETWORK_FULL_DOMAIN="automation-development-tools.frontend.publicdomain.com" # computed + +# Azure resource identifiers +TEST_SUBSCRIPTION_ID="mock-subscription-id" +TEST_RESOURCE_GROUP="test-resource-group" +TEST_DNS_ZONE_RESOURCE_GROUP="dns-resource-group" + +# ============================================================================= +# Test Setup +# ============================================================================= + +setup_file() { + source "${PROJECT_ROOT}/testing/integration_helpers.sh" + source "${PROJECT_ROOT}/testing/assertions.sh" + integration_setup --cloud-provider azure + + clear_mocks + + # Pre-create Azure DNS zone in the mock server (required for data source lookup) + echo "Creating test prerequisites in Azure Mock..." + + # Create DNS zone in dns-resource-group (for validation step in setup_network_layer) + azure_mock_put "/subscriptions/${TEST_SUBSCRIPTION_ID}/resourceGroups/${TEST_DNS_ZONE_RESOURCE_GROUP}/providers/Microsoft.Network/dnszones/${TEST_NETWORK_DOMAIN}" \ + '{"location": "global", "tags": {}}' >/dev/null 2>&1 || true + + # Also create DNS zone in test-resource-group (for Terraform data source lookup) + # The azure_dns module uses var.azure_provider.resource_group which is test-resource-group + azure_mock_put "/subscriptions/${TEST_SUBSCRIPTION_ID}/resourceGroups/${TEST_RESOURCE_GROUP}/providers/Microsoft.Network/dnszones/${TEST_NETWORK_DOMAIN}" \ + '{"location": "global", "tags": {}}' >/dev/null 2>&1 || true + + # Create Storage Account via REST API (for data source lookup) + azure_mock_put "/subscriptions/${TEST_SUBSCRIPTION_ID}/resourceGroups/${TEST_RESOURCE_GROUP}/providers/Microsoft.Storage/storageAccounts/${TEST_DISTRIBUTION_STORAGE_ACCOUNT}" \ + '{"location": "eastus", "kind": "StorageV2", "sku": {"name": "Standard_LRS", "tier": "Standard"}}' >/dev/null 2>&1 || true + + export TEST_SUBSCRIPTION_ID + export TEST_RESOURCE_GROUP + export TEST_DNS_ZONE_RESOURCE_GROUP +} + +teardown_file() { + source "${PROJECT_ROOT}/testing/integration_helpers.sh" + clear_mocks + integration_teardown +} + +setup() { + source "${PROJECT_ROOT}/testing/integration_helpers.sh" + source "${PROJECT_ROOT}/testing/assertions.sh" + source "${BATS_TEST_DIRNAME}/cdn_assertions.bash" + source "${BATS_TEST_DIRNAME}/dns_assertions.bash" + + clear_mocks + load_context "frontend/deployment/tests/resources/context_azure.json" + + # Export environment variables + export NETWORK_LAYER="azure_dns" + export DISTRIBUTION_LAYER="blob-cdn" + export TOFU_PROVIDER="azure" + export SERVICE_PATH="$INTEGRATION_MODULE_ROOT/frontend" + export CUSTOM_TOFU_MODULES="$INTEGRATION_MODULE_ROOT/testing/azure-mock-provider" + + # Azure provider required environment variables + export AZURE_SUBSCRIPTION_ID="$TEST_SUBSCRIPTION_ID" + export AZURE_RESOURCE_GROUP="$TEST_RESOURCE_GROUP" + # Use mock storage account for backend (handled by azure-mock) + export TOFU_PROVIDER_STORAGE_ACCOUNT="devstoreaccount1" + export TOFU_PROVIDER_CONTAINER="tfstate" + + # Setup API mocks for np CLI calls + local mocks_dir="frontend/deployment/tests/integration/mocks/" + mock_request "GET" "/category" "$mocks_dir/asset_repository/category.json" + mock_request "GET" "/provider_specification" "$mocks_dir/asset_repository/list_provider_spec.json" + mock_request "GET" "/provider" "$mocks_dir/azure_asset_repository/list_provider.json" + mock_request "GET" "/provider/azure-blob-asset-repository-id" "$mocks_dir/azure_asset_repository/get_provider.json" + mock_request "PATCH" "/scope/7" "$mocks_dir/scope/patch.json" + + # Ensure tfstate container exists in azure-mock for Terraform backend + curl -s -X PUT "${AZURE_MOCK_ENDPOINT}/tfstate?restype=container" \ + -H "Host: devstoreaccount1.blob.core.windows.net" \ + -H "x-ms-version: 2021-06-08" >/dev/null 2>&1 || true + + # Ensure DNS zone exists in azure-mock (for validation and Terraform data source) + azure_mock_put "/subscriptions/${TEST_SUBSCRIPTION_ID}/resourceGroups/${TEST_DNS_ZONE_RESOURCE_GROUP}/providers/Microsoft.Network/dnszones/${TEST_NETWORK_DOMAIN}" \ + '{"location": "global", "tags": {}}' >/dev/null 2>&1 || true + azure_mock_put "/subscriptions/${TEST_SUBSCRIPTION_ID}/resourceGroups/${TEST_RESOURCE_GROUP}/providers/Microsoft.Network/dnszones/${TEST_NETWORK_DOMAIN}" \ + '{"location": "global", "tags": {}}' >/dev/null 2>&1 || true + + # Ensure storage account exists in azure-mock (for Terraform data source) + azure_mock_put "/subscriptions/${TEST_SUBSCRIPTION_ID}/resourceGroups/${TEST_RESOURCE_GROUP}/providers/Microsoft.Storage/storageAccounts/${TEST_DISTRIBUTION_STORAGE_ACCOUNT}" \ + '{"location": "eastus", "kind": "StorageV2", "sku": {"name": "Standard_LRS", "tier": "Standard"}}' >/dev/null 2>&1 || true +} + +# ============================================================================= +# Test: Create Infrastructure +# ============================================================================= + +@test "create infrastructure deploys Azure CDN and DNS resources" { + run_workflow "frontend/deployment/workflows/initial.yaml" + + assert_azure_cdn_configured \ + "$TEST_DISTRIBUTION_APP_NAME" \ + "$TEST_DISTRIBUTION_STORAGE_ACCOUNT" \ + "$TEST_SUBSCRIPTION_ID" \ + "$TEST_RESOURCE_GROUP" + + assert_azure_dns_configured \ + "$TEST_NETWORK_SUBDOMAIN" \ + "$TEST_NETWORK_DOMAIN" \ + "$TEST_SUBSCRIPTION_ID" \ + "$TEST_RESOURCE_GROUP" +} + +# ============================================================================= +# Test: Destroy Infrastructure +# ============================================================================= + +@test "destroy infrastructure removes Azure CDN and DNS resources" { + run_workflow "frontend/deployment/workflows/delete.yaml" + + assert_azure_cdn_not_configured \ + "$TEST_DISTRIBUTION_APP_NAME" \ + "$TEST_SUBSCRIPTION_ID" \ + "$TEST_RESOURCE_GROUP" + + assert_azure_dns_not_configured \ + "$TEST_NETWORK_SUBDOMAIN" \ + "$TEST_NETWORK_DOMAIN" \ + "$TEST_SUBSCRIPTION_ID" \ + "$TEST_DNS_ZONE_RESOURCE_GROUP" +} diff --git a/frontend/deployment/tests/network/azure_dns/setup_test.bats b/frontend/deployment/tests/network/azure_dns/setup_test.bats new file mode 100644 index 00000000..8b26ce02 --- /dev/null +++ b/frontend/deployment/tests/network/azure_dns/setup_test.bats @@ -0,0 +1,289 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for network/azure_dns/setup script +# +# Requirements: +# - bats-core: brew install bats-core +# - jq: brew install jq +# +# Run tests: +# bats tests/network/azure_dns/setup_test.bats +# ============================================================================= + +# Setup - runs before each test +setup() { + TEST_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")" && pwd)" + PROJECT_DIR="$(cd "$TEST_DIR/../../.." && pwd)" + PROJECT_ROOT="$(cd "$PROJECT_DIR/../.." && pwd)" + SCRIPT_PATH="$PROJECT_DIR/network/azure_dns/setup" + RESOURCES_DIR="$PROJECT_DIR/tests/resources" + AZURE_MOCKS_DIR="$RESOURCES_DIR/azure_mocks" + NP_MOCKS_DIR="$RESOURCES_DIR/np_mocks" + + # Load shared test utilities + source "$PROJECT_ROOT/testing/assertions.sh" + + # Add mock az and np to PATH (must be first) + export PATH="$AZURE_MOCKS_DIR:$NP_MOCKS_DIR:$PATH" + + # Load context with public_dns_zone_name and public_dns_zone_resource_group_name + export CONTEXT='{ + "application": {"slug": "automation"}, + "scope": {"slug": "development-tools", "id": "7"}, + "providers": { + "cloud-providers": { + "networking": { + "public_dns_zone_resource_group_name": "my-resource-group", + "public_dns_zone_name": "example.com" + } + } + } + }' + + # Initialize TOFU_VARIABLES with existing keys to verify script merges (not replaces) + export TOFU_VARIABLES='{ + "application_slug": "automation", + "scope_slug": "development-tools", + "scope_id": "7" + }' + + export MODULES_TO_USE="" + + # Set default np scope patch mock (success) + export NP_MOCK_RESPONSE="$NP_MOCKS_DIR/scope/patch/success.json" + export NP_MOCK_EXIT_CODE="0" +} + +# ============================================================================= +# Helper functions +# ============================================================================= +run_azure_dns_setup() { + source "$SCRIPT_PATH" +} + +# ============================================================================= +# Test: Required environment variables +# ============================================================================= +@test "Should fail when public_dns_zone_name is not present in context" { + export CONTEXT='{ + "application": {"slug": "automation"}, + "scope": {"slug": "development-tools"}, + "providers": { + "cloud-providers": { + "public_dns_zone_resource_group_name": "my-resource-group", + "networking": {} + } + } + }' + + run source "$SCRIPT_PATH" + + assert_equal "$status" "1" + assert_contains "$output" " ❌ public_dns_zone_name is not set in context" + + assert_contains "$output" " 🔧 How to fix:" + assert_contains "$output" " • Ensure there is an Azure cloud-provider configured at the correct NRN hierarchy level" + assert_contains "$output" " • Set the 'public_dns_zone_name' field with the Azure DNS zone name" +} + +@test "Should fail when public_dns_zone_resource_group_name is not present in context" { + export CONTEXT='{ + "application": {"slug": "automation"}, + "scope": {"slug": "development-tools"}, + "providers": { + "cloud-providers": { + "networking": { + "public_dns_zone_name": "example.com" + } + } + } + }' + + run source "$SCRIPT_PATH" + + assert_equal "$status" "1" + assert_contains "$output" " ❌ public_dns_zone_resource_group_name is not set in context" + + assert_contains "$output" " 🔧 How to fix:" + assert_contains "$output" " • Ensure the Azure cloud-provider has 'public_dns_zone_resource_group_name' configured" +} + +# ============================================================================= +# Test: ResourceNotFound error +# ============================================================================= +@test "Should fail if DNS zone does not exist" { + set_az_mock "$AZURE_MOCKS_DIR/dns_zone/not_found.json" 1 + + run source "$SCRIPT_PATH" + + assert_equal "$status" "1" + assert_contains "$output" " ❌ Failed to fetch Azure DNS zone information" + assert_contains "$output" " 🔎 Error: DNS zone 'example.com' does not exist in resource group 'my-resource-group'" + + assert_contains "$output" " 💡 Possible causes:" + assert_contains "$output" " • The DNS zone name is incorrect or has a typo" + assert_contains "$output" " • The DNS zone was deleted" + assert_contains "$output" " • The resource group is incorrect" + + assert_contains "$output" " 🔧 How to fix:" + assert_contains "$output" " • Verify the DNS zone exists: az network dns zone list --resource-group my-resource-group" + assert_contains "$output" " • Update 'public_dns_zone_name' in the Azure cloud-provider configuration" +} + +# ============================================================================= +# Test: AccessDenied error +# ============================================================================= +@test "Should fail if lacking permissions to read DNS zones" { + set_az_mock "$AZURE_MOCKS_DIR/dns_zone/access_denied.json" 1 + + run source "$SCRIPT_PATH" + + assert_equal "$status" "1" + assert_contains "$output" " 🔒 Error: Permission denied when accessing Azure DNS" + + assert_contains "$output" " 💡 Possible causes:" + assert_contains "$output" " • The Azure credentials don't have DNS Zone read permissions" + assert_contains "$output" " • The service principal is missing the 'DNS Zone Contributor' role" + + assert_contains "$output" " 🔧 How to fix:" + assert_contains "$output" " • Check the Azure credentials are configured correctly" + assert_contains "$output" " • Ensure the service principal has 'DNS Zone Reader' or 'DNS Zone Contributor' role" +} + +# ============================================================================= +# Test: InvalidSubscription error +# ============================================================================= +@test "Should fail if subscription is invalid" { + set_az_mock "$AZURE_MOCKS_DIR/dns_zone/invalid_subscription.json" 1 + + run source "$SCRIPT_PATH" + + assert_equal "$status" "1" + assert_contains "$output" " ⚠️ Error: Invalid subscription" + + assert_contains "$output" " 🔧 How to fix:" + assert_contains "$output" " • Verify the Azure subscription is correct" + assert_contains "$output" " • Check the service principal has access to the subscription" +} + +# ============================================================================= +# Test: Credentials error +# ============================================================================= +@test "Should fail if Azure credentials are missing" { + set_az_mock "$AZURE_MOCKS_DIR/dns_zone/credentials_error.json" 1 + + run source "$SCRIPT_PATH" + + assert_equal "$status" "1" + assert_contains "$output" " 🔑 Error: Azure credentials issue" + + assert_contains "$output" " 💡 Possible causes:" + assert_contains "$output" " • The nullplatform agent is not configured with Azure credentials" + assert_contains "$output" " • The service principal credentials have expired" + + assert_contains "$output" " 🔧 How to fix:" + assert_contains "$output" " • Configure Azure credentials in the nullplatform agent" + assert_contains "$output" " • Verify the service principal credentials are valid" +} + +# ============================================================================= +# Test: Unknown Azure DNS error +# ============================================================================= +@test "Should handle unknown error getting the Azure DNS zone" { + set_az_mock "$AZURE_MOCKS_DIR/dns_zone/unknown_error.json" 1 + + run source "$SCRIPT_PATH" + + assert_equal "$status" "1" + assert_contains "$output" " 📋 Error details:" + assert_contains "$output" "Unknown error fetching Azure DNS zone." +} + +# ============================================================================= +# Test: Empty domain in response +# ============================================================================= +@test "Should handle missing DNS zone name from response" { + set_az_mock "$AZURE_MOCKS_DIR/dns_zone/empty_name.json" + + run source "$SCRIPT_PATH" + + assert_equal "$status" "1" + assert_contains "$output" " ❌ Failed to extract domain name from DNS zone response" + + assert_contains "$output" " 💡 Possible causes:" + assert_contains "$output" " • The DNS zone does not have a valid domain name configured" + + assert_contains "$output" " 🔧 How to fix:" + assert_contains "$output" " • Verify the DNS zone has a valid domain: az network dns zone show --name example.com --resource-group my-resource-group" +} + +# ============================================================================= +# Test: Scope patch error +# ============================================================================= +@test "Should handle auth error updating scope domain" { + set_az_mock "$AZURE_MOCKS_DIR/dns_zone/success.json" + set_np_mock "$NP_MOCKS_DIR/scope/patch/auth_error.json" 1 + + run source "$SCRIPT_PATH" + + assert_contains "$output" " ❌ Failed to update scope domain" + assert_contains "$output" " 🔒 Error: Permission denied" + + assert_contains "$output" " 💡 Possible causes:" + assert_contains "$output" " • The nullplatform API Key doesn't have 'Developer' permissions" + + assert_contains "$output" " 🔧 How to fix:" + assert_contains "$output" " • Ensure the API Key has 'Developer' permissions at the correct NRN hierarchy level" +} + +@test "Should handle unknown error updating scope domain" { + set_az_mock "$AZURE_MOCKS_DIR/dns_zone/success.json" + set_np_mock "$NP_MOCKS_DIR/scope/patch/unknown_error.json" 1 + + run source "$SCRIPT_PATH" + + assert_equal "$status" "1" + assert_contains "$output" " ❌ Failed to update scope domain" + assert_contains "$output" " 📋 Error details:" + assert_contains "$output" "Unknown error updating scope" +} + +# ============================================================================= +# Test: TOFU_VARIABLES - verifies the entire JSON structure +# ============================================================================= +@test "Should add network variables to TOFU_VARIABLES" { + set_az_mock "$AZURE_MOCKS_DIR/dns_zone/success.json" + + run_azure_dns_setup + + local expected='{ + "application_slug": "automation", + "scope_slug": "development-tools", + "scope_id": "7", + "network_dns_zone_name": "example.com", + "network_domain": "example.com", + "network_subdomain": "automation-development-tools" +}' + + assert_json_equal "$TOFU_VARIABLES" "$expected" "TOFU_VARIABLES" +} + +# ============================================================================= +# Test: MODULES_TO_USE +# ============================================================================= +@test "Should register the provider in the MODULES_TO_USE variable when it's empty" { + set_az_mock "$AZURE_MOCKS_DIR/dns_zone/success.json" + + run_azure_dns_setup + + assert_equal "$MODULES_TO_USE" "$PROJECT_DIR/network/azure_dns/modules" +} + +@test "Should append the provider in the MODULES_TO_USE variable when it's not empty" { + set_az_mock "$AZURE_MOCKS_DIR/dns_zone/success.json" + export MODULES_TO_USE="existing/module" + + run_azure_dns_setup + + assert_equal "$MODULES_TO_USE" "existing/module,$PROJECT_DIR/network/azure_dns/modules" +} diff --git a/frontend/deployment/tests/network/route53/setup_test.bats b/frontend/deployment/tests/network/route53/setup_test.bats new file mode 100644 index 00000000..dc7309ed --- /dev/null +++ b/frontend/deployment/tests/network/route53/setup_test.bats @@ -0,0 +1,277 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for network/route53/setup script +# +# Requirements: +# - bats-core: brew install bats-core +# - jq: brew install jq +# +# Run tests: +# bats tests/network/route53/setup_test.bats +# ============================================================================= + +# Setup - runs before each test +setup() { + TEST_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")" && pwd)" + PROJECT_DIR="$(cd "$TEST_DIR/../../.." && pwd)" + PROJECT_ROOT="$(cd "$PROJECT_DIR/../.." && pwd)" + SCRIPT_PATH="$PROJECT_DIR/network/route53/setup" + RESOURCES_DIR="$PROJECT_DIR/tests/resources" + AWS_MOCKS_DIR="$RESOURCES_DIR/aws_mocks" + NP_MOCKS_DIR="$RESOURCES_DIR/np_mocks" + + # Load shared test utilities + source "$PROJECT_ROOT/testing/assertions.sh" + + # Add mock aws and np to PATH (must be first) + export PATH="$AWS_MOCKS_DIR:$NP_MOCKS_DIR:$PATH" + + # Load context with hosted_public_zone_id + export CONTEXT='{ + "application": {"slug": "automation"}, + "scope": {"slug": "development-tools", "id": "7"}, + "providers": { + "cloud-providers": { + "networking": { + "hosted_public_zone_id": "Z1234567890ABC" + } + } + } + }' + + # Initialize TOFU_VARIABLES with existing keys to verify script merges (not replaces) + export TOFU_VARIABLES='{ + "application_slug": "automation", + "scope_slug": "development-tools", + "scope_id": "7" + }' + + export MODULES_TO_USE="" + + # Set default np scope patch mock (success) + export NP_MOCK_RESPONSE="$NP_MOCKS_DIR/scope/patch/success.json" + export NP_MOCK_EXIT_CODE="0" +} + +# ============================================================================= +# Helper functions +# ============================================================================= +run_route53_setup() { + source "$SCRIPT_PATH" +} + +#set_np_scope_patch_mock() { +# local mock_file="$1" +# local exit_code="${2:-0}" +# export NP_MOCK_RESPONSE="$mock_file" +# export NP_MOCK_EXIT_CODE="$exit_code" +#} + +# ============================================================================= +# Test: Required environment variables +# ============================================================================= +@test "Should fail when hosted_public_zone_id is not present in context" { + export CONTEXT='{ + "application": {"slug": "automation"}, + "scope": {"slug": "development-tools"}, + "providers": { + "cloud-providers": { + "networking": {} + } + } + }' + + run source "$SCRIPT_PATH" + + assert_equal "$status" "1" + assert_contains "$output" " ❌ hosted_public_zone_id is not set in context" + + assert_contains "$output" " 🔧 How to fix:" + assert_contains "$output" " 1. Ensure there is an AWS cloud-provider configured at the correct NRN hierarchy level" + assert_contains "$output" " 2. Set the 'hosted_public_zone_id' field with the Route 53 hosted zone ID" +} + +# ============================================================================= +# Test: NoSuchHostedZone error +# ============================================================================= +@test "Should fail if hosted zone does not exist" { + set_aws_mock "$AWS_MOCKS_DIR/route53/no_such_zone.json" 1 + + run source "$SCRIPT_PATH" + + assert_equal "$status" "1" + assert_contains "$output" " ❌ Failed to fetch Route 53 hosted zone information" + assert_contains "$output" " 🔎 Error: Hosted zone 'Z1234567890ABC' does not exist" + + assert_contains "$output" " 💡 Possible causes:" + assert_contains "$output" " • The hosted zone ID is incorrect or has a typo" + assert_contains "$output" " • The hosted zone was deleted" + assert_contains "$output" " • The hosted zone ID format is wrong (should be like 'Z1234567890ABC' or '/hostedzone/Z1234567890ABC')" + + assert_contains "$output" " 🔧 How to fix:" + assert_contains "$output" " 1. Verify the hosted zone exists: aws route53 list-hosted-zones" + assert_contains "$output" " 2. Update 'hosted_public_zone_id' in the AWS cloud-provider configuration" +} + +# ============================================================================= +# Test: AccessDenied error +# ============================================================================= +@test "Should fail if lacking permissions to read hosted zones" { + set_aws_mock "$AWS_MOCKS_DIR/route53/access_denied.json" 1 + + run source "$SCRIPT_PATH" + + assert_equal "$status" "1" + assert_contains "$output" " 🔒 Error: Permission denied when accessing Route 53" + + assert_contains "$output" " 💡 Possible causes:" + assert_contains "$output" " • The AWS credentials don't have Route 53 read permissions" + assert_contains "$output" " • The IAM role/user is missing the 'route53:GetHostedZone' permission" + + assert_contains "$output" " 🔧 How to fix:" + assert_contains "$output" " 1. Check the AWS credentials are configured correctly" + assert_contains "$output" " 2. Ensure the IAM policy includes:" +} + +# ============================================================================= +# Test: InvalidInput error +# ============================================================================= +@test "Should fail if hosted zone id is not valid" { + set_aws_mock "$AWS_MOCKS_DIR/route53/invalid_input.json" 1 + + run source "$SCRIPT_PATH" + + assert_equal "$status" "1" + assert_contains "$output" " ⚠️ Error: Invalid hosted zone ID format" + assert_contains "$output" " The hosted zone ID 'Z1234567890ABC' is not valid." + + assert_contains "$output" " 🔧 How to fix:" + assert_contains "$output" " • Use the format 'Z1234567890ABC' or '/hostedzone/Z1234567890ABC'" + assert_contains "$output" " • Find valid zone IDs with: aws route53 list-hosted-zones" +} + +# ============================================================================= +# Test: Credentials error +# ============================================================================= +@test "Should fail if AWS credentials are missing" { + set_aws_mock "$AWS_MOCKS_DIR/route53/credentials_error.json" 1 + + run source "$SCRIPT_PATH" + + assert_equal "$status" "1" + assert_contains "$output" " 🔑 Error: AWS credentials issue" + + assert_contains "$output" " 💡 Possible causes:" + assert_contains "$output" " • The nullplatform agent is not configured with AWS credentials" + assert_contains "$output" " • The IAM role associated with the service account does not exist" + + assert_contains "$output" " 🔧 How to fix:" + assert_contains "$output" " 1. Configure a service account in the nullplatform agent Helm installation" + assert_contains "$output" " 2. Verify the IAM role associated with the service account exists and has the required permissions" + assert_contains "$output" " 🔑 Error: AWS credentials issue" +} + +# ============================================================================= +# Test: Unknown Route53 error +# ============================================================================= +@test "Should handle unknown error getting the route53 hosted zone" { + set_aws_mock "$AWS_MOCKS_DIR/route53/unknown_error.json" 1 + + run source "$SCRIPT_PATH" + + assert_equal "$status" "1" + assert_contains "$output" " 📋 Error details:" + assert_contains "$output" "Unknown error getting route53 hosted zone." + +} + +# ============================================================================= +# Test: Empty domain in response +# ============================================================================= +@test "Should handle missing hosted zone name from response" { + set_aws_mock "$AWS_MOCKS_DIR/route53/empty_domain.json" + + run source "$SCRIPT_PATH" + + assert_equal "$status" "1" + assert_contains "$output" " ❌ Failed to extract domain name from hosted zone response" + + assert_contains "$output" " 💡 Possible causes:" + assert_contains "$output" " • The hosted zone does not have a valid domain name configured" + assert_contains "$output" " ❌ Failed to extract domain name from hosted zone response" + + assert_contains "$output" " ❌ Failed to extract domain name from hosted zone response" + assert_contains "$output" " 1. Verify the hosted zone has a valid domain: aws route53 get-hosted-zone --id Z1234567890ABC" +} + +# ============================================================================= +# Test: Scope patch error +# ============================================================================= +@test "Should handle auth error updating scope domain" { + set_aws_mock "$AWS_MOCKS_DIR/route53/success.json" + set_np_mock "$NP_MOCKS_DIR/scope/patch/auth_error.json" 1 + + run source "$SCRIPT_PATH" + +# assert_equal "$status" "1" + assert_contains "$output" " ❌ Failed to update scope domain" + assert_contains "$output" " 🔒 Error: Permission denied" + + assert_contains "$output" " 💡 Possible causes:" + assert_contains "$output" " • The nullplatform API Key doesn't have 'Developer' permissions" + + assert_contains "$output" " 🔧 How to fix:" + assert_contains "$output" " 1. Ensure the API Key has 'Developer' permissions at the correct NRN hierarchy level" +} + +@test "Should handle unknown error updating scope domain" { + set_aws_mock "$AWS_MOCKS_DIR/route53/success.json" + set_np_mock "$NP_MOCKS_DIR/scope/patch/unknown_error.json" 1 + + run source "$SCRIPT_PATH" + + assert_equal "$status" "1" + assert_contains "$output" " ❌ Failed to update scope domain" + assert_contains "$output" " 📋 Error details:" + assert_contains "$output" "Unknown error updating scope" +} + +# ============================================================================= +# Test: TOFU_VARIABLES - verifies the entire JSON structure +# ============================================================================= +@test "Should add network variables to TOFU_VARIABLES" { + set_aws_mock "$AWS_MOCKS_DIR/route53/success.json" + + run_route53_setup + + local expected='{ + "application_slug": "automation", + "scope_slug": "development-tools", + "scope_id": "7", + "network_hosted_zone_id": "Z1234567890ABC", + "network_domain": "example.com", + "network_subdomain": "automation-development-tools" +}' + + assert_json_equal "$TOFU_VARIABLES" "$expected" "TOFU_VARIABLES" +} + +# ============================================================================= +# Test: MODULES_TO_USE +# ============================================================================= +@test "Should register the provider in the MODULES_TO_USE variable when it's empty" { + set_aws_mock "$AWS_MOCKS_DIR/route53/success.json" + + run_route53_setup + + assert_equal "$MODULES_TO_USE" "$PROJECT_DIR/network/route53/modules" +} + +@test "Should append the provider in the MODULES_TO_USE variable when it's not empty" { + set_aws_mock "$AWS_MOCKS_DIR/route53/success.json" + export MODULES_TO_USE="existing/module" + + run_route53_setup + + assert_equal "$MODULES_TO_USE" "existing/module,$PROJECT_DIR/network/route53/modules" +} \ No newline at end of file diff --git a/frontend/deployment/tests/provider/aws/setup_test.bats b/frontend/deployment/tests/provider/aws/setup_test.bats new file mode 100644 index 00000000..4fc50557 --- /dev/null +++ b/frontend/deployment/tests/provider/aws/setup_test.bats @@ -0,0 +1,185 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for provider/aws/setup script +# +# Requirements: +# - bats-core: brew install bats-core +# - jq: brew install jq +# +# Run tests: +# bats tests/provider/aws/setup_test.bats +# ============================================================================= + +setup() { + TEST_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")" && pwd)" + PROJECT_DIR="$(cd "$TEST_DIR/../../.." && pwd)" + PROJECT_ROOT="$(cd "$PROJECT_DIR/../.." && pwd)" + SCRIPT_PATH="$PROJECT_DIR/provider/aws/setup" + + source "$PROJECT_ROOT/testing/assertions.sh" + + export AWS_REGION="us-east-1" + export TOFU_PROVIDER_BUCKET="my-terraform-state-bucket" + export TOFU_LOCK_TABLE="terraform-locks" + + # Base tofu variables + export TOFU_VARIABLES='{ + "application_slug": "automation", + "scope_slug": "development-tools", + "scope_id": "7" + }' + + export TOFU_INIT_VARIABLES="" + export MODULES_TO_USE="" +} + +# ============================================================================= +# Helper functions +# ============================================================================= +run_aws_setup() { + source "$SCRIPT_PATH" +} + +# ============================================================================= +# Test: Required environment variables +# ============================================================================= +@test "Should fail when AWS_REGION is not set" { + unset AWS_REGION + + run source "$SCRIPT_PATH" + + assert_equal "$status" "1" + assert_contains "$output" " ❌ AWS_REGION is missing" + assert_contains "$output" " 🔧 How to fix:" + assert_contains "$output" " Set the missing variable(s) in the nullplatform agent Helm installation:" + assert_contains "$output" " • AWS_REGION" +} + +@test "Should fail when TOFU_PROVIDER_BUCKET is not set" { + unset TOFU_PROVIDER_BUCKET + + run source "$SCRIPT_PATH" + + assert_equal "$status" "1" + assert_contains "$output" " ❌ TOFU_PROVIDER_BUCKET is missing" + assert_contains "$output" " 🔧 How to fix:" + assert_contains "$output" " Set the missing variable(s) in the nullplatform agent Helm installation:" + assert_contains "$output" " • TOFU_PROVIDER_BUCKET" +} + +@test "Should fail when TOFU_LOCK_TABLE is not set" { + unset TOFU_LOCK_TABLE + + run source "$SCRIPT_PATH" + + assert_equal "$status" "1" + assert_contains "$output" " ❌ TOFU_LOCK_TABLE is missing" + assert_contains "$output" " 🔧 How to fix:" + assert_contains "$output" " Set the missing variable(s) in the nullplatform agent Helm installation:" + assert_contains "$output" " • TOFU_LOCK_TABLE" +} + +@test "Should report all the variables that are not set" { + unset AWS_REGION + unset TOFU_PROVIDER_BUCKET + unset TOFU_LOCK_TABLE + + run source "$SCRIPT_PATH" + + assert_equal "$status" "1" + assert_contains "$output" " ❌ AWS_REGION is missing" + assert_contains "$output" " ❌ TOFU_PROVIDER_BUCKET is missing" + assert_contains "$output" " ❌ TOFU_LOCK_TABLE is missing" + assert_contains "$output" " 🔧 How to fix:" + assert_contains "$output" " Set the missing variable(s) in the nullplatform agent Helm installation:" + assert_contains "$output" " • AWS_REGION" + assert_contains "$output" " • TOFU_PROVIDER_BUCKET" + assert_contains "$output" " • TOFU_LOCK_TABLE" +} + +# ============================================================================= +# Test: TOFU_VARIABLES - verifies the entire JSON structure +# ============================================================================= +@test "Should add aws_provider field to TOFU_VARIABLES" { + run_aws_setup + + local expected='{ + "application_slug": "automation", + "scope_slug": "development-tools", + "scope_id": "7", + "aws_provider": { + "region": "us-east-1", + "state_bucket": "my-terraform-state-bucket", + "lock_table": "terraform-locks" + }, + "provider_resource_tags_json": {} +}' + + assert_json_equal "$TOFU_VARIABLES" "$expected" "TOFU_VARIABLES" +} + +@test "Should add provider_resource_tags_json to TOFU_VARIABLES" { + export RESOURCE_TAGS_JSON='{"Environment": "production", "Team": "platform"}' + + run_aws_setup + + local expected='{ + "application_slug": "automation", + "scope_slug": "development-tools", + "scope_id": "7", + "aws_provider": { + "region": "us-east-1", + "state_bucket": "my-terraform-state-bucket", + "lock_table": "terraform-locks" + }, + "provider_resource_tags_json": {"Environment": "production", "Team": "platform"} +}' + + assert_json_equal "$TOFU_VARIABLES" "$expected" "TOFU_VARIABLES" +} + +# ============================================================================= +# Test: TOFU_INIT_VARIABLES - backend configuration +# ============================================================================= +@test "Should add S3 bucket configuration to TOFU_INIT_VARIABLES" { + run_aws_setup + + assert_contains "$TOFU_INIT_VARIABLES" "-backend-config=bucket=my-terraform-state-bucket" +} + +@test "Should add AWS region configuration to TOFU_INIT_VARIABLES" { + run_aws_setup + + assert_contains "$TOFU_INIT_VARIABLES" "-backend-config=region=us-east-1" +} + +@test "Should add Dynamo table configuration to TOFU_INIT_VARIABLES" { + run_aws_setup + + assert_contains "$TOFU_INIT_VARIABLES" "-backend-config=dynamodb_table=terraform-locks" +} + +@test "Should append to TOFU_INIT_VARIABLES when it previous settings are present" { + export TOFU_INIT_VARIABLES="-var=existing=value" + + run_aws_setup + + assert_equal "$TOFU_INIT_VARIABLES" "-var=existing=value -backend-config=bucket=my-terraform-state-bucket -backend-config=region=us-east-1 -backend-config=dynamodb_table=terraform-locks" +} + +# ============================================================================= +# Test: MODULES_TO_USE +# ============================================================================= +@test "Should register the provider in the MODULES_TO_USE variable when it's empty" { + run_aws_setup + + assert_equal "$MODULES_TO_USE" "$PROJECT_DIR/provider/aws/modules" +} + +@test "Should append the provider in the MODULES_TO_USE variable when it's not empty" { + export MODULES_TO_USE="existing/module" + + run_aws_setup + + assert_equal "$MODULES_TO_USE" "existing/module,$PROJECT_DIR/provider/aws/modules" +} diff --git a/frontend/deployment/tests/provider/azure/setup_test.bats b/frontend/deployment/tests/provider/azure/setup_test.bats new file mode 100644 index 00000000..a6f0b39c --- /dev/null +++ b/frontend/deployment/tests/provider/azure/setup_test.bats @@ -0,0 +1,203 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for provider/azure/setup script +# +# Requirements: +# - bats-core: brew install bats-core +# - jq: brew install jq +# +# Run tests: +# bats tests/provider/azure/setup_test.bats +# ============================================================================= + +setup() { + TEST_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")" && pwd)" + PROJECT_DIR="$(cd "$TEST_DIR/../../.." && pwd)" + PROJECT_ROOT="$(cd "$PROJECT_DIR/../.." && pwd)" + SCRIPT_PATH="$PROJECT_DIR/provider/azure/setup" + + source "$PROJECT_ROOT/testing/assertions.sh" + + export AZURE_SUBSCRIPTION_ID="00000000-0000-0000-0000-000000000000" + export AZURE_RESOURCE_GROUP="my-resource-group" + export TOFU_PROVIDER_STORAGE_ACCOUNT="mytfstatestorage" + export TOFU_PROVIDER_CONTAINER="tfstate" + + # Base tofu variables + export TOFU_VARIABLES='{ + "application_slug": "automation", + "scope_slug": "development-tools", + "scope_id": "7" + }' + + export TOFU_INIT_VARIABLES="" + export MODULES_TO_USE="" +} + +# ============================================================================= +# Helper functions +# ============================================================================= +run_azure_setup() { + source "$SCRIPT_PATH" +} + +# ============================================================================= +# Test: Required environment variables +# ============================================================================= +@test "Should fail when AZURE_SUBSCRIPTION_ID is not set" { + unset AZURE_SUBSCRIPTION_ID + + run source "$SCRIPT_PATH" + + assert_equal "$status" "1" + assert_contains "$output" " ❌ AZURE_SUBSCRIPTION_ID is missing" + assert_contains "$output" " 🔧 How to fix:" + assert_contains "$output" " Set the missing variable(s) in the nullplatform agent Helm installation:" + assert_contains "$output" " • AZURE_SUBSCRIPTION_ID" +} + +@test "Should fail when AZURE_RESOURCE_GROUP is not set" { + unset AZURE_RESOURCE_GROUP + + run source "$SCRIPT_PATH" + + assert_equal "$status" "1" + assert_contains "$output" " ❌ AZURE_RESOURCE_GROUP is missing" + assert_contains "$output" " 🔧 How to fix:" + assert_contains "$output" " Set the missing variable(s) in the nullplatform agent Helm installation:" + assert_contains "$output" " • AZURE_RESOURCE_GROUP" +} + +@test "Should fail when TOFU_PROVIDER_STORAGE_ACCOUNT is not set" { + unset TOFU_PROVIDER_STORAGE_ACCOUNT + + run source "$SCRIPT_PATH" + + assert_equal "$status" "1" + assert_contains "$output" " ❌ TOFU_PROVIDER_STORAGE_ACCOUNT is missing" + assert_contains "$output" " 🔧 How to fix:" + assert_contains "$output" " Set the missing variable(s) in the nullplatform agent Helm installation:" + assert_contains "$output" " • TOFU_PROVIDER_STORAGE_ACCOUNT" +} + +@test "Should fail when TOFU_PROVIDER_CONTAINER is not set" { + unset TOFU_PROVIDER_CONTAINER + + run source "$SCRIPT_PATH" + + assert_equal "$status" "1" + assert_contains "$output" " ❌ TOFU_PROVIDER_CONTAINER is missing" + assert_contains "$output" " 🔧 How to fix:" + assert_contains "$output" " Set the missing variable(s) in the nullplatform agent Helm installation:" + assert_contains "$output" " • TOFU_PROVIDER_CONTAINER" +} + +@test "Should report all the variables that are not set" { + unset AZURE_SUBSCRIPTION_ID + unset AZURE_RESOURCE_GROUP + unset TOFU_PROVIDER_STORAGE_ACCOUNT + unset TOFU_PROVIDER_CONTAINER + + run source "$SCRIPT_PATH" + + assert_equal "$status" "1" + assert_contains "$output" " ❌ AZURE_SUBSCRIPTION_ID is missing" + assert_contains "$output" " ❌ AZURE_RESOURCE_GROUP is missing" + assert_contains "$output" " ❌ TOFU_PROVIDER_STORAGE_ACCOUNT is missing" + assert_contains "$output" " ❌ TOFU_PROVIDER_CONTAINER is missing" + assert_contains "$output" " 🔧 How to fix:" + assert_contains "$output" " Set the missing variable(s) in the nullplatform agent Helm installation:" + assert_contains "$output" " • AZURE_SUBSCRIPTION_ID" + assert_contains "$output" " • AZURE_RESOURCE_GROUP" + assert_contains "$output" " • TOFU_PROVIDER_STORAGE_ACCOUNT" + assert_contains "$output" " • TOFU_PROVIDER_CONTAINER" +} + +# ============================================================================= +# Test: TOFU_VARIABLES - verifies the entire JSON structure +# ============================================================================= +@test "Should add azure_provider field to TOFU_VARIABLES" { + run_azure_setup + + local expected='{ + "application_slug": "automation", + "scope_slug": "development-tools", + "scope_id": "7", + "azure_provider": { + "subscription_id": "00000000-0000-0000-0000-000000000000", + "resource_group": "my-resource-group", + "storage_account": "mytfstatestorage", + "container": "tfstate" + }, + "provider_resource_tags_json": {} +}' + + assert_json_equal "$TOFU_VARIABLES" "$expected" "TOFU_VARIABLES" +} + +@test "Should add provider_resource_tags_json to TOFU_VARIABLES" { + export RESOURCE_TAGS_JSON='{"Environment": "production", "Team": "platform"}' + + run_azure_setup + + local expected='{ + "application_slug": "automation", + "scope_slug": "development-tools", + "scope_id": "7", + "azure_provider": { + "subscription_id": "00000000-0000-0000-0000-000000000000", + "resource_group": "my-resource-group", + "storage_account": "mytfstatestorage", + "container": "tfstate" + }, + "provider_resource_tags_json": {"Environment": "production", "Team": "platform"} +}' + + assert_json_equal "$TOFU_VARIABLES" "$expected" "TOFU_VARIABLES" +} + +# ============================================================================= +# Test: TOFU_INIT_VARIABLES - backend configuration +# ============================================================================= +@test "Should add storage_account_name configuration to TOFU_INIT_VARIABLES" { + run_azure_setup + + assert_contains "$TOFU_INIT_VARIABLES" "-backend-config=storage_account_name=mytfstatestorage" +} + +@test "Should add container_name configuration to TOFU_INIT_VARIABLES" { + run_azure_setup + + assert_contains "$TOFU_INIT_VARIABLES" "-backend-config=container_name=tfstate" +} + +@test "Should add resource_group_name configuration to TOFU_INIT_VARIABLES" { + run_azure_setup + + assert_contains "$TOFU_INIT_VARIABLES" "-backend-config=resource_group_name=my-resource-group" +} + +@test "Should append to TOFU_INIT_VARIABLES when it previous settings are present" { + export TOFU_INIT_VARIABLES="-var=existing=value" + + run_azure_setup + + assert_equal "$TOFU_INIT_VARIABLES" "-var=existing=value -backend-config=storage_account_name=mytfstatestorage -backend-config=container_name=tfstate -backend-config=resource_group_name=my-resource-group" +} + +# ============================================================================= +# Test: MODULES_TO_USE +# ============================================================================= +@test "Should register the provider in the MODULES_TO_USE variable when it's empty" { + run_azure_setup + + assert_equal "$MODULES_TO_USE" "$PROJECT_DIR/provider/azure/modules" +} + +@test "Should append the provider in the MODULES_TO_USE variable when it's not empty" { + export MODULES_TO_USE="existing/module" + + run_azure_setup + + assert_equal "$MODULES_TO_USE" "existing/module,$PROJECT_DIR/provider/azure/modules" +} diff --git a/frontend/deployment/tests/resources/aws_mocks/aws b/frontend/deployment/tests/resources/aws_mocks/aws new file mode 100755 index 00000000..0ab580f3 --- /dev/null +++ b/frontend/deployment/tests/resources/aws_mocks/aws @@ -0,0 +1,18 @@ +#!/bin/bash +# Mock aws CLI for testing +# Set AWS_MOCK_RESPONSE to the path of the mock file to return +# Set AWS_MOCK_EXIT_CODE to the exit code (default: 0) + +if [ -z "$AWS_MOCK_RESPONSE" ]; then + echo "AWS_MOCK_RESPONSE not set" >&2 + exit 1 +fi + +if [ -f "$AWS_MOCK_RESPONSE" ]; then + cat "$AWS_MOCK_RESPONSE" +else + echo "Mock file not found: $AWS_MOCK_RESPONSE" >&2 + exit 1 +fi + +exit "${AWS_MOCK_EXIT_CODE:-0}" diff --git a/frontend/deployment/tests/resources/aws_mocks/route53/access_denied.json b/frontend/deployment/tests/resources/aws_mocks/route53/access_denied.json new file mode 100644 index 00000000..13ad79c6 --- /dev/null +++ b/frontend/deployment/tests/resources/aws_mocks/route53/access_denied.json @@ -0,0 +1 @@ +An error occurred (AccessDenied) when calling the GetHostedZone operation: User: arn:aws:iam::123456789012:user/testuser is not authorized to perform: route53:GetHostedZone on resource: arn:aws:route53:::hostedzone/Z1234567890ABC diff --git a/frontend/deployment/tests/resources/aws_mocks/route53/credentials_error.json b/frontend/deployment/tests/resources/aws_mocks/route53/credentials_error.json new file mode 100644 index 00000000..81dcfbb7 --- /dev/null +++ b/frontend/deployment/tests/resources/aws_mocks/route53/credentials_error.json @@ -0,0 +1 @@ +Unable to locate credentials. You can configure credentials by running "aws configure". diff --git a/frontend/deployment/tests/resources/aws_mocks/route53/empty_domain.json b/frontend/deployment/tests/resources/aws_mocks/route53/empty_domain.json new file mode 100644 index 00000000..73956353 --- /dev/null +++ b/frontend/deployment/tests/resources/aws_mocks/route53/empty_domain.json @@ -0,0 +1,10 @@ +{ + "HostedZone": { + "Id": "/hostedzone/Z1234567890ABC", + "Name": null, + "CallerReference": "2024-01-15T10:30:00Z", + "Config": { + "PrivateZone": false + } + } +} diff --git a/frontend/deployment/tests/resources/aws_mocks/route53/invalid_input.json b/frontend/deployment/tests/resources/aws_mocks/route53/invalid_input.json new file mode 100644 index 00000000..e3a344ca --- /dev/null +++ b/frontend/deployment/tests/resources/aws_mocks/route53/invalid_input.json @@ -0,0 +1 @@ +An error occurred (InvalidInput) when calling the GetHostedZone operation: Invalid id: not-a-valid-zone-id diff --git a/frontend/deployment/tests/resources/aws_mocks/route53/no_such_zone.json b/frontend/deployment/tests/resources/aws_mocks/route53/no_such_zone.json new file mode 100644 index 00000000..142d504e --- /dev/null +++ b/frontend/deployment/tests/resources/aws_mocks/route53/no_such_zone.json @@ -0,0 +1 @@ +An error occurred (NoSuchHostedZone) when calling the GetHostedZone operation: No hosted zone found with ID: Z9999999999XXX diff --git a/frontend/deployment/tests/resources/aws_mocks/route53/success.json b/frontend/deployment/tests/resources/aws_mocks/route53/success.json new file mode 100644 index 00000000..56d84baa --- /dev/null +++ b/frontend/deployment/tests/resources/aws_mocks/route53/success.json @@ -0,0 +1,20 @@ +{ + "HostedZone": { + "Id": "/hostedzone/Z1234567890ABC", + "Name": "example.com.", + "CallerReference": "2024-01-15T10:30:00Z", + "Config": { + "Comment": "Production hosted zone", + "PrivateZone": false + }, + "ResourceRecordSetCount": 42 + }, + "DelegationSet": { + "NameServers": [ + "ns-1234.awsdns-12.org", + "ns-567.awsdns-34.com", + "ns-890.awsdns-56.co.uk", + "ns-123.awsdns-78.net" + ] + } +} diff --git a/frontend/deployment/tests/resources/aws_mocks/route53/unknown_error.json b/frontend/deployment/tests/resources/aws_mocks/route53/unknown_error.json new file mode 100644 index 00000000..ceee2bb5 --- /dev/null +++ b/frontend/deployment/tests/resources/aws_mocks/route53/unknown_error.json @@ -0,0 +1 @@ +Unknown error getting route53 hosted zone. diff --git a/frontend/deployment/tests/resources/azure_mocks/az b/frontend/deployment/tests/resources/azure_mocks/az new file mode 100755 index 00000000..d33b723f --- /dev/null +++ b/frontend/deployment/tests/resources/azure_mocks/az @@ -0,0 +1,22 @@ +#!/bin/bash +# Mock az CLI for testing +# Set AZ_MOCK_RESPONSE to the path of the mock file to return +# Set AZ_MOCK_EXIT_CODE to the exit code (default: 0) + +if [ -z "$AZ_MOCK_RESPONSE" ]; then + echo "AZ_MOCK_RESPONSE not set" >&2 + exit 1 +fi + +if [ -f "$AZ_MOCK_RESPONSE" ]; then + exit_code="${AZ_MOCK_EXIT_CODE:-0}" + if [ "$exit_code" -eq 0 ]; then + cat "$AZ_MOCK_RESPONSE" + else + cat "$AZ_MOCK_RESPONSE" >&2 + fi + exit "$exit_code" +else + echo "Mock file not found: $AZ_MOCK_RESPONSE" >&2 + exit 1 +fi \ No newline at end of file diff --git a/frontend/deployment/tests/resources/azure_mocks/dns_zone/access_denied.json b/frontend/deployment/tests/resources/azure_mocks/dns_zone/access_denied.json new file mode 100644 index 00000000..69673831 --- /dev/null +++ b/frontend/deployment/tests/resources/azure_mocks/dns_zone/access_denied.json @@ -0,0 +1 @@ +AuthorizationFailed: The client does not have authorization to perform action 'Microsoft.Network/dnszones/read' over scope '/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/my-resource-group/providers/Microsoft.Network/dnszones/example.com' diff --git a/frontend/deployment/tests/resources/azure_mocks/dns_zone/credentials_error.json b/frontend/deployment/tests/resources/azure_mocks/dns_zone/credentials_error.json new file mode 100644 index 00000000..e866bc96 --- /dev/null +++ b/frontend/deployment/tests/resources/azure_mocks/dns_zone/credentials_error.json @@ -0,0 +1 @@ +AADSTS700016: Application with identifier 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' was not found in the directory. InvalidAuthenticationToken. diff --git a/frontend/deployment/tests/resources/azure_mocks/dns_zone/empty_name.json b/frontend/deployment/tests/resources/azure_mocks/dns_zone/empty_name.json new file mode 100644 index 00000000..1af2865d --- /dev/null +++ b/frontend/deployment/tests/resources/azure_mocks/dns_zone/empty_name.json @@ -0,0 +1,6 @@ +{ + "id": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/my-resource-group/providers/Microsoft.Network/dnszones/example.com", + "name": null, + "type": "Microsoft.Network/dnszones", + "location": "global" +} diff --git a/frontend/deployment/tests/resources/azure_mocks/dns_zone/invalid_subscription.json b/frontend/deployment/tests/resources/azure_mocks/dns_zone/invalid_subscription.json new file mode 100644 index 00000000..6fba5221 --- /dev/null +++ b/frontend/deployment/tests/resources/azure_mocks/dns_zone/invalid_subscription.json @@ -0,0 +1 @@ +InvalidSubscriptionId: The provided subscription identifier 'invalid' is malformed or invalid. diff --git a/frontend/deployment/tests/resources/azure_mocks/dns_zone/not_found.json b/frontend/deployment/tests/resources/azure_mocks/dns_zone/not_found.json new file mode 100644 index 00000000..fdd91ad7 --- /dev/null +++ b/frontend/deployment/tests/resources/azure_mocks/dns_zone/not_found.json @@ -0,0 +1 @@ +ResourceNotFound: The resource 'example.com' was not found in resource group 'my-resource-group' diff --git a/frontend/deployment/tests/resources/azure_mocks/dns_zone/success.json b/frontend/deployment/tests/resources/azure_mocks/dns_zone/success.json new file mode 100644 index 00000000..37f9262c --- /dev/null +++ b/frontend/deployment/tests/resources/azure_mocks/dns_zone/success.json @@ -0,0 +1,17 @@ +{ + "id": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/my-resource-group/providers/Microsoft.Network/dnszones/example.com", + "name": "example.com", + "type": "Microsoft.Network/dnszones", + "location": "global", + "tags": {}, + "properties": { + "maxNumberOfRecordSets": 10000, + "numberOfRecordSets": 5, + "nameServers": [ + "ns1-01.azure-dns.com.", + "ns2-01.azure-dns.net.", + "ns3-01.azure-dns.org.", + "ns4-01.azure-dns.info." + ] + } +} diff --git a/frontend/deployment/tests/resources/azure_mocks/dns_zone/unknown_error.json b/frontend/deployment/tests/resources/azure_mocks/dns_zone/unknown_error.json new file mode 100644 index 00000000..f115bae1 --- /dev/null +++ b/frontend/deployment/tests/resources/azure_mocks/dns_zone/unknown_error.json @@ -0,0 +1 @@ +Unknown error fetching Azure DNS zone. diff --git a/frontend/deployment/tests/resources/context.json b/frontend/deployment/tests/resources/context.json new file mode 100644 index 00000000..91b6ac31 --- /dev/null +++ b/frontend/deployment/tests/resources/context.json @@ -0,0 +1,183 @@ +{ + "account": { + "created_at": "2023-01-31T21:53:32.597Z", + "id": 2, + "metadata": {}, + "name": "Playground", + "nrn": "organization=1:account=2", + "organization_id": 1, + "repository_prefix": "playground-repos", + "repository_provider": "github", + "settings": {}, + "slug": "playground", + "status": "active", + "updated_at": "2023-01-31T21:53:32.597Z" + }, + "application": { + "auto_deploy_on_creation": false, + "created_at": "2025-10-07T03:22:21.385Z", + "id": 4, + "is_mono_repo": false, + "messages": [], + "metadata": {}, + "name": "Automation", + "namespace_id": 3, + "nrn": "organization=1:account=2:namespace=3:application=4", + "repository_app_path": null, + "repository_url": "https://github.com/playground-repos/tools-automation", + "settings": {}, + "slug": "automation", + "status": "active", + "tags": {}, + "template_id": 1037172878, + "updated_at": "2025-10-07T03:22:30.695Z" + }, + "asset": { + "id": 6, + "build_id": 612605537, + "name": "main", + "type": "bundle", + "url": "s3://my-asset-bucket/tools/automation/v1.0.0", + "platform": "x86_64", + "metadata": {}, + "nrn": "organization=1:account=2:namespace=3:application=4:build=5:asset=6" + }, + "deployment": { + "created_at": "2025-12-22T18:27:54.701Z", + "created_by": 123456789, + "deployment_group_id": null, + "deployment_token": "dep-token", + "expires_at": null, + "external_strategy_id": 10, + "id": 8, + "messages": [], + "metadata": {}, + "nrn": "organization=1:account=2:namespace=3:application=4:scope=7:deployment=8", + "parameters": [], + "release_id": 9, + "scope_id": 7, + "status": "creating", + "status_in_scope": "inactive", + "status_started_at": { + "creating": "2025-12-22T18:27:54.629Z" + }, + "strategy": "initial", + "strategy_data": { + "parameters": { + "metrics": { + "enabled": false, + "rules": [] + }, + "traffic": { + "enable_auto_switch": false, + "interval": 10, + "step": "0.1" + } + } + }, + "updated_at": "2025-12-23T13:22:06.345Z", + "updated_by": null + }, + "namespace": { + "account_id": 2, + "created_at": "2025-05-15T21:34:40.725Z", + "id": 3, + "metadata": {}, + "name": "Tools", + "nrn": "organization=1:account=2:namespace=3", + "slug": "tools", + "status": "active", + "updated_at": "2025-05-15T21:34:40.725Z" + }, + "parameters": { + "results": [ + { + "destination_path": null, + "id": 10, + "name": "TEST", + "type": "environment", + "values": [ + { + "id": "11", + "value": "testing-tools" + } + ], + "variable": "TEST", + "version_id": 12 + }, + { + "destination_path": null, + "id": 13, + "name": "CLUSTER_NAME", + "type": "environment", + "values": [ + { + "id": "14", + "value": "development-cluster" + } + ], + "variable": "CLUSTER_NAME", + "version_id": 15 + } + ] + }, + "providers": { + "cloud-providers": { + "account": { + "id": "aws-account-id", + "region": "us-east-2" + }, + "iam": { + "scope_workflow_role": "" + }, + "networking": { + "application_domain": true, + "hosted_public_zone_id": "public-zone-id", + "hosted_zone_id": "private-zone-id" + } + } + }, + "release": { + "application_id": 4, + "build_id": 5, + "created_at": "2025-12-12T13:07:27.435Z", + "id": 9, + "metadata": {}, + "nrn": "organization=1:account=2:namespace=3:application=4:release=9", + "semver": "v1.0.0", + "status": "active", + "updated_at": "2025-12-12T13:07:27.702Z" + }, + "scope": { + "application_id": 4, + "asset_name": "main", + "capabilities": {}, + "created_at": "2025-12-22T18:27:04.949Z", + "dimensions": { + "country": "argentina", + "environment": "development" + }, + "domain": "", + "domains": [], + "external_created": false, + "id": 7, + "instance_id": "some-instance-id", + "messages": [], + "metadata": {}, + "name": "Development tools", + "nrn": "organization=1:account=2:namespace=3:application=4:scope=7", + "profiles": [ + "environment_development", + "environment_development_country_argentina" + ], + "provider": "scope-type-id", + "requested_spec": {}, + "runtime_configurations": [], + "slug": "development-tools", + "status": "active", + "tags": [], + "tier": "important", + "type": "custom", + "updated_at": "2025-12-29T18:25:55.908Z" + } +} diff --git a/frontend/deployment/tests/resources/context_azure.json b/frontend/deployment/tests/resources/context_azure.json new file mode 100644 index 00000000..56f90dd3 --- /dev/null +++ b/frontend/deployment/tests/resources/context_azure.json @@ -0,0 +1,170 @@ +{ + "account": { + "created_at": "2023-01-31T21:53:32.597Z", + "id": 2, + "metadata": {}, + "name": "Playground", + "nrn": "organization=1:account=2", + "organization_id": 1, + "repository_prefix": "playground-repos", + "repository_provider": "github", + "settings": {}, + "slug": "playground", + "status": "active", + "updated_at": "2023-01-31T21:53:32.597Z" + }, + "application": { + "auto_deploy_on_creation": false, + "created_at": "2025-10-07T03:22:21.385Z", + "id": 4, + "is_mono_repo": false, + "messages": [], + "metadata": {}, + "name": "Automation", + "namespace_id": 3, + "nrn": "organization=1:account=2:namespace=3:application=4", + "repository_app_path": null, + "repository_url": "https://github.com/playground-repos/tools-automation", + "settings": {}, + "slug": "automation", + "status": "active", + "tags": {}, + "template_id": 1037172878, + "updated_at": "2025-10-07T03:22:30.695Z" + }, + "asset": { + "id": 6, + "build_id": 612605537, + "name": "main", + "type": "bundle", + "url": "https://assetsaccount.blob.core.windows.net/assets/tools/automation/v1.0.0", + "platform": "x86_64", + "metadata": {}, + "nrn": "organization=1:account=2:namespace=3:application=4:build=5:asset=6" + }, + "deployment": { + "created_at": "2025-12-22T18:27:54.701Z", + "created_by": 123456789, + "deployment_group_id": null, + "deployment_token": "dep-token", + "expires_at": null, + "external_strategy_id": 10, + "id": 8, + "messages": [], + "metadata": {}, + "nrn": "organization=1:account=2:namespace=3:application=4:scope=7:deployment=8", + "parameters": [], + "release_id": 9, + "scope_id": 7, + "status": "creating", + "status_in_scope": "inactive", + "status_started_at": { + "creating": "2025-12-22T18:27:54.629Z" + }, + "strategy": "initial", + "strategy_data": { + "parameters": { + "metrics": { + "enabled": false, + "rules": [] + }, + "traffic": { + "enable_auto_switch": false, + "interval": 10, + "step": "0.1" + } + } + }, + "updated_at": "2025-12-23T13:22:06.345Z", + "updated_by": null + }, + "namespace": { + "account_id": 2, + "created_at": "2025-05-15T21:34:40.725Z", + "id": 3, + "metadata": {}, + "name": "Tools", + "nrn": "organization=1:account=2:namespace=3", + "slug": "tools", + "status": "active", + "updated_at": "2025-05-15T21:34:40.725Z" + }, + "parameters": { + "results": [ + { + "destination_path": null, + "id": 10, + "name": "TEST", + "type": "environment", + "values": [ + { + "id": "11", + "value": "testing-tools" + } + ], + "variable": "TEST", + "version_id": 12 + } + ] + }, + "providers": { + "cloud-providers": { + "authentication": { + "client_id": "test-client", + "subscription_id": "test-subscription", + "tenant_id": "test-tenant" + }, + "networking": { + "application_domain": false, + "domain_name": "publicdomain.com", + "private_dns_zone_name": "frontend.privatedomain.com", + "private_dns_zone_resource_group_name": "private-dns-resource-grou", + "public_dns_zone_name": "frontend.publicdomain.com", + "public_dns_zone_resource_group_name": "dns-resource-group" + } + } + }, + "release": { + "application_id": 4, + "build_id": 5, + "created_at": "2025-12-12T13:07:27.435Z", + "id": 9, + "metadata": {}, + "nrn": "organization=1:account=2:namespace=3:application=4:release=9", + "semver": "v1.0.0", + "status": "active", + "updated_at": "2025-12-12T13:07:27.702Z" + }, + "scope": { + "application_id": 4, + "asset_name": "main", + "capabilities": {}, + "created_at": "2025-12-22T18:27:04.949Z", + "dimensions": { + "country": "argentina", + "environment": "development" + }, + "domain": "", + "domains": [], + "external_created": false, + "id": 7, + "instance_id": "some-instance-id", + "messages": [], + "metadata": {}, + "name": "Development tools", + "nrn": "organization=1:account=2:namespace=3:application=4:scope=7", + "profiles": [ + "environment_development", + "environment_development_country_argentina" + ], + "provider": "scope-type-id", + "requested_spec": {}, + "runtime_configurations": [], + "slug": "development-tools", + "status": "active", + "tags": [], + "tier": "important", + "type": "custom", + "updated_at": "2025-12-29T18:25:55.908Z" + } +} diff --git a/frontend/deployment/tests/resources/np_mocks/asset_repository/auth_error.json b/frontend/deployment/tests/resources/np_mocks/asset_repository/auth_error.json new file mode 100644 index 00000000..f3a69bc4 --- /dev/null +++ b/frontend/deployment/tests/resources/np_mocks/asset_repository/auth_error.json @@ -0,0 +1,3 @@ +{ + "error": "provider specification fetch error: request failed with status 403: {\"statusCode\":403,\"code\":\"FST_ERR_AUTHORIZATION\",\"error\":\"Forbidden\",\"message\":\"Authorization error, insufficient permissions to access the requested resource. Insufficient permissions to access the requested resource\"}" +} \ No newline at end of file diff --git a/frontend/deployment/tests/resources/np_mocks/asset_repository/no_bucket_data.json b/frontend/deployment/tests/resources/np_mocks/asset_repository/no_bucket_data.json new file mode 100644 index 00000000..5315ddda --- /dev/null +++ b/frontend/deployment/tests/resources/np_mocks/asset_repository/no_bucket_data.json @@ -0,0 +1,27 @@ +{ + "results": [ + { + "attributes": { + "ci": { + "access_key": "", + "region": "us-east-1", + "secret_key": "" + }, + "setup": { + "naming_rule": "\"\\(.namespace.slug)/\\(.application.slug)\"", + "region": "us-east-1", + "role_arn": "arn:aws:iam::688720756067:role/null-application-manager" + } + }, + "category": "assets-repository", + "created_at": "2024-10-25T12:47:52.552Z", + "dimensions": {}, + "groups": [], + "id": "d397e46b-89b8-419d-ac14-2b483ace511c", + "nrn": "organization=1255165411:account=95118862", + "specification_id": "a6cf5ddf-ee3e-4960-b56a-ee9a13f8b5d2", + "tags": [], + "updated_at": "2025-08-21T01:58:19.844Z" + } + ] +} \ No newline at end of file diff --git a/frontend/deployment/tests/resources/np_mocks/asset_repository/no_data.json b/frontend/deployment/tests/resources/np_mocks/asset_repository/no_data.json new file mode 100644 index 00000000..914332ed --- /dev/null +++ b/frontend/deployment/tests/resources/np_mocks/asset_repository/no_data.json @@ -0,0 +1,3 @@ +{ + "results": [] +} \ No newline at end of file diff --git a/frontend/deployment/tests/resources/np_mocks/asset_repository/success.json b/frontend/deployment/tests/resources/np_mocks/asset_repository/success.json new file mode 100644 index 00000000..2d5c6f4a --- /dev/null +++ b/frontend/deployment/tests/resources/np_mocks/asset_repository/success.json @@ -0,0 +1,49 @@ +{ + "results": [ + { + "attributes": { + "bucket": { + "name": "assets-bucket" + } + }, + "category": "assets-repository", + "created_at": "2026-01-07T16:28:17.036Z", + "dimensions": {}, + "groups": [], + "id": "4a7be073-92ee-4f66-91be-02d115bc3e7c", + "nrn": "organization=1255165411:account=95118862", + "specification_id": "85e164dc-3149-40c6-b85d-28bddf6e21e8", + "tags": [ + { + "id": "ceb2021b-714e-4fa5-9202-6965c744ffd9", + "key": "bucket.name", + "value": "assets-bucket" + } + ], + "updated_at": "2026-01-07T16:28:17.036Z" + }, + { + "attributes": { + "ci": { + "access_key": "", + "region": "us-east-1", + "secret_key": "" + }, + "setup": { + "naming_rule": "\"\\(.namespace.slug)/\\(.application.slug)\"", + "region": "us-east-1", + "role_arn": "arn:aws:iam::688720756067:role/null-application-manager" + } + }, + "category": "assets-repository", + "created_at": "2024-10-25T12:47:52.552Z", + "dimensions": {}, + "groups": [], + "id": "d397e46b-89b8-419d-ac14-2b483ace511c", + "nrn": "organization=1255165411:account=95118862", + "specification_id": "a6cf5ddf-ee3e-4960-b56a-ee9a13f8b5d2", + "tags": [], + "updated_at": "2025-08-21T01:58:19.844Z" + } + ] +} \ No newline at end of file diff --git a/frontend/deployment/tests/resources/np_mocks/asset_repository/unknown_error.json b/frontend/deployment/tests/resources/np_mocks/asset_repository/unknown_error.json new file mode 100644 index 00000000..185c8263 --- /dev/null +++ b/frontend/deployment/tests/resources/np_mocks/asset_repository/unknown_error.json @@ -0,0 +1,3 @@ +{ + "error": "Unknown error fetching provider: {\"statusCode\":500,\"code\":\"FST_INTERNAL_SERVER_ERROR\",\"error\":\"Internal Server Error\",\"message\":\"Error processing the request\"}" +} \ No newline at end of file diff --git a/frontend/deployment/tests/resources/np_mocks/asset_repository_azure/no_storage_account_data.json b/frontend/deployment/tests/resources/np_mocks/asset_repository_azure/no_storage_account_data.json new file mode 100644 index 00000000..7c32671f --- /dev/null +++ b/frontend/deployment/tests/resources/np_mocks/asset_repository_azure/no_storage_account_data.json @@ -0,0 +1,12 @@ +{ + "results": [ + { + "id": "d397e46b-89b8-419d-ac14-2b483ace511c", + "name": "Other Storage Provider", + "type": "other", + "attributes": { + "endpoint": "https://example.com" + } + } + ] +} diff --git a/frontend/deployment/tests/resources/np_mocks/asset_repository_azure/success.json b/frontend/deployment/tests/resources/np_mocks/asset_repository_azure/success.json new file mode 100644 index 00000000..21d55159 --- /dev/null +++ b/frontend/deployment/tests/resources/np_mocks/asset_repository_azure/success.json @@ -0,0 +1,17 @@ +{ + "results": [ + { + "id": "d397e46b-89b8-419d-ac14-2b483ace511c", + "name": "Azure Blob Storage", + "type": "azure-blob-storage", + "attributes": { + "storage_account": { + "name": "mystaticstorage" + }, + "container": { + "name": "$web" + } + } + } + ] +} diff --git a/frontend/deployment/tests/resources/np_mocks/np b/frontend/deployment/tests/resources/np_mocks/np new file mode 100755 index 00000000..15f54a7f --- /dev/null +++ b/frontend/deployment/tests/resources/np_mocks/np @@ -0,0 +1,23 @@ +#!/bin/bash +# Mock np CLI for testing +# +# Usage: +# Set NP_MOCK_RESPONSE to the path of the mock JSON file to return +# Set NP_MOCK_EXIT_CODE to the exit code (default: 0) + +MOCK_RESPONSE="$NP_MOCK_RESPONSE" +MOCK_EXIT_CODE="${NP_MOCK_EXIT_CODE:-0}" + +if [ -z "$MOCK_RESPONSE" ]; then + echo "No mock response configured for: np $*" >&2 + exit 1 +fi + +if [ -f "$MOCK_RESPONSE" ]; then + cat "$MOCK_RESPONSE" +else + echo "Mock file not found: $MOCK_RESPONSE" >&2 + exit 1 +fi + +exit "$MOCK_EXIT_CODE" diff --git a/frontend/deployment/tests/resources/np_mocks/scope/patch/auth_error.json b/frontend/deployment/tests/resources/np_mocks/scope/patch/auth_error.json new file mode 100644 index 00000000..1935b96c --- /dev/null +++ b/frontend/deployment/tests/resources/np_mocks/scope/patch/auth_error.json @@ -0,0 +1,3 @@ +{ + "error": "scope write error: request failed with status 403: {\"statusCode\":403,\"code\":\"FST_ERR_AUTHORIZATION\",\"error\":\"Forbidden\",\"message\":\"Authorization error, insufficient permissions to access the requested resource. Insufficient permissions to access the requested resource\"}" +} \ No newline at end of file diff --git a/frontend/deployment/tests/resources/np_mocks/scope/patch/success.json b/frontend/deployment/tests/resources/np_mocks/scope/patch/success.json new file mode 100644 index 00000000..28e7be11 --- /dev/null +++ b/frontend/deployment/tests/resources/np_mocks/scope/patch/success.json @@ -0,0 +1,3 @@ +{ + "success": true +} \ No newline at end of file diff --git a/frontend/deployment/tests/resources/np_mocks/scope/patch/unknown_error.json b/frontend/deployment/tests/resources/np_mocks/scope/patch/unknown_error.json new file mode 100644 index 00000000..613c648b --- /dev/null +++ b/frontend/deployment/tests/resources/np_mocks/scope/patch/unknown_error.json @@ -0,0 +1,3 @@ +{ + "error": "Unknown error updating scope: {\"statusCode\":500,\"code\":\"FST_INTERNAL_SERVER_ERROR\",\"error\":\"Internal Server Error\",\"message\":\"Error processing the request\"}" +} \ No newline at end of file diff --git a/frontend/deployment/tests/scripts/build_context_test.bats b/frontend/deployment/tests/scripts/build_context_test.bats new file mode 100644 index 00000000..1c49fe5a --- /dev/null +++ b/frontend/deployment/tests/scripts/build_context_test.bats @@ -0,0 +1,149 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for build_context script +# +# Requirements: +# - bats-core: brew install bats-core +# - jq: brew install jq +# +# Run tests: +# bats tests/build_context_test.bats +# +# Or run all tests: +# bats tests/*.bats +# ============================================================================= + +scope_id=7 + +# Setup - runs before each test +setup() { + # Get the directory of the test file + TEST_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")" && pwd)" + PROJECT_DIR="$(cd "$TEST_DIR/../.." && pwd)" + PROJECT_ROOT="$(cd "$TEST_DIR/../../../.." && pwd)" + + # Load shared test utilities + source "$PROJECT_ROOT/testing/assertions.sh" + + CONTEXT=$(cat "$PROJECT_DIR/tests/resources/context.json") + SERVICE_PATH="$PROJECT_DIR" + TEST_OUTPUT_DIR=$(mktemp -d) + + export CONTEXT SERVICE_PATH TEST_OUTPUT_DIR +} + +# Teardown - runs after each test +teardown() { + # Clean up temp directory + if [ -d "$TEST_OUTPUT_DIR" ]; then + rm -rf "$TEST_OUTPUT_DIR" + fi +} + +# ============================================================================= +# Helper functions +# ============================================================================= +run_build_context() { + # Source the build_context script + source "$PROJECT_DIR/scripts/build_context" +} + +# ============================================================================= +# Test: TOFU_VARIABLES - verifies the entire JSON structure +# ============================================================================= +@test "Should generate TOFU_VARIABLES with expected structure" { + run_build_context + + # Expected JSON - update this when adding new fields + local expected='{}' + + assert_json_equal "$TOFU_VARIABLES" "$expected" "TOFU_VARIABLES" +} + +# ============================================================================= +# Test: TOFU_INIT_VARIABLES +# ============================================================================= +@test "Should generate correct tf_state_key format in TOFU_INIT_VARIABLES" { + run_build_context + + # Should contain the expected backend-config key + + assert_contains "$TOFU_INIT_VARIABLES" "key=frontend/tools/automation/development-tools-$scope_id" +} + +# ============================================================================= +# Test: TOFU_MODULE_DIR +# ============================================================================= +@test "Should create TOFU_MODULE_DIR path with scope_id" { + run_build_context + + # Should end with the scope_id + assert_contains "$TOFU_MODULE_DIR" "$SERVICE_PATH/output/$scope_id" +} + +@test "Should create TOFU_MODULE_DIR as a directory" { + run_build_context + + assert_directory_exists "$TOFU_MODULE_DIR" +} + +# ============================================================================= +# Test: MODULES_TO_USE initialization +# ============================================================================= +@test "Should initialize MODULES_TO_USE as empty by default" { + unset CUSTOM_TOFU_MODULES + run_build_context + + assert_empty "$MODULES_TO_USE" "MODULES_TO_USE" +} + +@test "Should inherit MODULES_TO_USE from CUSTOM_TOFU_MODULES" { + export CUSTOM_TOFU_MODULES="custom/module1,custom/module2" + run_build_context + + assert_equal "$MODULES_TO_USE" "custom/module1,custom/module2" +} + +# ============================================================================= +# Test: exports are set +# ============================================================================= +@test "Should export TOFU_VARIABLES" { + run_build_context + + assert_not_empty "$TOFU_VARIABLES" "TOFU_VARIABLES" +} + +@test "Should export TOFU_INIT_VARIABLES" { + run_build_context + + assert_not_empty "$TOFU_INIT_VARIABLES" "TOFU_INIT_VARIABLES" +} + +@test "Should export TOFU_MODULE_DIR" { + run_build_context + + assert_not_empty "$TOFU_MODULE_DIR" "TOFU_MODULE_DIR" +} + +# ============================================================================= +# Test: RESOURCE_TAGS_JSON - verifies the entire JSON structure +# ============================================================================= +@test "Should generate RESOURCE_TAGS_JSON with expected structure" { + run_build_context + + # Expected JSON - update this when adding new fields + local expected='{ + "account": "playground", + "account_id": 2, + "application": "automation", + "application_id": 4, + "deployment_id": 8, + "namespace": "tools", + "namespace_id": 3, + "nullplatform": "true", + "scope": "development-tools", + "scope_id": 7 + }' + + assert_json_equal "$RESOURCE_TAGS_JSON" "$expected" "RESOURCE_TAGS_JSON" +} diff --git a/frontend/deployment/tests/scripts/compose_modules_test.bats b/frontend/deployment/tests/scripts/compose_modules_test.bats new file mode 100644 index 00000000..81ffd0bc --- /dev/null +++ b/frontend/deployment/tests/scripts/compose_modules_test.bats @@ -0,0 +1,353 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for compose_modules script +# +# Requirements: +# - bats-core: brew install bats-core +# - jq: brew install jq +# +# Run tests: +# bats tests/scripts/compose_modules_test.bats +# ============================================================================= + +setup() { + TEST_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")" && pwd)" + PROJECT_DIR="$(cd "$TEST_DIR/../.." && pwd)" + PROJECT_ROOT="$(cd "$PROJECT_DIR/../.." && pwd)" + SCRIPT_PATH="$PROJECT_DIR/scripts/compose_modules" + + source "$PROJECT_ROOT/testing/assertions.sh" + + # Create temporary directories for testing + TEST_OUTPUT_DIR=$(mktemp -d) + TEST_MODULES_DIR=$(mktemp -d) + + export TOFU_MODULE_DIR="$TEST_OUTPUT_DIR" +} + +teardown() { + # Clean up temp directories + if [ -d "$TEST_OUTPUT_DIR" ]; then + rm -rf "$TEST_OUTPUT_DIR" + fi + if [ -d "$TEST_MODULES_DIR" ]; then + rm -rf "$TEST_MODULES_DIR" + fi +} + +# ============================================================================= +# Helper functions +# ============================================================================= +create_test_module() { + local module_path="$1" + local module_dir="$TEST_MODULES_DIR/$module_path" + mkdir -p "$module_dir" + echo "$module_dir" +} + +create_tf_file() { + local module_dir="$1" + local filename="$2" + local content="${3:-# Test terraform file}" + echo "$content" > "$module_dir/$filename" +} + +create_setup_script() { + local module_dir="$1" + local content="${2:-echo 'Setup executed'}" + echo "#!/bin/bash" > "$module_dir/setup" + echo "$content" >> "$module_dir/setup" + chmod +x "$module_dir/setup" +} + +# ============================================================================= +# Test: Required environment variables - Error messages +# ============================================================================= +@test "Should fail when MODULES_TO_USE is not set" { + unset MODULES_TO_USE + + run source "$SCRIPT_PATH" + + assert_equal "$status" "1" + assert_contains "$output" "🔍 Validating module composition configuration..." + assert_contains "$output" " ❌ MODULES_TO_USE is not set" + assert_contains "$output" " 🔧 How to fix:" + assert_contains "$output" " • Ensure MODULES_TO_USE is set before calling compose_modules" + assert_contains "$output" " • This is typically done by the setup scripts (provider, network, distribution)" +} + +@test "Should fail when TOFU_MODULE_DIR is not set" { + export MODULES_TO_USE="some/module" + unset TOFU_MODULE_DIR + + run source "$SCRIPT_PATH" + + assert_equal "$status" "1" + assert_contains "$output" "🔍 Validating module composition configuration..." + assert_contains "$output" " ❌ TOFU_MODULE_DIR is not set" + assert_contains "$output" " 🔧 How to fix:" + assert_contains "$output" " • Ensure TOFU_MODULE_DIR is set before calling compose_modules" + assert_contains "$output" " • This is typically done by the build_context script" +} + +@test "Should fail when module directory does not exist" { + export MODULES_TO_USE="/nonexistent/module/path" + + run source "$SCRIPT_PATH" + + assert_equal "$status" "1" + assert_contains "$output" " ❌ Module directory not found: /nonexistent/module/path" + assert_contains "$output" " 🔧 How to fix:" + assert_contains "$output" " • Verify the module path is correct and the directory exists" +} + +# ============================================================================= +# Test: Validation success messages +# ============================================================================= +@test "Should display validation header message" { + local module_dir=$(create_test_module "test/module") + export MODULES_TO_USE="$module_dir" + + run source "$SCRIPT_PATH" + + assert_equal "$status" "0" + assert_contains "$output" "🔍 Validating module composition configuration..." +} + +@test "Should display modules and target in validation output" { + local module_dir=$(create_test_module "test/module") + export MODULES_TO_USE="$module_dir" + + run source "$SCRIPT_PATH" + + assert_equal "$status" "0" + assert_contains "$output" " ✅ modules=$module_dir" + assert_contains "$output" " ✅ target=$TOFU_MODULE_DIR" +} + +# ============================================================================= +# Test: Module processing messages +# ============================================================================= +@test "Should display module path with package emoji when processing" { + local module_dir=$(create_test_module "network/route53") + create_tf_file "$module_dir" "main.tf" + export MODULES_TO_USE="$module_dir" + + run source "$SCRIPT_PATH" + + assert_equal "$status" "0" + assert_contains "$output" "📦 $module_dir" +} + +@test "Should display each copied file name" { + local module_dir=$(create_test_module "network/route53") + create_tf_file "$module_dir" "main.tf" + create_tf_file "$module_dir" "variables.tf" + create_tf_file "$module_dir" "outputs.tf" + export MODULES_TO_USE="$module_dir" + + run source "$SCRIPT_PATH" + + assert_equal "$status" "0" + assert_contains "$output" " main.tf" + assert_contains "$output" " variables.tf" + assert_contains "$output" " outputs.tf" +} + +@test "Should display copy success message with file count and prefix" { + local module_dir=$(create_test_module "network/route53") + create_tf_file "$module_dir" "main.tf" + create_tf_file "$module_dir" "variables.tf" + create_tf_file "$module_dir" "outputs.tf" + export MODULES_TO_USE="$module_dir" + + run source "$SCRIPT_PATH" + + assert_equal "$status" "0" + assert_contains "$output" " ✅ Copied 3 file(s) with prefix: network_route53_" +} + +@test "Should display setup script running message" { + local module_dir=$(create_test_module "provider/aws") + create_setup_script "$module_dir" "echo 'AWS provider configured'" + + export MODULES_TO_USE="$module_dir" + + run source "$SCRIPT_PATH" + + assert_equal "$status" "0" + assert_contains "$output" " 📡 Running setup script..." +} + +@test "Should display setup completed message" { + local module_dir=$(create_test_module "provider/aws") + create_setup_script "$module_dir" "echo 'AWS provider configured'" + + export MODULES_TO_USE="$module_dir" + + run source "$SCRIPT_PATH" + + assert_equal "$status" "0" + assert_contains "$output" " ✅ Setup completed" +} + +@test "Should display final success message" { + local module_dir=$(create_test_module "test/module") + export MODULES_TO_USE="$module_dir" + + run source "$SCRIPT_PATH" + + assert_equal "$status" "0" + assert_contains "$output" "✨ All modules composed successfully" +} + +# ============================================================================= +# Test: Setup script failure messages +# ============================================================================= +@test "Should display setup failed message when setup script fails" { + local module_dir=$(create_test_module "provider/aws") + # Use 'return 1' instead of 'exit 1' since sourced scripts that call exit + # will exit the entire parent script before the error message can be printed + create_setup_script "$module_dir" "echo 'Error during setup'; return 1" + + export MODULES_TO_USE="$module_dir" + + run source "$SCRIPT_PATH" + + assert_equal "$status" "1" + assert_contains "$output" " 📡 Running setup script..." + assert_contains "$output" " ❌ Setup script failed for module: $module_dir" +} + +# ============================================================================= +# Test: Module copying functionality +# ============================================================================= +@test "Should copy .tf files to TOFU_MODULE_DIR" { + local module_dir=$(create_test_module "network/route53") + create_tf_file "$module_dir" "main.tf" "resource \"aws_route53_record\" \"main\" {}" + create_tf_file "$module_dir" "variables.tf" "variable \"domain\" {}" + + export MODULES_TO_USE="$module_dir" + + run source "$SCRIPT_PATH" + + assert_equal "$status" "0" + assert_file_exists "$TOFU_MODULE_DIR/network_route53_main.tf" + assert_file_exists "$TOFU_MODULE_DIR/network_route53_variables.tf" +} + +@test "Should skip test_*.tf files when copying" { + local module_dir=$(create_test_module "network/route53") + create_tf_file "$module_dir" "main.tf" + create_tf_file "$module_dir" "test_locals.tf" + + export MODULES_TO_USE="$module_dir" + + run source "$SCRIPT_PATH" + + assert_equal "$status" "0" + assert_file_exists "$TOFU_MODULE_DIR/network_route53_main.tf" + assert_file_not_exists "$TOFU_MODULE_DIR/network_route53_test_locals.tf" +} + +@test "Should use correct prefix based on parent and leaf directory names" { + local module_dir=$(create_test_module "provider/aws") + create_tf_file "$module_dir" "provider.tf" + + export MODULES_TO_USE="$module_dir" + + run source "$SCRIPT_PATH" + + assert_equal "$status" "0" + assert_file_exists "$TOFU_MODULE_DIR/provider_aws_provider.tf" +} + +@test "Should handle modules with no .tf files" { + local module_dir=$(create_test_module "custom/empty") + # Don't create any .tf files + + export MODULES_TO_USE="$module_dir" + + run source "$SCRIPT_PATH" + + assert_equal "$status" "0" + assert_contains "$output" "✨ All modules composed successfully" +} + +# ============================================================================= +# Test: Multiple modules +# ============================================================================= +@test "Should process multiple modules from comma-separated list" { + local module1=$(create_test_module "provider/aws") + local module2=$(create_test_module "network/route53") + create_tf_file "$module1" "provider.tf" + create_tf_file "$module2" "main.tf" + + export MODULES_TO_USE="$module1,$module2" + + run source "$SCRIPT_PATH" + + assert_equal "$status" "0" + assert_file_exists "$TOFU_MODULE_DIR/provider_aws_provider.tf" + assert_file_exists "$TOFU_MODULE_DIR/network_route53_main.tf" + # Verify both modules were logged + assert_contains "$output" "📦 $module1" + assert_contains "$output" "📦 $module2" +} + +@test "Should handle whitespace in comma-separated module list" { + local module1=$(create_test_module "provider/aws") + local module2=$(create_test_module "network/route53") + create_tf_file "$module1" "provider.tf" + create_tf_file "$module2" "main.tf" + + export MODULES_TO_USE="$module1 , $module2" + + run source "$SCRIPT_PATH" + + assert_equal "$status" "0" + assert_file_exists "$TOFU_MODULE_DIR/provider_aws_provider.tf" + assert_file_exists "$TOFU_MODULE_DIR/network_route53_main.tf" +} + +# ============================================================================= +# Test: Setup scripts execution +# ============================================================================= +@test "Should execute setup script and display its output" { + local module_dir=$(create_test_module "provider/aws") + create_setup_script "$module_dir" "echo 'Custom setup message from AWS provider'" + + export MODULES_TO_USE="$module_dir" + + run source "$SCRIPT_PATH" + + assert_equal "$status" "0" + assert_contains "$output" "Custom setup message from AWS provider" +} + +@test "Should not fail if module has no setup script" { + local module_dir=$(create_test_module "custom/nosetup") + create_tf_file "$module_dir" "main.tf" + # Don't create a setup script + + export MODULES_TO_USE="$module_dir" + + run source "$SCRIPT_PATH" + + assert_equal "$status" "0" + assert_contains "$output" "✨ All modules composed successfully" +} + +# ============================================================================= +# Test: TOFU_MODULE_DIR creation +# ============================================================================= +@test "Should create TOFU_MODULE_DIR if it does not exist" { + local module_dir=$(create_test_module "test/module") + export MODULES_TO_USE="$module_dir" + export TOFU_MODULE_DIR="$TEST_OUTPUT_DIR/nested/deep/dir" + + run source "$SCRIPT_PATH" + + assert_equal "$status" "0" + assert_directory_exists "$TOFU_MODULE_DIR" +} diff --git a/frontend/deployment/tests/scripts/do_tofu_test.bats b/frontend/deployment/tests/scripts/do_tofu_test.bats new file mode 100644 index 00000000..67748dbd --- /dev/null +++ b/frontend/deployment/tests/scripts/do_tofu_test.bats @@ -0,0 +1,231 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for do_tofu script +# +# Requirements: +# - bats-core: brew install bats-core +# - jq: brew install jq +# +# Run tests: +# bats tests/scripts/do_tofu_test.bats +# ============================================================================= + +setup() { + TEST_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")" && pwd)" + PROJECT_DIR="$(cd "$TEST_DIR/../.." && pwd)" + PROJECT_ROOT="$(cd "$PROJECT_DIR/../.." && pwd)" + SCRIPT_PATH="$PROJECT_DIR/scripts/do_tofu" + + source "$PROJECT_ROOT/testing/assertions.sh" + + # Create temporary directory for testing + TEST_OUTPUT_DIR=$(mktemp -d) + MOCK_BIN_DIR=$(mktemp -d) + + # Setup mock tofu command + cat > "$MOCK_BIN_DIR/tofu" << 'EOF' +#!/bin/bash +# Mock tofu command - logs calls to a file for verification +echo "tofu $*" >> "$TOFU_MOCK_LOG" +exit 0 +EOF + chmod +x "$MOCK_BIN_DIR/tofu" + + # Export environment variables + export TOFU_MODULE_DIR="$TEST_OUTPUT_DIR" + export TOFU_VARIABLES='{"key": "value", "number": 42}' + export TOFU_INIT_VARIABLES="-backend-config=bucket=test-bucket -backend-config=region=us-east-1" + export TOFU_ACTION="apply" + export TOFU_MOCK_LOG="$TEST_OUTPUT_DIR/tofu_calls.log" + + # Add mock bin to PATH + export PATH="$MOCK_BIN_DIR:$PATH" +} + +teardown() { + # Clean up temp directories + if [ -d "$TEST_OUTPUT_DIR" ]; then + rm -rf "$TEST_OUTPUT_DIR" + fi + if [ -d "$MOCK_BIN_DIR" ]; then + rm -rf "$MOCK_BIN_DIR" + fi +} + +# ============================================================================= +# Test: tfvars file creation +# ============================================================================= +@test "Should write TOFU_VARIABLES to .tfvars.json file" { + export TOFU_VARIABLES='{"environment": "production", "replicas": 3}' + + run bash "$SCRIPT_PATH" + + assert_equal "$status" "0" + assert_file_exists "$TOFU_MODULE_DIR/.tfvars.json" + + local content=$(cat "$TOFU_MODULE_DIR/.tfvars.json") + assert_equal "$content" '{"environment": "production", "replicas": 3}' + + # Verify it's valid JSON by parsing with jq + run jq '.' "$TOFU_MODULE_DIR/.tfvars.json" + assert_equal "$status" "0" +} + +# ============================================================================= +# Test: tofu init command +# ============================================================================= +@test "Should call tofu init with correct chdir" { + run bash "$SCRIPT_PATH" + + assert_equal "$status" "0" + assert_file_exists "$TOFU_MOCK_LOG" + + local init_call=$(grep "tofu -chdir=" "$TOFU_MOCK_LOG" | grep "init" | head -1) + assert_contains "$init_call" "-chdir=$TOFU_MODULE_DIR" +} + +@test "Should call tofu init with -input=false" { + run bash "$SCRIPT_PATH" + + assert_equal "$status" "0" + + local init_call=$(grep "init" "$TOFU_MOCK_LOG" | head -1) + assert_contains "$init_call" "-input=false" +} + +@test "Should call tofu init with TOFU_INIT_VARIABLES" { + export TOFU_INIT_VARIABLES="-backend-config=bucket=my-bucket -backend-config=key=state.tfstate" + + run bash "$SCRIPT_PATH" + + assert_equal "$status" "0" + + local init_call=$(grep "init" "$TOFU_MOCK_LOG" | head -1) + assert_contains "$init_call" "-backend-config=bucket=my-bucket" + assert_contains "$init_call" "-backend-config=key=state.tfstate" +} + +@test "Should call tofu init with empty TOFU_INIT_VARIABLES" { + export TOFU_INIT_VARIABLES="" + + run bash "$SCRIPT_PATH" + + assert_equal "$status" "0" + + local init_call=$(grep "init" "$TOFU_MOCK_LOG" | head -1) + assert_contains "$init_call" "init -input=false" +} + +# ============================================================================= +# Test: tofu action command +# ============================================================================= +@test "Should call tofu with TOFU_ACTION=apply" { + export TOFU_ACTION="apply" + + run bash "$SCRIPT_PATH" + + assert_equal "$status" "0" + + local action_call=$(grep -v "init" "$TOFU_MOCK_LOG" | head -1) + assert_contains "$action_call" "apply" +} + +@test "Should call tofu with TOFU_ACTION=destroy" { + export TOFU_ACTION="destroy" + + run bash "$SCRIPT_PATH" + + assert_equal "$status" "0" + + local action_call=$(grep -v "init" "$TOFU_MOCK_LOG" | head -1) + assert_contains "$action_call" "destroy" +} + +@test "Should call tofu with TOFU_ACTION=plan" { + export TOFU_ACTION="plan" + + run bash "$SCRIPT_PATH" + + assert_equal "$status" "0" + + local action_call=$(grep -v "init" "$TOFU_MOCK_LOG" | head -1) + assert_contains "$action_call" "plan" +} + +@test "Should call tofu action with -auto-approve" { + run bash "$SCRIPT_PATH" + + assert_equal "$status" "0" + + local action_call=$(grep -v "init" "$TOFU_MOCK_LOG" | head -1) + assert_contains "$action_call" "-auto-approve" +} + +@test "Should call tofu action with correct var-file path" { + run bash "$SCRIPT_PATH" + + assert_equal "$status" "0" + + local action_call=$(grep -v "init" "$TOFU_MOCK_LOG" | head -1) + assert_contains "$action_call" "-var-file=$TOFU_MODULE_DIR/.tfvars.json" +} + +@test "Should call tofu action with correct chdir" { + run bash "$SCRIPT_PATH" + + assert_equal "$status" "0" + + local action_call=$(grep -v "init" "$TOFU_MOCK_LOG" | head -1) + assert_contains "$action_call" "-chdir=$TOFU_MODULE_DIR" +} + +# ============================================================================= +# Test: Command execution order +# ============================================================================= +@test "Should call tofu init before tofu action" { + run bash "$SCRIPT_PATH" + + assert_equal "$status" "0" + assert_command_order "$TOFU_MOCK_LOG" \ + "tofu -chdir=$TOFU_MODULE_DIR init" \ + "tofu -chdir=$TOFU_MODULE_DIR apply" +} + +# ============================================================================= +# Test: Error handling +# ============================================================================= +@test "Should fail if tofu init fails" { + # Create a failing mock + cat > "$MOCK_BIN_DIR/tofu" << 'EOF' +#!/bin/bash +if [[ "$*" == *"init"* ]]; then + echo "Error: Failed to initialize" >&2 + exit 1 +fi +exit 0 +EOF + chmod +x "$MOCK_BIN_DIR/tofu" + + run bash "$SCRIPT_PATH" + + assert_equal "$status" "1" +} + +@test "Should fail if tofu action fails" { + # Create a mock that fails on action + cat > "$MOCK_BIN_DIR/tofu" << 'EOF' +#!/bin/bash +if [[ "$*" == *"apply"* ]] || [[ "$*" == *"destroy"* ]] || [[ "$*" == *"plan"* ]]; then + if [[ "$*" != *"init"* ]]; then + echo "Error: Action failed" >&2 + exit 1 + fi +fi +exit 0 +EOF + chmod +x "$MOCK_BIN_DIR/tofu" + + run bash "$SCRIPT_PATH" + + assert_equal "$status" "1" +} diff --git a/frontend/deployment/tests/scripts/test.json b/frontend/deployment/tests/scripts/test.json new file mode 100644 index 00000000..e254e642 --- /dev/null +++ b/frontend/deployment/tests/scripts/test.json @@ -0,0 +1,2 @@ +tofu -chdir=/var/folders/lz/8bf63tz10kz930s8w1553s900000gq/T/tmp.iQhGScm139 init -input=false -backend-config=bucket=test-bucket -backend-config=region=us-east-1 +tofu -chdir=/var/folders/lz/8bf63tz10kz930s8w1553s900000gq/T/tmp.iQhGScm139 apply -auto-approve -var-file=/var/folders/lz/8bf63tz10kz930s8w1553s900000gq/T/tmp.iQhGScm139/.tfvars.json diff --git a/frontend/deployment/workflows/blue_green.yaml b/frontend/deployment/workflows/blue_green.yaml new file mode 100644 index 00000000..c5dcb0c6 --- /dev/null +++ b/frontend/deployment/workflows/blue_green.yaml @@ -0,0 +1,2 @@ +include: + - "$SERVICE_PATH/deployment/workflows/initial.yaml" \ No newline at end of file diff --git a/frontend/deployment/workflows/delete.yaml b/frontend/deployment/workflows/delete.yaml new file mode 100644 index 00000000..1fae8f33 --- /dev/null +++ b/frontend/deployment/workflows/delete.yaml @@ -0,0 +1,4 @@ +include: + - "$SERVICE_PATH/deployment/workflows/initial.yaml" +configuration: + TOFU_ACTION: "destroy" \ No newline at end of file diff --git a/frontend/deployment/workflows/finalize.yaml b/frontend/deployment/workflows/finalize.yaml new file mode 100644 index 00000000..3e1ffbdb --- /dev/null +++ b/frontend/deployment/workflows/finalize.yaml @@ -0,0 +1,4 @@ +steps: + - name: no_op + type: command + command: "$SERVICE_PATH/no_op" \ No newline at end of file diff --git a/frontend/deployment/workflows/initial.yaml b/frontend/deployment/workflows/initial.yaml new file mode 100644 index 00000000..abcdafca --- /dev/null +++ b/frontend/deployment/workflows/initial.yaml @@ -0,0 +1,26 @@ +provider_categories: + - cloud-providers +configuration: + TOFU_ACTION: "apply" +steps: + - name: build_context + type: script + file: "$SERVICE_PATH/deployment/scripts/build_context" + output: + - name: TOFU_VARIABLES + type: environment + - name: setup_provider_layer + type: script + file: "$SERVICE_PATH/deployment/provider/$TOFU_PROVIDER/setup" + - name: setup_network_layer + type: script + file: "$SERVICE_PATH/deployment/network/$NETWORK_LAYER/setup" + - name: setup_distribution_layer + type: script + file: "$SERVICE_PATH/deployment/distribution/$DISTRIBUTION_LAYER/setup" + - name: build_modules + type: script + file: "$SERVICE_PATH/deployment/scripts/compose_modules" + - name: tofu + type: script + file: "$SERVICE_PATH/deployment/scripts/do_tofu" \ No newline at end of file diff --git a/frontend/deployment/workflows/rollback.yaml b/frontend/deployment/workflows/rollback.yaml new file mode 100644 index 00000000..3e1ffbdb --- /dev/null +++ b/frontend/deployment/workflows/rollback.yaml @@ -0,0 +1,4 @@ +steps: + - name: no_op + type: command + command: "$SERVICE_PATH/no_op" \ No newline at end of file diff --git a/frontend/instance/workflows/list.yaml b/frontend/instance/workflows/list.yaml new file mode 100644 index 00000000..e69de29b diff --git a/frontend/log/workflows/log.yaml b/frontend/log/workflows/log.yaml new file mode 100644 index 00000000..e69de29b diff --git a/frontend/metric/workflows/list.yaml b/frontend/metric/workflows/list.yaml new file mode 100644 index 00000000..e69de29b diff --git a/frontend/metric/workflows/metric.yaml b/frontend/metric/workflows/metric.yaml new file mode 100644 index 00000000..e69de29b diff --git a/frontend/no_op b/frontend/no_op new file mode 100755 index 00000000..c6918a62 --- /dev/null +++ b/frontend/no_op @@ -0,0 +1,3 @@ +#!/bin/bash + +echo "No action needed on this scope" \ No newline at end of file diff --git a/frontend/scope/workflows/create.yaml b/frontend/scope/workflows/create.yaml new file mode 100644 index 00000000..3e1ffbdb --- /dev/null +++ b/frontend/scope/workflows/create.yaml @@ -0,0 +1,4 @@ +steps: + - name: no_op + type: command + command: "$SERVICE_PATH/no_op" \ No newline at end of file diff --git a/frontend/scope/workflows/delete.yaml b/frontend/scope/workflows/delete.yaml new file mode 100644 index 00000000..5cd04cd7 --- /dev/null +++ b/frontend/scope/workflows/delete.yaml @@ -0,0 +1,2 @@ +include: + - "$SERVICE_PATH/scope/workflows/create.yaml" \ No newline at end of file diff --git a/frontend/scope/workflows/update.yaml b/frontend/scope/workflows/update.yaml new file mode 100644 index 00000000..5cd04cd7 --- /dev/null +++ b/frontend/scope/workflows/update.yaml @@ -0,0 +1,2 @@ +include: + - "$SERVICE_PATH/scope/workflows/create.yaml" \ No newline at end of file diff --git a/frontend/specs/actions/create-scope.json.tpl b/frontend/specs/actions/create-scope.json.tpl new file mode 100644 index 00000000..4c0fb3a6 --- /dev/null +++ b/frontend/specs/actions/create-scope.json.tpl @@ -0,0 +1,29 @@ +{ + "name": "create-scope", + "slug": "create-scope", + "type": "create", + "retryable": false, + "service_specification_id": "{{ env.Getenv "SERVICE_SPECIFICATION_ID" }}", + "parameters": { + "schema": { + "type": "object", + "required": [ + "scope_id" + ], + "properties": { + "scope_id": { + "type": "string" + } + } + }, + "values": {} + }, + "results": { + "schema": { + "type": "object", + "required": [], + "properties": {} + }, + "values": {} + } +} \ No newline at end of file diff --git a/frontend/specs/actions/delete-deployment.json.tpl b/frontend/specs/actions/delete-deployment.json.tpl new file mode 100644 index 00000000..0334b10c --- /dev/null +++ b/frontend/specs/actions/delete-deployment.json.tpl @@ -0,0 +1,33 @@ +{ + "name": "delete-deployment", + "slug": "delete-deployment", + "type": "custom", + "retryable": false, + "service_specification_id": "{{ env.Getenv "SERVICE_SPECIFICATION_ID" }}", + "parameters": { + "schema": { + "type": "object", + "required": [ + "scope_id", + "deployment_id" + ], + "properties": { + "scope_id": { + "type": "string" + }, + "deployment_id": { + "type": "string" + } + } + }, + "values": {} + }, + "results": { + "schema": { + "type": "object", + "required": [], + "properties": {} + }, + "values": {} + } +} \ No newline at end of file diff --git a/frontend/specs/actions/delete-scope.json.tpl b/frontend/specs/actions/delete-scope.json.tpl new file mode 100644 index 00000000..16d9b992 --- /dev/null +++ b/frontend/specs/actions/delete-scope.json.tpl @@ -0,0 +1,29 @@ +{ + "name": "delete-scope", + "slug": "delete-scope", + "type": "custom", + "retryable": false, + "service_specification_id": "{{ env.Getenv "SERVICE_SPECIFICATION_ID" }}", + "parameters": { + "schema": { + "type": "object", + "required": [ + "scope_id" + ], + "properties": { + "scope_id": { + "type": "string" + } + } + }, + "values": {} + }, + "results": { + "schema": { + "type": "object", + "required": [], + "properties": {} + }, + "values": {} + } +} \ No newline at end of file diff --git a/frontend/specs/actions/finalize-blue-green.json.tpl b/frontend/specs/actions/finalize-blue-green.json.tpl new file mode 100644 index 00000000..4dc780cb --- /dev/null +++ b/frontend/specs/actions/finalize-blue-green.json.tpl @@ -0,0 +1,33 @@ +{ + "name": "finalize-blue-green", + "slug": "finalize-blue-green", + "type": "custom", + "retryable": false, + "service_specification_id": "{{ env.Getenv "SERVICE_SPECIFICATION_ID" }}", + "parameters": { + "schema": { + "type": "object", + "required": [ + "scope_id", + "deployment_id" + ], + "properties": { + "scope_id": { + "type": "string" + }, + "deployment_id": { + "type": "string" + } + } + }, + "values": {} + }, + "results": { + "schema": { + "type": "object", + "required": [], + "properties": {} + }, + "values": {} + } +} \ No newline at end of file diff --git a/frontend/specs/actions/rollback-deployment.json.tpl b/frontend/specs/actions/rollback-deployment.json.tpl new file mode 100644 index 00000000..dcbf4cd1 --- /dev/null +++ b/frontend/specs/actions/rollback-deployment.json.tpl @@ -0,0 +1,33 @@ +{ + "name": "rollback-deployment", + "slug": "rollback-deployment", + "type": "custom", + "retryable": false, + "service_specification_id": "{{ env.Getenv "SERVICE_SPECIFICATION_ID" }}", + "parameters": { + "schema": { + "type": "object", + "required": [ + "scope_id", + "deployment_id" + ], + "properties": { + "scope_id": { + "type": "string" + }, + "deployment_id": { + "type": "string" + } + } + }, + "values": {} + }, + "results": { + "schema": { + "type": "object", + "required": [], + "properties": {} + }, + "values": {} + } +} \ No newline at end of file diff --git a/frontend/specs/actions/start-blue-green.json.tpl b/frontend/specs/actions/start-blue-green.json.tpl new file mode 100644 index 00000000..a5e387b5 --- /dev/null +++ b/frontend/specs/actions/start-blue-green.json.tpl @@ -0,0 +1,33 @@ +{ + "name": "start-blue-green", + "slug": "start-blue-green", + "type": "custom", + "retryable": false, + "service_specification_id": "{{ env.Getenv "SERVICE_SPECIFICATION_ID" }}", + "parameters": { + "schema": { + "type": "object", + "required": [ + "scope_id", + "deployment_id" + ], + "properties": { + "scope_id": { + "type": "string" + }, + "deployment_id": { + "type": "string" + } + } + }, + "values": {} + }, + "results": { + "schema": { + "type": "object", + "required": [], + "properties": {} + }, + "values": {} + } +} \ No newline at end of file diff --git a/frontend/specs/actions/start-initial.json.tpl b/frontend/specs/actions/start-initial.json.tpl new file mode 100644 index 00000000..b00708e0 --- /dev/null +++ b/frontend/specs/actions/start-initial.json.tpl @@ -0,0 +1,32 @@ +{ + "name": "start-initial", + "slug": "start-initial", + "type": "custom", + "service_specification_id": "{{ env.Getenv "SERVICE_SPECIFICATION_ID" }}", + "parameters": { + "schema": { + "type": "object", + "required": [ + "scope_id", + "deployment_id" + ], + "properties": { + "scope_id": { + "type": "string" + }, + "deployment_id": { + "type": "string" + } + } + }, + "values": {} + }, + "results": { + "schema": { + "type": "object", + "required": [], + "properties": {} + }, + "values": {} + } +} \ No newline at end of file diff --git a/frontend/specs/notification-channel.json.tpl b/frontend/specs/notification-channel.json.tpl new file mode 100644 index 00000000..53f217f2 --- /dev/null +++ b/frontend/specs/notification-channel.json.tpl @@ -0,0 +1,35 @@ +{ + "nrn": "{{ env.Getenv "NRN" }}", + "status": "active", + "description": "Channel to handle static files scopes", + "type": "agent", + "source": [ + "telemetry", + "service" + ], + "configuration": { + "api_key": "{{ env.Getenv "NP_API_KEY" }}", + "command": { + "data": { + "cmdline": "{{ env.Getenv "REPO_PATH" }}/entrypoint --service-path={{ env.Getenv "REPO_PATH" }}/{{ env.Getenv "SERVICE_PATH" }}", + "environment": { + "NP_ACTION_CONTEXT": "'${NOTIFICATION_CONTEXT}'" + } + }, + "type": "exec" + }, + "selector": { + "environment": "{{ env.Getenv "ENVIRONMENT" }}" + } + }, + "filters": { + "$or": [ + { + "service.specification.slug": "{{ env.Getenv "SERVICE_SLUG" }}" + }, + { + "arguments.scope_provider": "{{ env.Getenv "SERVICE_SPECIFICATION_ID" }}" + } + ] + } +} \ No newline at end of file diff --git a/frontend/specs/scope-type-definition.json.tpl b/frontend/specs/scope-type-definition.json.tpl new file mode 100644 index 00000000..122fff1d --- /dev/null +++ b/frontend/specs/scope-type-definition.json.tpl @@ -0,0 +1,9 @@ +{ + "description": "Allows you to deploy static files applications", + "name": "Static files", + "nrn": "{{ env.Getenv "NRN" }}", + "provider_id": "{{ env.Getenv "SERVICE_SPECIFICATION_ID" }}", + "provider_type": "service", + "status": "active", + "type": "custom" +} \ No newline at end of file diff --git a/frontend/specs/service-spec.json.tpl b/frontend/specs/service-spec.json.tpl new file mode 100644 index 00000000..5e8f0a68 --- /dev/null +++ b/frontend/specs/service-spec.json.tpl @@ -0,0 +1,34 @@ +{ + "assignable_to": "any", + "attributes": { + "schema": { + "properties": { + "asset_type": { + "default": "bundle", + "export": false, + "type": "string" + } + }, + "required": [], + "uiSchema": { + "elements": [], + "type": "VerticalLayout" + } + }, + "values": {} + }, + "dimensions": {}, + "name": "Static files", + "scopes": {}, + "selectors": { + "category": "Scope", + "imported": false, + "provider": "Agent", + "sub_category": "Static files" + }, + "type": "scope", + "use_default_actions": false, + "visible_to": [ + "{{ env.Getenv "NRN" }}" + ] +} \ No newline at end of file diff --git a/testing/assertions.sh b/testing/assertions.sh index ab36c582..cd2abc44 100644 --- a/testing/assertions.sh +++ b/testing/assertions.sh @@ -321,4 +321,4 @@ USAGE IN TESTS ================================================================================ EOF -} +} \ No newline at end of file diff --git a/testing/azure-mock-provider/provider_override.tf b/testing/azure-mock-provider/provider_override.tf index 6b1a4406..822b17b2 100644 --- a/testing/azure-mock-provider/provider_override.tf +++ b/testing/azure-mock-provider/provider_override.tf @@ -29,4 +29,4 @@ provider "azurerm" { default_tags { tags = var.resource_tags } -} +} \ No newline at end of file diff --git a/testing/bin/az b/testing/bin/az new file mode 100755 index 00000000..67e18dcc --- /dev/null +++ b/testing/bin/az @@ -0,0 +1,265 @@ +#!/bin/bash +# ============================================================================= +# Mock Azure CLI for Integration Testing +# +# This script emulates the Azure CLI by querying the Azure Mock server. +# Only implements the commands needed for integration testing. +# +# Supported commands: +# az network dns zone show --name --resource-group +# az network dns record-set cname show --name --zone-name --resource-group +# az cdn profile show --name --resource-group +# az cdn endpoint show --name --profile-name --resource-group +# az storage account show --name --resource-group +# ============================================================================= + +AZURE_MOCK_ENDPOINT="${AZURE_MOCK_ENDPOINT:-http://localhost:8090}" +ARM_SUBSCRIPTION_ID="${ARM_SUBSCRIPTION_ID:-mock-subscription-id}" + +# Parse command structure +cmd="$1" +subcmd="$2" +action="$3" + +case "$cmd" in + network) + case "$subcmd" in + dns) + case "$action" in + zone) + shift 3 + subaction="$1" + case "$subaction" in + show) + shift + # Parse arguments + while [[ $# -gt 0 ]]; do + case $1 in + --name|-n) zone_name="$2"; shift 2 ;; + --resource-group|-g) resource_group="$2"; shift 2 ;; + *) shift ;; + esac + done + + if [[ -z "$zone_name" || -z "$resource_group" ]]; then + echo "ERROR: --name and --resource-group are required" >&2 + exit 1 + fi + + # Query Azure Mock + response=$(curl -s "${AZURE_MOCK_ENDPOINT}/subscriptions/${ARM_SUBSCRIPTION_ID}/resourceGroups/${resource_group}/providers/Microsoft.Network/dnszones/${zone_name}") + + # Check for error + error_code=$(echo "$response" | jq -r '.error.code // empty') + if [[ -n "$error_code" ]]; then + echo "ERROR: ResourceNotFound - The DNS zone '${zone_name}' was not found." >&2 + exit 1 + fi + + echo "$response" + ;; + *) + echo "ERROR: Unknown subaction: $subaction" >&2 + exit 1 + ;; + esac + ;; + record-set) + shift 3 + record_type="$1" + subaction="$2" + case "$record_type" in + cname) + case "$subaction" in + show) + shift 2 + # Parse arguments + while [[ $# -gt 0 ]]; do + case $1 in + --name|-n) record_name="$2"; shift 2 ;; + --zone-name|-z) zone_name="$2"; shift 2 ;; + --resource-group|-g) resource_group="$2"; shift 2 ;; + *) shift ;; + esac + done + + if [[ -z "$record_name" || -z "$zone_name" || -z "$resource_group" ]]; then + echo "ERROR: --name, --zone-name, and --resource-group are required" >&2 + exit 1 + fi + + # Query Azure Mock + response=$(curl -s "${AZURE_MOCK_ENDPOINT}/subscriptions/${ARM_SUBSCRIPTION_ID}/resourceGroups/${resource_group}/providers/Microsoft.Network/dnszones/${zone_name}/CNAME/${record_name}") + + # Check for error + error_code=$(echo "$response" | jq -r '.error.code // empty') + if [[ -n "$error_code" ]]; then + echo "ERROR: ResourceNotFound - The CNAME record '${record_name}' was not found." >&2 + exit 1 + fi + + echo "$response" + ;; + *) + echo "ERROR: Unknown subaction: $subaction" >&2 + exit 1 + ;; + esac + ;; + *) + echo "ERROR: Unknown record type: $record_type" >&2 + exit 1 + ;; + esac + ;; + *) + echo "ERROR: Unknown dns action: $action" >&2 + exit 1 + ;; + esac + ;; + *) + echo "ERROR: Unknown network subcommand: $subcmd" >&2 + exit 1 + ;; + esac + ;; + + cdn) + case "$subcmd" in + profile) + case "$action" in + show) + shift 3 + # Parse arguments + while [[ $# -gt 0 ]]; do + case $1 in + --name|-n) profile_name="$2"; shift 2 ;; + --resource-group|-g) resource_group="$2"; shift 2 ;; + *) shift ;; + esac + done + + if [[ -z "$profile_name" || -z "$resource_group" ]]; then + echo "ERROR: --name and --resource-group are required" >&2 + exit 1 + fi + + # Query Azure Mock + response=$(curl -s "${AZURE_MOCK_ENDPOINT}/subscriptions/${ARM_SUBSCRIPTION_ID}/resourceGroups/${resource_group}/providers/Microsoft.Cdn/profiles/${profile_name}") + + # Check for error + error_code=$(echo "$response" | jq -r '.error.code // empty') + if [[ -n "$error_code" ]]; then + echo "ERROR: ResourceNotFound - The CDN profile '${profile_name}' was not found." >&2 + exit 1 + fi + + echo "$response" + ;; + *) + echo "ERROR: Unknown cdn profile action: $action" >&2 + exit 1 + ;; + esac + ;; + endpoint) + case "$action" in + show) + shift 3 + # Parse arguments + while [[ $# -gt 0 ]]; do + case $1 in + --name|-n) endpoint_name="$2"; shift 2 ;; + --profile-name) profile_name="$2"; shift 2 ;; + --resource-group|-g) resource_group="$2"; shift 2 ;; + *) shift ;; + esac + done + + if [[ -z "$endpoint_name" || -z "$profile_name" || -z "$resource_group" ]]; then + echo "ERROR: --name, --profile-name, and --resource-group are required" >&2 + exit 1 + fi + + # Query Azure Mock + response=$(curl -s "${AZURE_MOCK_ENDPOINT}/subscriptions/${ARM_SUBSCRIPTION_ID}/resourceGroups/${resource_group}/providers/Microsoft.Cdn/profiles/${profile_name}/endpoints/${endpoint_name}") + + # Check for error + error_code=$(echo "$response" | jq -r '.error.code // empty') + if [[ -n "$error_code" ]]; then + echo "ERROR: ResourceNotFound - The CDN endpoint '${endpoint_name}' was not found." >&2 + exit 1 + fi + + echo "$response" + ;; + *) + echo "ERROR: Unknown cdn endpoint action: $action" >&2 + exit 1 + ;; + esac + ;; + *) + echo "ERROR: Unknown cdn subcommand: $subcmd" >&2 + exit 1 + ;; + esac + ;; + + storage) + case "$subcmd" in + account) + case "$action" in + show) + shift 3 + # Parse arguments + while [[ $# -gt 0 ]]; do + case $1 in + --name|-n) account_name="$2"; shift 2 ;; + --resource-group|-g) resource_group="$2"; shift 2 ;; + *) shift ;; + esac + done + + if [[ -z "$account_name" || -z "$resource_group" ]]; then + echo "ERROR: --name and --resource-group are required" >&2 + exit 1 + fi + + # Query Azure Mock + response=$(curl -s "${AZURE_MOCK_ENDPOINT}/subscriptions/${ARM_SUBSCRIPTION_ID}/resourceGroups/${resource_group}/providers/Microsoft.Storage/storageAccounts/${account_name}") + + # Check for error + error_code=$(echo "$response" | jq -r '.error.code // empty') + if [[ -n "$error_code" ]]; then + echo "ERROR: ResourceNotFound - The storage account '${account_name}' was not found." >&2 + exit 1 + fi + + echo "$response" + ;; + *) + echo "ERROR: Unknown storage account action: $action" >&2 + exit 1 + ;; + esac + ;; + *) + echo "ERROR: Unknown storage subcommand: $subcmd" >&2 + exit 1 + ;; + esac + ;; + + version) + echo '{"azure-cli": "2.99.0-mock", "azure-cli-core": "2.99.0-mock"}' + ;; + + *) + echo "ERROR: Unknown command: $cmd" >&2 + echo "This is a mock Azure CLI for integration testing." >&2 + echo "Supported commands: network dns zone, cdn profile/endpoint, storage account" >&2 + exit 1 + ;; +esac diff --git a/testing/docker/Dockerfile.test-runner b/testing/docker/Dockerfile.test-runner index 4323fbdb..66a6fcae 100644 --- a/testing/docker/Dockerfile.test-runner +++ b/testing/docker/Dockerfile.test-runner @@ -44,4 +44,4 @@ ENV PATH="/root/.local/bin:${PATH}" WORKDIR /workspace # Default command - run bats tests -ENTRYPOINT ["/bin/bash"] +ENTRYPOINT ["/bin/bash"] \ No newline at end of file diff --git a/testing/docker/azure-mock/main.go b/testing/docker/azure-mock/main.go index 57c81baf..74439640 100644 --- a/testing/docker/azure-mock/main.go +++ b/testing/docker/azure-mock/main.go @@ -3666,4 +3666,4 @@ func main() { if err := http.ListenAndServe(":8080", server); err != nil { log.Fatalf("Server failed: %v", err) } -} +} \ No newline at end of file diff --git a/testing/docker/docker-compose.integration.yml b/testing/docker/docker-compose.integration.yml index 0faeb76c..57638837 100644 --- a/testing/docker/docker-compose.integration.yml +++ b/testing/docker/docker-compose.integration.yml @@ -179,4 +179,4 @@ networks: - subnet: 172.28.0.0/16 volumes: - localstack-data: + localstack-data: \ No newline at end of file diff --git a/testing/docker/generate-certs.sh b/testing/docker/generate-certs.sh index 02f7f7bf..1c3069f8 100755 --- a/testing/docker/generate-certs.sh +++ b/testing/docker/generate-certs.sh @@ -1,5 +1,6 @@ #!/bin/bash -# Generate self-signed certificates for smocker TLS +# Generate self-signed certificates for integration test TLS proxy +# These certificates are used by nginx to proxy requests to mock services CERT_DIR="$(dirname "$0")/certs" mkdir -p "$CERT_DIR" @@ -7,13 +8,14 @@ mkdir -p "$CERT_DIR" # Generate private key openssl genrsa -out "$CERT_DIR/key.pem" 2048 2>/dev/null -# Generate self-signed certificate +# Generate self-signed certificate with all required SANs +# These hostnames match the nginx proxy configuration openssl req -new -x509 \ -key "$CERT_DIR/key.pem" \ -out "$CERT_DIR/cert.pem" \ -days 365 \ - -subj "/CN=api.nullplatform.com" \ - -addext "subjectAltName=DNS:api.nullplatform.com,DNS:localhost" \ + -subj "/CN=integration-test-proxy" \ + -addext "subjectAltName=DNS:api.nullplatform.com,DNS:management.azure.com,DNS:login.microsoftonline.com,DNS:devstoreaccount1.blob.core.windows.net,DNS:localhost" \ 2>/dev/null echo "Certificates generated in $CERT_DIR" diff --git a/testing/docker/nginx.conf b/testing/docker/nginx.conf index f3940af1..a0f6e687 100644 --- a/testing/docker/nginx.conf +++ b/testing/docker/nginx.conf @@ -80,4 +80,4 @@ http { proxy_set_header X-Forwarded-Proto $scheme; } } -} +} \ No newline at end of file diff --git a/testing/integration_helpers.sh b/testing/integration_helpers.sh index c8d620e3..aae40326 100755 --- a/testing/integration_helpers.sh +++ b/testing/integration_helpers.sh @@ -69,14 +69,14 @@ integration_setup() { # Parse arguments while [[ $# -gt 0 ]]; do case $1 in - --cloud-provider) - cloud_provider="$2" - shift 2 - ;; - *) - echo -e "${INTEGRATION_RED}Unknown argument: $1${INTEGRATION_NC}" - return 1 - ;; + --cloud-provider) + cloud_provider="$2" + shift 2 + ;; + *) + echo -e "${INTEGRATION_RED}Unknown argument: $1${INTEGRATION_NC}" + return 1 + ;; esac done @@ -88,14 +88,14 @@ integration_setup() { fi case "$cloud_provider" in - aws|azure|gcp) - INTEGRATION_CLOUD_PROVIDER="$cloud_provider" - ;; - *) - echo -e "${INTEGRATION_RED}Error: Unsupported cloud provider: $cloud_provider${INTEGRATION_NC}" - echo "Supported providers: aws, azure, gcp" - return 1 - ;; + aws|azure|gcp) + INTEGRATION_CLOUD_PROVIDER="$cloud_provider" + ;; + *) + echo -e "${INTEGRATION_RED}Error: Unsupported cloud provider: $cloud_provider${INTEGRATION_NC}" + echo "Supported providers: aws, azure, gcp" + return 1 + ;; esac export INTEGRATION_CLOUD_PROVIDER @@ -111,15 +111,15 @@ integration_setup() { # Call provider-specific setup case "$INTEGRATION_CLOUD_PROVIDER" in - aws) - _setup_aws - ;; - azure) - _setup_azure - ;; - gcp) - _setup_gcp - ;; + aws) + _setup_aws + ;; + azure) + _setup_azure + ;; + gcp) + _setup_gcp + ;; esac } @@ -129,15 +129,15 @@ integration_teardown() { # Call provider-specific teardown case "$INTEGRATION_CLOUD_PROVIDER" in - aws) - _teardown_aws - ;; - azure) - _teardown_azure - ;; - gcp) - _teardown_gcp - ;; + aws) + _teardown_aws + ;; + azure) + _teardown_azure + ;; + gcp) + _teardown_gcp + ;; esac } @@ -607,7 +607,7 @@ _setup_default_mocks() { } }] EOF -) + ) curl -s -X POST "${SMOCKER_HOST}/mocks" \ -H "Content-Type: application/json" \ -d "$token_mock" >/dev/null 2>&1 @@ -921,4 +921,4 @@ ENVIRONMENT VARIABLES ================================================================================ EOF -} +} \ No newline at end of file diff --git a/testing/run_integration_tests.sh b/testing/run_integration_tests.sh index 0a020f60..97af739f 100755 --- a/testing/run_integration_tests.sh +++ b/testing/run_integration_tests.sh @@ -34,15 +34,15 @@ VERBOSE="" for arg in "$@"; do case $arg in - --build) - BUILD_FLAG="--build" - ;; - -v|--verbose) - VERBOSE="--show-output-of-passing-tests" - ;; - *) - MODULE="$arg" - ;; + --build) + BUILD_FLAG="--build" + ;; + -v|--verbose) + VERBOSE="--show-output-of-passing-tests" + ;; + *) + MODULE="$arg" + ;; esac done @@ -220,4 +220,4 @@ if [ $TOTAL_FAILED -gt 0 ]; then exit 1 else echo -e "${GREEN}All integration tests passed!${NC}" -fi +fi \ No newline at end of file