diff --git a/.github/workflows/terraform-deploy.yml b/.github/workflows/terraform-deploy.yml new file mode 100644 index 0000000..06e5952 --- /dev/null +++ b/.github/workflows/terraform-deploy.yml @@ -0,0 +1,146 @@ +name: Terraform Deploy + +on: + push: + branches: [main, staging, develop] + + workflow_dispatch: + inputs: + import: + description: 'Import existing resources into Terraform state' + required: false + default: false + type: boolean + +permissions: + id-token: write + contents: read + pull-requests: write + +# ─────────────────────────────────────────────── +# DEVELOPMENT JOB +# ─────────────────────────────────────────────── +jobs: + deploy-development: + name: Deploy Infrastructure (development) + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/develop' + strategy: + fail-fast: false + matrix: + # env: [development, staging, production] + stack: [backend, frontend] + environment: development + + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: arn:aws:iam::${{ secrets.IAM_INFRA_ROLE_ID }}:role/flagging-infra-ci + aws-region: ${{ secrets.AWS_REGION }} + + - name: Verify AWS access + run: aws sts get-caller-identity + + - name: Set up Terraform + uses: hashicorp/setup-terraform@v3 + + - name: Set paths + id: paths + run: | + echo "env_dir=environments/development/${{ matrix.stack }}" >> $GITHUB_OUTPUT + echo "s3_key=environments/development/${{ matrix.stack }}/terraform.tfstate" >> $GITHUB_OUTPUT + + - name: Terraform Init + run: | + terraform -chdir=${{ steps.paths.outputs.env_dir }} init -reconfigure \ + -backend-config="bucket=${{ secrets.S3_BUCKET_NAME }}" \ + -backend-config="key=${{ steps.paths.outputs.s3_key }}" \ + -backend-config="region=${{ secrets.AWS_REGION }}" \ + -backend-config="encrypt=true" + + - name: Terraform Plan & Apply + run: | + terraform -chdir=${{ steps.paths.outputs.env_dir }} plan -out=tfplan + terraform -chdir=${{ steps.paths.outputs.env_dir }} apply -auto-approve tfplan + env: + TF_VAR_allowed_ssh_cidrs: ${{ secrets.ALLOWED_SSH_CIDRS }} + TF_VAR_allowed_api_cidrs: ${{ secrets.ALLOWED_API_CIDRS }} + TF_VAR_allowed_cms_cidrs: ${{ secrets.ALLOWED_CMS_CIDRS }} + TF_VAR_ghcr_token: ${{ secrets.GHCR_PAT }} + TF_VAR_admin_key: ${{ matrix.stack == 'backend' && secrets.ADMIN_KEY_DEV || '' }} + TF_VAR_sa_password: ${{ matrix.stack == 'backend' && secrets.SA_PASSWORD_DEV || '' }} + TF_VAR_redis_password: ${{ matrix.stack == 'backend' && secrets.REDIS_PASSWORD_DEV || '' }} + +# ─────────────────────────────────────────────── +# STAGING JOB +# ─────────────────────────────────────────────── + deploy-staging: + name: Deploy Infrastructure (staging) + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/staging' + strategy: + fail-fast: false + matrix: + stack: [backend, frontend] + environment: staging + + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: arn:aws:iam::${{ secrets.IAM_INFRA_ROLE_ID }}:role/flagging-infra-ci + aws-region: ${{ secrets.AWS_REGION }} + + - name: Setup Terraform + uses: hashicorp/setup-terraform@v3 + + - name: Terraform Plan + run: | + terraform -chdir=environments/staging/${{ matrix.stack }} init -reconfigure \ + -backend-config="bucket=${{ secrets.S3_BUCKET_NAME }}" \ + -backend-config="key=environments/staging/${{ matrix.stack }}/terraform.tfstate" \ + -backend-config="region=${{ secrets.AWS_REGION }}" \ + -backend-config="encrypt=true" + terraform -chdir=environments/staging/${{ matrix.stack }} plan -no-color + +# ─────────────────────────────────────────────── +# PRODUCTION JOB +# ─────────────────────────────────────────────── + deploy-production: + name: Deploy Infrastructure (production) + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/main' + strategy: + fail-fast: false + matrix: + stack: [backend, frontend] + environment: production + + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: arn:aws:iam::${{ secrets.IAM_INFRA_ROLE_ID }}:role/flagging-infra-ci + aws-region: ${{ secrets.AWS_REGION }} + + - name: Setup Terraform + uses: hashicorp/setup-terraform@v3 + + - name: Terraform Plan + run: | + terraform -chdir=environments/production/${{ matrix.stack }} init -reconfigure \ + -backend-config="bucket=${{ secrets.S3_BUCKET_NAME }}" \ + -backend-config="key=environments/production/${{ matrix.stack }}/terraform.tfstate" \ + -backend-config="region=${{ secrets.AWS_REGION }}" \ + -backend-config="encrypt=true" + terraform -chdir=environments/production/${{ matrix.stack }} plan -no-color diff --git a/.github/workflows/terraform-validate.yml b/.github/workflows/terraform-validate.yml new file mode 100644 index 0000000..aead7d1 --- /dev/null +++ b/.github/workflows/terraform-validate.yml @@ -0,0 +1,116 @@ +name: Terraform Validation + +on: + pull_request: + branches: [main, staging, develop] + + workflow_dispatch: {} + +permissions: + id-token: write + contents: read + pull-requests: write + +jobs: + terraform-validation: + name: Validate Terraform configuration + runs-on: ubuntu-latest + strategy: + matrix: + stack: [backend, frontend] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Terraform + uses: hashicorp/setup-terraform@v3 + + - name: Terraform Format Check + run: terraform fmt -check -recursive + + - name: Terraform Init (development env) + working-directory: environments/development/${{ matrix.stack }} + run: terraform init -backend=false + + - name: Terraform Validate + working-directory: environments/development/${{ matrix.stack }} + run: terraform validate + + - name: Terraform Lint + uses: terraform-linters/setup-tflint@v6 + with: + tflint_version: v0.52.0 + + - name: Run TFLint in stack dir + working-directory: environments/development/${{ matrix.stack }} + run: | + tflint --init + tflint + + - name: Summary + run: echo "Terraform syntax and lint checks completed successfully." + + terraform-plan: + name: Terraform Plan (${{ matrix.stack }}) + runs-on: ubuntu-latest + strategy: + matrix: + stack: [backend, frontend] + needs: terraform-validation + if: github.event_name == 'pull_request' + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v5 + with: + role-to-assume: arn:aws:iam::${{ secrets.IAM_INFRA_ROLE_ID }}:role/flagging-infra-ci + aws-region: ${{ secrets.AWS_REGION }} + + - name: Setup Terraform + uses: hashicorp/setup-terraform@v3 + + - name: Terraform Init (with backend) + working-directory: environments/development/${{ matrix.stack }} + run: terraform init -backend-config=backend.hcl + + - name: Terraform Plan (${{ matrix.stack }}) + id: plan + working-directory: environments/development/${{ matrix.stack }} + env: + TF_VAR_admin_key: ${{ matrix.stack == 'backend' && secrets.ADMIN_KEY_DEV || '' }} + TF_VAR_sa_password: ${{ matrix.stack == 'backend' && secrets.SA_PASSWORD_DEV || '' }} + TF_VAR_redis_password: ${{ matrix.stack == 'backend' && secrets.REDIS_PASSWORD_DEV || '' }} + TF_VAR_ghcr_token: ${{ secrets.GHCR_PAT }} + TF_VAR_allowed_ssh_cidrs: ${{ secrets.ALLOWED_SSH_CIDRS }} + TF_VAR_allowed_api_cidrs: ${{ secrets.ALLOWED_API_CIDRS }} + TF_VAR_allowed_cms_cidrs: ${{ secrets.ALLOWED_CMS_CIDRS }} + TF_LOG: DEBUG + run: terraform plan -no-color -out=tfplan + + + - name: Save Plan Output + working-directory: environments/development/${{ matrix.stack }} + run: terraform show -no-color tfplan > plan-${{ matrix.stack }}.txt + + - name: Upload Plan as Artifact + uses: actions/upload-artifact@v4 + with: + name: terraform-plan ${{ matrix.stack }} + path: environments/development/${{ matrix.stack }}/plan-${{ matrix.stack }}.txt + + - name: Comment Plan on PR + uses: marocchino/sticky-pull-request-comment@v2 + with: + header: "Terraform Plan – ${{ matrix.stack }}" + path: environments/development/${{ matrix.stack }}/plan-${{ matrix.stack }}.txt + + - name: Force unlock on failure + if: failure() + working-directory: environments/development/${{ matrix.stack }} + run: | + echo "Attempting to remove Terraform lock..." + terraform force-unlock -force $(terraform show -json | jq -r '.lock.id') || true diff --git a/.gitignore b/.gitignore index 6349e36..86319a3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Local .terraform directories .terraform/ +.terraform.lock.hcl # .tfstate files *.tfstate diff --git a/README.md b/README.md index 53953ff..1f5d2d1 100644 --- a/README.md +++ b/README.md @@ -1 +1,110 @@ -# flagging-infrastructure \ No newline at end of file +# Feature Flags Infrastructure + +This repository manages the **cloud infrastructure** for the **Feature Flags Platform**, which includes: + +- The **backend API** (built with .NET) +- The **frontend dashboard** (built with Vue.js) +- Supporting services such as **Redis** and **SQL Server** + +All infrastructure is defined using **Terraform (Infrastructure as Code)** and deployed to **AWS**. + +--- + +## Repository Structure +``` +feature-flags-infra/ +├── .github/workflows/ # CI/CD automation for Terraform and deployments +│ +├── bootstrap/ # One-time setup for remote Terraform state (S3 + DynamoDB to keep infrastructure state consistent) +│ +├── environments/ # Per-environment Terraform configurations +│ ├── development/ # Dev environment (testing, internal usage) +│ ├── staging/ # Staging environment (QA, integration) +│ └── production/ # Production environment (Production deployment) +│ +├── modules/ # Reusable Terraform modules +│ ├── compose/ # Handles Docker Compose deployments on EC2 +│ ├── compute-ec2/ # Provisions EC2 instances and security groups +│ ├── dns/ # Manages DNS records, SSL certs, and optional load balancer +│ ├── network/ # Creates VPCs, subnets, and networking resources +│ └── secrets/ # Manages sensitive data +│ +├── scripts/ # Helper shell scripts for rendering, deployment, and SSM commands +│ +└── templates/ # Template files (e.g., docker-compose.yaml) used for deployments +``` +--- + +## Key Concepts + +| **Component** | **Description** | +|--------------------|------------------------------------------------------------------------| +| **Terraform** | Used to define, provision, and manage AWS resources. | +| **AWS EC2** | Hosts Docker Compose deployments for API + Frontend containers. | +| **AWS SSM** | Enables secure, keyless remote commands and configuration. | +| **Docker Compose** | Orchestrates multi-container setup (API, Frontend, Redis, SQL Server). | +| **GitHub Actions** | Automates build, plan, and deploy workflows across environments. | + +--- + +## ⚙️ How It Works + +1. **API & Frontend Repositories** + - Build and push Docker images to **GitHub Container Registry (GHCR)**. + - Trigger a `repository_dispatch` event to this infrastructure repository. + +2. **Infrastructure Repository** + - Terraform provisions AWS resources per environment (**Development**, **Staging**, **Production**). + - AWS **SSM** executes deployment commands on EC2 instances such as: + + ```text + docker compose pull && docker compose up -d + ``` + +3. **Environment Isolation** + - Each environment has its own **Terraform state**, **variables**, and **resource set**. + - Promoting changes is done by merging `develop → staging → main`. + +--- + +## Environments and branches + +| **Environment** | **Branch** | **Purpose** | **Trigger** | +|------------------|------------|--------------|----------------------------------------| +| 🧪 Development | `develop` | Active feature testing | On merge to `develop` | +| 🚀 Staging | `staging` | QA and pre-production testing | On merge to `staging` | +| 🏆 Production | `main` | Live production deployment | On merge to `main` | + +--- + +## Typical Workflow + +### Bootstrap Terraform Remote State + +Used to create the S3 bucket and DynamoDB table for Terraform state management. + +```bash +cd bootstrap +terraform init +terraform apply -auto-approve + +cd environments/development +terraform init -backend-config=backend.hcl +terraform apply -auto-approve +``` + +## Promote to Staging / Production + +Merge develop → staging → main + +GitHub Actions automatically runs terraform apply for each environment + +## Notes for Contributors + +Each environment is fully isolated and can be applied independently. + +Never commit AWS credentials — use GitHub OIDC authentication for Terraform. + +Keep module logic reusable; environment folders should only contain configuration. + +Use tags (e.g., Project, Env) on all resources for cost tracking and organization. diff --git a/bootstrap/main.tf b/bootstrap/main.tf new file mode 100644 index 0000000..3a15d5d --- /dev/null +++ b/bootstrap/main.tf @@ -0,0 +1,42 @@ +terraform { + required_version = ">= 1.6.0" + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.0" + } + } +} + +provider "aws" { + region = var.aws_region +} + +resource "aws_s3_bucket" "tf_state" { + bucket = var.state_bucket_name +} + +resource "aws_s3_bucket_versioning" "tf_state" { + bucket = aws_s3_bucket.tf_state.id + versioning_configuration { status = "Enabled" } +} + +resource "aws_s3_bucket_server_side_encryption_configuration" "tf_state" { + bucket = aws_s3_bucket.tf_state.id + rule { + apply_server_side_encryption_by_default { + sse_algorithm = "AES256" + } + } +} + +resource "aws_dynamodb_table" "tf_lock" { + name = var.lock_table_name + billing_mode = "PAY_PER_REQUEST" + hash_key = "LockID" + + attribute { + name = "LockID" + type = "S" + } +} diff --git a/bootstrap/outputs.tf b/bootstrap/outputs.tf new file mode 100644 index 0000000..a57dabf --- /dev/null +++ b/bootstrap/outputs.tf @@ -0,0 +1,3 @@ +output "state_bucket" { value = aws_s3_bucket.tf_state.bucket } +output "lock_table" { value = aws_dynamodb_table.tf_lock.name } +output "region" { value = var.aws_region } \ No newline at end of file diff --git a/bootstrap/variables.tf b/bootstrap/variables.tf new file mode 100644 index 0000000..a324a09 --- /dev/null +++ b/bootstrap/variables.tf @@ -0,0 +1,14 @@ +variable "aws_region" { + type = string + default = "af-south-1" +} + +variable "state_bucket_name" { + type = string + default = "flagging-infra-tf-state-code-crafters" +} + +variable "lock_table_name" { + type = string + default = "flagging-infra-tf-locks" +} \ No newline at end of file diff --git a/environments/development/backend/backend.hcl b/environments/development/backend/backend.hcl new file mode 100644 index 0000000..e2344d1 --- /dev/null +++ b/environments/development/backend/backend.hcl @@ -0,0 +1,11 @@ +############################################## +# DEVELOPMENT ENVIRONMENT BACKEND +# env/dev/backend.hcl +############################################## + +bucket = "flagging-infra-tf-state-code-crafters" +dynamodb_table = "flagging-infra-tf-locks" +key = "environments/development/backend/terraform.tfstate" +region = "af-south-1" +use_lockfile = true +encrypt = true diff --git a/environments/development/backend/compute.tf b/environments/development/backend/compute.tf new file mode 100644 index 0000000..b0909ab --- /dev/null +++ b/environments/development/backend/compute.tf @@ -0,0 +1,14 @@ +############################################## +# DEVELOPMENT ENVIRONMENT OUTPUTS +# env/dev/compute.tf +############################################## +module "compute" { + source = "../../../modules/compute-ec2" + name_prefix = "ff-dev" + environment = "development" + environment_type = "frontend" + subnet_ids = module.network.public_subnet_ids + security_group_id = module.network.host_sg_id + key_name = "ff-dev-admin" + create_key_pair = true +} diff --git a/environments/development/backend/main.tf b/environments/development/backend/main.tf new file mode 100644 index 0000000..1a218a7 --- /dev/null +++ b/environments/development/backend/main.tf @@ -0,0 +1,25 @@ +############################################## +# DEVELOPMENT ENVIRONMENT INFRASTRUCTURE +# env/dev/main.tf +############################################## + +# S3 Bucket for Dev Testing +resource "random_id" "suffix" { + byte_length = 4 +} + +resource "aws_s3_bucket" "dev_test_bucket" { + bucket = "ff-dev-test-bucket-${random_id.suffix.hex}" + + tags = { + Name = "DevTestBucket" + Environment = "development" + ManagedBy = "Terraform" + } +} + +resource "aws_eip" "dev_app" { + instance = module.compute.instance_id + domain = "vpc" + tags = { Name = "ff-dev-eip" } +} diff --git a/environments/development/backend/network.tf b/environments/development/backend/network.tf new file mode 100644 index 0000000..027e863 --- /dev/null +++ b/environments/development/backend/network.tf @@ -0,0 +1,18 @@ +############################################## +# DEVELOPMENT ENVIRONMENT NETWORK +# env/dev/network.tf +############################################## + +module "network" { + source = "../../../modules/network" + name = "ff-dev" + environment = "development" + vpc_cidr = "10.10.0.0/16" + public_subnet_cidr_a = "10.10.1.0/24" + public_subnet_cidr_b = "10.10.2.0/24" + az_a = "af-south-1a" + az_b = "af-south-1b" + allowed_api_cidrs = var.allowed_api_cidrs + allowed_ssh_cidrs = var.allowed_ssh_cidrs + allowed_cms_cidrs = var.allowed_cms_cidrs +} diff --git a/environments/development/backend/outputs.tf b/environments/development/backend/outputs.tf new file mode 100644 index 0000000..589e34f --- /dev/null +++ b/environments/development/backend/outputs.tf @@ -0,0 +1,52 @@ +############################################## +# DEVELOPMENT ENVIRONMENT OUTPUTS +# env/dev/outputs.tf +############################################## +output "dev_backend_region" { + description = "Region where resources are deployed" + value = var.aws_region +} + +output "dev_backend_vpc_id" { + description = "VPC ID for development environment" + value = module.network.vpc_id +} + +output "dev_backend_public_subnets" { + description = "List of public subnet IDs" + value = module.network.public_subnet_ids +} + +output "dev_backend_instance_public_ip" { + description = "Public IP of the EC2 instance" + value = module.compute.instance_public_ip +} + +output "dev_backend_dev_test_bucket" { + description = "S3 bucket name for dev test bucket" + value = aws_s3_bucket.dev_test_bucket.bucket +} + +output "dev_backend_admin_key_arn" { + description = "ARN of the admin key parameter in SSM" + value = module.secrets.admin_key_arn +} + +output "dev_backend_sa_password" { + description = "SA password parameter in SSM" + value = module.secrets.sa_password_arn +} + +output "dev_backend_redis_password_arn" { + description = "Redis password parameter in SSM" + value = module.secrets.redis_password_arn +} + +output "dev_backend_ghcr_token_arn" { + description = "GHCR Token parameter in SSM" + value = module.secrets.ghcr_token_arn +} +output "dev_backend_elastic_ip" { + description = "Elastic IP address associated with the EC2 instance" + value = aws_eip.dev_app.public_ip +} diff --git a/environments/development/backend/providers.tf b/environments/development/backend/providers.tf new file mode 100644 index 0000000..8d325dc --- /dev/null +++ b/environments/development/backend/providers.tf @@ -0,0 +1,24 @@ +############################################## +# DEVELOPMENT ENVIRONMENT PROVIDERS +# env/dev/providers.tf +############################################## + +terraform { + required_version = ">= 1.6.0" + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.0" + } + random = { + source = "hashicorp/random" + version = "~> 3.0" + } + } + + backend "s3" {} +} + +provider "aws" { + region = var.aws_region +} diff --git a/environments/development/backend/secrets.tf b/environments/development/backend/secrets.tf new file mode 100644 index 0000000..f88858e --- /dev/null +++ b/environments/development/backend/secrets.tf @@ -0,0 +1,13 @@ +############################################## +# DEVELOPMENT ENVIRONMENT SECRETS +# env/dev/secrets.tf +############################################## + +module "secrets" { + source = "../../../modules/secrets" + backend_environment = "development" + sa_password = var.sa_password + admin_key = var.admin_key + redis_password = var.redis_password + ghcr_token = var.ghcr_token +} diff --git a/environments/development/backend/variables.tf b/environments/development/backend/variables.tf new file mode 100644 index 0000000..670da0f --- /dev/null +++ b/environments/development/backend/variables.tf @@ -0,0 +1,48 @@ +############################################## +# DEVELOPMENT ENVIRONMENT VARIABLES +# env/dev/variables.tf +############################################## +variable "aws_region" { + description = "AWS region to deploy resources" + type = string + default = "af-south-1" +} + +variable "sa_password" { + description = "SQL SA password for development" + type = string + sensitive = true +} + +variable "redis_password" { + description = "Redis password for development" + type = string + sensitive = true +} + +variable "admin_key" { + description = "Admin API key for development" + type = string + sensitive = true +} + +variable "ghcr_token" { + description = "GHCR token" + type = string + sensitive = true +} + +variable "allowed_ssh_cidrs" { + description = "List of CIDR blocks allowed to SSH into EC2 (22)" + type = list(string) +} + +variable "allowed_api_cidrs" { + description = "List of CIDR blocks allowed to reach API (8080)" + type = list(string) +} + +variable "allowed_cms_cidrs" { + description = "CIDR blocks allowed to access CMS (8081)" + type = list(string) +} diff --git a/environments/development/frontend/backend.hcl b/environments/development/frontend/backend.hcl new file mode 100644 index 0000000..66d690c --- /dev/null +++ b/environments/development/frontend/backend.hcl @@ -0,0 +1,11 @@ +############################################## +# DEVELOPMENT ENVIRONMENT BACKEND +# env/dev/frontend/backend.hcl +############################################## + +bucket = "flagging-infra-tf-state-code-crafters" +dynamodb_table = "flagging-infra-tf-locks" +key = "environments/development/frontend/terraform.tfstate" +region = "af-south-1" +use_lockfile = true +encrypt = true diff --git a/environments/development/frontend/compute.tf b/environments/development/frontend/compute.tf new file mode 100644 index 0000000..56fd04d --- /dev/null +++ b/environments/development/frontend/compute.tf @@ -0,0 +1,14 @@ +############################################## +# DEVELOPMENT ENVIRONMENT OUTPUTS +# env/dev/frontend/compute.tf +############################################## +module "compute" { + source = "../../../modules/compute-ec2" + name_prefix = "ff-dev-frontend" + environment = "development" + environment_type = "frontend" + subnet_ids = module.network.public_subnet_ids + security_group_id = module.network.host_sg_id + key_name = "ff-dev-admin" + create_key_pair = false +} diff --git a/environments/development/frontend/main.tf b/environments/development/frontend/main.tf new file mode 100644 index 0000000..f548eeb --- /dev/null +++ b/environments/development/frontend/main.tf @@ -0,0 +1,25 @@ +############################################## +# DEVELOPMENT ENVIRONMENT INFRASTRUCTURE +# env/dev/frontend/main.tf +############################################## + +# S3 Bucket for Dev Testing +resource "random_id" "suffix" { + byte_length = 4 +} + +resource "aws_s3_bucket" "dev_test_bucket" { + bucket = "ff-dev-frontend-test-bucket-${random_id.suffix.hex}" + + tags = { + Name = "DevFrontendTestBucket" + Environment = "development" + ManagedBy = "Terraform" + } +} + +resource "aws_eip" "dev_app" { + instance = module.compute.instance_id + domain = "vpc" + tags = { Name = "ff-dev-frontend-eip" } +} diff --git a/environments/development/frontend/network.tf b/environments/development/frontend/network.tf new file mode 100644 index 0000000..b93cf3e --- /dev/null +++ b/environments/development/frontend/network.tf @@ -0,0 +1,17 @@ +############################################## +# DEVELOPMENT ENVIRONMENT NETWORK +# env/dev/frontend/network.tf +############################################## + +module "network" { + source = "../../../modules/network" + name = "ff-dev-frontend" + environment = "development" + vpc_cidr = "10.10.0.0/16" + public_subnet_cidr_a = "10.10.1.0/24" + public_subnet_cidr_b = "10.10.2.0/24" + az_a = "af-south-1a" + az_b = "af-south-1b" + allowed_api_cidrs = var.allowed_api_cidrs + allowed_ssh_cidrs = var.allowed_ssh_cidrs +} diff --git a/environments/development/frontend/outputs.tf b/environments/development/frontend/outputs.tf new file mode 100644 index 0000000..552b97a --- /dev/null +++ b/environments/development/frontend/outputs.tf @@ -0,0 +1,37 @@ +############################################## +# DEVELOPMENT ENVIRONMENT OUTPUTS +# env/dev/frontend/outputs.tf +############################################## +output "dev_frontend_region" { + description = "Region where resources are deployed" + value = var.aws_region +} + +output "dev_frontend_vpc_id" { + description = "VPC ID for development environment" + value = module.network.vpc_id +} + +output "dev_frontend_public_subnets" { + description = "List of public subnet IDs" + value = module.network.public_subnet_ids +} + +output "dev_frontend_instance_public_ip" { + description = "Public IP of the EC2 instance" + value = module.compute.instance_public_ip +} + +output "dev_frontend_test_bucket" { + description = "S3 bucket name for dev frontend test bucket" + value = aws_s3_bucket.dev_test_bucket.bucket +} + +output "dev_frontend_ghcr_token_arn" { + description = "GHCR Token parameter in SSM" + value = module.secrets.ghcr_token_arn +} +output "dev_frontend_elastic_ip" { + description = "Elastic IP address associated with the EC2 instance" + value = aws_eip.dev_app.public_ip +} diff --git a/environments/development/frontend/providers.tf b/environments/development/frontend/providers.tf new file mode 100644 index 0000000..837bf9b --- /dev/null +++ b/environments/development/frontend/providers.tf @@ -0,0 +1,24 @@ +############################################## +# DEVELOPMENT ENVIRONMENT PROVIDERS +# env/dev/frontend/providers.tf +############################################## + +terraform { + required_version = ">= 1.6.0" + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.0" + } + random = { + source = "hashicorp/random" + version = "~> 3.0" + } + } + + backend "s3" {} +} + +provider "aws" { + region = var.aws_region +} diff --git a/environments/development/frontend/secrets.tf b/environments/development/frontend/secrets.tf new file mode 100644 index 0000000..85b3f4b --- /dev/null +++ b/environments/development/frontend/secrets.tf @@ -0,0 +1,10 @@ +############################################## +# DEVELOPMENT ENVIRONMENT SECRETS +# eenv/dev/frontend/secrets.tf +############################################## + +module "secrets" { + source = "../../../modules/secrets" + backend_environment = "development" + ghcr_token = var.ghcr_token +} diff --git a/environments/development/frontend/variables.tf b/environments/development/frontend/variables.tf new file mode 100644 index 0000000..be8c03c --- /dev/null +++ b/environments/development/frontend/variables.tf @@ -0,0 +1,25 @@ +############################################## +# DEVELOPMENT ENVIRONMENT VARIABLES +# env/dev/frontend/variables.tf +############################################## +variable "aws_region" { + description = "AWS region to deploy resources" + type = string + default = "af-south-1" +} + +variable "ghcr_token" { + description = "GHCR token" + type = string + sensitive = true +} + +variable "allowed_ssh_cidrs" { + description = "List of CIDR blocks allowed to SSH into EC2 (22)" + type = list(string) +} + +variable "allowed_api_cidrs" { + description = "List of CIDR blocks allowed to reach API (8080)" + type = list(string) +} diff --git a/modules/compute-ec2/main.tf b/modules/compute-ec2/main.tf new file mode 100644 index 0000000..863a4b7 --- /dev/null +++ b/modules/compute-ec2/main.tf @@ -0,0 +1,87 @@ +############################################## +# COMPUTE (EC2) MODULE +# modules/compute-ec2/main.tf +############################################## + +# Amazon Linux 2023 AMI (x86_64 architecture) +data "aws_ami" "al2023" { + most_recent = true + owners = ["amazon"] + + filter { + name = "name" + values = ["al2023-ami-*-x86_64"] + } + + filter { + name = "architecture" + values = ["x86_64"] + } + + filter { + name = "root-device-type" + values = ["ebs"] + } +} + +# IAM ROLE + INSTANCE PROFILE +resource "aws_iam_role" "ec2_role" { + name = "${var.name_prefix}-ec2-role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Principal = { Service = "ec2.amazonaws.com" } + Action = "sts:AssumeRole" + }] + }) + + tags = { + Name = "${var.name_prefix}-ec2-role" + } +} + +resource "aws_iam_role_policy_attachment" "ssm_core" { + role = aws_iam_role.ec2_role.name + policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore" +} + +resource "aws_iam_instance_profile" "ec2_profile" { + name = "${var.name_prefix}-ec2-profile" + role = aws_iam_role.ec2_role.name +} + +# EC2 INSTANCE +resource "aws_instance" "app_server" { + ami = data.aws_ami.al2023.id + instance_type = var.instance_type + subnet_id = element(var.subnet_ids, 0) + vpc_security_group_ids = [var.security_group_id] + associate_public_ip_address = true + iam_instance_profile = aws_iam_instance_profile.ec2_profile.name + key_name = var.key_name + user_data = templatefile("${path.module}/user-data.sh", { + ENVIRONMENT_TYPE = var.environment_type + }) + + tags = { + Name = "${var.name_prefix}-server" + Environment = var.environment + ManagedBy = "Terraform" + } +} + +# SSH KEY PAIR +resource "aws_key_pair" "dev_admin" { + count = var.create_key_pair ? 1 : 0 + key_name = var.key_name + public_key = file("${path.module}/../../ssh/ff-dev-admin.pub") + + tags = { + Name = var.key_name + Environment = var.environment + ManagedBy = "Terraform" + } +} + diff --git a/modules/compute-ec2/outputs.tf b/modules/compute-ec2/outputs.tf new file mode 100644 index 0000000..5407780 --- /dev/null +++ b/modules/compute-ec2/outputs.tf @@ -0,0 +1,29 @@ +############################################## +# COMPUTE (EC2) MODULE OUTPUTS +# modules/compute-ec2/outputs.tf +############################################## + +output "instance_id" { + description = "The ID of the EC2 instance" + value = aws_instance.app_server.id +} + +output "instance_public_ip" { + description = "The public IP address of the EC2 instance" + value = aws_instance.app_server.public_ip +} + +output "instance_ami_id" { + description = "The AMI ID used for this EC2 instance" + value = data.aws_ami.al2023.id +} + +output "iam_role_name" { + description = "IAM Role name attached to the EC2 instance" + value = aws_iam_role.ec2_role.name +} + +output "instance_profile_name" { + description = "Instance profile name for EC2" + value = aws_iam_instance_profile.ec2_profile.name +} diff --git a/modules/compute-ec2/user-data.sh b/modules/compute-ec2/user-data.sh new file mode 100644 index 0000000..0aaaf88 --- /dev/null +++ b/modules/compute-ec2/user-data.sh @@ -0,0 +1,114 @@ +#!/bin/bash +# ------------------------------------------------------------------ +# create users (superuser + normal users), installs Docker, +# enables SSH key-based access. +# ------------------------------------------------------------------ + +set -xe + +if [[ "$EUID" -ne 0 ]]; then + echo "This script must be run as root or with sudo." + exit 1 +fi + +echo "Creating 1GB swap file..." +fallocate -l 1G /swapfile +chmod 600 /swapfile +mkswap /swapfile +swapon /swapfile +echo '/swapfile none swap sw 0 0' | tee -a /etc/fstab +swapon --show +free -h + +# --- Install Docker manually for Amazon Linux 2023 --- +dnf update -y + +# Remove any previous versions +dnf remove -y docker docker-client docker-client-latest docker-common docker-latest docker-latest-logrotate docker-logrotate docker-engine || true + +# Add Docker’s Fedora 38 repo (compatible with AL2023) +cat <<'EOF' > /etc/yum.repos.d/docker-ce.repo +[docker-ce-stable] +name=Docker CE Stable - Fedora 38 +baseurl=https://download.docker.com/linux/fedora/38/x86_64/stable +enabled=1 +gpgcheck=1 +gpgkey=https://download.docker.com/linux/fedora/gpg +EOF + +# Install Docker and Compose plugin +dnf install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin +# Install git +dnf install -y git + +# Enable and start Docker +systemctl enable --now docker +usermod -aG docker ec2-user + +# Verify Docker and Compose are available +docker --version +docker compose version + +create_user() { + local username=$1 + local pubkey=$2 + local sudo_access=$3 + + if id "$username" &>/dev/null; then + echo "User '$username' already exists, updating SSH keys..." + else + useradd -m -s /bin/bash "$username" + echo "User '$username' created." + fi + + usermod -aG docker "$username" + + if [ "$sudo_access" = "true" ]; then + usermod -aG wheel "$username" + echo "$username ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/$username + fi + + mkdir -p /home/$username/.ssh + echo "$pubkey" > /home/$username/.ssh/authorized_keys + chmod 700 /home/$username/.ssh + chmod 600 /home/$username/.ssh/authorized_keys + chown -R $username:$username /home/$username/.ssh + + echo "SSH access configured for $username" +} + +ongeziwe_pubkey="ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDL0jT8kt8H2Dv7VWO28IAiu89xTZHrhEvBu8qZy2wMeoaEmyj168h7yQF+FoO/HGFxPvySnVjZ7IG9R+ZsFCPsJvd4AqAp9sZ8CBqNkPGpaxj1ytOicluQOtZF/IhW0Q6xlwn0WgEI/myFwj51rAFAfuNm+sqPm7b6W2SRGcr06p67LYsGi0BPdVcgqo0x1LscT+y6Zi7GtyZOoWKZTTsMeSrN1qxnnT21viIc6N/KZjIqomt9Qp7f17GemjC0pu2m0x8KOa/iruyHGY/aAJpEb2RSU0dP5fAe4zq/8qnwBtyrhe0L6/2Npm6ssU1Jah2qt89xVPg62NjcRSJQKyukV1WqFLXDX17ZBwXTGvKPz8PHk7Ve2yHRD15DOTuwIPMLvJ6AoFTSyhJd+NXFK9nPIYB4Dy3SrxsjVm+DHw1zXn7CXN7EoaGEEp+kUEnSxXmxmlb3/iBTE1+Rckma4niwqWWJUEIwqNRbPCph8qtI3WfuXpCLtUsYJ47135Plowbe0p7tBo+lw6v9LS79qpS5oxqbXTm9KFabOk0XssIX6WFFvI3BZx0+Ol5MIW0bN+FKcQ2ktXGK3m/ZqTR6z26zP0lGxLguqWAGgz7hr3Yi7gfyMrF4NhhztPFNDAhRzdrm8sbu/ouGxpShEdcq33fEleEQjOES1L1PRRlk2CD6vQ== hollywoodbets\ongeziwem@ZAWC-BET-3FDD8I" + +# normal users +# teammate1_pubkey="ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQD...TEAMMATE1_KEY" + +create_user "ongeziwe" "$ongeziwe_pubkey" "true" +# create_user "teammate1" "$teammate1_pubkey" "false" + +# --- api directory --- +APP_DIR="/app/flagging-api" +mkdir -p $APP_DIR +chmod 775 /app +chmod 775 /app/flagging-api + +# group ownership to 'docker' for collaborative work +chown root:docker /app +chown root:docker /app/flagging-api +chown -R ongeziwe:docker /app/flagging-api +chmod g+s /app /app/flagging-api + +sed -i 's/^#PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config +sed -i 's/^PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config +sed -i 's/^#PermitRootLogin.*/PermitRootLogin no/' /etc/ssh/sshd_config +sed -i 's/^PermitRootLogin.*/PermitRootLogin no/' /etc/ssh/sshd_config +systemctl restart sshd + +# ------- Frontend-only bit (Node.js + npm) ------- +if [[ "${ENVIRONMENT_TYPE}" == "frontend" ]]; then + # Node 22 on AL2023 + curl -fsSL https://rpm.nodesource.com/setup_22.x | bash - + dnf install -y nodejs + npm install -g npm@latest +fi + +echo "Bootstrap complete. Users ready for SSH access." >> /var/log/userdata-bootstrap.log diff --git a/modules/compute-ec2/variables.tf b/modules/compute-ec2/variables.tf new file mode 100644 index 0000000..106cf1c --- /dev/null +++ b/modules/compute-ec2/variables.tf @@ -0,0 +1,55 @@ +############################################## +# COMPUTE (EC2) MODULE VARIABLES +# modules/compute-ec2/variables.tf +############################################## + +variable "name_prefix" { + description = "Prefix used for naming resources (e.g., ff-dev)" + type = string +} + +variable "environment" { + description = "Environment name (e.g., development, staging, production)" + type = string +} + +variable "environment_type" { + description = "Type of environment to configure (backend or frontend)" + type = string + default = "backend" +} + +variable "instance_type" { + description = "EC2 instance type for compute environment" + type = string + default = "t3.small" +} + +variable "subnet_ids" { + description = "List of subnet IDs for placing the EC2 instance" + type = list(string) +} + +variable "security_group_id" { + description = "Security group ID for the EC2 instance" + type = string +} + +variable "key_name" { + description = "EC2 key pair name for SSH access" + type = string + default = null +} + +variable "user_data_script" { + description = "User data script for EC2 instance initialization" + type = string + default = "" +} + +variable "create_key_pair" { + type = bool + default = true + description = "Whether to create the key pair or just reuse an existing one" +} + diff --git a/modules/network/main.tf b/modules/network/main.tf new file mode 100644 index 0000000..07c0918 --- /dev/null +++ b/modules/network/main.tf @@ -0,0 +1,133 @@ +############################################## +# NETWORK MODULE (Updated for Multi-Env + Security) +# modules/network/main.tf +############################################## + +resource "aws_vpc" "this" { + cidr_block = var.vpc_cidr + enable_dns_support = true + enable_dns_hostnames = true + + tags = { + Name = "${var.name}-vpc" + Environment = var.environment + } +} + +# Internet Gateway +resource "aws_internet_gateway" "igw" { + vpc_id = aws_vpc.this.id + tags = { Name = "${var.name}-igw" } +} + +# Public Subnet A +resource "aws_subnet" "public_a" { + vpc_id = aws_vpc.this.id + cidr_block = var.public_subnet_cidr_a + map_public_ip_on_launch = true + availability_zone = var.az_a + + tags = { Name = "${var.name}-public-a" } +} + +# Public Subnet B +resource "aws_subnet" "public_b" { + vpc_id = aws_vpc.this.id + cidr_block = var.public_subnet_cidr_b + map_public_ip_on_launch = true + availability_zone = var.az_b + + tags = { Name = "${var.name}-public-b" } +} + +# Public Route Table + Default Route +resource "aws_route_table" "public" { + vpc_id = aws_vpc.this.id + + route { + cidr_block = "0.0.0.0/0" + gateway_id = aws_internet_gateway.igw.id + } + + tags = { Name = "${var.name}-public-rt" } +} + +# Associate Subnets with Route Table +resource "aws_route_table_association" "a" { + subnet_id = aws_subnet.public_a.id + route_table_id = aws_route_table.public.id +} + +# Route Table Associations b +resource "aws_route_table_association" "b" { + subnet_id = aws_subnet.public_b.id + route_table_id = aws_route_table.public.id +} + +#--------------------------------------------- +# SECURITY GROUP (param-driven, safer setup) +#--------------------------------------------- +resource "aws_security_group" "host" { + name = "${var.name}-host-sg" + description = "Allow limited SSH and web/API access" + vpc_id = aws_vpc.this.id + + # Controlled SSH Access (team IPs) + ingress { + from_port = 22 + to_port = 22 + protocol = "tcp" + cidr_blocks = var.allowed_ssh_cidrs + description = "SSH access (restricted)" + } + + # HTTP (for testing / nginx reverse proxy) + ingress { + from_port = 80 + to_port = 80 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + description = "HTTP (public)" + } + + # HTTPS (for secure web) + ingress { + from_port = 443 + to_port = 443 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + description = "HTTPS (public)" + } + + # API / Dev-only access + ingress { + from_port = 8080 + to_port = 8080 + protocol = "tcp" + cidr_blocks = var.allowed_api_cidrs + description = "API (restricted to team or open during dev)" + } + + # CMS Access (restricted) + ingress { + from_port = 8081 + to_port = 8081 + protocol = "tcp" + cidr_blocks = var.allowed_cms_cidrs + description = "CMS Access (restricted)" + } + + # Egress - Allow all outbound + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = { + Name = "${var.name}-host-sg" + Environment = var.environment + ManagedBy = "Terraform" + } +} diff --git a/modules/network/outputs.tf b/modules/network/outputs.tf new file mode 100644 index 0000000..cea0747 --- /dev/null +++ b/modules/network/outputs.tf @@ -0,0 +1,8 @@ +############################################## +# NETWORK MODULE OUTPUTS +# modules/network/outputs.tf +############################################## + +output "vpc_id" { value = aws_vpc.this.id } +output "public_subnet_ids" { value = [aws_subnet.public_a.id, aws_subnet.public_b.id] } +output "host_sg_id" { value = aws_security_group.host.id } diff --git a/modules/network/variables.tf b/modules/network/variables.tf new file mode 100644 index 0000000..3502379 --- /dev/null +++ b/modules/network/variables.tf @@ -0,0 +1,30 @@ +############################################## +# NETWORK MODULE VARIABLES +# modules/network/variables.tf +############################################## + +variable "name" { type = string } +variable "environment" { type = string } +variable "vpc_cidr" { type = string } +variable "public_subnet_cidr_a" { type = string } +variable "public_subnet_cidr_b" { type = string } +variable "az_a" { type = string } +variable "az_b" { type = string } + +variable "allowed_ssh_cidrs" { + type = list(string) + description = "List of CIDR blocks allowed to SSH into EC2 (22)" + default = ["0.0.0.0/0"] +} + +variable "allowed_api_cidrs" { + type = list(string) + description = "List of CIDR blocks allowed to reach API (8080)" + default = ["0.0.0.0/0"] +} + +variable "allowed_cms_cidrs" { + type = list(string) + description = "CIDR blocks allowed to access CMS (8081)" + default = ["0.0.0.0/0"] +} diff --git a/modules/secrets/main.tf b/modules/secrets/main.tf new file mode 100644 index 0000000..94e8537 --- /dev/null +++ b/modules/secrets/main.tf @@ -0,0 +1,52 @@ +############################################## +# SECRETS MODULE +# modules/secrets/main.tf +############################################## + +resource "aws_ssm_parameter" "sa_password" { + count = var.sa_password == null ? 0 : 1 + name = "/ff/dev/SA_PASSWORD" + type = "SecureString" + value = var.sa_password + overwrite = true + tags = { + Environment = var.backend_environment + ManagedBy = "Terraform" + } +} + +resource "aws_ssm_parameter" "admin_key" { + count = var.admin_key == null ? 0 : 1 + name = "/ff/dev/ADMIN_KEY" + type = "SecureString" + value = var.admin_key + overwrite = true + tags = { + Environment = var.backend_environment + ManagedBy = "Terraform" + } +} + +resource "aws_ssm_parameter" "redis_password" { + count = var.redis_password == null ? 0 : 1 + name = "/ff/dev/REDIS_PASSWORD" + type = "SecureString" + value = var.redis_password + overwrite = true + tags = { + Environment = var.backend_environment + ManagedBy = "Terraform" + } +} + +resource "aws_ssm_parameter" "ghcr_token" { + count = var.ghcr_token == null ? 0 : 1 + name = "/ff/dev/GHCR_PAT" + type = "SecureString" + value = var.ghcr_token + overwrite = true + tags = { + Environment = var.backend_environment + ManagedBy = "Terraform" + } +} diff --git a/modules/secrets/outputs.tf b/modules/secrets/outputs.tf new file mode 100644 index 0000000..f0b6a9c --- /dev/null +++ b/modules/secrets/outputs.tf @@ -0,0 +1,24 @@ +############################################## +# SECRETS MODULE OUTPUTS +# modules/secrets/outputs.tf +############################################## + +output "admin_key_arn" { + description = "ARN of the Admin key parameter (if created)" + value = try(aws_ssm_parameter.admin_key[0].arn, null) +} + +output "sa_password_arn" { + description = "ARN of the SA password parameter (if created)" + value = try(aws_ssm_parameter.sa_password[0].arn, null) +} + +output "redis_password_arn" { + description = "ARN of the Redis password parameter (if created)" + value = try(aws_ssm_parameter.redis_password[0].arn, null) +} + +output "ghcr_token_arn" { + description = "ARN of the GHCR token parameter (if created)" + value = try(aws_ssm_parameter.ghcr_token[0].arn, null) +} diff --git a/modules/secrets/variables.tf b/modules/secrets/variables.tf new file mode 100644 index 0000000..58154aa --- /dev/null +++ b/modules/secrets/variables.tf @@ -0,0 +1,29 @@ +############################################## +# SECRETS MODULE VARIABLES +# modules/secrets/variables.tf +############################################## + +variable "sa_password" { + type = string + nullable = true + default = null +} +variable "admin_key" { + type = string + nullable = true + default = null +} +variable "redis_password" { + type = string + nullable = true + default = null +} +variable "ghcr_token" { + type = string + nullable = true + default = null +} +variable "backend_environment" { + type = string + description = "env label for SSM path tags" +} \ No newline at end of file diff --git a/ssh/ff-dev-admin.pub b/ssh/ff-dev-admin.pub new file mode 100644 index 0000000..8cc725c --- /dev/null +++ b/ssh/ff-dev-admin.pub @@ -0,0 +1 @@ +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDL0jT8kt8H2Dv7VWO28IAiu89xTZHrhEvBu8qZy2wMeoaEmyj168h7yQF+FoO/HGFxPvySnVjZ7IG9R+ZsFCPsJvd4AqAp9sZ8CBqNkPGpaxj1ytOicluQOtZF/IhW0Q6xlwn0WgEI/myFwj51rAFAfuNm+sqPm7b6W2SRGcr06p67LYsGi0BPdVcgqo0x1LscT+y6Zi7GtyZOoWKZTTsMeSrN1qxnnT21viIc6N/KZjIqomt9Qp7f17GemjC0pu2m0x8KOa/iruyHGY/aAJpEb2RSU0dP5fAe4zq/8qnwBtyrhe0L6/2Npm6ssU1Jah2qt89xVPg62NjcRSJQKyukV1WqFLXDX17ZBwXTGvKPz8PHk7Ve2yHRD15DOTuwIPMLvJ6AoFTSyhJd+NXFK9nPIYB4Dy3SrxsjVm+DHw1zXn7CXN7EoaGEEp+kUEnSxXmxmlb3/iBTE1+Rckma4niwqWWJUEIwqNRbPCph8qtI3WfuXpCLtUsYJ47135Plowbe0p7tBo+lw6v9LS79qpS5oxqbXTm9KFabOk0XssIX6WFFvI3BZx0+Ol5MIW0bN+FKcQ2ktXGK3m/ZqTR6z26zP0lGxLguqWAGgz7hr3Yi7gfyMrF4NhhztPFNDAhRzdrm8sbu/ouGxpShEdcq33fEleEQjOES1L1PRRlk2CD6vQ== hollywoodbets\ongeziwem@ZAWC-BET-3FDD8I