diff --git a/.dockerignore b/.dockerignore index 5635d29..4ed9321 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,9 +1,25 @@ -.venv -.pytest-cache -.vscode -.coverage -dpaste.db -build -dist -dpaste.egg-info +.git +.github node_modules +*.pyc +__pycache__ +.pytest_cache +.tox +*.egg-info +dist +build +.venv +venv +*.sqlite +Dockerfile* +docker-compose* +.dockerignore +.travis.yml +.gitattributes +docs +terraform +monitoring +scripts +*.md +!setup.cfg +!README.md \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..27efceb --- /dev/null +++ b/.gitattributes @@ -0,0 +1,13 @@ +* text=auto +*.py text eol=lf +*.cfg text eol=lf +*.txt text eol=lf +*.md text eol=lf +*.yml text eol=lf +*.yaml text eol=lf +*.sh text eol=lf +*.json text eol=lf +Dockerfile text eol=lf +Makefile text eol=lf +*.tf text eol=lf +*.hcl text eol=lf diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml new file mode 100644 index 0000000..ef39b2b --- /dev/null +++ b/.github/workflows/cd.yml @@ -0,0 +1,177 @@ +# ============================================================ +# CloudPulse - CD pipeline +# Build -> Trivy gate -> push to ECR -> deploy to EC2 via SSM Run Command +# -> HTTPS smoke test. Runs on merges to master that change the app, +# the image or the deploy script; can also be started manually. +# ============================================================ +name: CD + +on: + push: + branches: [master] + paths: + - "dpaste/**" + - "client/**" + - "Dockerfile.hardened" + - "setup.py" + - "setup.cfg" + - "package.json" + - "package-lock.json" + - "scripts/deploy.sh" + - ".github/workflows/cd.yml" + workflow_dispatch: + +# Never run two deployments at once; queue instead of cancelling one mid-way +concurrency: + group: cd-production + cancel-in-progress: false + +permissions: + contents: read + +env: + AWS_REGION: us-east-1 + ECR_REPOSITORY: cloudpulse + INSTANCE_NAME: cloudpulse-server + +jobs: + build-scan-push: + name: Build, scan, push + runs-on: ubuntu-latest + outputs: + image_tag: ${{ steps.meta.outputs.tag }} + steps: + - uses: actions/checkout@v4 + + - name: Image tag = short commit SHA + id: meta + run: echo "tag=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT" + + # AWS Academy session credentials (OIDC is blocked in the Learner Lab). + # They expire with the lab session; refreshed by scripts/refresh-github-aws-secrets.ps1 + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-session-token: ${{ secrets.AWS_SESSION_TOKEN }} + aws-region: ${{ env.AWS_REGION }} + + - name: Log in to Amazon ECR + id: ecr + uses: aws-actions/amazon-ecr-login@v2 + + - uses: docker/setup-buildx-action@v3 + + - name: Build image + uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfile.hardened + load: true + push: false + provenance: false + sbom: false + tags: ${{ steps.ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:${{ steps.meta.outputs.tag }} + + # Same policy as CI: fail on fixable CRITICAL/HIGH. Nothing is pushed if this fails. + - name: Trivy scan (release gate) + uses: aquasecurity/trivy-action@master + with: + image-ref: ${{ steps.ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:${{ steps.meta.outputs.tag }} + format: table + exit-code: "1" + severity: CRITICAL,HIGH + ignore-unfixed: true + trivyignores: .trivyignore + + # ECR tags are immutable: a re-run for the same commit must not fail on push + - name: Push image to ECR + run: | + TAG="${{ steps.meta.outputs.tag }}" + if aws ecr describe-images --repository-name "$ECR_REPOSITORY" --image-ids imageTag="$TAG" >/dev/null 2>&1; then + echo "Tag $TAG already exists in ECR (immutable) - skipping push" + else + docker push "${{ steps.ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:$TAG" + fi + + deploy: + name: Deploy to EC2 via SSM + needs: build-scan-push + runs-on: ubuntu-latest + environment: + name: production + url: ${{ steps.deploy.outputs.app_url }} + steps: + - uses: actions/checkout@v4 + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-session-token: ${{ secrets.AWS_SESSION_TOKEN }} + aws-region: ${{ env.AWS_REGION }} + + - name: Deploy via SSM Run Command + id: deploy + env: + IMAGE_TAG: ${{ needs.build-scan-push.outputs.image_tag }} + run: | + set -euo pipefail + INSTANCE_ID=$(aws ec2 describe-instances \ + --filters "Name=tag:Name,Values=${INSTANCE_NAME}" "Name=instance-state-name,Values=running" \ + --query "Reservations[0].Instances[0].InstanceId" --output text) + if [ -z "$INSTANCE_ID" ] || [ "$INSTANCE_ID" = "None" ]; then + echo "::error::No running instance tagged ${INSTANCE_NAME}"; exit 1 + fi + PUBLIC_IP=$(aws ec2 describe-instances --instance-ids "$INSTANCE_ID" \ + --query "Reservations[0].Instances[0].PublicIpAddress" --output text) + echo "Deploying tag ${IMAGE_TAG} to ${INSTANCE_ID} (${PUBLIC_IP})" + + # Ship this commit's deploy.sh with the command, so the instance + # always runs the reviewed version from Git. + SCRIPT_B64=$(base64 -w0 scripts/deploy.sh) + jq -n --arg b64 "$SCRIPT_B64" --arg tag "$IMAGE_TAG" '{commands: [ + "set -e", + "echo \($b64) | base64 -d > /opt/cloudpulse/deploy.sh", + "chmod 0755 /opt/cloudpulse/deploy.sh", + "/opt/cloudpulse/deploy.sh \($tag)" + ]}' > ssm-params.json + + CMD_ID=$(aws ssm send-command \ + --instance-ids "$INSTANCE_ID" \ + --document-name AWS-RunShellScript \ + --comment "CloudPulse deploy ${IMAGE_TAG}" \ + --parameters file://ssm-params.json \ + --query Command.CommandId --output text) + echo "SSM command ID: ${CMD_ID}" + + # Poll until the command finishes (max 5 minutes) + STATUS=Pending + for _ in $(seq 1 60); do + sleep 5 + STATUS=$(aws ssm get-command-invocation --command-id "$CMD_ID" \ + --instance-id "$INSTANCE_ID" --query Status --output text 2>/dev/null || echo Pending) + case "$STATUS" in Pending|InProgress|Delayed) continue ;; *) break ;; esac + done + + echo "----- deploy.sh output -----" + aws ssm get-command-invocation --command-id "$CMD_ID" --instance-id "$INSTANCE_ID" \ + --query StandardOutputContent --output text || true + echo "----- stderr -----" + aws ssm get-command-invocation --command-id "$CMD_ID" --instance-id "$INSTANCE_ID" \ + --query StandardErrorContent --output text || true + echo "SSM status: ${STATUS}" + if [ "$STATUS" != "Success" ]; then + echo "::error::Deployment failed with status ${STATUS}"; exit 1 + fi + + echo "app_url=https://${PUBLIC_IP//./-}.sslip.io" >> "$GITHUB_OUTPUT" + + - name: Smoke test over HTTPS + env: + APP_URL: ${{ steps.deploy.outputs.app_url }} + run: | + curl -sS --fail --retry 10 --retry-delay 5 --retry-all-errors \ + -o /dev/null -w "GET / -> HTTP %{http_code} in %{time_total}s\n" "${APP_URL}/" \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..fc56319 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,66 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.10" + + - name: Install dependencies + run: pip install -e ".[dev]" + + - name: Run tests with coverage + run: pytest dpaste/ --tb=short -q + + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.10" + + - name: Install ruff + run: pip install ruff + + - name: Run linter + run: ruff check dpaste/ + continue-on-error: true + + docker-build-scan: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build hardened image + uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfile.hardened + push: false + load: true + tags: cloudpulse:ci-${{ github.sha }} + + - name: Run Trivy vulnerability scanner + uses: aquasecurity/trivy-action@master + with: + image-ref: cloudpulse:ci-${{ github.sha }} + format: table + exit-code: 1 + severity: CRITICAL,HIGH + ignore-unfixed: true + trivyignores: .trivyignore \ No newline at end of file diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml.disabled similarity index 100% rename from .github/workflows/docker.yml rename to .github/workflows/docker.yml.disabled diff --git a/.github/workflows/terraform.yml b/.github/workflows/terraform.yml new file mode 100644 index 0000000..2f38b97 --- /dev/null +++ b/.github/workflows/terraform.yml @@ -0,0 +1,161 @@ +# ============================================================ +# CloudPulse - Terraform pipeline +# PR: fmt -> validate -> Checkov IaC scan -> plan (shown in the run summary) +# Merge: plan -> MANUAL APPROVAL (environment "infrastructure") -> apply the exact saved plan +# ============================================================ +name: Terraform + +on: + pull_request: + branches: [master] + paths: + - "terraform/**" + - ".github/workflows/terraform.yml" + push: + branches: [master] + paths: + - "terraform/**" + - ".github/workflows/terraform.yml" + workflow_dispatch: + +# One Terraform run at a time (the S3 lock protects state; this avoids runs colliding on it) +concurrency: + group: terraform + cancel-in-progress: false + +permissions: + contents: read + +env: + TF_VERSION: "1.16.2" + TF_IN_AUTOMATION: "true" + TF_INPUT: "false" + AWS_REGION: us-east-1 + TF_VAR_allowed_ssh_cidr: ${{ secrets.TF_VAR_ALLOWED_SSH_CIDR }} + +defaults: + run: + working-directory: terraform + +jobs: + static-checks: + name: fmt, validate, Checkov + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: hashicorp/setup-terraform@v3 + with: + terraform_version: ${{ env.TF_VERSION }} + terraform_wrapper: false + + - name: terraform fmt + run: terraform fmt -check -recursive -diff + + - name: terraform validate + run: | + terraform init -backend=false + terraform validate + + # Fails on any finding that is neither fixed nor skipped with a justification in the code + - name: Checkov IaC security scan + uses: bridgecrewio/checkov-action@v12 + with: + directory: terraform + framework: terraform + quiet: true + + plan: + name: Plan + needs: static-checks + runs-on: ubuntu-latest + outputs: + has_changes: ${{ steps.plan.outputs.has_changes }} + steps: + - uses: actions/checkout@v4 + + - uses: hashicorp/setup-terraform@v3 + with: + terraform_version: ${{ env.TF_VERSION }} + terraform_wrapper: false + + # AWS Academy session credentials (OIDC is blocked in the Learner Lab) + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-session-token: ${{ secrets.AWS_SESSION_TOKEN }} + aws-region: ${{ env.AWS_REGION }} + + - name: terraform init (S3 backend) + run: terraform init + + - name: terraform plan + id: plan + run: | + set +e + terraform plan -out=tfplan -no-color -lock-timeout=120s -detailed-exitcode > plan.txt 2>&1 + code=$? + set -e + cat plan.txt + if [ "$code" -eq 1 ]; then exit 1; fi + if [ "$code" -eq 2 ]; then echo "has_changes=true" >> "$GITHUB_OUTPUT"; else echo "has_changes=false" >> "$GITHUB_OUTPUT"; fi + + - name: Plan summary + run: | + { + echo "## Terraform plan" + echo '```' + grep -E '^(Plan:|No changes)|^ # ' plan.txt || true + echo '```' + echo "
Full plan" + echo + echo '```' + cat plan.txt + echo '```' + echo "
" + } >> "$GITHUB_STEP_SUMMARY" + + # The apply job applies exactly this reviewed plan (short retention: plan files can contain sensitive values) + - name: Upload plan + if: github.event_name != 'pull_request' && steps.plan.outputs.has_changes == 'true' + uses: actions/upload-artifact@v4 + with: + name: tfplan + path: terraform/tfplan + retention-days: 1 + + apply: + name: Apply (manual approval) + needs: plan + if: github.event_name != 'pull_request' && github.ref == 'refs/heads/master' && needs.plan.outputs.has_changes == 'true' + runs-on: ubuntu-latest + environment: infrastructure + steps: + - uses: actions/checkout@v4 + + - uses: hashicorp/setup-terraform@v3 + with: + terraform_version: ${{ env.TF_VERSION }} + terraform_wrapper: false + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-session-token: ${{ secrets.AWS_SESSION_TOKEN }} + aws-region: ${{ env.AWS_REGION }} + + - name: terraform init (S3 backend) + run: terraform init + + - name: Download reviewed plan + uses: actions/download-artifact@v4 + with: + name: tfplan + path: terraform + + - name: terraform apply (exact saved plan) + run: terraform apply -no-color -lock-timeout=120s tfplan \ No newline at end of file diff --git a/.gitignore b/.gitignore index b69a35d..bd967a6 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,6 @@ dpaste/static/ dpaste.egg-info dpaste.sqlite node_modules -**/__pycache__/ \ No newline at end of file +**/__pycache__/ +# Generated monitoring target (changes with the EC2 IP) +monitoring/prometheus/targets.d/*.yml diff --git a/.trivyignore b/.trivyignore new file mode 100644 index 0000000..6c51fb1 --- /dev/null +++ b/.trivyignore @@ -0,0 +1,8 @@ +# Python vulnerabilities vendored inside setuptools — cannot be +# independently upgraded without forking the base image's Python. +# These are build-tool internals, not application runtime code. +# Tracked: will be resolved when python:3.10-slim updates setuptools. +CVE-2026-23949 +CVE-2026-24049 +CVE-2025-47273 +GHSA-6v7p-g79w-8964 \ No newline at end of file diff --git a/Dockerfile.hardened b/Dockerfile.hardened new file mode 100644 index 0000000..e633a69 --- /dev/null +++ b/Dockerfile.hardened @@ -0,0 +1,80 @@ +# ============================================================ +# CloudPulse — Hardened multi-stage Dockerfile +# Original application: dpaste (MIT License, DarrenOfficial) +# Infrastructure/containerization: Rayen Mabrouk +# ============================================================ + +# Stage 1: Build static files (CSS/JS) with Node +FROM node:lts-slim AS staticfiles + +WORKDIR /app +RUN apt-get update && apt-get install -y --no-install-recommends make && rm -rf /var/lib/apt/lists/* +COPY package.json package-lock.json Makefile ./ +RUN npm ci --ignore-scripts +COPY client ./client +RUN mkdir -p dpaste/static && make css && make js + +# Stage 2: Build Python application +FROM python:3.10-slim AS build + +WORKDIR /app + +# Install build dependencies needed for compiling Python packages +RUN apt-get update && \ + apt-get install -y --no-install-recommends gcc libpq-dev && \ + rm -rf /var/lib/apt/lists/* + +RUN pip install --no-cache-dir -U pip + +# Copy static files from Stage 1 +COPY --from=staticfiles /app /app/ + +# Install Python dependencies +COPY setup.py setup.cfg ./ +COPY dpaste/__init__.py dpaste/ +RUN pip install --no-cache-dir -e .[production] + +# Copy application code +COPY . . + +# Collect static files +RUN python manage.py collectstatic --noinput + +# Clean up build artifacts not needed at runtime +RUN rm -rf node_modules + +# Stage 3: Production runtime +FROM python:3.10-slim AS runtime + +# Install runtime dependencies and curl for health check +RUN apt-get update && \ + apt-get install -y --no-install-recommends libpq5 curl libexpat1 && \ + rm -rf /var/lib/apt/lists/* + +# Create non-root user +RUN groupadd -r dpaste && useradd -r -g dpaste -d /app -s /sbin/nologin dpaste + +WORKDIR /app + +# Copy the entire app with installed packages from build stage +COPY --from=build /usr/local/lib/python3.10/site-packages /usr/local/lib/python3.10/site-packages +COPY --from=build /usr/local/bin /usr/local/bin +COPY --from=build /app /app + +# Create directory for SQLite database and set permissions +RUN mkdir -p /data && chown -R dpaste:dpaste /app /data + +ENV PORT=8000 +ENV DATABASE_URL=sqlite:////data/dpaste.sqlite + +# Switch to non-root user +USER dpaste + +EXPOSE ${PORT} + +# Health check +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD curl -f http://localhost:${PORT}/ || exit 1 + +# Use exec form for proper signal handling +CMD ["sh", "-c", "python manage.py migrate --noinput && python manage.py pyuwsgi --http=:${PORT} --logger file:/dev/null"] \ No newline at end of file diff --git a/README.md b/README.md index ee7f98b..f01a8ad 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,116 @@ -Dpaste +# CloudPulse + +**Taking an existing web application and running it on AWS the way a Cloud/DevOps team would:** containerised, provisioned with Terraform, delivered by CI/CD, secured, and monitored. + +[![CI](https://github.com/rayenmabrouk/cloudpulse/actions/workflows/ci.yml/badge.svg)](https://github.com/rayenmabrouk/cloudpulse/actions/workflows/ci.yml) +[![CD](https://github.com/rayenmabrouk/cloudpulse/actions/workflows/cd.yml/badge.svg)](https://github.com/rayenmabrouk/cloudpulse/actions/workflows/cd.yml) +[![Terraform](https://github.com/rayenmabrouk/cloudpulse/actions/workflows/terraform.yml/badge.svg)](https://github.com/rayenmabrouk/cloudpulse/actions/workflows/terraform.yml) + +> **Live demo:** the app runs in an AWS Academy Learner Lab, which stops the server between lab sessions and gives it a new public IP each time, so there is no permanent URL. The [evidence](#evidence) section shows the running system. + --- -![dpaste image](https://img.shields.io/pypi/v/dpaste.svg) -[![Python CI](https://github.com/DarrenOfficial/dpaste/actions/workflows/python.yml/badge.svg)](https://github.com/DarrenOfficial/dpaste/actions/workflows/python.yml) -[![Docker Image CI](https://github.com/DarrenOfficial/dpaste/actions/workflows/docker.yml/badge.svg)](https://hub.docker.com/r/darrenofficial/dpaste) -![Code Quality](https://api.codacy.com/project/badge/Grade/185cfbe9b4b447e59a40f816c4a5ebf4) ----- +## The application is not mine + +The workload is **[dpaste](https://github.com/DarrenOfficial/dpaste)** (v3.5, MIT License), a Django pastebin created by Martin Mahner and Darren Nathanael. **I did not write the application code** (`dpaste/`, `client/`, `manage.py`, `setup.*`, `package*.json`, the original `Dockerfile` and `docker-compose.yml`). The original README is kept in [`docs/upstream-dpaste-README.md`](docs/upstream-dpaste-README.md). + +That is deliberate: a Cloud/DevOps engineer usually receives an application from a development team and builds everything around it. This repository is that "everything around it". + +## What I built + +| Area | What | Where | +|---|---|---| +| **Container** | Hardened 3-stage Dockerfile: Node build stage for static assets, Python build stage, slim runtime; non-root `dpaste` user; health check. Measured: **369 MB** on disk, **~77 MB** compressed in ECR | `Dockerfile.hardened`, `.dockerignore`, `.trivyignore` | +| **Infrastructure as Code** | Terraform, 3 modules (networking, compute, monitoring): VPC, public subnet, IGW, security groups, EC2, ECR, SSM parameter, CloudWatch log group and alarms | `terraform/` | +| **Remote state** | S3 backend: versioned, encrypted, public access blocked, TLS-only bucket policy, S3-native state locking. Bucket bootstrapped outside Terraform | `terraform/backend.tf`, `scripts/bootstrap-tfstate.ps1` | +| **Deployment** | Deploy script run on the instance: pulls the image from ECR with the instance role, injects the secret from SSM, health-checks, and **rolls back automatically** to the previous image on failure | `scripts/deploy.sh` | +| **HTTPS** | Caddy reverse proxy with automatic Let's Encrypt certificates (sslip.io hostname); the app port is not exposed publicly | `scripts/deploy.sh` | +| **CI** | Tests (pytest), lint (ruff), image build + Trivy scan on every PR | `.github/workflows/ci.yml` | +| **CD** | On merge: build -> Trivy gate -> push to ECR -> deploy via **SSM Run Command** (no SSH keys in CI) -> HTTPS smoke test | `.github/workflows/cd.yml` | +| **Infra pipeline** | On PR: `fmt` -> `validate` -> **Checkov** -> `plan`. On merge: plan -> **manual approval** -> apply the exact saved plan | `.github/workflows/terraform.yml` | +| **Operations** | Daily expired-snippet cleanup (systemd timer), ECR credential helper (no registry token on disk), branch protection | `scripts/deploy.sh` | +| **Monitoring** | Prometheus + blackbox exporter probing local and production (availability, latency, TLS expiry), 3 alert rules; Grafana dashboard provisioned from files with Prometheus **and CloudWatch** data sources | `monitoring/` | +| **Docs** | Runbook, troubleshooting, cost, architecture, security | `docs/` | + +## Architecture + +```mermaid +flowchart LR + dev["Developer"] -->|pull request| gh["GitHub"] + gh --> ci["CI
tests, lint, image build + Trivy"] + gh --> tf["Terraform pipeline
fmt, validate, Checkov, plan
manual approval, apply"] + gh --> cd["CD
build, Trivy gate, push, deploy, smoke test"] + cd -->|push image| ecr[("ECR
immutable tags, scan on push")] + cd -->|SSM Run Command| ec2 + tf -->|state + lock| s3[("S3 state bucket")] + user["User"] -->|HTTPS 443| ec2 + subgraph aws["AWS us-east-1 - VPC 10.0.0.0/16 - public subnet"] + ec2["EC2 t3.micro - Amazon Linux 2023
Caddy :443 -> dpaste :8000
SQLite on a Docker volume"] + end + ec2 -->|pull, instance role| ecr + ec2 -->|SECRET_KEY| ssm[("SSM Parameter Store
SecureString")] + ec2 -->|container logs, metrics| cw[("CloudWatch
logs, alarms")] + mon["Prometheus + Grafana
(local)"] -->|blackbox HTTPS probe| ec2 + mon -->|metrics| cw +``` + +Design choices, trade-offs and what a production version would change are in [`docs/architecture.md`](docs/architecture.md). Security controls are in [`docs/security.md`](docs/security.md). + +## How a change reaches production + +| Change | Path | +|---|---| +| Application / image / deploy script | PR -> CI (tests, lint, Trivy) -> merge -> **CD** builds, scans, pushes `cloudpulse:`, deploys via SSM, smoke-tests over HTTPS | +| Infrastructure | PR -> CI + Terraform checks (fmt, validate, Checkov, plan in the run summary) -> merge -> plan -> **waits for my approval** -> apply | + +`master` is protected: pull request required, CI checks must pass, no force-push, enforced for admins. + +## Verified behaviour + +All of the following were executed and observed, not just configured: + +- **Automatic rollback:** deployed a deliberately broken image; the health check failed, `deploy.sh` rolled back to the previous release, and production kept serving HTTP 200. +- **Data persistence:** snippets survive container replacement and automated deploys (SQLite on a named volume; startup logs show `No migrations to apply`). +- **Approval gate:** the Terraform apply job paused until approved, then applied exactly the reviewed plan (1 add, 2 in-place changes); the local plan afterwards reported no changes. +- **Alerting:** stopping the container fired `DpasteDown` in Prometheus and Grafana; it resolved after restart. +- **Security gates:** Checkov passes with 21 checks, 0 failures, 11 documented exceptions; Trivy gates every image. + +## Key decisions and trade-offs + +- **OIDC for GitHub -> AWS was tested first** and is blocked in AWS Academy (`iam:CreateOpenIDConnectProvider` denied). CI/CD uses the lab's short-lived session credentials instead, refreshed each session with a script. In a real account: an OIDC role scoped to this repository. +- **Single EC2 instance + SQLite**, which is what dpaste is designed for. Production would use an ALB, an Auto Scaling group and a managed database. +- **Public subnet, no NAT gateway, no load balancer** to keep the cost near zero; production would place the instance in a private subnet. +- **sslip.io hostname** because there is no domain; production would use Route 53 and an Elastic IP. +- **cAdvisor was evaluated and removed**: it cannot identify containers with Docker Desktop's containerd image store, so container metrics come from CloudWatch instead. + +## Problems solved along the way + +Fifteen real issues are documented in [`docs/troubleshooting.md`](docs/troubleshooting.md). Examples: a 403 CSRF error that turned out to require HTTPS (dpaste sets secure cookies), `user_data` drift caused by Windows line endings, an AMI filter that silently selected the ECS-optimized image, and Checkov silently skipping files that contained an invalid byte. -📖 Full documentation on [https://docs.dpaste.org](https://docs.dpaste.org) +## Run it +- **Locally:** `docker compose up --build` -> http://localhost:8000 +- **Monitoring stack:** see [`monitoring/docker-compose.yml`](monitoring/docker-compose.yml) (Grafana on :3000, Prometheus on :9090) +- **On AWS, from zero:** [`docs/deployment-runbook.md`](docs/deployment-runbook.md) +- **Cost:** [`docs/cost.md`](docs/cost.md) -dpaste is a [pastebin](https://en.wikipedia.org/wiki/Pastebin) application written in [Python](https://www.python.org/) using the [Django](https://www.djangoproject.com/) framework. You can find a live installation on [dpaste.org.](https://dpaste.org) +## How this was built -The project is intended to run standalone as any regular Django Project, but it's also possible to install it into an existing project as a typical Django application. +I built this project with an AI assistant (Claude) as a pair programmer. It proposed designs and drafted code, commands and parts of this documentation. I ran every command, reviewed every Terraform plan and pull request, worked through every failure, and made the final decisions. `docs/architecture.md` and `docs/security.md` are written by me. +## Evidence -The code is open source and available on Github: [https://github.com/darrenofficial/dpaste](https://github.com/darrenofficial/dpaste). If you found bugs, have problems or ideas with the project or the website installation, please create an *Issue* there. +Screenshots of the running system are in [`docs/screenshots/`](docs/screenshots/). -⚠️ dpaste requires at a minimum Python 3.9 and Django 3.2. +| | | +|---|---| +| HTTPS site with valid certificate | ![HTTPS](docs/screenshots/77-final-site-https.png) | +| Grafana: probes + CloudWatch | ![Grafana](docs/screenshots/65-grafana-dashboard-final.png) | +| CD pipeline run | ![CD](docs/screenshots/52-cd-run-success.png) | +| Terraform apply waiting for approval | ![Approval](docs/screenshots/55-tf-apply-waiting-approval.png) | +| Automatic rollback: logs of the container started by the rollback, same database | ![Rollback](docs/screenshots/79-rollback-container-logs.png) | +## License -dpaste.org: https://dpaste.org/ -pastebin: https://en.wikipedia.org/wiki/Pastebin +- dpaste application code: MIT License, (c) the dpaste authors (see [`LICENSE`](LICENSE)). +- Infrastructure, pipelines, scripts and documentation added in this repository: MIT License, (c) Rayen Mabrouk. \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 071532b..c38a32c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,48 +1,18 @@ -version: "3.4" - services: - staticfiles: - build: - context: . - target: staticfiles - args: - BUILD_EXTRAS: dev - image: staticfiles - volumes: - - ./package.json:/app/package.json - - ./package-log.json:/app/package-log.json - - ./client:/app/client:delegated - - ./dpaste/static:/app/dpaste/static - app: - stdin_open: true - tty: true - restart: always build: context: . - target: build - args: - BUILD_EXTRAS: dev - image: app - environment: - STATIC_ROOT: /collectstatic - DATABASE_URL: sqlite:////db/dpaste.sqlite - PORT: 8000 - volumes: - - .:/app:delegated - - data_collectstatic:/collectstatic - - data_db:/db + dockerfile: Dockerfile.hardened ports: - "8000:8000" - command: ./manage.py runserver 0:8000 - - migration: - image: app - command: ./manage.py migrate --noinput + environment: + - DATABASE_URL=sqlite:////data/dpaste.sqlite + - SECRET_KEY=local-dev-secret-key-not-for-production + - DEBUG=True + - ALLOWED_HOSTS=* volumes: - - .:/app:delegated - - data_db:/db + - dpaste_data:/data + restart: unless-stopped volumes: - data_db: - data_collectstatic: + dpaste_data: \ No newline at end of file diff --git a/docs/cost.md b/docs/cost.md new file mode 100644 index 0000000..9e06d75 --- /dev/null +++ b/docs/cost.md @@ -0,0 +1,46 @@ +# Cost analysis + +Last updated: 2026-09-23 + +The project runs in an **AWS Academy Learner Lab** with a **$50 credit budget**. Academy stops the EC2 instance when a lab session ends, so the real spend is far below a 24/7 estimate. + +## Actual spend + +| Date | Credits used (Vocareum "Used $X of $50") | +|---|---| +| 2026-09-23 | _fill in from Vocareum_ | + +## What a 24/7 month would cost (estimate) + +Estimates use public **us-east-1 on-demand** prices at the time of writing; check the [AWS pricing pages](https://aws.amazon.com/pricing/) for current values. + +| Resource | Basis | ~ USD / month | +|---|---|---| +| EC2 t3.micro | $0.0104 / hour x 730 h | 7.59 | +| Public IPv4 address | $0.005 / hour x 730 h | 3.65 | +| EBS gp3, 20 GB | $0.08 / GB-month | 1.60 | +| EC2 detailed monitoring | 7 metrics x $0.30 | 2.10 | +| CloudWatch alarms | 2 x $0.10 | 0.20 | +| CloudWatch Logs | < 1 GB ingested, 7-day retention | < 0.50 | +| ECR storage | < 1 GB x $0.10 | < 0.10 | +| S3 state bucket | a few KB, versioned | ~0 | +| SSM Parameter Store | standard parameter | 0 | +| Data transfer out | a pastebin demo, well under 1 GB | < 0.10 | +| **Total** | | **~ $15-16** | + +GitHub Actions minutes are free for public repositories. Let's Encrypt certificates and sslip.io DNS are free. + +## Deliberate cost decisions + +| Not used | Typical monthly cost | Why skipped | Production alternative | +|---|---|---|---| +| Application Load Balancer | ~$16+ | single instance; Caddy terminates TLS | ALB + ACM certificate | +| NAT gateway | ~$32+ | instance sits in a public subnet | private subnet + NAT or VPC endpoints | +| RDS | ~$12+ (smallest) | dpaste is designed for SQLite | RDS PostgreSQL, stateless app tier | +| Route 53 hosted zone + domain | ~$0.50 + domain | sslip.io hostname | Route 53 + Elastic IP | +| EKS | ~$73 control plane | far beyond the need | EKS or ECS Fargate when there are several services | +| Customer-managed KMS keys | $1 / key | AWS-managed encryption is already on | CMKs where key policy control is required | + +## Where the money would go first + +Detailed monitoring (~$2.10) was enabled on purpose: 1-minute metrics make the CPU alarm react faster. On a tight budget it is the first thing to turn off, followed by the public IPv4 charge (only avoidable with a load balancer or IPv6). \ No newline at end of file diff --git a/docs/deployment-runbook.md b/docs/deployment-runbook.md new file mode 100644 index 0000000..56de309 --- /dev/null +++ b/docs/deployment-runbook.md @@ -0,0 +1,251 @@ +# Deployment runbook + +Last updated: 2026-09-23 + +How to operate CloudPulse on AWS, from an empty AWS Academy account to a running, monitored deployment. +All commands are for **Windows PowerShell 5.1** (the environment this project was built on), run from the repository root unless stated otherwise. + +Each step is marked: +- **[verified]**: executed and observed working during the build +- **[not yet verified]**: written from the design, still to be tested end to end + +--- + +## 0. Prerequisites + +| Tool | Version used | Install | +|---|---|---| +| Git | any recent | `winget install -e --id Git.Git` | +| Docker Desktop | any recent | `winget install -e --id Docker.DockerDesktop` | +| Terraform | 1.16.2 (**>= 1.10 required** for S3-native locking) | `winget install -e --id Hashicorp.Terraform` | +| AWS CLI | v2 | `winget install -e --id Amazon.AWSCLI` | +| GitHub CLI | any recent | `winget install -e --id GitHub.cli`, then `gh auth login` | + +AWS access: an **AWS Academy Learner Lab** (region `us-east-1`). The lab provides the `LabInstanceProfile` and the `vockey` key pair used by Terraform. + +--- + +## 1. At the start of every lab session [verified] + +Academy credentials expire when the lab session ends (about 4 hours), and the EC2 instance is stopped between sessions. + +1. In Vocareum: **Start Lab**, wait for the green dot, open **AWS Details -> AWS CLI: Show**. +2. Write the credentials **without a BOM** (Windows PowerShell 5's `Set-Content -Encoding UTF8` adds a BOM that the AWS SDK cannot parse): + +```powershell + $content = @" + [default] + aws_access_key_id=PASTE + aws_secret_access_key=PASTE + aws_session_token=PASTE + "@ + [System.IO.File]::WriteAllText("$HOME\.aws\credentials", $content) +``` + +3. One-time only, the region: + +```powershell + [System.IO.File]::WriteAllText("$HOME\.aws\config", "[default]`nregion = us-east-1`noutput = json`n") +``` + +4. Verify, then push the new credentials to GitHub Actions: + +```powershell + aws sts get-caller-identity + powershell -ExecutionPolicy Bypass -File scripts\refresh-github-aws-secrets.ps1 +``` + +5. If the AWS Console shows `explicit deny ... voc-cancel-cred`, the console tab is from an older session: close all console tabs and reopen the console from Vocareum. + +### After a lab restart: redeploy [not yet verified] + +When the instance starts again it gets a **new public IP**, so the sslip.io hostname changes. The containers restart automatically, but Caddy's certificate and Django's `ALLOWED_HOSTS` still refer to the old hostname. Redeploy so `deploy.sh` regenerates both: + +```powershell +terraform -chdir=terraform apply -refresh-only -auto-approve # refresh outputs (new IP) in state +terraform -chdir=terraform output app_url +gh workflow run cd.yml --repo rayenmabrouk/cloudpulse # rebuild + redeploy through CD +``` + +If your home IP changed, update `terraform/terraform.tfvars` and the `TF_VAR_ALLOWED_SSH_CIDR` secret, then change the SSH rule through a pull request. + +--- + +## 2. First-time setup from zero + +### 2.1 Remote state bucket [verified] + +The state bucket is created outside Terraform (Terraform cannot store its state in a bucket it has not created yet). The script is idempotent: + +```powershell +powershell -ExecutionPolicy Bypass -File scripts\bootstrap-tfstate.ps1 +``` + +It creates `cloudpulse-tfstate-` with versioning, SSE-S3 encryption, all public access blocked and a TLS-only bucket policy. + +### 2.2 Infrastructure [verified] + +```powershell +Copy-Item terraform\terraform.tfvars.example terraform\terraform.tfvars +# edit terraform.tfvars: allowed_ssh_cidr = "/32" +cd terraform +terraform init +terraform plan -out=tfplan +terraform apply tfplan +terraform output +cd .. +``` + +This creates the VPC, subnet, internet gateway, security groups, EC2 instance (Docker installed by `user_data`), ECR repository, SSM `SecureString` parameter with a generated Django `SECRET_KEY`, CloudWatch log group and alarms. + +After this first bootstrap, **infrastructure changes go through pull requests** and the Terraform pipeline (section 4.2). + +### 2.3 GitHub configuration [verified] + +```powershell +# AWS credentials for CI/CD (repeat every lab session) +powershell -ExecutionPolicy Bypass -File scripts\refresh-github-aws-secrets.ps1 + +# SSH CIDR for the Terraform pipeline +gh secret set TF_VAR_ALLOWED_SSH_CIDR --repo rayenmabrouk/cloudpulse --body "/32" +``` + +Environments (created with `gh api`, see the PR history for exact commands): +- `production`: deployments allowed from `master` only (used by CD) +- `infrastructure`: `master` only **and a required reviewer** (used by Terraform apply) + +Branch protection on `master`: pull request required, required checks `test`, `lint`, `docker-build-scan`, branch up to date, enforced for admins, no force-push or deletion. + +### 2.4 First deployment [verified] + +The first release was deployed manually to validate `deploy.sh`, then all later releases went through CD. To deploy the current `master` through CD: + +```powershell +gh workflow run cd.yml --repo rayenmabrouk/cloudpulse +``` + +**[not yet verified]** The manual trigger (`workflow_dispatch`) runs the same jobs as a push but has not been exercised yet; every verified CD run was triggered by a merge. + +Get the URL: + +```powershell +terraform -chdir=terraform output app_url +``` + +--- + +## 3. What a deployment does (`scripts/deploy.sh`) [verified] + +Runs on the instance as root (sent by SSM Run Command from CD): + +1. Reads account ID and public IP from **IMDSv2** (session token required). +2. Authenticates to ECR with the **ECR credential helper** via the instance role (falls back to `docker login` if the helper is unavailable). +3. Pulls `cloudpulse:`. +4. Reads `SECRET_KEY` from SSM Parameter Store and writes a root-only env file (`umask 077`) with `DEBUG=False` and `ALLOWED_HOSTS=,localhost,127.0.0.1`. +5. Replaces the `dpaste` container on a private Docker network, published on `127.0.0.1:8000` only, SQLite on the `dpaste_data` volume, logs to CloudWatch. +6. Health-checks `http://localhost:8000/` for up to 60 s. **On failure it restarts the previous image.** +7. Starts or reloads **Caddy** (ports 80/443, automatic Let's Encrypt certificate, JSON access logs to CloudWatch). +8. Installs the daily `cloudpulse-cleanup.timer`. + +--- + +## 4. Day-to-day changes + +### 4.1 Application, image or deploy script [verified] + +```powershell +git checkout -b feature/my-change +# edit, commit +git push -u origin feature/my-change +gh pr create --repo rayenmabrouk/cloudpulse --base master --fill +gh pr checks --repo rayenmabrouk/cloudpulse --watch +gh pr merge --repo rayenmabrouk/cloudpulse --merge --delete-branch +``` + +The merge triggers CD when it touches `dpaste/`, `client/`, `Dockerfile.hardened`, `setup.*`, `package*.json`, `scripts/deploy.sh` or `.github/workflows/cd.yml`. Watch it: + +```powershell +$RUN = gh run list --repo rayenmabrouk/cloudpulse --workflow cd.yml --limit 1 --json databaseId --jq ".[0].databaseId" +gh run watch $RUN --repo rayenmabrouk/cloudpulse --exit-status +``` + +### 4.2 Infrastructure [verified] + +Same PR flow for changes under `terraform/`. On the PR, the Terraform workflow runs `fmt`, `validate`, Checkov and `plan` (the plan is in the run summary). After merge: + +1. Actions -> the Terraform run -> the **Apply (manual approval)** job waits. +2. Review the plan in the run summary. +3. **Review deployments -> infrastructure -> Approve and deploy.** +4. Verify locally: `terraform -chdir=terraform plan` should report **No changes**. + +A Checkov finding must be either fixed or skipped inline with a justification (`# checkov:skip=:`). + +--- + +## 5. Operations + +### Run a command on the server without SSH [verified] + +```powershell +$ID = aws ec2 describe-instances --filters "Name=tag:Name,Values=cloudpulse-server" "Name=instance-state-name,Values=running" --query "Reservations[0].Instances[0].InstanceId" --output text +[System.IO.File]::WriteAllText("$env:TEMP\cmd.json", '{"commands":["docker ps --format ''{{.Names}} {{.Image}} {{.Status}}''"]}') +$CMD = aws ssm send-command --instance-ids $ID --document-name AWS-RunShellScript --parameters "file://$env:TEMP\cmd.json" --query Command.CommandId --output text +Start-Sleep -Seconds 5 +aws ssm get-command-invocation --command-id $CMD --instance-id $ID --query StandardOutputContent --output text +``` + +The AWS CLI on Windows crashes when the output contains emoji (dpaste's startup banner). Append `| tr -cd '\11\12\40-\176'` to the remote command to strip them. + +### Logs [verified] + +```powershell +aws logs tail /cloudpulse/dpaste --since 15m # app + Caddy +aws logs tail /cloudpulse/dpaste --since 1h --filter-pattern "migrations" +``` + +### Manual rollback to a specific release [verified] (automatic rollback verified; manual uses the same script) + +List releases, then deploy an older tag through SSM: + +```powershell +aws ecr describe-images --repository-name cloudpulse --query "sort_by(imageDetails,&imagePushedAt)[].imageTags[0]" --output text +# then send-command with: /opt/cloudpulse/deploy.sh +``` + +### Snippet cleanup [verified] + +Runs daily via `cloudpulse-cleanup.timer`. Run it now: `systemctl start cloudpulse-cleanup.service` (through SSM), then `journalctl -u cloudpulse-cleanup.service -n 5`. + +### SSH (break-glass) [verified] + +Only from the IP in `allowed_ssh_cidr`, with the Academy key (`labsuser.pem`, permissions restricted with `icacls`): + +```powershell +ssh -i "$HOME\.ssh\labsuser.pem" "ec2-user@$(terraform -chdir=terraform output -raw instance_public_ip)" +``` + +--- + +## 6. Monitoring stack (local) [verified] + +```powershell +cd monitoring +powershell -ExecutionPolicy Bypass -File set-production-target.ps1 +docker compose up -d --build +cd .. +``` + +- Grafana: http://localhost:3000 (admin / `GRAFANA_ADMIN_PASSWORD`, default `cloudpulse-local`) -> Dashboards -> CloudPulse +- Prometheus: http://localhost:9090 (targets, alerts) +- The CloudWatch panels use `~/.aws` (read-only mount) and stop working when the lab session credentials expire. + +--- + +## 7. Teardown [not yet verified] + +```powershell +terraform -chdir=terraform plan -destroy -out=destroy.tfplan +terraform -chdir=terraform apply destroy.tfplan +``` + +`force_delete = true` on the ECR repository removes its images. The state bucket is not managed by Terraform: empty all object **versions** and delete it manually afterwards, only once the state is no longer needed. \ No newline at end of file diff --git a/docs/screenshots/01-github-fork-dpaste.png b/docs/screenshots/01-github-fork-dpaste.png new file mode 100644 index 0000000..bb44984 Binary files /dev/null and b/docs/screenshots/01-github-fork-dpaste.png differ diff --git a/docs/screenshots/02-docker-build-hardened.png b/docs/screenshots/02-docker-build-hardened.png new file mode 100644 index 0000000..b7ecd29 Binary files /dev/null and b/docs/screenshots/02-docker-build-hardened.png differ diff --git a/docs/screenshots/03-dpaste-local-docker.png b/docs/screenshots/03-dpaste-local-docker.png new file mode 100644 index 0000000..de7ce34 Binary files /dev/null and b/docs/screenshots/03-dpaste-local-docker.png differ diff --git a/docs/screenshots/04-api-security-headers.png b/docs/screenshots/04-api-security-headers.png new file mode 100644 index 0000000..fbbcbbb Binary files /dev/null and b/docs/screenshots/04-api-security-headers.png differ diff --git a/docs/screenshots/05-ci-first-runs-failing.png b/docs/screenshots/05-ci-first-runs-failing.png new file mode 100644 index 0000000..be2a53b Binary files /dev/null and b/docs/screenshots/05-ci-first-runs-failing.png differ diff --git a/docs/screenshots/06-ci-trivy-failure-annotations.png b/docs/screenshots/06-ci-trivy-failure-annotations.png new file mode 100644 index 0000000..95cfc9e Binary files /dev/null and b/docs/screenshots/06-ci-trivy-failure-annotations.png differ diff --git a/docs/screenshots/07-ci-green-after-fix.png b/docs/screenshots/07-ci-green-after-fix.png new file mode 100644 index 0000000..b2d8c97 Binary files /dev/null and b/docs/screenshots/07-ci-green-after-fix.png differ diff --git a/docs/screenshots/08-ci-run-success-3-jobs.png b/docs/screenshots/08-ci-run-success-3-jobs.png new file mode 100644 index 0000000..546f182 Binary files /dev/null and b/docs/screenshots/08-ci-run-success-3-jobs.png differ diff --git a/docs/screenshots/10-academy-learner-lab.png b/docs/screenshots/10-academy-learner-lab.png new file mode 100644 index 0000000..dbe91a6 Binary files /dev/null and b/docs/screenshots/10-academy-learner-lab.png differ diff --git a/docs/screenshots/11-academy-console-home.png b/docs/screenshots/11-academy-console-home.png new file mode 100644 index 0000000..b5db141 Binary files /dev/null and b/docs/screenshots/11-academy-console-home.png differ diff --git a/docs/screenshots/12-academy-ec2-dashboard.png b/docs/screenshots/12-academy-ec2-dashboard.png new file mode 100644 index 0000000..e7510cb Binary files /dev/null and b/docs/screenshots/12-academy-ec2-dashboard.png differ diff --git a/docs/screenshots/13-academy-ecr.png b/docs/screenshots/13-academy-ecr.png new file mode 100644 index 0000000..bbabfb8 Binary files /dev/null and b/docs/screenshots/13-academy-ecr.png differ diff --git a/docs/screenshots/14-academy-vpc.png b/docs/screenshots/14-academy-vpc.png new file mode 100644 index 0000000..80ee200 Binary files /dev/null and b/docs/screenshots/14-academy-vpc.png differ diff --git a/docs/screenshots/15-academy-s3.png b/docs/screenshots/15-academy-s3.png new file mode 100644 index 0000000..6a13983 Binary files /dev/null and b/docs/screenshots/15-academy-s3.png differ diff --git a/docs/screenshots/16-academy-systems-manager.png b/docs/screenshots/16-academy-systems-manager.png new file mode 100644 index 0000000..1312601 Binary files /dev/null and b/docs/screenshots/16-academy-systems-manager.png differ diff --git a/docs/screenshots/17-academy-parameter-store.png b/docs/screenshots/17-academy-parameter-store.png new file mode 100644 index 0000000..46a3728 Binary files /dev/null and b/docs/screenshots/17-academy-parameter-store.png differ diff --git a/docs/screenshots/18-academy-cloudwatch.png b/docs/screenshots/18-academy-cloudwatch.png new file mode 100644 index 0000000..a13fdec Binary files /dev/null and b/docs/screenshots/18-academy-cloudwatch.png differ diff --git a/docs/screenshots/19-academy-iam.png b/docs/screenshots/19-academy-iam.png new file mode 100644 index 0000000..10099a6 Binary files /dev/null and b/docs/screenshots/19-academy-iam.png differ diff --git a/docs/screenshots/20-academy-labrole.png b/docs/screenshots/20-academy-labrole.png new file mode 100644 index 0000000..ad95944 Binary files /dev/null and b/docs/screenshots/20-academy-labrole.png differ diff --git a/docs/screenshots/21-academy-labrole-policies.png b/docs/screenshots/21-academy-labrole-policies.png new file mode 100644 index 0000000..e201269 Binary files /dev/null and b/docs/screenshots/21-academy-labrole-policies.png differ diff --git a/docs/screenshots/30-terraform-init.png b/docs/screenshots/30-terraform-init.png new file mode 100644 index 0000000..6c87436 Binary files /dev/null and b/docs/screenshots/30-terraform-init.png differ diff --git a/docs/screenshots/31-terraform-apply-complete.png b/docs/screenshots/31-terraform-apply-complete.png new file mode 100644 index 0000000..deb7a11 Binary files /dev/null and b/docs/screenshots/31-terraform-apply-complete.png differ diff --git a/docs/screenshots/32-console-voc-cancel-cred-denied.png b/docs/screenshots/32-console-voc-cancel-cred-denied.png new file mode 100644 index 0000000..68a3f42 Binary files /dev/null and b/docs/screenshots/32-console-voc-cancel-cred-denied.png differ diff --git a/docs/screenshots/33-ec2-instance-running.png b/docs/screenshots/33-ec2-instance-running.png new file mode 100644 index 0000000..8759cf4 Binary files /dev/null and b/docs/screenshots/33-ec2-instance-running.png differ diff --git a/docs/screenshots/34-ecr-repo-created.png b/docs/screenshots/34-ecr-repo-created.png new file mode 100644 index 0000000..4a22bea Binary files /dev/null and b/docs/screenshots/34-ecr-repo-created.png differ diff --git a/docs/screenshots/35-vpc-created.png b/docs/screenshots/35-vpc-created.png new file mode 100644 index 0000000..4d35821 Binary files /dev/null and b/docs/screenshots/35-vpc-created.png differ diff --git a/docs/screenshots/36-ec2-bootstrap-verified.png b/docs/screenshots/36-ec2-bootstrap-verified.png new file mode 100644 index 0000000..f0edbc3 Binary files /dev/null and b/docs/screenshots/36-ec2-bootstrap-verified.png differ diff --git a/docs/screenshots/37-pr-base-upstream-trap.png b/docs/screenshots/37-pr-base-upstream-trap.png new file mode 100644 index 0000000..bda00ac Binary files /dev/null and b/docs/screenshots/37-pr-base-upstream-trap.png differ diff --git a/docs/screenshots/38-pr1-terraform.png b/docs/screenshots/38-pr1-terraform.png new file mode 100644 index 0000000..3a1c444 Binary files /dev/null and b/docs/screenshots/38-pr1-terraform.png differ diff --git a/docs/screenshots/39-pr1-checks-running.png b/docs/screenshots/39-pr1-checks-running.png new file mode 100644 index 0000000..00e3c66 Binary files /dev/null and b/docs/screenshots/39-pr1-checks-running.png differ diff --git a/docs/screenshots/40-local-prod-mode.png b/docs/screenshots/40-local-prod-mode.png new file mode 100644 index 0000000..06a8a20 Binary files /dev/null and b/docs/screenshots/40-local-prod-mode.png differ diff --git a/docs/screenshots/41-local-prod-snippet.png b/docs/screenshots/41-local-prod-snippet.png new file mode 100644 index 0000000..b9e6aa2 Binary files /dev/null and b/docs/screenshots/41-local-prod-snippet.png differ diff --git a/docs/screenshots/42-deploy-script-healthy.png b/docs/screenshots/42-deploy-script-healthy.png new file mode 100644 index 0000000..c1d4470 Binary files /dev/null and b/docs/screenshots/42-deploy-script-healthy.png differ diff --git a/docs/screenshots/43-cloudwatch-logs-cli.png b/docs/screenshots/43-cloudwatch-logs-cli.png new file mode 100644 index 0000000..dc20d04 Binary files /dev/null and b/docs/screenshots/43-cloudwatch-logs-cli.png differ diff --git a/docs/screenshots/44-csrf-403-over-http.png b/docs/screenshots/44-csrf-403-over-http.png new file mode 100644 index 0000000..50b2a86 Binary files /dev/null and b/docs/screenshots/44-csrf-403-over-http.png differ diff --git a/docs/screenshots/45-https-snippet-created.png b/docs/screenshots/45-https-snippet-created.png new file mode 100644 index 0000000..04e246e Binary files /dev/null and b/docs/screenshots/45-https-snippet-created.png differ diff --git a/docs/screenshots/50-github-environment-production.png b/docs/screenshots/50-github-environment-production.png new file mode 100644 index 0000000..7257e95 Binary files /dev/null and b/docs/screenshots/50-github-environment-production.png differ diff --git a/docs/screenshots/51-github-actions-secrets.png b/docs/screenshots/51-github-actions-secrets.png new file mode 100644 index 0000000..9818ba1 Binary files /dev/null and b/docs/screenshots/51-github-actions-secrets.png differ diff --git a/docs/screenshots/52-cd-run-success.png b/docs/screenshots/52-cd-run-success.png new file mode 100644 index 0000000..7b34c29 Binary files /dev/null and b/docs/screenshots/52-cd-run-success.png differ diff --git a/docs/screenshots/53-cd-pr-checks.png b/docs/screenshots/53-cd-pr-checks.png new file mode 100644 index 0000000..430c597 Binary files /dev/null and b/docs/screenshots/53-cd-pr-checks.png differ diff --git a/docs/screenshots/54-tf-pr-checks.png b/docs/screenshots/54-tf-pr-checks.png new file mode 100644 index 0000000..b2d666c Binary files /dev/null and b/docs/screenshots/54-tf-pr-checks.png differ diff --git a/docs/screenshots/55-tf-apply-waiting-approval.png b/docs/screenshots/55-tf-apply-waiting-approval.png new file mode 100644 index 0000000..e3aa8ae Binary files /dev/null and b/docs/screenshots/55-tf-apply-waiting-approval.png differ diff --git a/docs/screenshots/60-monitoring-stack-up.png b/docs/screenshots/60-monitoring-stack-up.png new file mode 100644 index 0000000..e200caf Binary files /dev/null and b/docs/screenshots/60-monitoring-stack-up.png differ diff --git a/docs/screenshots/61-prometheus-targets.png b/docs/screenshots/61-prometheus-targets.png new file mode 100644 index 0000000..417efdc Binary files /dev/null and b/docs/screenshots/61-prometheus-targets.png differ diff --git a/docs/screenshots/62-prometheus-alerts.png b/docs/screenshots/62-prometheus-alerts.png new file mode 100644 index 0000000..ff191f4 Binary files /dev/null and b/docs/screenshots/62-prometheus-alerts.png differ diff --git a/docs/screenshots/63-grafana-dashboard.png b/docs/screenshots/63-grafana-dashboard.png new file mode 100644 index 0000000..e0dbb13 Binary files /dev/null and b/docs/screenshots/63-grafana-dashboard.png differ diff --git a/docs/screenshots/64-grafana-alert-firing.png b/docs/screenshots/64-grafana-alert-firing.png new file mode 100644 index 0000000..5621e68 Binary files /dev/null and b/docs/screenshots/64-grafana-alert-firing.png differ diff --git a/docs/screenshots/65-grafana-dashboard-final.png b/docs/screenshots/65-grafana-dashboard-final.png new file mode 100644 index 0000000..130bee0 Binary files /dev/null and b/docs/screenshots/65-grafana-dashboard-final.png differ diff --git a/docs/screenshots/70-final-ec2-instance.png b/docs/screenshots/70-final-ec2-instance.png new file mode 100644 index 0000000..ff8d589 Binary files /dev/null and b/docs/screenshots/70-final-ec2-instance.png differ diff --git a/docs/screenshots/71-final-security-group.png b/docs/screenshots/71-final-security-group.png new file mode 100644 index 0000000..c6c869c Binary files /dev/null and b/docs/screenshots/71-final-security-group.png differ diff --git a/docs/screenshots/72-final-ecr-images.png b/docs/screenshots/72-final-ecr-images.png new file mode 100644 index 0000000..c6beff5 Binary files /dev/null and b/docs/screenshots/72-final-ecr-images.png differ diff --git a/docs/screenshots/73-final-ssm-parameter.png b/docs/screenshots/73-final-ssm-parameter.png new file mode 100644 index 0000000..423bab5 Binary files /dev/null and b/docs/screenshots/73-final-ssm-parameter.png differ diff --git a/docs/screenshots/74-final-s3-state-versions.png b/docs/screenshots/74-final-s3-state-versions.png new file mode 100644 index 0000000..67baf1c Binary files /dev/null and b/docs/screenshots/74-final-s3-state-versions.png differ diff --git a/docs/screenshots/75-final-cloudwatch-overview.png b/docs/screenshots/75-final-cloudwatch-overview.png new file mode 100644 index 0000000..190dcdc Binary files /dev/null and b/docs/screenshots/75-final-cloudwatch-overview.png differ diff --git a/docs/screenshots/76-final-cloudwatch-alarms.png b/docs/screenshots/76-final-cloudwatch-alarms.png new file mode 100644 index 0000000..a80ff7a Binary files /dev/null and b/docs/screenshots/76-final-cloudwatch-alarms.png differ diff --git a/docs/screenshots/77-final-site-https.png b/docs/screenshots/77-final-site-https.png new file mode 100644 index 0000000..cd4bbe0 Binary files /dev/null and b/docs/screenshots/77-final-site-https.png differ diff --git a/docs/screenshots/78-final-cloudwatch-log-streams.png b/docs/screenshots/78-final-cloudwatch-log-streams.png new file mode 100644 index 0000000..bbeeb5c Binary files /dev/null and b/docs/screenshots/78-final-cloudwatch-log-streams.png differ diff --git a/docs/screenshots/79-rollback-container-logs.png b/docs/screenshots/79-rollback-container-logs.png new file mode 100644 index 0000000..448bebd Binary files /dev/null and b/docs/screenshots/79-rollback-container-logs.png differ diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000..dcd118b --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,81 @@ +# Troubleshooting + +Last updated: 2026-09-23 + +Every problem below actually happened while building CloudPulse. Each entry gives the symptom, the root cause and the fix. + +## Windows / PowerShell + +### 1. `terraform init`: "Invalid character encoding" +- **Symptom:** `Invalid character encoding` / `Unterminated template string` in `outputs.tf`. +- **Cause:** an em-dash in a description was written by PowerShell in a legacy encoding, and `` inside a string looked like a template. +- **Fix:** keep `.tf` files ASCII-only. + +### 2. Terraform / AWS CLI: "No valid credential sources found" +- **Symptom:** credentials file present but not read. +- **Cause:** `Set-Content -Encoding UTF8` in Windows PowerShell 5 writes a **byte order mark**; the AWS SDK cannot parse the first line. +- **Fix:** `[System.IO.File]::WriteAllText($path, $content)` (UTF-8 without BOM). + +### 3. `docker login` to ECR: `400 Bad Request` +- **Cause:** piping `aws ecr get-login-password` into `docker login` in PowerShell 5 re-encodes the token. +- **Fix:** run the pipe in cmd: `cmd /c "aws ecr get-login-password | docker login --username AWS --password-stdin "`. + +### 4. AWS CLI: `'charmap' codec can't encode character` +- **Cause:** remote output contained an emoji (dpaste's startup banner) and the CLI prints with the Windows console code page; `PYTHONUTF8` is ignored by the bundled Python. +- **Fix:** strip non-ASCII on the server: `... | tr -cd '\11\12\40-\176'`. + +### 5. `terraform plan` wants to modify `user_data` although nothing changed +- **Symptom:** in-place update of the instance (which would restart it) after switching Git branches. +- **Cause:** `user_data.sh` switched between CRLF and LF line endings; Terraform hashes the bytes. +- **Fix:** `.gitattributes` with `*.sh text eol=lf` (and `*.tf`, `*.hcl`), then a one-time apply. Lesson learned: the repository already had a `.gitattributes`; **append** to existing config files instead of overwriting them. + +### 6. AWS CLI: "You must specify a region" +- **Fix:** create `~/.aws/config` with `region = us-east-1`. + +## AWS Academy + +### 7. Console: `explicit deny ... policy/voc-cancel-cred` +- **Cause:** a console tab opened in a previous lab session; Vocareum revokes older sessions. +- **Fix:** close all console tabs and reopen the console from the Vocareum AWS link. + +### 8. OIDC for GitHub Actions: `AccessDenied` on `iam:CreateOpenIDConnectProvider` +- **Cause:** the Learner Lab does not allow creating IAM identity providers or roles. +- **Fix / trade-off:** use the lab's short-lived session credentials as GitHub secrets, refreshed by `scripts/refresh-github-aws-secrets.ps1` each session. + +## Terraform + +### 9. `InvalidBlockDeviceMapping: Volume of size 20GB is smaller than snapshot ... expect size >= 30GB` +- **Cause:** the AMI filter `al2023-ami-*-x86_64` also matched `al2023-ami-ecs-hvm-...` (ECS-optimized, 30 GB snapshot) and `most_recent` picked it. The login banner said "Amazon Linux 2023 (ECS Optimized)". +- **Fix:** filter `al2023-ami-2023.*-x86_64`. The replacement plan also updated both CloudWatch alarms (their `InstanceId` dimension), which is why plans are reviewed. + +### 10. Checkov passes but ignores some files +- **Symptom:** `Parsing errors: 3` and CloudWatch resources never scanned. +- **Cause:** three files contained byte `0x97` (a Windows-1252 em-dash) in comments. Terraform tolerated it; Checkov could not parse the files and skipped them. +- **Fix:** convert every `.tf` file to ASCII. + +## Application / deployment + +### 11. `403 Forbidden - CSRF verification failed` when creating a snippet +- **Cause:** dpaste sets `CSRF_COOKIE_SECURE = True` and `SESSION_COOKIE_SECURE = True`. Over plain HTTP the browser drops these cookies. It worked locally because browsers treat `localhost` as secure. +- **Fix:** serve over HTTPS (Caddy + Let's Encrypt on an sslip.io hostname). dpaste already sets `SECURE_PROXY_SSL_HEADER`, so it trusts `X-Forwarded-Proto` from the proxy. Disabling the secure cookies was rejected. + +### 12. Snippets would have been lost on every redeploy +- **Cause:** the deploy script mounted the volume at `/db`, but the image's `DATABASE_URL` is `sqlite:////data/dpaste.sqlite`. +- **Fix:** read the path from the image (`docker image inspect --format '{{json .Config.Env}}'`) and mount at `/data`. Caught before the first deploy. + +### 13. ECR scan status `None`, three entries for one image +- **Cause:** Docker Buildx adds a provenance attestation, so the push is an image index, which ECR basic scanning does not handle. +- **Fix:** build with `--provenance=false --sbom=false`. + +### 14. `docker login` warning: password stored unencrypted in `/root/.docker/config.json` +- **Fix:** Amazon ECR credential helper; `config.json` now only contains `credHelpers`. + +## Monitoring + +### 15. cAdvisor running but container panels empty +- **Symptom:** only one anonymous series; logs show `failed to identify the read-write layer ID ... layerdb/mounts/...: no such file or directory`. +- **Cause:** Docker Desktop uses the containerd image store; cAdvisor v0.49 expects the classic `overlay2` layout. +- **Fix:** removed cAdvisor; the Grafana dashboard shows production EC2 CPU and network from CloudWatch instead. + +### Grafana: "Invalid username or password" +- **Fix:** `docker exec cloudpulse-grafana grafana cli admin reset-admin-password `. Repeated failures trigger a short lockout. \ No newline at end of file diff --git a/docs/upstream-dpaste-README.md b/docs/upstream-dpaste-README.md new file mode 100644 index 0000000..ee7f98b --- /dev/null +++ b/docs/upstream-dpaste-README.md @@ -0,0 +1,24 @@ +Dpaste +--- +![dpaste image](https://img.shields.io/pypi/v/dpaste.svg) +[![Python CI](https://github.com/DarrenOfficial/dpaste/actions/workflows/python.yml/badge.svg)](https://github.com/DarrenOfficial/dpaste/actions/workflows/python.yml) +[![Docker Image CI](https://github.com/DarrenOfficial/dpaste/actions/workflows/docker.yml/badge.svg)](https://hub.docker.com/r/darrenofficial/dpaste) +![Code Quality](https://api.codacy.com/project/badge/Grade/185cfbe9b4b447e59a40f816c4a5ebf4) + +---- + +📖 Full documentation on [https://docs.dpaste.org](https://docs.dpaste.org) + + +dpaste is a [pastebin](https://en.wikipedia.org/wiki/Pastebin) application written in [Python](https://www.python.org/) using the [Django](https://www.djangoproject.com/) framework. You can find a live installation on [dpaste.org.](https://dpaste.org) + +The project is intended to run standalone as any regular Django Project, but it's also possible to install it into an existing project as a typical Django application. + + +The code is open source and available on Github: [https://github.com/darrenofficial/dpaste](https://github.com/darrenofficial/dpaste). If you found bugs, have problems or ideas with the project or the website installation, please create an *Issue* there. + +⚠️ dpaste requires at a minimum Python 3.9 and Django 3.2. + + +dpaste.org: https://dpaste.org/ +pastebin: https://en.wikipedia.org/wiki/Pastebin diff --git a/monitoring/blackbox/blackbox.yml b/monitoring/blackbox/blackbox.yml new file mode 100644 index 0000000..ec077e0 --- /dev/null +++ b/monitoring/blackbox/blackbox.yml @@ -0,0 +1,9 @@ +modules: + http_2xx: + prober: http + timeout: 10s + http: + method: GET + valid_status_codes: [200] + follow_redirects: true + preferred_ip_protocol: ip4 \ No newline at end of file diff --git a/monitoring/docker-compose.yml b/monitoring/docker-compose.yml new file mode 100644 index 0000000..dcd3a7a --- /dev/null +++ b/monitoring/docker-compose.yml @@ -0,0 +1,71 @@ +# CloudPulse - local observability stack +# Probes the local container AND the live AWS deployment (black-box monitoring: +# no changes to the dpaste application code). +# Usage (from monitoring/): +# powershell -ExecutionPolicy Bypass -File set-production-target.ps1 +# docker compose up -d --build +# Grafana: http://localhost:3000 Prometheus: http://localhost:9090 +name: cloudpulse-monitoring + +services: + dpaste: + build: + context: .. + dockerfile: Dockerfile.hardened + image: cloudpulse:local + container_name: cloudpulse-dpaste + environment: + SECRET_KEY: local-monitoring-demo-not-a-secret + DEBUG: "False" + ALLOWED_HOSTS: localhost,127.0.0.1,dpaste + ports: + - "127.0.0.1:8000:8000" + volumes: + - dpaste_data:/data + restart: unless-stopped + + blackbox: + image: prom/blackbox-exporter:v0.25.0 + container_name: cloudpulse-blackbox + command: ["--config.file=/etc/blackbox/blackbox.yml"] + volumes: + - ./blackbox/blackbox.yml:/etc/blackbox/blackbox.yml:ro + restart: unless-stopped + + + prometheus: + image: prom/prometheus:v3.2.1 + container_name: cloudpulse-prometheus + command: + - --config.file=/etc/prometheus/prometheus.yml + - --storage.tsdb.retention.time=7d + volumes: + - ./prometheus:/etc/prometheus:ro + - prometheus_data:/prometheus + ports: + - "127.0.0.1:9090:9090" + restart: unless-stopped + + grafana: + image: grafana/grafana:11.6.0 + container_name: cloudpulse-grafana + environment: + GF_SECURITY_ADMIN_USER: admin + GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-cloudpulse-local} + GF_USERS_ALLOW_SIGN_UP: "false" + volumes: + - ./grafana/provisioning:/etc/grafana/provisioning:ro + - ./grafana/dashboards:/var/lib/grafana/dashboards:ro + - grafana_data:/var/lib/grafana + # AWS Academy session credentials, read-only, for the CloudWatch datasource + - ${USERPROFILE}/.aws:/usr/share/grafana/.aws:ro + ports: + - "127.0.0.1:3000:3000" + depends_on: + - prometheus + restart: unless-stopped + +volumes: + dpaste_data: + prometheus_data: + grafana_data: \ No newline at end of file diff --git a/monitoring/grafana/dashboards/cloudpulse-overview.json b/monitoring/grafana/dashboards/cloudpulse-overview.json new file mode 100644 index 0000000..9b82f3c --- /dev/null +++ b/monitoring/grafana/dashboards/cloudpulse-overview.json @@ -0,0 +1,111 @@ +{ + "uid": "cloudpulse-overview", + "title": "CloudPulse - dpaste overview", + "tags": ["cloudpulse"], + "timezone": "browser", + "schemaVersion": 39, + "version": 1, + "refresh": "30s", + "time": { "from": "now-1h", "to": "now" }, + "panels": [ + { + "id": 1, "type": "stat", "title": "Production", + "gridPos": { "h": 4, "w": 4, "x": 0, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [{ "refId": "A", "expr": "probe_success{env=\"production\"}" }], + "fieldConfig": { "defaults": { + "mappings": [{ "type": "value", "options": { "0": { "text": "DOWN", "color": "red" }, "1": { "text": "UP", "color": "green" } } }], + "thresholds": { "mode": "absolute", "steps": [{ "color": "red", "value": null }, { "color": "green", "value": 1 }] } + }, "overrides": [] }, + "options": { "colorMode": "background", "graphMode": "none", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false } } + }, + { + "id": 2, "type": "stat", "title": "Local", + "gridPos": { "h": 4, "w": 4, "x": 4, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [{ "refId": "A", "expr": "probe_success{env=\"local\"}" }], + "fieldConfig": { "defaults": { + "mappings": [{ "type": "value", "options": { "0": { "text": "DOWN", "color": "red" }, "1": { "text": "UP", "color": "green" } } }], + "thresholds": { "mode": "absolute", "steps": [{ "color": "red", "value": null }, { "color": "green", "value": 1 }] } + }, "overrides": [] }, + "options": { "colorMode": "background", "graphMode": "none", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false } } + }, + { + "id": 3, "type": "stat", "title": "Production HTTP status", + "gridPos": { "h": 4, "w": 4, "x": 8, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [{ "refId": "A", "expr": "probe_http_status_code{env=\"production\"}" }], + "fieldConfig": { "defaults": { + "thresholds": { "mode": "absolute", "steps": [{ "color": "red", "value": null }, { "color": "green", "value": 200 }, { "color": "red", "value": 300 }] } + }, "overrides": [] }, + "options": { "colorMode": "value", "graphMode": "none", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false } } + }, + { + "id": 4, "type": "stat", "title": "TLS certificate expires in", + "gridPos": { "h": 4, "w": 6, "x": 12, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [{ "refId": "A", "expr": "(probe_ssl_earliest_cert_expiry{env=\"production\"} - time()) / 86400" }], + "fieldConfig": { "defaults": { + "unit": "d", "decimals": 1, + "thresholds": { "mode": "absolute", "steps": [{ "color": "red", "value": null }, { "color": "orange", "value": 14 }, { "color": "green", "value": 30 }] } + }, "overrides": [] }, + "options": { "colorMode": "value", "graphMode": "none", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false } } + }, + { + "id": 5, "type": "stat", "title": "Firing alerts", + "gridPos": { "h": 4, "w": 6, "x": 18, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [{ "refId": "A", "expr": "count(ALERTS{alertstate=\"firing\"}) or vector(0)" }], + "fieldConfig": { "defaults": { + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "red", "value": 1 }] } + }, "overrides": [] }, + "options": { "colorMode": "background", "graphMode": "none", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false } } + }, + { + "id": 6, "type": "timeseries", "title": "Response time (full request)", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 4 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [{ "refId": "A", "expr": "probe_duration_seconds", "legendFormat": "{{env}}" }], + "fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] } + }, + { + "id": 7, "type": "timeseries", "title": "Production request phases (DNS, connect, TLS, processing, transfer)", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 4 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [{ "refId": "A", "expr": "probe_http_duration_seconds{env=\"production\"}", "legendFormat": "{{phase}}" }], + "fieldConfig": { "defaults": { "unit": "s", "custom": { "stacking": { "mode": "normal" }, "fillOpacity": 30 } }, "overrides": [] } + }, + { + "id": 8, "type": "timeseries", "title": "Production EC2 CPU (CloudWatch, 1-minute detailed monitoring)", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 12 }, + "datasource": { "type": "cloudwatch", "uid": "cloudwatch" }, + "targets": [{ + "refId": "A", "datasource": { "type": "cloudwatch", "uid": "cloudwatch" }, + "queryMode": "Metrics", "region": "default", "namespace": "AWS/EC2", "metricName": "CPUUtilization", + "dimensions": { "InstanceId": "*" }, "matchExact": false, "statistic": "Average", "period": "60", + "metricQueryType": 0, "metricEditorMode": 0, "id": "", "expression": "" + }], + "fieldConfig": { "defaults": { "unit": "percent", "min": 0 }, "overrides": [] } + }, + { + "id": 9, "type": "timeseries", "title": "Production EC2 network (CloudWatch)", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 12 }, + "datasource": { "type": "cloudwatch", "uid": "cloudwatch" }, + "targets": [ + { + "refId": "A", "datasource": { "type": "cloudwatch", "uid": "cloudwatch" }, + "queryMode": "Metrics", "region": "default", "namespace": "AWS/EC2", "metricName": "NetworkIn", + "dimensions": { "InstanceId": "*" }, "matchExact": false, "statistic": "Sum", "period": "60", + "metricQueryType": 0, "metricEditorMode": 0, "id": "", "expression": "" + }, + { + "refId": "B", "datasource": { "type": "cloudwatch", "uid": "cloudwatch" }, + "queryMode": "Metrics", "region": "default", "namespace": "AWS/EC2", "metricName": "NetworkOut", + "dimensions": { "InstanceId": "*" }, "matchExact": false, "statistic": "Sum", "period": "60", + "metricQueryType": 0, "metricEditorMode": 0, "id": "", "expression": "" + } + ], + "fieldConfig": { "defaults": { "unit": "bytes" }, "overrides": [] } + } + ] +} \ No newline at end of file diff --git a/monitoring/grafana/provisioning/dashboards/cloudpulse.yml b/monitoring/grafana/provisioning/dashboards/cloudpulse.yml new file mode 100644 index 0000000..3113b4b --- /dev/null +++ b/monitoring/grafana/provisioning/dashboards/cloudpulse.yml @@ -0,0 +1,8 @@ +apiVersion: 1 +providers: + - name: CloudPulse + folder: CloudPulse + type: file + disableDeletion: true + options: + path: /var/lib/grafana/dashboards \ No newline at end of file diff --git a/monitoring/grafana/provisioning/datasources/prometheus.yml b/monitoring/grafana/provisioning/datasources/prometheus.yml new file mode 100644 index 0000000..13b4ee8 --- /dev/null +++ b/monitoring/grafana/provisioning/datasources/prometheus.yml @@ -0,0 +1,16 @@ +apiVersion: 1 +datasources: + - name: Prometheus + uid: prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true + + # Uses the AWS SDK default credential chain (~/.aws mounted read-only) + - name: CloudWatch + uid: cloudwatch + type: cloudwatch + jsonData: + authType: default + defaultRegion: us-east-1 \ No newline at end of file diff --git a/monitoring/prometheus/alerts.yml b/monitoring/prometheus/alerts.yml new file mode 100644 index 0000000..81b99b4 --- /dev/null +++ b/monitoring/prometheus/alerts.yml @@ -0,0 +1,29 @@ +groups: + - name: cloudpulse + rules: + - alert: DpasteDown + expr: probe_success == 0 + for: 1m + labels: + severity: critical + annotations: + summary: "dpaste is down ({{ $labels.env }})" + description: "HTTP probe of {{ $labels.instance }} has been failing for more than 1 minute." + + - alert: DpasteSlowResponse + expr: probe_duration_seconds > 2 + for: 5m + labels: + severity: warning + annotations: + summary: "dpaste is slow ({{ $labels.env }})" + description: "Probe of {{ $labels.instance }} takes more than 2s for 5 minutes." + + - alert: TLSCertificateExpiringSoon + expr: (probe_ssl_earliest_cert_expiry - time()) / 86400 < 14 + for: 10m + labels: + severity: warning + annotations: + summary: "TLS certificate expires in less than 14 days" + description: "Certificate for {{ $labels.instance }} expires soon; Caddy should have renewed it." \ No newline at end of file diff --git a/monitoring/prometheus/prometheus.yml b/monitoring/prometheus/prometheus.yml new file mode 100644 index 0000000..299e592 --- /dev/null +++ b/monitoring/prometheus/prometheus.yml @@ -0,0 +1,35 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s + +rule_files: + - /etc/prometheus/alerts.yml + +scrape_configs: + - job_name: prometheus + static_configs: + - targets: ["localhost:9090"] + + # Black-box HTTP probes: local container (static) + production (file-based, + # written by set-production-target.ps1 because the sslip.io hostname changes with the IP) + - job_name: blackbox-http + metrics_path: /probe + params: + module: [http_2xx] + static_configs: + - targets: ["http://dpaste:8000/"] + labels: + env: local + file_sd_configs: + - files: ["/etc/prometheus/targets.d/*.yml"] + relabel_configs: + - source_labels: [__address__] + target_label: __param_target + - source_labels: [__param_target] + target_label: instance + - target_label: __address__ + replacement: blackbox:9115 + + - job_name: blackbox-exporter + static_configs: + - targets: ["blackbox:9115"] diff --git a/monitoring/prometheus/targets.d/.gitkeep b/monitoring/prometheus/targets.d/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/monitoring/set-production-target.ps1 b/monitoring/set-production-target.ps1 new file mode 100644 index 0000000..b617108 --- /dev/null +++ b/monitoring/set-production-target.ps1 @@ -0,0 +1,9 @@ +# Point the black-box probe at the current production URL. +# The sslip.io hostname is derived from the EC2 public IP, which changes when the +# AWS Academy session restarts, so the target is generated from Terraform output. +$url = (terraform -chdir="$PSScriptRoot\..\terraform" output -raw app_url) +if ($LASTEXITCODE -ne 0 -or -not $url) { throw "Could not read app_url from Terraform (are AWS credentials valid?)" } +$yml = "- targets: [`"$url/`"]`n labels:`n env: production`n" +New-Item -ItemType Directory -Force "$PSScriptRoot\prometheus\targets.d" | Out-Null +[System.IO.File]::WriteAllText("$PSScriptRoot\prometheus\targets.d\production.yml", $yml) +Write-Host "Production probe target: $url/" \ No newline at end of file diff --git a/scripts/bootstrap-tfstate.ps1 b/scripts/bootstrap-tfstate.ps1 new file mode 100644 index 0000000..1b8a02e --- /dev/null +++ b/scripts/bootstrap-tfstate.ps1 @@ -0,0 +1,52 @@ +# CloudPulse - one-time bootstrap of the Terraform remote state bucket. +# Created outside Terraform on purpose: Terraform cannot keep its state in a +# bucket it has not created yet. Idempotent: safe to re-run. +# Run: powershell -ExecutionPolicy Bypass -File scripts\bootstrap-tfstate.ps1 +param([string]$Region = "us-east-1") + +function Invoke-Aws { + & aws @args + if ($LASTEXITCODE -ne 0) { throw "aws $($args -join ' ') failed" } +} + +$account = (aws sts get-caller-identity --query Account --output text).Trim() +$bucket = "cloudpulse-tfstate-$account" +Write-Host "State bucket: $bucket" + +aws s3api head-bucket --bucket $bucket *> $null +if ($LASTEXITCODE -ne 0) { + Invoke-Aws s3api create-bucket --bucket $bucket --region $Region | Out-Null + Write-Host "Created bucket" +} else { + Write-Host "Bucket already exists" +} + +# Versioning: every state write is kept; a bad apply can be rolled back +Invoke-Aws s3api put-bucket-versioning --bucket $bucket --versioning-configuration Status=Enabled + +# Encryption at rest (SSE-S3). State contains secrets (e.g. the Django SECRET_KEY). +Invoke-Aws s3api put-bucket-encryption --bucket $bucket --server-side-encryption-configuration "Rules=[{ApplyServerSideEncryptionByDefault={SSEAlgorithm=AES256},BucketKeyEnabled=true}]" + +# No public access, ever +Invoke-Aws s3api put-public-access-block --bucket $bucket --public-access-block-configuration "BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true" + +# Deny any request not made over TLS +$policy = @" +{ + "Version": "2012-10-17", + "Statement": [{ + "Sid": "DenyInsecureTransport", + "Effect": "Deny", + "Principal": "*", + "Action": "s3:*", + "Resource": ["arn:aws:s3:::$bucket", "arn:aws:s3:::$bucket/*"], + "Condition": {"Bool": {"aws:SecureTransport": "false"}} + }] +} +"@ +$policyFile = Join-Path $env:TEMP "cloudpulse-tfstate-policy.json" +[System.IO.File]::WriteAllText($policyFile, $policy) +Invoke-Aws s3api put-bucket-policy --bucket $bucket --policy "file://$policyFile" +Remove-Item $policyFile + +Write-Host "Bucket $bucket ready: versioned, encrypted, public access blocked, TLS-only." \ No newline at end of file diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100644 index 0000000..1b74584 --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,188 @@ +#!/bin/bash +# CloudPulse deploy script - runs ON the EC2 instance +# (manually over SSH for now, via SSM Run Command from the CD pipeline). +# - Pulls a dpaste image tag from ECR and replaces the running container +# - Health-checks it and rolls back to the previous image on failure +# - Runs Caddy as the TLS-terminating reverse proxy: automatic HTTPS via +# Let's Encrypt on .sslip.io +# Usage: sudo deploy.sh +set -euo pipefail + +IMAGE_TAG="${1:?usage: deploy.sh }" +REGION="us-east-1" +REPO="cloudpulse" +CONTAINER="dpaste" +APP_PORT=8000 +DATA_DIR="/data" +NETWORK="cloudpulse" +PROXY="caddy" +PROXY_IMAGE="caddy:2-alpine" +LOG_GROUP="/cloudpulse/dpaste" +SECRET_PARAM="/cloudpulse/django/secret_key" +STATE_DIR="/opt/cloudpulse" +ENV_FILE="${STATE_DIR}/app.env" +CADDYFILE="${STATE_DIR}/Caddyfile" + +log() { echo "[deploy $(date -u +%H:%M:%S)] $*"; } + +# --- Instance metadata via IMDSv2 (session token required; IMDSv1 disabled) --- +IMDS="http://169.254.169.254/latest" +TOKEN=$(curl -sf -X PUT "${IMDS}/api/token" -H "X-aws-ec2-metadata-token-ttl-seconds: 300") +meta() { curl -sf -H "X-aws-ec2-metadata-token: ${TOKEN}" "${IMDS}/$1"; } +ACCOUNT_ID=$(meta dynamic/instance-identity/document | grep -oP '"accountId"\s*:\s*"\K[0-9]+') +PUBLIC_IP=$(meta meta-data/public-ipv4) +APP_HOST="${PUBLIC_IP//./-}.sslip.io" + +REGISTRY="${ACCOUNT_ID}.dkr.ecr.${REGION}.amazonaws.com" +IMAGE="${REGISTRY}/${REPO}:${IMAGE_TAG}" +log "Deploying ${IMAGE} for https://${APP_HOST}" + +# --- Pull image (instance role provides ECR read access; no stored credentials) --- +# Preferred: Amazon ECR credential helper - fetches short-lived registry credentials +# from the instance role on demand, so no token is written to /root/.docker/config.json. +# Fallback: classic docker login if the helper package is unavailable. +if command -v docker-credential-ecr-login >/dev/null 2>&1 \ + || dnf install -y -q amazon-ecr-credential-helper >/dev/null 2>&1; then + mkdir -p /root/.docker + echo "{\"credHelpers\":{\"${REGISTRY}\":\"ecr-login\"}}" > /root/.docker/config.json + log "ECR auth: credential helper (no stored token)" +else + aws ecr get-login-password --region "${REGION}" \ + | docker login --username AWS --password-stdin "${REGISTRY}" >/dev/null 2>&1 + log "ECR auth: docker login fallback" +fi +docker pull -q "${IMAGE}" >/dev/null + +# --- Runtime config: SECRET_KEY from SSM SecureString into a root-only env file --- +SECRET_KEY=$(aws ssm get-parameter --region "${REGION}" --name "${SECRET_PARAM}" \ + --with-decryption --query Parameter.Value --output text) +mkdir -p "${STATE_DIR}" +( + umask 077 + cat > "${ENV_FILE}" </dev/null 2>&1 || docker network create "${NETWORK}" >/dev/null + +# awslogs driver options; $1 = stream prefix +set_log_opts() { + LOG_OPTS=(--log-driver awslogs + --log-opt "awslogs-region=${REGION}" + --log-opt "awslogs-group=${LOG_GROUP}" + --log-opt "awslogs-stream=$1-$(date -u +%Y%m%d-%H%M%S)") +} + +PREVIOUS_IMAGE=$(docker inspect --format '{{.Config.Image}}' "${CONTAINER}" 2>/dev/null || true) + +# dpaste listens only on the Docker network and on localhost (for health checks); +# it is NOT reachable from the internet directly. +start_container() { + docker rm -f "${CONTAINER}" >/dev/null 2>&1 || true + set_log_opts "${CONTAINER}" + docker run -d --name "${CONTAINER}" --restart unless-stopped \ + --network "${NETWORK}" \ + -p "127.0.0.1:${APP_PORT}:8000" \ + --env-file "${ENV_FILE}" \ + -v "dpaste_data:${DATA_DIR}" \ + "${LOG_OPTS[@]}" \ + "$1" >/dev/null +} + +healthy() { + for _ in $(seq 1 30); do + if curl -sf -o /dev/null "http://localhost:${APP_PORT}/"; then return 0; fi + sleep 2 + done + return 1 +} + +# Caddy: TLS termination + reverse proxy. Certificates persist in the caddy_data volume. +ensure_proxy() { + cat > "${CADDYFILE}" </dev/null 2>&1 + log "Caddy config reloaded" + else + docker rm -f "${PROXY}" >/dev/null 2>&1 || true + set_log_opts "${PROXY}" + docker pull -q "${PROXY_IMAGE}" >/dev/null + docker run -d --name "${PROXY}" --restart unless-stopped \ + --network "${NETWORK}" \ + -p 80:80 -p 443:443 \ + -v "${CADDYFILE}:/etc/caddy/Caddyfile:ro" \ + -v caddy_data:/data -v caddy_config:/config \ + "${LOG_OPTS[@]}" \ + "${PROXY_IMAGE}" >/dev/null + log "Caddy started" + fi +} + +# Daily purge of expired snippets (dpaste's cleanup_snippets command). +# systemd timer because Amazon Linux 2023 ships without cron. +ensure_cleanup_timer() { + cat > /etc/systemd/system/cloudpulse-cleanup.service < /etc/systemd/system/cloudpulse-cleanup.timer </dev/null 2>&1 + log "Cleanup timer active" +} + +start_container "${IMAGE}" +if healthy; then + log "Healthy: ${IMAGE}" + echo "${IMAGE}" > "${STATE_DIR}/current_image" + ensure_proxy + ensure_cleanup_timer + docker image prune -f >/dev/null + log "Live at https://${APP_HOST}" + exit 0 +fi + +log "Health check FAILED for ${IMAGE}. Last container logs:" +docker logs --tail 30 "${CONTAINER}" 2>&1 || true + +if [ -n "${PREVIOUS_IMAGE}" ] && [ "${PREVIOUS_IMAGE}" != "${IMAGE}" ]; then + log "Rolling back to ${PREVIOUS_IMAGE}" + start_container "${PREVIOUS_IMAGE}" + if healthy; then + ensure_proxy + log "Rollback healthy: ${PREVIOUS_IMAGE}" + else + log "Rollback ALSO unhealthy" + fi +fi +exit 1 \ No newline at end of file diff --git a/scripts/refresh-github-aws-secrets.ps1 b/scripts/refresh-github-aws-secrets.ps1 new file mode 100644 index 0000000..45083b8 --- /dev/null +++ b/scripts/refresh-github-aws-secrets.ps1 @@ -0,0 +1,32 @@ +# CloudPulse - push the current AWS Academy session credentials to GitHub Actions secrets. +# OIDC is not available in the Learner Lab (iam:CreateOpenIDConnectProvider is denied), +# so CI/CD uses the temporary session credentials, which expire when the lab session ends. +# Run at the start of every lab session, after updating ~/.aws/credentials: +# powershell -ExecutionPolicy Bypass -File scripts\refresh-github-aws-secrets.ps1 +param([string]$Repo = "rayenmabrouk/cloudpulse") + +$credFile = Join-Path $HOME ".aws\credentials" +$values = @{} +foreach ($line in Get-Content $credFile) { + if ($line -match '^\s*(aws_access_key_id|aws_secret_access_key|aws_session_token)\s*=\s*(\S+)\s*$') { + $values[$Matches[1]] = $Matches[2] + } +} +foreach ($k in 'aws_access_key_id', 'aws_secret_access_key', 'aws_session_token') { + if (-not $values.ContainsKey($k)) { throw "$k not found in $credFile" } +} + +# Refuse to upload expired credentials +$account = aws sts get-caller-identity --query Account --output text +if ($LASTEXITCODE -ne 0) { throw "Credentials in $credFile are not valid (lab session expired?)" } + +$map = @{ + AWS_ACCESS_KEY_ID = 'aws_access_key_id' + AWS_SECRET_ACCESS_KEY = 'aws_secret_access_key' + AWS_SESSION_TOKEN = 'aws_session_token' +} +foreach ($secret in $map.Keys) { + gh secret set $secret --repo $Repo --body $values[$map[$secret]] + if ($LASTEXITCODE -ne 0) { throw "Failed to set $secret" } +} +Write-Host "GitHub secrets updated for $Repo (account $account). Valid until the lab session ends." \ No newline at end of file diff --git a/terraform/.gitignore b/terraform/.gitignore new file mode 100644 index 0000000..edad16e --- /dev/null +++ b/terraform/.gitignore @@ -0,0 +1,8 @@ +# Terraform +.terraform/ +*.tfstate +*.tfstate.* +*.tfvars +!*.tfvars.example +tfplan +crash.log diff --git a/terraform/.terraform.lock.hcl b/terraform/.terraform.lock.hcl new file mode 100644 index 0000000..c3ac322 --- /dev/null +++ b/terraform/.terraform.lock.hcl @@ -0,0 +1,46 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "5.100.0" + constraints = "~> 5.0" + hashes = [ + "h1:H3mU/7URhP0uCRGK8jeQRKxx2XFzEqLiOq/L2Bbiaxs=", + "zh:054b8dd49f0549c9a7cc27d159e45327b7b65cf404da5e5a20da154b90b8a644", + "zh:0b97bf8d5e03d15d83cc40b0530a1f84b459354939ba6f135a0086c20ebbe6b2", + "zh:1589a2266af699cbd5d80737a0fe02e54ec9cf2ca54e7e00ac51c7359056f274", + "zh:6330766f1d85f01ae6ea90d1b214b8b74cc8c1badc4696b165b36ddd4cc15f7b", + "zh:7c8c2e30d8e55291b86fcb64bdf6c25489d538688545eb48fd74ad622e5d3862", + "zh:99b1003bd9bd32ee323544da897148f46a527f622dc3971af63ea3e251596342", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:9f8b909d3ec50ade83c8062290378b1ec553edef6a447c56dadc01a99f4eaa93", + "zh:aaef921ff9aabaf8b1869a86d692ebd24fbd4e12c21205034bb679b9caf883a2", + "zh:ac882313207aba00dd5a76dbd572a0ddc818bb9cbf5c9d61b28fe30efaec951e", + "zh:bb64e8aff37becab373a1a0cc1080990785304141af42ed6aa3dd4913b000421", + "zh:dfe495f6621df5540d9c92ad40b8067376350b005c637ea6efac5dc15028add4", + "zh:f0ddf0eaf052766cfe09dea8200a946519f653c384ab4336e2a4a64fdd6310e9", + "zh:f1b7e684f4c7ae1eed272b6de7d2049bb87a0275cb04dbb7cda6636f600699c9", + "zh:ff461571e3f233699bf690db319dfe46aec75e58726636a0d97dd9ac6e32fb70", + ] +} + +provider "registry.terraform.io/hashicorp/random" { + version = "3.9.1" + constraints = "~> 3.6" + hashes = [ + "h1:7uiStw0Rl9KOdX5UNMG/sp9nyadoD4LZekQTiYlYPhE=", + "zh:05f4734c1f0be840b711b3eff259ebc5fca436784c728955b1678078466f48d7", + "zh:0b91bf19371d012434eba1deeb6aab77158def9b39601dcbd94450b3974a2a26", + "zh:0ee6eacd47ec00183d55d726a4b6c4ce951a199f944bf22f1aa58392ebdfa7a2", + "zh:19388a4074b76a89a43a6c8328d7ae8ee2e7de3d346af51e80d3e6d3d12925f1", + "zh:23e74d48c5e2ac2e823fd527f49fee9db37d32a1990c9e3bf126ead697b843eb", + "zh:3cabf7fbd096c520064aae3aba61aba670af83ab91291a71fa1b1332929c2b7f", + "zh:5c0a3b8af0be60be4eca12ddee385cfa8babc1ec8e98cdf9de2f2274c73eabfa", + "zh:60b4f8a8ef18f52bf8e19215229dae408bee732825964092db7c989fd2de4097", + "zh:7359015acfedcbd6366f2329c854cf8d3c8ca5cd0faa89d2d37db358d6eba6c5", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:7b38758402f0e13a1071162da28994023cd2ac676e54af350c9ffd8dfa73fa7b", + "zh:7c7fbb8895eb75bb4de1f933e98553bd99c8d048c89a925ddba490aa5a67f7dc", + "zh:8c2b8c6a7ccdec16b73e2fb9f3700ea097f58c592571e4c5de60c93d2301732c", + ] +} diff --git a/terraform/backend.tf b/terraform/backend.tf new file mode 100644 index 0000000..2802be8 --- /dev/null +++ b/terraform/backend.tf @@ -0,0 +1,12 @@ +# Remote state in S3 (bucket bootstrapped by scripts/bootstrap-tfstate.ps1): +# versioned, SSE-S3 encrypted, public access blocked, TLS-only bucket policy. +# use_lockfile = S3-native state locking (Terraform >= 1.10); no DynamoDB table needed. +terraform { + backend "s3" { + bucket = "cloudpulse-tfstate-530008597446" + key = "dev/terraform.tfstate" + region = "us-east-1" + encrypt = true + use_lockfile = true + } +} \ No newline at end of file diff --git a/terraform/main.tf b/terraform/main.tf new file mode 100644 index 0000000..b39a3b5 --- /dev/null +++ b/terraform/main.tf @@ -0,0 +1,28 @@ +# ============================================================ +# CloudPulse - Root Module +# Wires together: networking ? compute ? monitoring +# Infrastructure: Rayen Mabrouk +# ============================================================ + +module "networking" { + source = "./modules/networking" + + project_name = var.project_name + allowed_ssh_cidr = var.allowed_ssh_cidr +} + +module "compute" { + source = "./modules/compute" + + project_name = var.project_name + aws_region = var.aws_region + subnet_id = module.networking.public_subnet_id + security_group_id = module.networking.app_security_group_id +} + +module "monitoring" { + source = "./modules/monitoring" + + project_name = var.project_name + instance_id = module.compute.instance_id +} diff --git a/terraform/modules/compute/main.tf b/terraform/modules/compute/main.tf new file mode 100644 index 0000000..330cbbd --- /dev/null +++ b/terraform/modules/compute/main.tf @@ -0,0 +1,72 @@ +# ============================================================ +# CloudPulse ? Compute Module +# Creates: ECR repository, EC2 instance with Docker +# ============================================================ + +# --- Find latest Amazon Linux 2023 AMI --- +data "aws_ami" "amazon_linux" { + most_recent = true + owners = ["amazon"] + + filter { + name = "name" + values = ["al2023-ami-2023.*-x86_64"] + } + + filter { + name = "virtualization-type" + values = ["hvm"] + } +} + +# --- Reference pre-existing Academy LabInstanceProfile --- +data "aws_iam_instance_profile" "lab" { + name = "LabInstanceProfile" +} + +# --- ECR Repository --- +resource "aws_ecr_repository" "app" { + # checkov:skip=CKV_AWS_136:AES-256 encryption at rest; a customer-managed KMS key adds cost and key management with no benefit in a single-account lab + name = var.project_name + image_tag_mutability = "IMMUTABLE" + force_delete = true + + image_scanning_configuration { + scan_on_push = true + } + + tags = { + Name = "${var.project_name}-ecr" + } +} + +# --- EC2 Instance --- +resource "aws_instance" "app" { + # checkov:skip=CKV_AWS_135:t3 instance types are EBS-optimized by default; the flag does not apply + ami = data.aws_ami.amazon_linux.id + instance_type = var.instance_type + subnet_id = var.subnet_id + vpc_security_group_ids = [var.security_group_id] + iam_instance_profile = data.aws_iam_instance_profile.lab.name + key_name = var.key_name + monitoring = true # 1-minute CloudWatch metrics for faster alarms + + # Enforce IMDSv2 ? prevents SSRF token theft + metadata_options { + http_endpoint = "enabled" + http_tokens = "required" + http_put_response_hop_limit = 1 + } + + user_data = file("${path.module}/user_data.sh") + + root_block_device { + volume_size = 20 + volume_type = "gp3" + encrypted = true + } + + tags = { + Name = "${var.project_name}-server" + } +} diff --git a/terraform/modules/compute/outputs.tf b/terraform/modules/compute/outputs.tf new file mode 100644 index 0000000..48d9c1d --- /dev/null +++ b/terraform/modules/compute/outputs.tf @@ -0,0 +1,19 @@ +output "instance_id" { + description = "EC2 instance ID" + value = aws_instance.app.id +} + +output "instance_public_ip" { + description = "Public IP of the EC2 instance" + value = aws_instance.app.public_ip +} + +output "instance_public_dns" { + description = "Public DNS of the EC2 instance" + value = aws_instance.app.public_dns +} + +output "ecr_repository_url" { + description = "ECR repository URL" + value = aws_ecr_repository.app.repository_url +} diff --git a/terraform/modules/compute/secrets.tf b/terraform/modules/compute/secrets.tf new file mode 100644 index 0000000..a7f1dc8 --- /dev/null +++ b/terraform/modules/compute/secrets.tf @@ -0,0 +1,19 @@ +# --- Django SECRET_KEY: generated by Terraform, stored encrypted in SSM --- +# Special characters limited to a shell/env-file safe set. +resource "random_password" "django_secret_key" { + length = 64 + special = true + override_special = "-_=+@#%" +} + +resource "aws_ssm_parameter" "django_secret_key" { + # checkov:skip=CKV_AWS_337:encrypted with the AWS managed aws/ssm KMS key; a customer-managed key is not required in a single-account lab + name = "/${var.project_name}/django/secret_key" + description = "Django SECRET_KEY for dpaste (read by EC2 at deploy time)" + type = "SecureString" + value = random_password.django_secret_key.result + + tags = { + Name = "${var.project_name}-django-secret-key" + } +} diff --git a/terraform/modules/compute/user_data.sh b/terraform/modules/compute/user_data.sh new file mode 100644 index 0000000..606a2c7 --- /dev/null +++ b/terraform/modules/compute/user_data.sh @@ -0,0 +1,18 @@ +#!/bin/bash +set -euo pipefail + +# Update system +dnf update -y + +# Install Docker +dnf install -y docker +systemctl enable docker +systemctl start docker + +# Add ec2-user to docker group +usermod -aG docker ec2-user + +# Create application directory +mkdir -p /opt/cloudpulse + +echo "CloudPulse bootstrap complete" | tee /var/log/cloudpulse-init.log diff --git a/terraform/modules/compute/variables.tf b/terraform/modules/compute/variables.tf new file mode 100644 index 0000000..7ea330e --- /dev/null +++ b/terraform/modules/compute/variables.tf @@ -0,0 +1,37 @@ +variable "project_name" { + description = "Project name for resource naming" + type = string +} + +variable "aws_region" { + description = "AWS region" + type = string +} + +variable "subnet_id" { + description = "Subnet ID for the EC2 instance" + type = string +} + +variable "security_group_id" { + description = "Security group ID for the EC2 instance" + type = string +} + +variable "instance_type" { + description = "EC2 instance type" + type = string + default = "t3.micro" +} + +variable "key_name" { + description = "SSH key pair name" + type = string + default = "vockey" +} + +variable "app_port" { + description = "Application port" + type = number + default = 8000 +} diff --git a/terraform/modules/compute/versions.tf b/terraform/modules/compute/versions.tf new file mode 100644 index 0000000..11b2b8a --- /dev/null +++ b/terraform/modules/compute/versions.tf @@ -0,0 +1,8 @@ +terraform { + required_providers { + random = { + source = "hashicorp/random" + version = "~> 3.6" + } + } +} diff --git a/terraform/modules/monitoring/main.tf b/terraform/modules/monitoring/main.tf new file mode 100644 index 0000000..9b8aade --- /dev/null +++ b/terraform/modules/monitoring/main.tf @@ -0,0 +1,47 @@ +# ============================================================ +# CloudPulse - Monitoring Module +# Creates: CloudWatch log group, CPU alarm, status check alarm +# ============================================================ + +resource "aws_cloudwatch_log_group" "app" { + # checkov:skip=CKV_AWS_158:CloudWatch Logs encrypts log data at rest by default; a customer-managed key would add a key policy to maintain + # checkov:skip=CKV_AWS_338:7-day retention chosen deliberately to limit cost in a lab environment + name = "/cloudpulse/dpaste" + retention_in_days = 7 + + tags = { + Name = "${var.project_name}-logs" + } +} + +resource "aws_cloudwatch_metric_alarm" "cpu_high" { + alarm_name = "${var.project_name}-cpu-high" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 2 + metric_name = "CPUUtilization" + namespace = "AWS/EC2" + period = 300 + statistic = "Average" + threshold = 80 + alarm_description = "CPU utilization exceeds 80% for 10 minutes" + + dimensions = { + InstanceId = var.instance_id + } +} + +resource "aws_cloudwatch_metric_alarm" "status_check" { + alarm_name = "${var.project_name}-status-check" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 2 + metric_name = "StatusCheckFailed" + namespace = "AWS/EC2" + period = 300 + statistic = "Maximum" + threshold = 0 + alarm_description = "EC2 instance status check failed" + + dimensions = { + InstanceId = var.instance_id + } +} diff --git a/terraform/modules/monitoring/outputs.tf b/terraform/modules/monitoring/outputs.tf new file mode 100644 index 0000000..918dbe4 --- /dev/null +++ b/terraform/modules/monitoring/outputs.tf @@ -0,0 +1,4 @@ +output "log_group_name" { + description = "CloudWatch log group name" + value = aws_cloudwatch_log_group.app.name +} diff --git a/terraform/modules/monitoring/variables.tf b/terraform/modules/monitoring/variables.tf new file mode 100644 index 0000000..02ee3b7 --- /dev/null +++ b/terraform/modules/monitoring/variables.tf @@ -0,0 +1,9 @@ +variable "project_name" { + description = "Project name for resource naming" + type = string +} + +variable "instance_id" { + description = "EC2 instance ID to monitor" + type = string +} diff --git a/terraform/modules/networking/main.tf b/terraform/modules/networking/main.tf new file mode 100644 index 0000000..dae483d --- /dev/null +++ b/terraform/modules/networking/main.tf @@ -0,0 +1,122 @@ +# ============================================================ +# CloudPulse - Networking Module +# Creates: VPC, public subnet, internet gateway, route table, +# security group +# ============================================================ + +# --- VPC --- +resource "aws_vpc" "main" { + # checkov:skip=CKV2_AWS_11:VPC flow logs need a delivery IAM role, which cannot be created in AWS Academy; documented as a production requirement + cidr_block = var.vpc_cidr + enable_dns_support = true + enable_dns_hostnames = true + + tags = { + Name = "${var.project_name}-vpc" + } +} + +# --- Public Subnet --- +data "aws_availability_zones" "available" { + # checkov:skip=CKV_AWS_394:single-AZ deployment that only uses names[0] + state = "available" +} + +resource "aws_subnet" "public" { + # checkov:skip=CKV_AWS_130:public subnet by design (no NAT gateway, for cost); production would use a private subnet behind a load balancer + vpc_id = aws_vpc.main.id + cidr_block = var.public_subnet_cidr + map_public_ip_on_launch = true + availability_zone = data.aws_availability_zones.available.names[0] + + tags = { + Name = "${var.project_name}-public-subnet" + } +} + +# --- Internet Gateway --- +resource "aws_internet_gateway" "main" { + vpc_id = aws_vpc.main.id + + tags = { + Name = "${var.project_name}-igw" + } +} + +# --- Route Table --- +resource "aws_route_table" "public" { + vpc_id = aws_vpc.main.id + + route { + cidr_block = "0.0.0.0/0" + gateway_id = aws_internet_gateway.main.id + } + + tags = { + Name = "${var.project_name}-public-rt" + } +} + +resource "aws_route_table_association" "public" { + subnet_id = aws_subnet.public.id + route_table_id = aws_route_table.public.id +} + +# --- Security Group --- +resource "aws_security_group" "app" { + # checkov:skip=CKV_AWS_260:port 80 is required for the HTTP to HTTPS redirect and Let's Encrypt HTTP-01 validation + # checkov:skip=CKV_AWS_382:outbound access needed for ECR, SSM, Let's Encrypt and OS packages; production would use VPC endpoints and restricted egress + # checkov:skip=CKV2_AWS_5:false positive - attached to the EC2 instance in the compute module + name = "${var.project_name}-app-sg" + description = "Security group for CloudPulse application" + vpc_id = aws_vpc.main.id + + # SSH - restricted to your IP only + ingress { + description = "SSH from allowed IP" + from_port = 22 + to_port = 22 + protocol = "tcp" + cidr_blocks = [var.allowed_ssh_cidr] + } + + # HTTP - Caddy redirects to HTTPS and answers Let's Encrypt HTTP-01 challenges + ingress { + description = "HTTP redirect to HTTPS and ACME challenge" + from_port = 80 + to_port = 80 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + # HTTPS - public entry point; TLS terminated by Caddy on the instance + ingress { + description = "HTTPS" + from_port = 443 + to_port = 443 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + # All outbound + egress { + description = "All outbound traffic" + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = { + Name = "${var.project_name}-app-sg" + } +} + +# --- Default security group: all rules removed (nothing should use it) --- +resource "aws_default_security_group" "default" { + vpc_id = aws_vpc.main.id + + tags = { + Name = "${var.project_name}-default-sg-locked" + } +} \ No newline at end of file diff --git a/terraform/modules/networking/outputs.tf b/terraform/modules/networking/outputs.tf new file mode 100644 index 0000000..fcb8bc3 --- /dev/null +++ b/terraform/modules/networking/outputs.tf @@ -0,0 +1,14 @@ +output "vpc_id" { + description = "ID of the VPC" + value = aws_vpc.main.id +} + +output "public_subnet_id" { + description = "ID of the public subnet" + value = aws_subnet.public.id +} + +output "app_security_group_id" { + description = "ID of the application security group" + value = aws_security_group.app.id +} diff --git a/terraform/modules/networking/variables.tf b/terraform/modules/networking/variables.tf new file mode 100644 index 0000000..690f31f --- /dev/null +++ b/terraform/modules/networking/variables.tf @@ -0,0 +1,27 @@ +variable "project_name" { + description = "Project name for resource naming" + type = string +} + +variable "vpc_cidr" { + description = "CIDR block for the VPC" + type = string + default = "10.0.0.0/16" +} + +variable "public_subnet_cidr" { + description = "CIDR block for the public subnet" + type = string + default = "10.0.1.0/24" +} + +variable "allowed_ssh_cidr" { + description = "CIDR block allowed to SSH into EC2" + type = string +} + +variable "app_port" { + description = "Application port exposed by the container" + type = number + default = 8000 +} diff --git a/terraform/outputs.tf b/terraform/outputs.tf new file mode 100644 index 0000000..62805df --- /dev/null +++ b/terraform/outputs.tf @@ -0,0 +1,19 @@ +output "instance_public_ip" { + description = "Public IP of the EC2 instance running dpaste" + value = module.compute.instance_public_ip +} + +output "ecr_repository_url" { + description = "ECR repository URL for Docker image push" + value = module.compute.ecr_repository_url +} + +output "log_group_name" { + description = "CloudWatch log group for container logs" + value = module.monitoring.log_group_name +} + +output "app_url" { + description = "Public HTTPS URL (sslip.io hostname derived from the instance IP)" + value = "https://${replace(module.compute.instance_public_ip, ".", "-")}.sslip.io" +} \ No newline at end of file diff --git a/terraform/providers.tf b/terraform/providers.tf new file mode 100644 index 0000000..8772ef3 --- /dev/null +++ b/terraform/providers.tf @@ -0,0 +1,19 @@ +# ============================================================ +# CloudPulse - Terraform Provider Configuration +# Infrastructure: Rayen Mabrouk +# ============================================================ + +terraform { + required_version = ">= 1.10" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.0" + } + } +} + +provider "aws" { + region = var.aws_region +} diff --git a/terraform/terraform.tfvars.example b/terraform/terraform.tfvars.example new file mode 100644 index 0000000..8a2804c --- /dev/null +++ b/terraform/terraform.tfvars.example @@ -0,0 +1,3 @@ +aws_region = "us-east-1" +project_name = "cloudpulse" +allowed_ssh_cidr = "YOUR_PUBLIC_IP/32" diff --git a/terraform/variables.tf b/terraform/variables.tf new file mode 100644 index 0000000..4404e8e --- /dev/null +++ b/terraform/variables.tf @@ -0,0 +1,20 @@ +# ============================================================ +# CloudPulse - Root Variables +# ============================================================ + +variable "aws_region" { + description = "AWS region for all resources" + type = string + default = "us-east-1" +} + +variable "project_name" { + description = "Project name used for resource naming and tagging" + type = string + default = "cloudpulse" +} + +variable "allowed_ssh_cidr" { + description = "CIDR block allowed to SSH into EC2 (your IP)" + type = string +}