diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml new file mode 100644 index 0000000..5328690 --- /dev/null +++ b/.github/workflows/cd.yml @@ -0,0 +1,47 @@ +name: VidCast CD — Deploy to EKS + +on: + workflow_run: + workflows: ["VidCast CI — Lint, Scan, Build, Push"] + types: [completed] + branches: [main] + +permissions: + id-token: write # required to request the OIDC token + contents: read + +jobs: + deploy: + if: ${{ github.event.workflow_run.conclusion == 'success' }} + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Configure AWS credentials (OIDC) + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }} + aws-region: ${{ secrets.AWS_REGION }} + + - name: Update kubeconfig for EKS + run: | + aws eks update-kubeconfig \ + --name ${{ secrets.EKS_CLUSTER_NAME }} \ + --region ${{ secrets.AWS_REGION }} + + - name: Set short SHA from triggering workflow + run: | + echo "SHORT_SHA=$(echo ${{ github.event.workflow_run.head_sha }} | cut -c1-7)" >> $GITHUB_ENV + + - name: Deploy services to EKS + run: | + for svc in auth-service gateway-service converter-service notification-service; do + deploy_name="${svc%-service}" + kubectl set image deployment/${deploy_name} \ + ${deploy_name}=${{ secrets.DOCKERHUB_USERNAME }}/${svc}:${{ env.SHORT_SHA }} || true + kubectl rollout status deployment/${deploy_name} --timeout=120s || true + done + + - name: Verify all pods running + run: kubectl get pods -o wide diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..10d9187 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,65 @@ +name: VidCast CI — Lint, Scan, Build, Push + +on: + push: + branches: [main] + paths: ['src/**'] + pull_request: + branches: [main] + paths: ['src/**'] + +jobs: + 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: Lint Python services + run: ruff check src/ --exclude src/frontend + + build-and-scan: + needs: lint + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + service: [auth-service, gateway-service, converter-service, notification-service] + + steps: + - uses: actions/checkout@v4 + + - name: Set short SHA + run: echo "SHORT_SHA=${GITHUB_SHA::7}" >> $GITHUB_ENV + + - name: Build Docker image + run: | + docker build \ + -t ${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.service }}:${{ env.SHORT_SHA }} \ + src/${{ matrix.service }}/ + + - name: Trivy vulnerability scan + uses: aquasecurity/trivy-action@master + with: + image-ref: ${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.service }}:${{ env.SHORT_SHA }} + severity: CRITICAL,HIGH + exit-code: '1' + ignore-unfixed: true + format: table + + - name: Login to Docker Hub + if: github.ref == 'refs/heads/main' && github.event_name == 'push' + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Push image to Docker Hub + if: github.ref == 'refs/heads/main' && github.event_name == 'push' + run: docker push ${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.service }}:${{ env.SHORT_SHA }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..68ed5e0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,64 @@ +# Terraform +terraform.tfvars +terraform.tfvars.json +*.tfstate +*.tfstate.* +.terraform/ +.terraform.lock.hcl +tfplan +*.tfplan +crash.log + +# Kubernetes secrets +**/secret.yaml +# ...except Helm chart secret *templates*, which hold no literal credentials +# (they reference values.yaml via {{ .Values.secret.* }}) and must be tracked +# so a clean `helm install` can render the Secret resource. +!Helm_charts/MongoDB/templates/secret.yaml +!Helm_charts/RabbitMQ/templates/secret.yaml +!Helm_charts/Postgres/templates/secret.yaml + +# Deployment-specific files +DEPLOYMENT_CONFIG.md +DEPLOYMENT_HANDOVER.md +DEPLOYMENT_REPORT.md +SESSION_SUMMARY.md +DEPLOYMENT_PROBLEMS.md +deployment-ids.txt +customise.sh + +# Build artifacts +*.mp3 +!assets/video.mp4 +output.* + +# Python +__pycache__/ +*.pyc +*.pyo +.env +venv/ +*.egg-info/ + +# Node +node_modules/ +dist/ +build/ +.cache/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log + +# Explanation files (study material, not production) +*_EXPLAINED.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..324d013 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,645 @@ +# CLAUDE.md — VidCast Platform (Video-to-Audio Microservices on AWS EKS) + +--- + +## ⚠️ READ THIS FIRST — BEFORE ANYTHING ELSE + +### Step 1 — Identify which prompt type is being used + +This file supports two execution modes. The mode determines who builds the CI/CD pipeline, health endpoints, and security hardening. + +``` +FULL PROMPT (CLAUDE_CODE_FULL_PROMPT_V2.md) + → Claude builds everything — all phases, all files + → Sections marked [FULL ONLY] apply + → Sections marked [HYBRID ONLY] do NOT apply — skip them + +HYBRID PROMPT (CLAUDE_CODE_HYBRID_PROMPT_V2.md) + → Claude builds Terraform, monitoring, frontend, Swarm compose, docs + → Developer manually builds CI/CD, health endpoints, security hardening + → Sections marked [HYBRID ONLY] apply + → Sections marked [FULL ONLY] do NOT apply — skip them +``` + +Read the active prompt file to determine mode. If uncertain, ask. + +### Step 2 — Read all companion files + +```bash +ls -la *.md +cat VIDCAST_UPGRADE_PLAN.md +ls DEPLOYMENT_CONFIG.md 2>/dev/null && cat DEPLOYMENT_CONFIG.md +ls DEPLOYMENT_HANDOVER.md 2>/dev/null && cat DEPLOYMENT_HANDOVER.md +``` + +If `DEPLOYMENT_CONFIG.md` has unfilled bracket placeholders (`[VALUE]`), list them and ask the user to fill them before proceeding. Do NOT continue with placeholder values. + +### Step 3 — Check for a previous session + +If `DEPLOYMENT_HANDOVER.md` exists, read it, identify which phases are complete, and resume from the next incomplete phase. Never recreate resources that already exist. + +### Step 4 — Validate AWS access + +```bash +aws sts get-caller-identity +``` + +--- + +## Concurrent File Management (Non-Negotiable) + +Maintain two tracking files throughout ALL work. These are your crash recovery system. + +**DEPLOYMENT_HANDOVER.md** — Session state. Update this: +- BEFORE any destructive operation (terraform destroy, kubectl delete, helm uninstall) +- AFTER every completed phase +- AFTER every successful infrastructure change (terraform apply, helm install, kubectl apply) +- IMMEDIATELY if usage limits are approaching — save state before stopping + +**DEPLOYMENT_REPORT.md** — Full record of everything done. Update after every significant action. + +If Claude Code stops for any reason, the next session reads DEPLOYMENT_HANDOVER.md and resumes exactly from where it left off. Every phase completion and every resource ID must be recorded here. + +DEPLOYMENT_HANDOVER.md structure: +```markdown +# VidCast Deployment Handover +## Last Updated: [timestamp] + +### Base Deployment Phases (0-12) +- [x] Phase 0: Prerequisites +- [ ] Phase 1: IAM Roles +... + +### Upgrade Phases +- [ ] Phase U0: Repo Cleanup +- [ ] Phase U1: Terraform IaC +... + +### AWS Resources +- VPC ID: [value] +- EKS Cluster: [value] +- Node Group: [value] +- Node IP: [value] +- Security Group: [value] + +### Staging Environment +- Swarm EC2 IP: [value] +- Swarm status: [running/stopped/not created] + +### Resume Instructions +[Exact commands to pick up from current state] +``` + +--- + +## Project Overview + +**Product:** VidCast — "Turn video recordings into podcast-ready audio" + +This is a Python microservices platform that converts uploaded MP4 video files to MP3 audio files. It runs on AWS EKS with an event-driven, asynchronous architecture. A user uploads a video, it's processed via a RabbitMQ pipeline, and they receive an email with the download link. + +**Repository base:** https://github.com/N4si/K8s-video-converter.git (forked to student's account) + +--- + +## System Architecture + +``` +Client (Browser / curl / React Frontend) + │ + ▼ +┌──────────────────────────────────────────────────────┐ +│ Frontend — React + nginx (NodePort :30006) [NEW] │ +│ Login → Upload → Download → Dashboard → Arch Diagram│ +└──────────────────────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────┐ +│ Gateway Service — Flask :8080 (NodePort :30002) │ +│ POST /login → Auth Service (:5000) → PostgreSQL │ +│ POST /upload → MongoDB GridFS + RabbitMQ "video" │ +│ GET /download → MongoDB GridFS → stream MP3 │ +│ GET /healthz → health check endpoint [NEW] │ +└──────────────────────────────────────────────────────┘ + │ + ▼ RabbitMQ "video" queue +┌──────────────────────────────────────────────────────┐ +│ Converter Service — 4 replicas (Pika + ffmpeg) │ +│ Reads video → extracts audio → stores MP3 │ +│ → publishes to RabbitMQ "mp3" queue │ +└──────────────────────────────────────────────────────┘ + │ + ▼ RabbitMQ "mp3" queue +┌──────────────────────────────────────────────────────┐ +│ Notification Service — 2 replicas (Pika + smtplib) │ +│ Sends email with file ID for download │ +└──────────────────────────────────────────────────────┘ +``` + +### Services + +| Service | Technology | Replicas | Access | Health Check | +|---------|-----------|----------|--------|-------------| +| Frontend | React + nginx | 1 | NodePort :30006 | HTTP GET / | +| Auth Service | Flask + PyJWT + psycopg2 | 2 | ClusterIP :5000 | HTTP GET /healthz | +| Gateway Service | Flask + PyMongo + Pika | 2 | NodePort :30002 | HTTP GET /healthz | +| Converter Service | Pika + MoviePy + ffmpeg | 4 | None (queue consumer) | Exec: test -f /tmp/healthy | +| Notification Service | Pika + smtplib | 2 | None (queue consumer) | Exec: test -f /tmp/healthy | +| MongoDB | mongo:4.0.8 | 1 (StatefulSet) | NodePort :30005 | TCP :27017 | +| PostgreSQL | postgres | 1 (Deployment) | NodePort :30003 | TCP :5432 | +| RabbitMQ | rabbitmq:3-management | 1 (StatefulSet) | NodePort :30004 | TCP :5672 | + +### Environments + +| Environment | Platform | Purpose | Cost | +|-------------|----------|---------|------| +| Production | AWS EKS eu-west-2 (m7i-flex.large) | Live traffic | ~$150/month | +| Staging | Docker Swarm (t2.micro EC2) | Pre-production via Jenkins | ~$10/month | +| Local | Docker Compose | Developer testing | Free | + +**Why Docker Swarm for staging:** A second EKS staging environment costs ~$0.40/hour (~$290/month). A Swarm staging environment on a single t2.micro costs ~$0.01/hour (~$7.50/month, free tier eligible). 97% cost reduction for a functionally equivalent testing environment. The Jenkins pipeline deploys to Swarm first, runs a smoke test, waits for human approval, then deploys to EKS. This directly connects the Docker Swarm bootcamp module to the Kubernetes production deployment. + +### Port Map + +| Port | Service | Type | Purpose | +|------|---------|------|---------| +| 30002 | Gateway | NodePort | Client API | +| 30003 | PostgreSQL | NodePort | Admin access | +| 30004 | RabbitMQ UI | NodePort | Queue management | +| 30005 | MongoDB | NodePort | Admin access | +| 30006 | Frontend | NodePort | Web interface | +| 30007 | Grafana | NodePort | Monitoring dashboard | +| 30008 | Alertmanager | NodePort | Alert management | + +--- + +## Repository Structure + +``` +vidcast/ +├── CLAUDE.md # THIS FILE +├── VIDCAST_UPGRADE_PLAN.md # Detailed improvement plan +├── MEDIAFLOW_COMPARISON.md # MediaFlow comparison analysis +├── README.md # Public-facing documentation +├── .gitignore # Comprehensive — secrets, state, artifacts +├── Jenkinsfile # Staging → Approval → Production pipeline +├── docker-compose.swarm.yml # Docker Swarm staging environment +├── DEPLOYMENT_CONFIG.md # GITIGNORED — your AWS + app configuration +├── DEPLOYMENT_HANDOVER.md # GITIGNORED — session state +├── DEPLOYMENT_REPORT.md # GITIGNORED — deployment timeline +│ +├── .github/ +│ └── workflows/ +│ ├── ci.yml # Lint + Trivy + build + push +│ └── cd.yml # Deploy to EKS +│ +├── terraform/ +│ ├── environments/ +│ │ └── dev/ +│ │ ├── main.tf # Root module +│ │ ├── variables.tf # Inputs +│ │ ├── outputs.tf # Cluster endpoint, node IP, kubeconfig cmd +│ │ ├── backend.tf # S3 + DynamoDB state +│ │ └── terraform.tfvars # GITIGNORED — actual values +│ └── modules/ +│ ├── vpc/ # VPC, 2 subnets, IGW, routes +│ ├── eks/ # Cluster + node group + OIDC +│ ├── iam/ # Cluster role, node role +│ └── security-groups/ # NodePort rules 30002-30008 +│ +├── Helm_charts/ +│ ├── MongoDB/ +│ ├── Postgres/ +│ └── RabbitMQ/ +│ +├── src/ +│ ├── auth-service/ +│ ├── gateway-service/ +│ ├── converter-service/ +│ ├── notification-service/ +│ └── frontend/ # React web app +│ ├── Dockerfile +│ ├── nginx.conf +│ ├── package.json +│ ├── src/ +│ └── manifest/ +│ +├── monitoring/ +│ ├── values.yaml +│ ├── dashboards/ +│ │ └── vidcast-operations.json +│ └── alerts/ +│ └── vidcast-alerts.yaml +│ +├── docs/ +│ ├── architecture.md +│ ├── deployment-guide.md +│ └── presentation-notes.md +│ +└── assets/ + └── video.mp4 +``` + +--- + +## Configuration Values (from DEPLOYMENT_CONFIG.md) + +Parse DEPLOYMENT_CONFIG.md before proceeding. Validate no bracket placeholders remain: +```bash +grep -n '\[.*\]' DEPLOYMENT_CONFIG.md +``` + +| Variable | Description | +|----------|-------------| +| YOUR_NAME | For deployment report | +| AWS_ACCOUNT_ID | Auto-detect: `aws sts get-caller-identity` | +| AWS_REGION | eu-west-2 (London) | +| CLUSTER_NAME | e.g., vidcast-cluster | +| NODE_INSTANCE_TYPE | m7i-flex.large (NEVER T-type — see constraints) | +| NODE_COUNT | 1 | +| VPC_ID | Leave blank to create new | +| DOCKER_HUB_USERNAME | Your Docker Hub username | +| APP_LOGIN_EMAIL | Login email for the app | +| APP_LOGIN_PASSWORD | App login password | +| GMAIL_ADDRESS | Gmail for sending notifications | +| GMAIL_APP_PASSWORD | 16-char app password (or SKIP) | +| MONGODB_USERNAME | MongoDB app user | +| MONGODB_PASSWORD | MongoDB password | +| POSTGRES_USERNAME | PostgreSQL username | +| POSTGRES_PASSWORD | PostgreSQL password | +| JWT_SECRET | Random 32+ char string | + +--- + +## Customisation Checklist + +After setting config values, update these files consistently: + +### MongoDB Credentials (3 files must match) +- `Helm_charts/MongoDB/values.yaml` → username, password +- `src/gateway-service/manifest/configmap.yaml` → MONGODB_VIDEOS_URI, MONGODB_MP3S_URI +- `src/converter-service/manifest/configmap.yaml` → MONGODB_URI + +### PostgreSQL Credentials (4 files must match) +- `Helm_charts/Postgres/values.yaml` → user, password, db +- `Helm_charts/Postgres/init.sql` → INSERT INTO auth_user +- `src/auth-service/manifest/secret.yaml` → PSQL_PASSWORD (base64) +- `src/auth-service/manifest/configmap.yaml` → DATABASE_USER + +### JWT Secret, Gmail, Docker Images +- `src/auth-service/manifest/secret.yaml` → JWT_SECRET (base64) +- `src/notification-service/manifest/secret.yaml` → GMAIL_ADDRESS, GMAIL_PASSWORD (base64) +- All 4 deployment YAML files → image name + +Generate and run `customise.sh` using sed to apply all substitutions atomically. +Validate: `grep -r "nasi\|sarcasm\|iambatmanthegoat" . --include="*.yaml" --include="*.sql"` + +--- + +## Part 1 — Base Deployment Phases (Original Project) + +These phases deploy the base application. If already complete, check DEPLOYMENT_HANDOVER.md and skip to Part 2. + +``` +Phase 0: Prerequisites (tools + AWS credentials + repo) +Phase 1: IAM roles (eks-cluster-role, eks-node-role) +Phase 2: VPC and networking (CLI only — no console) +Phase 3: EKS cluster + node group (~20 minutes) +Phase 4: Security group rules (30002-30005) +Phase 5: Customise files + apply bug fixes +Phase 6: Helm deployments (MongoDB → PostgreSQL → RabbitMQ) +Phase 7: PostgreSQL init (run init.sql) +Phase 8: RabbitMQ queues (via HTTP Management API) +Phase 9: Docker images (prebuilt or build+push) +Phase 10: Deploy microservices +Phase 11: End-to-end test +Phase 12: Deployment report +``` + +### Phase 1: IAM Roles +```bash +# Check before creating — skip if already exists +aws iam get-role --role-name eks-cluster-role 2>/dev/null || \ + aws iam create-role --role-name eks-cluster-role \ + --assume-role-policy-document '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"eks.amazonaws.com"},"Action":"sts:AssumeRole"}]}' +aws iam attach-role-policy --role-name eks-cluster-role \ + --policy-arn arn:aws:iam::aws:policy/AmazonEKSClusterPolicy + +aws iam get-role --role-name eks-node-role 2>/dev/null || \ + aws iam create-role --role-name eks-node-role \ + --assume-role-policy-document '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"ec2.amazonaws.com"},"Action":"sts:AssumeRole"}]}' +aws iam attach-role-policy --role-name eks-node-role \ + --policy-arn arn:aws:iam::aws:policy/AmazonEKSWorkerNodePolicy +aws iam attach-role-policy --role-name eks-node-role \ + --policy-arn arn:aws:iam::aws:policy/AmazonEKS_CNI_Policy +aws iam attach-role-policy --role-name eks-node-role \ + --policy-arn arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly +``` +Save role ARNs to DEPLOYMENT_HANDOVER.md. + +### Phase 2: VPC and Networking (only if VPC_ID blank) +```bash +VPC_ID=$(aws ec2 create-vpc --cidr-block 10.0.0.0/16 \ + --tag-specifications 'ResourceType=vpc,Tags=[{Key=Name,Value=vidcast-vpc}]' \ + --query Vpc.VpcId --output text) +IGW_ID=$(aws ec2 create-internet-gateway --query InternetGateway.InternetGatewayId --output text) +aws ec2 attach-internet-gateway --internet-gateway-id $IGW_ID --vpc-id $VPC_ID +SUBNET_1=$(aws ec2 create-subnet --vpc-id $VPC_ID --cidr-block 10.0.1.0/24 \ + --availability-zone eu-west-2a --query Subnet.SubnetId --output text) +SUBNET_2=$(aws ec2 create-subnet --vpc-id $VPC_ID --cidr-block 10.0.2.0/24 \ + --availability-zone eu-west-2b --query Subnet.SubnetId --output text) +aws ec2 create-tags --resources $SUBNET_1 $SUBNET_2 \ + --tags Key=kubernetes.io/role/elb,Value=1 +aws ec2 modify-subnet-attribute --subnet-id $SUBNET_1 --map-public-ip-on-launch +aws ec2 modify-subnet-attribute --subnet-id $SUBNET_2 --map-public-ip-on-launch +RTB=$(aws ec2 create-route-table --vpc-id $VPC_ID --query RouteTable.RouteTableId --output text) +aws ec2 create-route --route-table-id $RTB --destination-cidr-block 0.0.0.0/0 \ + --gateway-id $IGW_ID +aws ec2 associate-route-table --route-table-id $RTB --subnet-id $SUBNET_1 +aws ec2 associate-route-table --route-table-id $RTB --subnet-id $SUBNET_2 +``` +Save all IDs to DEPLOYMENT_HANDOVER.md. + +### Phase 3: EKS Cluster + +⚠️ NEVER use T-type instances. Use m7i-flex.large or M/C/R-series only. + +```bash +aws eks create-cluster --name vidcast-cluster --region eu-west-2 \ + --kubernetes-version 1.31 \ + --role-arn arn:aws:iam::ACCOUNT_ID:role/eks-cluster-role \ + --resources-vpc-config subnetIds=SUBNET_1,SUBNET_2,endpointPublicAccess=true + +aws eks wait cluster-active --name vidcast-cluster --region eu-west-2 +aws eks update-kubeconfig --name vidcast-cluster --region eu-west-2 + +aws eks create-nodegroup --cluster-name vidcast-cluster \ + --nodegroup-name vidcast-nodes \ + --node-role arn:aws:iam::ACCOUNT_ID:role/eks-node-role \ + --subnets SUBNET_1 SUBNET_2 \ + --instance-types m7i-flex.large \ + --scaling-config minSize=1,maxSize=2,desiredSize=1 \ + --ami-type AL2_x86_64 --region eu-west-2 + +aws eks wait nodegroup-active --cluster-name vidcast-cluster \ + --nodegroup-name vidcast-nodes --region eu-west-2 + +kubectl get nodes -o wide # capture EXTERNAL-IP as NODE_IP +``` + +### Phase 4: Security Group Rules +```bash +NODE_SG=$(aws ec2 describe-security-groups \ + --filters "Name=tag:kubernetes.io/cluster/vidcast-cluster,Values=owned" \ + --query "SecurityGroups[0].GroupId" --output text) +for PORT in 30002 30003 30004 30005 30006 30007 30008; do + aws ec2 authorize-security-group-ingress \ + --group-id $NODE_SG --protocol tcp --port $PORT --cidr 0.0.0.0/0 +done +``` + +### Phase 6: Helm Deployments +```bash +cd Helm_charts/MongoDB && helm install mongodb . && cd ../.. +kubectl get pods -w # wait for mongodb-0 Running +cd Helm_charts/Postgres && helm install postgres . && cd ../.. +kubectl get pods -w # wait for postgres Running +cd Helm_charts/RabbitMQ && helm install rabbitmq . && cd ../.. +kubectl get pods -w # wait for rabbitmq-0 Running +``` + +### Phase 7: PostgreSQL Init +```bash +PGPASSWORD=YOUR_POSTGRES_PASSWORD psql -h NODE_IP -p 30003 \ + -U YOUR_POSTGRES_USERNAME -d authdb -f Helm_charts/Postgres/init.sql +PGPASSWORD=YOUR_POSTGRES_PASSWORD psql -h NODE_IP -p 30003 \ + -U YOUR_POSTGRES_USERNAME -d authdb -c "SELECT * FROM auth_user;" +``` + +### Phase 8: RabbitMQ Queues (HTTP API — not browser) +```bash +curl -u guest:guest -X PUT http://NODE_IP:30004/api/queues/%2F/video \ + -H "Content-Type: application/json" -d '{"durable":true}' +curl -u guest:guest -X PUT http://NODE_IP:30004/api/queues/%2F/mp3 \ + -H "Content-Type: application/json" -d '{"durable":true}' +curl -s -u guest:guest http://NODE_IP:30004/api/queues | python3 -m json.tool | grep name +``` + +### Phase 10: Deploy Microservices +```bash +kubectl apply -f src/auth-service/manifest/ +kubectl rollout status deployment/auth +kubectl apply -f src/gateway-service/manifest/ +kubectl rollout status deployment/gateway +kubectl apply -f src/converter-service/manifest/ +kubectl rollout status deployment/converter +kubectl apply -f src/notification-service/manifest/ +kubectl rollout status deployment/notification +kubectl get pods # all should be Running +``` + +### Phase 11: End-to-End Test +```bash +# Login +JWT=$(curl -s -X POST http://NODE_IP:30002/login -u "EMAIL:PASSWORD") +echo "JWT: $JWT" + +# Upload +curl -X POST http://NODE_IP:30002/upload \ + -F "file=@assets/video.mp4" -H "Authorization: Bearer $JWT" + +# Monitor queues +sleep 5 +curl -s -u guest:guest http://NODE_IP:30004/api/queues/%2F/video \ + | python3 -m json.tool | grep messages + +# Download (use FILE_ID from email) +curl -X GET "http://NODE_IP:30002/download?fid=FILE_ID" \ + -H "Authorization: Bearer $JWT" -o output.mp3 +``` + +--- + +## Part 2 — Upgrade Phases + +These phases transform the base project into a production-grade platform. + +``` +Phase U0: Repo cleanup + .gitignore +Phase U1: Terraform IaC (VPC, IAM, EKS, SGs) +Phase U2: CI/CD Pipeline + [FULL ONLY]: Claude generates ci.yml, cd.yml, Jenkinsfile + [HYBRID ONLY]: Claude generates docker-compose.swarm.yml only + Developer manually writes ci.yml, cd.yml, Jenkinsfile +Phase U3: Security Hardening + [FULL ONLY]: Claude adds probes, limits, security contexts, health endpoints + [HYBRID ONLY]: Developer writes all security hardening manually +Phase U4: Monitoring Stack (Prometheus + Grafana + Alertmanager) +Phase U5: Frontend Application (React) +Phase U6: Documentation +``` + +### Phase U2: CI/CD Pipeline + +**GitHub Actions ci.yml — all modes:** + +Matrix strategy running lint + Trivy scan + build + push for all four services in parallel: +- Matrix: `service: [auth-service, gateway-service, converter-service, notification-service]` +- Lint: ruff check +- Build: docker build tagged with SHORT_SHA (`${GITHUB_SHA::7}`) +- Scan: aquasecurity/trivy-action with CRITICAL,HIGH severity, exit-code 1, ignore-unfixed +- Push: docker/login-action + docker push (main branch only) + +**GitHub Actions cd.yml — all modes:** + +Trigger: `workflow_run` on CI completion (main branch). Uses `aws-actions/configure-aws-credentials@v4`, then `aws eks update-kubeconfig`, then `kubectl set image` + `kubectl rollout status` for each service. + +**Jenkinsfile — key stages (all modes):** + +``` +Stage 1: Lint (ruff) +Stage 2: Build Images (parallel — all 4 services) +Stage 3: Security Scan (Trivy — all 4 images) +Stage 4: Push Images (Docker Hub) +Stage 5: Deploy Staging → docker stack deploy to Swarm EC2 +Stage 6: Smoke Test → curl -f http://${STAGING_IP}:8080/healthz || exit 1 +Stage 7: Approve Production → input message: 'Deploy to Production?' +Stage 8: Deploy Production → kubectl set image + kubectl rollout status +post { failure { kubectl rollout undo all services } } +``` + +**docker-compose.swarm.yml:** All 7 services with overlay networking, named volumes for MongoDB and PostgreSQL, failure_action: rollback on all services, restart_policy: on-failure max 3. + +**[HYBRID ONLY]:** Developer builds ci.yml, cd.yml, and Jenkinsfile manually. See HYBRID_IMPLEMENTATION_GUIDE_V2.md for step-by-step instructions. + +### Phase U3: Security Hardening + +**Health endpoints:** +- `src/auth-service/server.py`: add Flask `/healthz` route testing PostgreSQL connectivity +- `src/gateway-service/server.py`: add `/healthz` testing MongoDB + RabbitMQ. Add flask-cors to requirements.txt and `CORS(server)` after app creation +- `src/converter-service/consumer.py`: in main loop, `pathlib.Path("/tmp/healthy").touch()` after processing +- `src/notification-service/consumer.py`: same touch file pattern + +**Deployment manifests — all four services:** + +Probes (auth/gateway — HTTP, converter/notification — exec): +```yaml +livenessProbe: + httpGet: {path: /healthz, port: PORT} + initialDelaySeconds: 15 + periodSeconds: 10 + failureThreshold: 3 +readinessProbe: + httpGet: {path: /healthz, port: PORT} + initialDelaySeconds: 5 + periodSeconds: 5 + failureThreshold: 3 +``` + +Resources: +``` +Auth: cpu 50m/200m mem 64Mi/128Mi +Gateway: cpu 100m/300m mem 128Mi/256Mi +Converter: cpu 250m/500m mem 256Mi/512Mi +Notification: cpu 50m/100m mem 64Mi/128Mi +``` + +Security context (all pods): +```yaml +securityContext: + runAsNonRoot: true + runAsUser: 1000 + readOnlyRootFilesystem: true + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] +``` + +Converter and notification: add writable emptyDir volume at /tmp. + +**[HYBRID ONLY]:** Developer writes all security hardening manually. See HYBRID_IMPLEMENTATION_GUIDE_V2.md. + +### Phase U4: Monitoring Stack + +Install via Helm: `helm install monitoring prometheus-community/kube-prometheus-stack -f monitoring/values.yaml -n monitoring` + +Key config: Grafana NodePort 30007 (password: vidcast-demo), Alertmanager 30008, 7d retention, 10Gi storage. Disable etcd/scheduler/controller-manager (EKS manages these). + +Custom dashboard "VidCast Operations": pod status, restarts, node CPU/memory, queue depth. +Alert rules: PodCrashLoopBackOff (critical), HighNodeMemory >85% (warning), HighNodeCPU >85% (warning). + +### Phase U5: Frontend + +React + Vite + Tailwind CSS. Pages: Login, Upload, Download, Dashboard (Grafana iframe), Architecture (animated diagram). Nginx multi-stage Dockerfile, runs as non-root on port 8080. NodePort 30006. + +--- + +## Known Issues and Applied Fixes + +| # | Severity | Issue | Fix | +|---|----------|-------|-----| +| 1 | High | NameError in gateway-service/server.py — unauth_count.inc() | Remove lines 36 and 60 | +| 2 | High | JWT secret was "sarcasm" | Replace with 32+ char random string | +| 3 | High | Plaintext passwords in PostgreSQL | Document — acceptable for learning | +| 4 | High | Credentials in source YAML | .gitignore for secret.yaml files | +| 5 | Low | ffmpeg in notification Dockerfile | Remove if rebuilding images | +| 6 | Medium | No liveness/readiness probes | Fixed in Phase U3 | +| 7 | Medium | No resource limits | Fixed in Phase U3 | +| 8 | Medium | PostgreSQL has no PersistentVolume | Acceptable — use RDS in production | +| 9 | Low | prometheus-client unused in gateway | Remove if rebuilding | + +--- + +## AWS Account Constraints + +- **NEVER use T-type instances.** SCPs reject `CreditSpecification: unlimited` which EKS auto-generates for T-type. Every attempt fails after a long wait. +- **Working instance type:** m7i-flex.large (2 vCPU, 8 GB) +- **Region:** eu-west-2 (London) +- This constraint is already encoded as a validation block in the Terraform eks module. + +--- + +## Error Handling Rules + +1. Never silently continue past a non-zero exit code — stop, report, diagnose +2. Show every command before running it +3. Pod in CrashLoopBackOff → immediately `kubectl logs` and `kubectl describe pod`, fix before continuing +4. Never delete AWS resources without explicit user confirmation +5. Update DEPLOYMENT_HANDOVER.md AND DEPLOYMENT_REPORT.md after every phase +6. If GMAIL_APP_PASSWORD is SKIP, skip Gmail configuration — user checks queues manually +7. If usage limits are approaching, update both tracking files immediately before stopping + +--- + +## Cleanup and Destroy + +```bash +# Helm +helm uninstall mongodb postgres rabbitmq +helm uninstall monitoring -n monitoring + +# Kubernetes +kubectl delete -f src/auth-service/manifest/ +kubectl delete -f src/gateway-service/manifest/ +kubectl delete -f src/converter-service/manifest/ +kubectl delete -f src/notification-service/manifest/ +kubectl delete -f src/frontend/manifest/ + +# EKS +aws eks delete-nodegroup --cluster-name vidcast-cluster \ + --nodegroup-name vidcast-nodes --region eu-west-2 +aws eks wait nodegroup-deleted --cluster-name vidcast-cluster \ + --nodegroup-name vidcast-nodes --region eu-west-2 +aws eks delete-cluster --name vidcast-cluster --region eu-west-2 + +# Terraform (if used) +cd terraform/environments/dev && terraform destroy + +# VPC (if created manually — use IDs from DEPLOYMENT_HANDOVER.md) +aws ec2 delete-route-table --route-table-id RTB_ID +aws ec2 detach-internet-gateway --internet-gateway-id IGW_ID --vpc-id VPC_ID +aws ec2 delete-internet-gateway --internet-gateway-id IGW_ID +aws ec2 delete-subnet --subnet-id SUBNET_1_ID +aws ec2 delete-subnet --subnet-id SUBNET_2_ID +aws ec2 delete-vpc --vpc-id VPC_ID +``` diff --git a/DEPLOYMENT_HANDOVER_TEMPLATE.md b/DEPLOYMENT_HANDOVER_TEMPLATE.md new file mode 100644 index 0000000..0e7f6c2 --- /dev/null +++ b/DEPLOYMENT_HANDOVER_TEMPLATE.md @@ -0,0 +1,271 @@ +# DEPLOYMENT_HANDOVER_TEMPLATE.md +# ═══════════════════════════════════════════════════════════════════════════════ +# This file is automatically generated by Claude Code during deployment. +# If the deployment must pause or resume in a new session, this document +# contains everything the next Claude session needs to continue seamlessly. +# ═══════════════════════════════════════════════════════════════════════════════ + +# DO NOT EDIT THIS FILE MANUALLY +# It is regenerated after each major phase completes + +--- + +## Session Handover Document +**Project:** Video-to-Audio Python Microservices on AWS EKS +**Previous Session Operator:** [NAME] +**Previous Session Date/Time:** [TIMESTAMP] +**Current Session:** [NEW OPERATOR NAME] + +--- + +## What Has Been Completed + +### ✅ Phase 0: Prerequisites Check +- **Status:** PASSED +- **Timestamp:** [HH:MM] +- **Details:** All 7 tools verified (aws, kubectl, helm, docker, python, psql) +- **AWS Identity:** [AWS_ACCOUNT_ID / email] + +### ✅ Phase 1: IAM Roles +- **Status:** COMPLETED +- **EKS Cluster Role:** arn:aws:iam::[ACCOUNT]:role/eks-cluster-role +- **EKS Node Role:** arn:aws:iam::[ACCOUNT]:role/eks-node-role + +### ✅ Phase 2: VPC and Networking +- **Status:** COMPLETED +- **VPC_ID:** vpc-xxxxxxxxx +- **CIDR:** 10.0.0.0/16 +- **Public Subnet 1 (AZ-a):** subnet-xxxxxxxxx +- **Public Subnet 2 (AZ-b):** subnet-xxxxxxxxx +- **Internet Gateway:** igw-xxxxxxxxx +- **Route Table:** rtb-xxxxxxxxx +- **Security Group:** sg-xxxxxxxxx + +### ✅ Phase 3: EKS Cluster +- **Status:** COMPLETED +- **Cluster Name:** microservices +- **Cluster ARN:** arn:aws:eks:[REGION]:[ACCOUNT]:cluster/microservices +- **Kubernetes Version:** 1.31 +- **Node Group Name:** node-group +- **Node Instance Type:** t3.medium +- **Node Group ARN:** arn:aws:eks:[REGION]:[ACCOUNT]:nodegroup/microservices/node-group +- **Node Count:** 1 (Running) +- **NODE_IP (Public):** x.x.x.x +- **kubectl access:** ✅ Configured + +### ✅ Phase 4: Security Groups +- **Status:** COMPLETED +- **Ports opened:** 30002 (Gateway), 30003 (PostgreSQL), 30004 (RabbitMQ), 30005 (MongoDB) +- **Inbound rule:** 0.0.0.0/0 → All NodePorts + +### ✅ Phase 5: File Customisation +- **Status:** COMPLETED +- **Files modified:** 10 +- **customise.sh script location:** ./customise.sh +- **Verification:** grep confirmed no default values remain +- **Bug fixes applied:** Gateway NameError (unauth_count.inc()) removed + +### 🔄 Phase 6: Infrastructure Deployments +- **MongoDB:** + - **Status:** RUNNING ✅ + - **Pod:** mongodb-0 + - **NodePort:** 30005 + - **Connection string:** mongodb://mongouser:MongoSecure2024@x.x.x.x:30005/admin + - **Init:** ensure-users.js (users 'mongouser' created) + - **Databases:** videos, mp3s +- **PostgreSQL:** + - **Status:** RUNNING ✅ + - **Pod:** postgres-xxxxx + - **NodePort:** 30003 + - **Connection command:** psql -h x.x.x.x -p 30003 -U pguser -d authdb + - **Init status:** init.sql applied ✅ + - **Tables created:** auth_user (1 row inserted) +- **RabbitMQ:** + - **Status:** RUNNING ✅ + - **Pod:** rabbitmq-0 + - **AMQP port (internal):** 5672 + - **Management UI:** http://x.x.x.x:30004 (guest/guest) + - **Queues created:** video (durable: true), mp3 (durable: true) + +--- + +## What Still Needs to Be Done + +### ⏳ Phase 9: Docker Images +- **Status:** NOT STARTED +- **Strategy:** Use prebuilt images (nasi101/*) +- **Action required:** Confirm manifests reference nasi101/* images +- **Alternative if building:** Build and push DOCKER_HUB_USERNAME/* images + +### ⏳ Phase 10: Deploy Microservices +- **Status:** NOT STARTED +- **Required order:** auth → gateway → converter → notification +- **Replicas:** auth (2), gateway (2), converter (4), notification (2) +- **Prerequisites:** All Phase 6-8 must be complete +- **Verification needed:** kubectl get pods should show all Running + +### ⏳ Phase 11: End-to-End Test +- **Status:** NOT STARTED +- **Test sequence:** + 1. Login and get JWT token + 2. Upload assets/video.mp4 + 3. Verify queue activity + 4. Wait for notification email + 5. Download converted MP3 + +### ⏳ Phase 12: Final Report +- **Status:** NOT STARTED +- **Deliverables:** Final DEPLOYMENT_REPORT.md with cleanup commands + +--- + +## Configuration (For Next Session) + +**Critical values — write these down if continuing in a new session:** + +``` +AWS_ACCOUNT_ID = [ACCOUNT] +AWS_REGION = [REGION] +CLUSTER_NAME = microservices +NODE_INSTANCE_TYPE = t3.medium +NODE_COUNT = 1 +VPC_ID = vpc-xxxxxxxxx +PUBLIC_SUBNET_1_CIDR = 10.0.1.0/24 +PUBLIC_SUBNET_2_CIDR = 10.0.2.0/24 +DOCKER_HUB_USERNAME = [USERNAME] +USE_PREBUILT_IMAGES = true +APP_LOGIN_EMAIL = [EMAIL] +APP_LOGIN_PASSWORD = [PASSWORD] +GMAIL_ADDRESS = [GMAIL] +GMAIL_APP_PASSWORD = [APP_PASSWORD or SKIP] +MONGODB_USERNAME = mongouser +MONGODB_PASSWORD = MongoSecure2024 +POSTGRES_USERNAME = pguser +POSTGRES_PASSWORD = PgSecure2024 +JWT_SECRET = [SECRET] +NODE_IP = x.x.x.x +``` + +--- + +## To Resume in Next Session + +### IF you have credit remaining: + +1. Open Claude Code: + ```bash + cd /path/to/K8s-video-converter + claude + ``` + +2. Ask Claude to read this file: + ``` + Read DEPLOYMENT_HANDOVER.md first. Then continue from Phase 9 + (Docker images and microservices deployment). Use the NODE_IP and + configuration values from the handover document. + ``` + +3. Claude will ask for the remaining configuration (DOCKER_HUB_USERNAME, etc.) + +### IF you're starting a fresh session tomorrow: + +1. The handover document stays in the project root +2. All customisation changes (Phase 5) are persisted in the modified files +3. All AWS resources (VPC, cluster, databases) remain in your account +4. Just resume from Phase 9 — the expensive parts (VPC/cluster/databases) are already done + +### IF you hit Claude Code token limit: + +The handover document captures: +- All resource IDs created so far +- Which phases are complete +- Connection credentials for existing resources +- Exact configuration for remaining phases +- Commands to resume + +**Cost savings:** Completing 60% of the deployment in one session, then resuming the remaining 40% in the next, is much cheaper than restarting from Phase 0. + +--- + +## All Resource IDs (Needed for Cleanup) + +**Save these if you need to delete everything later:** + +```bash +# VPC and Networking +VPC_ID="vpc-xxxxxxxxx" +PUBLIC_SUBNET_1_ID="subnet-xxxxxxxxx" +PUBLIC_SUBNET_2_ID="subnet-xxxxxxxxx" +INTERNET_GATEWAY_ID="igw-xxxxxxxxx" +ROUTE_TABLE_ID="rtb-xxxxxxxxx" +SECURITY_GROUP_ID="sg-xxxxxxxxx" + +# EKS +CLUSTER_ARN="arn:aws:eks:[REGION]:[ACCOUNT]:cluster/microservices" +CLUSTER_NAME="microservices" +NODE_GROUP_ARN="arn:aws:eks:[REGION]:[ACCOUNT]:nodegroup/microservices/node-group" +NODE_GROUP_NAME="node-group" + +# Instance +NODE_IP="x.x.x.x" +NODE_INSTANCE_ID="i-xxxxxxxxx" +``` + +--- + +## Cleanup Commands (If You Need to Stop) + +**If you need to pause and resume in a new session, DO NOT run cleanup.** The resources will stay active and you can resume. + +**If you decide to stop the project entirely:** + +```bash +# Delete in this exact order: +helm uninstall mongodb +helm uninstall postgres +helm uninstall rabbitmq + +# Delete microservices (after Phase 10) +kubectl delete -f src/auth-service/manifest/ +kubectl delete -f src/gateway-service/manifest/ +kubectl delete -f src/converter-service/manifest/ +kubectl delete -f src/notification-service/manifest/ + +# Delete EKS node group FIRST, wait for completion +aws eks delete-nodegroup \ + --cluster-name microservices \ + --nodegroup-name node-group \ + --region [REGION] + +aws eks wait nodegroup-deleted \ + --cluster-name microservices \ + --nodegroup-name node-group \ + --region [REGION] + +# Then delete EKS cluster +aws eks delete-cluster \ + --name microservices \ + --region [REGION] + +# Delete VPC resources +aws ec2 delete-route-table --route-table-id rtb-xxxxxxxxx --region [REGION] +aws ec2 detach-internet-gateway --internet-gateway-id igw-xxxxxxxxx --vpc-id vpc-xxxxxxxxx --region [REGION] +aws ec2 delete-internet-gateway --internet-gateway-id igw-xxxxxxxxx --region [REGION] +aws ec2 delete-subnet --subnet-id subnet-xxxxxxxxx --region [REGION] +aws ec2 delete-subnet --subnet-id subnet-xxxxxxxxx --region [REGION] +aws ec2 delete-vpc --vpc-id vpc-xxxxxxxxx --region [REGION] +``` + +**Cost warning:** Every hour a cluster runs costs ~$0.10 (control plane) + ~$0.042/hour per t3.medium node. A forgotten cluster for 24 hours costs ~$3.50. Always delete if you're not actively using it. + +--- + +## Notes from Previous Session + +[OPERATOR NOTES GO HERE - any gotchas, workarounds, or special circumstances] + +--- + +**This document was auto-generated at [TIMESTAMP].** +**Next expected update:** After Phase 9 completion +**Last verified:** [TIMESTAMP] diff --git a/GITHUB_SECRETS_REQUIRED.md b/GITHUB_SECRETS_REQUIRED.md new file mode 100644 index 0000000..6d2da49 --- /dev/null +++ b/GITHUB_SECRETS_REQUIRED.md @@ -0,0 +1,56 @@ +# GitHub Secrets Required + +Configure these secrets in your GitHub repository under **Settings → Secrets and variables → Actions**. + +## CI Pipeline (ci.yml) + +| Secret Name | Description | Example | +|-------------|-------------|---------| +| `DOCKERHUB_USERNAME` | Docker Hub username | `johnbaabalola` | +| `DOCKERHUB_TOKEN` | Docker Hub access token (not password) | `dckr_pat_...` | + +## CD Pipeline (cd.yml) — OIDC, no static AWS keys + +CD authenticates to AWS via GitHub OIDC (short-lived credentials). There are no +`AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` secrets. The deploy role and OIDC +provider are created by Terraform (`terraform/modules/github-oidc`); after +`terraform apply`, read the role ARN from `terraform output github_actions_role_arn`. + +| Secret Name | Description | Source | +|-------------|-------------|--------| +| `AWS_DEPLOY_ROLE_ARN` | IAM role the workflow assumes via OIDC | `terraform output github_actions_role_arn` | +| `AWS_REGION` | AWS region | `eu-west-2` | +| `EKS_CLUSTER_NAME` | EKS cluster name | `vidcast-cluster` | +| `DOCKERHUB_USERNAME` | Used to set the deployment image name | your Docker Hub username | + +The workflow also needs `permissions: id-token: write` (already set in cd.yml) to +request the OIDC token. + +## Jenkins Pipeline (Jenkinsfile) + +Configure these in Jenkins under **Manage Jenkins → Credentials**. + +| Credential ID | Type | Description | +|---------------|------|-------------| +| `dockerhub-credentials` | Username/Password | Docker Hub login | +| `aws-credentials` | AWS Credentials | IAM key for EKS access | +| `swarm-staging-ip` | Secret text | IP address of Swarm staging EC2 | + +## How to Create a Docker Hub Access Token + +1. Log in to hub.docker.com +2. Account Settings → Security → New Access Token +3. Name it `github-actions-vidcast` +4. Copy the token immediately — it won't be shown again +5. Add as `DOCKERHUB_TOKEN` in GitHub Secrets + +## How to Create the AWS IAM User for CI/CD + +```bash +aws iam create-user --user-name vidcast-cicd +aws iam attach-user-policy --user-name vidcast-cicd \ + --policy-arn arn:aws:iam::aws:policy/AmazonEKSClusterPolicy +# For minimal permissions, use a custom policy allowing only: +# eks:UpdateClusterVersion, eks:DescribeCluster, and kubectl via kubeconfig +aws iam create-access-key --user-name vidcast-cicd +``` diff --git a/Helm_charts/MongoDB/templates/pvc.yaml b/Helm_charts/MongoDB/templates/pvc.yaml index cd90e16..5e678c1 100644 --- a/Helm_charts/MongoDB/templates/pvc.yaml +++ b/Helm_charts/MongoDB/templates/pvc.yaml @@ -6,6 +6,11 @@ spec: accessModes: - ReadWriteOnce resources: + # NOTE: the backing PersistentVolume (templates/pv.yaml) is 10Gi but this + # claim only requests 1Gi. The bind still succeeds (a PVC binds to any PV + # that is >= the request), but ~9Gi of the manual hostPath volume sits + # unused. Raise this to 10Gi to consume the full volume, or shrink the PV to + # match if 1Gi is the real intent. requests: storage: 1Gi storageClassName: manual diff --git a/Helm_charts/MongoDB/templates/secret.yaml b/Helm_charts/MongoDB/templates/secret.yaml index 8f280ab..aaf8d6f 100644 --- a/Helm_charts/MongoDB/templates/secret.yaml +++ b/Helm_charts/MongoDB/templates/secret.yaml @@ -4,8 +4,8 @@ metadata: name: mongodb-secret type: Opaque stringData: - MONGO_ROOT_USERNAME: {{ .Values.secret.root_username }} - MONGO_ROOT_PASSWORD: {{ .Values.secret.root_password }} - MONGO_USERNAME: {{ .Values.secret.username }} - MONGO_PASSWORD: {{ .Values.secret.password }} - MONGO_USERS_LIST: {{ .Values.secret.users_list }} + MONGO_ROOT_USERNAME: {{ .Values.secret.root_username | quote }} + MONGO_ROOT_PASSWORD: {{ .Values.secret.root_password | quote }} + MONGO_USERNAME: {{ .Values.secret.username | quote }} + MONGO_PASSWORD: {{ .Values.secret.password | quote }} + MONGO_USERS_LIST: {{ .Values.secret.users_list | quote }} diff --git a/Helm_charts/MongoDB/templates/statefulset.yaml b/Helm_charts/MongoDB/templates/statefulset.yaml index be88df1..87a49a7 100644 --- a/Helm_charts/MongoDB/templates/statefulset.yaml +++ b/Helm_charts/MongoDB/templates/statefulset.yaml @@ -16,7 +16,11 @@ spec: spec: containers: - name: mongodb - image: mongo:4.0.8 + # mongo:4.2 (wire v8) is the minimum the services' pinned PyMongo + # supports after the CVE dependency bump (commit 5c224a3). mongo:4.0.8 + # (wire v7) was rejected at runtime with PyMongo error + # "requires at least 8 (MongoDB 4.2)", breaking gateway/converter. + image: mongo:4.2 env: - name: MONGO_INITDB_ROOT_USERNAME_FILE value: /etc/k8-test/admin/MONGO_ROOT_USERNAME diff --git a/Helm_charts/MongoDB/values.yaml b/Helm_charts/MongoDB/values.yaml index c2677f3..dd0c1af 100644 --- a/Helm_charts/MongoDB/values.yaml +++ b/Helm_charts/MongoDB/values.yaml @@ -1,6 +1,6 @@ secret: - root_username: nasi - root_password: nasi1234 - username: nasi - password: nasi1234 - users_list: nasi \ No newline at end of file + root_username: mongouser + root_password: MongoSecure2024 + username: mongouser + password: MongoSecure2024 + users_list: mongouser \ No newline at end of file diff --git a/Helm_charts/Postgres/init.sql b/Helm_charts/Postgres/init.sql index 8f7b0c7..c173c4a 100644 --- a/Helm_charts/Postgres/init.sql +++ b/Helm_charts/Postgres/init.sql @@ -1,9 +1,28 @@ -CREATE TABLE auth_user ( +CREATE TABLE IF NOT EXISTS auth_user ( id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - email VARCHAR (255) NOT NULL, - password VARCHAR (255) NOT NULL + email VARCHAR (255) NOT NULL UNIQUE, + password VARCHAR (255) NOT NULL, + role VARCHAR (32) NOT NULL DEFAULT 'user', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); ---Add Username and Password for Admin User --- INSERT INTO auth_user (email, password) VALUES ('thomasfookins007helby@gmail.com', '123456'); -INSERT INTO auth_user (email, password) VALUES ('iambatmanthegoat@gmail.com', '123456'); \ No newline at end of file +-- SECURITY: the password column stores a bcrypt hash (NOT plaintext). The auth +-- service verifies logins with bcrypt.checkpw (constant-time) and hashes new +-- sign-ups with bcrypt.hashpw. The hashes below were generated locally from the +-- plaintext in DEPLOYMENT_CONFIG.md (gitignored) — only the hashes are committed, +-- never the plaintext. Regenerate with: +-- python3 -c "import bcrypt; print(bcrypt.hashpw(b'', bcrypt.gensalt(rounds=12)).decode())" +-- +-- RBAC: every row has a role. 'admin' unlocks Dashboard/Architecture/Users in the +-- frontend and any admin-gated backend endpoint; 'user' is the default for sign-ups. + +-- Seed admin accounts. ON CONFLICT makes this re-runnable on cluster rebuilds: +-- re-applying init.sql resets the seeded admins' role + password hash without +-- erroring on the UNIQUE(email) constraint. +INSERT INTO auth_user (email, password, role) +VALUES ('baabalola@gmail.com', '$2b$12$27w9I7SBkuawEIE9Is/nAennwQNfo16nwz.yQbuYBGUHIj4JUCs.6', 'admin') +ON CONFLICT (email) DO UPDATE SET role = EXCLUDED.role, password = EXCLUDED.password; + +INSERT INTO auth_user (email, password, role) +VALUES ('johnbsignups@gmail.com', '$2b$12$UAKcprFDrJ9bH84OSCjkXOXzJcARL.K1qIaiGl.casOtTtBeGjR76', 'admin') +ON CONFLICT (email) DO UPDATE SET role = EXCLUDED.role, password = EXCLUDED.password; diff --git a/Helm_charts/Postgres/values.yaml b/Helm_charts/Postgres/values.yaml index fd2d455..b50976b 100644 --- a/Helm_charts/Postgres/values.yaml +++ b/Helm_charts/Postgres/values.yaml @@ -6,6 +6,6 @@ service: container: image: postgres env: - user: nasi - password: cnd2023 + user: pguser + password: PgSecure2024 db: authdb \ No newline at end of file diff --git a/Helm_charts/RabbitMQ/templates/secret.yaml b/Helm_charts/RabbitMQ/templates/secret.yaml index d714599..ed0608b 100644 --- a/Helm_charts/RabbitMQ/templates/secret.yaml +++ b/Helm_charts/RabbitMQ/templates/secret.yaml @@ -2,6 +2,7 @@ apiVersion: v1 kind: Secret metadata: name: rabbitmq-secret +type: Opaque stringData: - PLACEHOLDER: "NONE" -type: Opaque \ No newline at end of file + RABBITMQ_DEFAULT_USER: {{ .Values.secret.default_user | quote }} + RABBITMQ_DEFAULT_PASS: {{ .Values.secret.default_pass | quote }} diff --git a/Helm_charts/RabbitMQ/values.yaml b/Helm_charts/RabbitMQ/values.yaml index 53003fa..a1b4521 100644 --- a/Helm_charts/RabbitMQ/values.yaml +++ b/Helm_charts/RabbitMQ/values.yaml @@ -1,3 +1,7 @@ service: name: rabbitmq - port: 15672 \ No newline at end of file + port: 15672 + +secret: + default_user: rabbituser + default_pass: RabbitSecure2024 \ No newline at end of file diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 0000000..9169850 --- /dev/null +++ b/Jenkinsfile @@ -0,0 +1,122 @@ +pipeline { + agent any + + environment { + DOCKERHUB = credentials('dockerhub-credentials') + AWS_CREDS = credentials('aws-credentials') + CLUSTER = 'vidcast-cluster' + REGION = 'eu-west-2' + BUILD_TAG = "${env.BUILD_NUMBER}-${env.GIT_COMMIT?.take(7) ?: 'unknown'}" + STAGING_IP = credentials('swarm-staging-ip') + } + + stages { + stage('Checkout') { + steps { + git branch: 'main', url: 'https://github.com/johnbaabalola/microservices-python-app.git' + } + } + + stage('Lint') { + steps { + sh 'pip install ruff && ruff check src/ --exclude src/frontend' + } + } + + stage('Build Images') { + parallel { + stage('Build Auth') { + steps { + sh "docker build -t vidcast/auth:${BUILD_TAG} src/auth-service/" + } + } + stage('Build Gateway') { + steps { + sh "docker build -t vidcast/gateway:${BUILD_TAG} src/gateway-service/" + } + } + stage('Build Converter') { + steps { + sh "docker build -t vidcast/converter:${BUILD_TAG} src/converter-service/" + } + } + stage('Build Notification') { + steps { + sh "docker build -t vidcast/notification:${BUILD_TAG} src/notification-service/" + } + } + } + } + + stage('Security Scan') { + steps { + sh """ + for svc in auth gateway converter notification; do + trivy image --severity CRITICAL,HIGH --exit-code 1 \ + --ignore-unfixed vidcast/\${svc}:${BUILD_TAG} + done + """ + } + } + + stage('Push Images') { + steps { + sh "echo \$DOCKERHUB_PSW | docker login -u \$DOCKERHUB_USR --password-stdin" + sh """ + for svc in auth gateway converter notification; do + docker push vidcast/\${svc}:${BUILD_TAG} + done + """ + } + } + + stage('Deploy Staging (Swarm)') { + steps { + sh """ + ssh -o StrictHostKeyChecking=no ubuntu@${STAGING_IP} \ + 'docker stack deploy -c docker-compose.swarm.yml vidcast' + """ + sh 'sleep 30' + } + } + + stage('Smoke Test Staging') { + steps { + sh "curl -f http://${STAGING_IP}:8080/healthz || exit 1" + } + } + + stage('Approve Production') { + steps { + input message: 'Staging tests passed. Deploy to Production?', ok: 'Deploy to Production' + } + } + + stage('Deploy Production (EKS)') { + steps { + sh """ + aws eks update-kubeconfig --name ${CLUSTER} --region ${REGION} + for svc in auth gateway converter notification; do + kubectl set image deployment/\${svc} \${svc}=vidcast/\${svc}:${BUILD_TAG} + kubectl rollout status deployment/\${svc} --timeout=120s + done + """ + } + } + } + + post { + failure { + sh """ + aws eks update-kubeconfig --name ${CLUSTER} --region ${REGION} || true + for svc in auth gateway converter notification; do + kubectl rollout undo deployment/\${svc} || true + done + """ + echo "PIPELINE FAILED — automatic rollback executed for all services" + } + success { + echo "Pipeline completed — build ${BUILD_TAG} deployed to production" + } + } +} diff --git a/PROJECT_SUMMARY.md b/PROJECT_SUMMARY.md new file mode 100644 index 0000000..794abae --- /dev/null +++ b/PROJECT_SUMMARY.md @@ -0,0 +1,578 @@ +# Project Summary — Video-to-MP3 Microservices on AWS EKS + +**Date:** 2026-05-30 +**Cluster:** `cba-microservices` (AWS EKS, `eu-west-2`) +**Node IP:** `13.42.28.15` +**Status:** Deployed and operational — end-to-end test passed + +--- + +## Table of Contents + +1. [What This Project Does](#1-what-this-project-does) +2. [High-Level Architecture](#2-high-level-architecture) +3. [Directory Structure](#3-directory-structure) +4. [Microservices — Detailed Breakdown](#4-microservices--detailed-breakdown) + - [Auth Service](#41-auth-service) + - [Gateway Service](#42-gateway-service) + - [Converter Service](#43-converter-service) + - [Notification Service](#44-notification-service) +5. [Infrastructure Services (Helm Charts)](#5-infrastructure-services-helm-charts) + - [MongoDB](#51-mongodb) + - [PostgreSQL](#52-postgresql) + - [RabbitMQ](#53-rabbitmq) +6. [Data Flow — Step by Step](#6-data-flow--step-by-step) +7. [Kubernetes Configuration](#7-kubernetes-configuration) +8. [Port Map](#8-port-map) +9. [Configuration and Credentials](#9-configuration-and-credentials) +10. [Known Issues and Applied Fixes](#10-known-issues-and-applied-fixes) +11. [Deployment Summary](#11-deployment-summary) +12. [Technology Stack](#12-technology-stack) + +--- + +## 1. What This Project Does + +This is a cloud-native microservices application that converts uploaded MP4 video files into MP3 audio files. It runs on AWS EKS (Elastic Kubernetes Service) and is fully event-driven: a video upload triggers an async conversion pipeline, and the user receives an email notification when the MP3 is ready to download. + +The project is primarily a learning exercise demonstrating: +- Python Flask microservices +- Kubernetes orchestration on AWS EKS +- Event-driven architecture with RabbitMQ +- GridFS binary storage in MongoDB +- JWT-based authentication +- Helm chart packaging + +--- + +## 2. High-Level Architecture + +``` +Client (HTTP) + │ + ▼ +┌─────────────────────────────────────────────────────┐ +│ Gateway Service (Flask :8080 → NodePort :30002) │ +│ │ +│ POST /login ──► Auth Service (:5000) │ +│ │ │ +│ ▼ │ +│ PostgreSQL (authdb.auth_user) │ +│ │ +│ POST /upload ──► MongoDB GridFS (videos DB) │ +│ ──► RabbitMQ "video" queue │ +│ │ +│ GET /download ─► MongoDB GridFS (mp3s DB) │ +│ ──► MP3 stream back to client │ +└─────────────────────────────────────────────────────┘ + │ + RabbitMQ "video" queue + │ + ▼ +┌─────────────────────────────────────────────────────┐ +│ Converter Service (4 replicas) │ +│ MoviePy + ffmpeg │ +│ │ +│ 1. Read video from MongoDB GridFS │ +│ 2. Write to temp file │ +│ 3. Extract audio → MP3 │ +│ 4. Store MP3 in MongoDB GridFS (mp3s DB) │ +│ 5. Publish to RabbitMQ "mp3" queue │ +└─────────────────────────────────────────────────────┘ + │ + RabbitMQ "mp3" queue + │ + ▼ +┌─────────────────────────────────────────────────────┐ +│ Notification Service (2 replicas) │ +│ smtplib + Gmail SMTP │ +│ │ +│ Sends email: "mp3 file_id: <fid> is now ready!" │ +└─────────────────────────────────────────────────────┘ +``` + +--- + +## 3. Directory Structure + +``` +microservices-python-app/ +│ +├── CLAUDE.md # Deployment orchestration master guide +├── DEPLOYMENT_CONFIG.md # All deployment-specific values +├── DEPLOYMENT_HANDOVER.md # Session state / resume document +├── DEPLOYMENT_REPORT.md # Post-deployment report +├── DEPLOYMENT_PROBLEMS.md # Problems log +├── PROJECT_SUMMARY.md # This file +├── README.md # Public-facing documentation +├── SESSION_SUMMARY.md # Narrative of the deployment session +├── Claude_Code_Deployment_Prompt.md # Prompt used to drive deployment +│ +├── customise.sh # Sed script that stamps credentials into all files +├── install_prerequisites.sh # WSL2 tool installer (kubectl, helm, aws cli, etc.) +├── deployment-ids.txt # AWS resource IDs recorded during deployment +│ +├── assets/ +│ ├── video.mp4 # Test input video +│ └── output.mp3 # Test output (downloaded during E2E test) +│ +├── Helm_charts/ +│ ├── MongoDB/ +│ │ ├── Chart.yaml +│ │ ├── values.yaml # MongoDB root & app credentials +│ │ └── templates/ +│ │ ├── statefulset.yaml # MongoDB StatefulSet (1 replica) +│ │ ├── service.yaml # NodePort :27017 → :30005 +│ │ ├── configmap.yaml # mongo.conf + ensure-users.js init script +│ │ ├── secret.yaml # Credentials injected as files +│ │ ├── pv.yaml # hostPath PV at /mnt/data (10Gi) +│ │ ├── pvc.yaml # PVC requesting 1Gi +│ │ └── storageclass.yaml # manual StorageClass +│ │ +│ ├── Postgres/ +│ │ ├── Chart.yaml +│ │ ├── values.yaml # DB user, password, db name +│ │ ├── init.sql # CREATE TABLE + INSERT auth_user row +│ │ └── templates/ +│ │ ├── postgres-deploy.yaml # Deployment (1 replica, no PV) +│ │ └── postgres-service.yaml # NodePort :5432 → :30003 +│ │ +│ └── RabbitMQ/ +│ ├── Chart.yaml +│ ├── values.yaml +│ └── templates/ +│ ├── statefulset.yaml # rabbitmq:3-management image +│ ├── service.yaml # NodePort :15672→:30004, ClusterIP :5672 +│ ├── configmap.yaml # Placeholder only +│ ├── secret.yaml # Placeholder only +│ ├── pv.yaml # hostPath PV at /mnt/data (10Gi) +│ ├── pvc.yaml # PVC requesting 1Gi +│ └── storageclasses.yaml # local-storage StorageClass +│ +└── src/ + ├── auth-service/ + │ ├── Dockerfile # python:3.10-slim, exposes :5000 + │ ├── requirements.txt # Flask, psycopg2, PyJWT + │ ├── server.py # /login and /validate endpoints + │ └── manifest/ + │ ├── deployment.yaml # 2 replicas, nasi101/auth image + │ ├── service.yaml # ClusterIP :5000 + │ ├── configmap.yaml # DB host, name, user, table + │ └── secret.yaml # PSQL_PASSWORD, JWT_SECRET (plaintext in stringData) + │ + ├── gateway-service/ + │ ├── Dockerfile # python:3.10-slim, exposes :8080 + │ ├── requirements.txt # Flask, PyMongo, Pika, Requests, prometheus-client + │ ├── server.py # /login, /upload, /download routes + │ ├── auth/validate.py # Calls auth-service /validate endpoint + │ ├── auth_svc/access.py # Calls auth-service /login endpoint + │ ├── storage/util.py # GridFS upload + RabbitMQ publish + │ └── manifest/ + │ ├── gateway-deploy.yaml # 2 replicas, nasi101/gateway image + │ ├── service.yaml # NodePort :8080 → :30002 + │ ├── configmap.yaml # AUTH_SVC_ADDRESS, MongoDB URIs + │ └── secret.yaml # Placeholder only + │ + ├── converter-service/ + │ ├── Dockerfile # python:3.10-slim + ffmpeg system package + │ ├── requirements.txt # Pika, PyMongo, MoviePy + │ ├── consumer.py # RabbitMQ consumer main loop + │ ├── convert/to_mp3.py # Core video→audio logic via MoviePy + │ └── manifest/ + │ ├── converter-deploy.yaml # 4 replicas, nasi101/converter image + │ ├── configmap.yaml # VIDEO_QUEUE, MP3_QUEUE, MONGODB_URI + │ └── secret.yaml # Placeholder only + │ + └── notification-service/ + ├── Dockerfile # python:3.10-slim (+ unnecessary ffmpeg) + ├── requirements.txt # Pika only + ├── consumer.py # RabbitMQ consumer main loop + ├── send/email.py # Gmail SMTP sender + └── manifest/ + ├── notification-deploy.yaml # 2 replicas, nasi101/notification image + ├── configmap.yaml # MP3_QUEUE, VIDEO_QUEUE + └── secret.yaml # GMAIL_ADDRESS, GMAIL_PASSWORD +``` + +--- + +## 4. Microservices — Detailed Breakdown + +### 4.1 Auth Service + +**Image:** `nasi101/auth` | **Replicas:** 2 | **Port:** ClusterIP :5000 + +**Purpose:** Validates user credentials against PostgreSQL and issues JWT tokens. Never exposed externally — only the Gateway calls it. + +**Endpoints:** + +| Method | Path | Input | Output | +|--------|------|-------|--------| +| POST | `/login` | HTTP Basic Auth (username:password) | JWT token string (HS256) | +| POST | `/validate` | `Authorization: Bearer <jwt>` header | Decoded JWT payload (JSON) | + +**Logic (`server.py`):** + +- `/login`: Reads `auth.username` and `auth.password` from the Basic Auth header. Queries `authdb.auth_user` via psycopg2 for a matching email row. If the email and password match exactly (plaintext comparison — no hashing), calls `CreateJWT()`. +- `CreateJWT()`: Issues an HS256 JWT with payload `{username, exp (+1 day), iat, admin: True}`. +- `/validate`: Splits `Authorization: Bearer <token>`, decodes using `JWT_SECRET`, returns the decoded dict as JSON with HTTP 200. + +**Environment Variables (from ConfigMap + Secret):** + +| Variable | Source | Value | +|----------|--------|-------| +| `DATABASE_HOST` | ConfigMap | `db` (PostgreSQL service name) | +| `DATABASE_NAME` | ConfigMap | `authdb` | +| `DATABASE_USER` | ConfigMap | `pguser` | +| `AUTH_TABLE` | ConfigMap | `auth_user` | +| `DATABASE_PASSWORD` | Secret | `PgSecure2024` | +| `JWT_SECRET` | Secret | `nt0l9Lr3D794SR1IS6Q6vPUu9A91x3AqL0` | + +**Dependencies:** PostgreSQL (`db:5432`) + +--- + +### 4.2 Gateway Service + +**Image:** `nasi101/gateway` | **Replicas:** 2 | **Port:** NodePort :30002 + +**Purpose:** Single entry point for all external clients. Handles authentication delegation, file upload to GridFS, and MP3 download from GridFS. + +**Endpoints:** + +| Method | Path | Auth Required | Description | +|--------|------|---------------|-------------| +| POST | `/login` | No | Proxies credentials to auth-service, returns JWT | +| POST | `/upload` | Yes (JWT) | Accepts one file, stores in MongoDB GridFS, publishes to RabbitMQ | +| GET | `/download?fid=<id>` | Yes (JWT) | Streams MP3 from MongoDB GridFS | + +**Logic (`server.py`):** + +- **Startup:** Creates two PyMongo connections (`mongo_video`, `mongo_mp3`), two GridFS instances (`fs_videos`, `fs_mp3s`), and one persistent RabbitMQ `BlockingConnection` with `heartbeat=0`. +- `/login`: Delegates to `auth_svc/access.py` which POSTs to `http://auth:5000/login` with the same Basic Auth credentials. +- `/upload`: Calls `auth/validate.py` to POST the JWT to `http://auth:5000/validate`. If valid and `access["admin"]` is True, calls `storage/util.py:upload()` which puts the file in `fs_videos` (GridFS), then publishes a durable JSON message `{video_fid, mp3_fid: null, username}` to the `video` RabbitMQ queue. +- `/download`: Same JWT validation. Retrieves the MP3 by `ObjectId(fid)` from `fs_mp3s` and streams it as a file attachment. + +**Sub-modules:** + +- `auth/validate.py` — Forwards Authorization header to auth service `/validate` +- `auth_svc/access.py` — Forwards Basic Auth to auth service `/login` +- `storage/util.py` — GridFS `put()` + `channel.basic_publish()` to `video` queue + +**Environment Variables:** + +| Variable | Source | Value | +|----------|--------|-------| +| `AUTH_SVC_ADDRESS` | ConfigMap | `auth:5000` | +| `MONGODB_VIDEOS_URI` | ConfigMap | `mongodb://mongouser:MongoSecure2024@mongodb:27017/videos?authSource=admin` | +| `MONGODB_MP3S_URI` | ConfigMap | `mongodb://mongouser:MongoSecure2024@mongodb:27017/mp3s?authSource=admin` | + +**Dependencies:** Auth Service (`auth:5000`), MongoDB (`mongodb:27017`), RabbitMQ (`rabbitmq:5672`) + +--- + +### 4.3 Converter Service + +**Image:** `nasi101/converter` | **Replicas:** 4 | **No external port** + +**Purpose:** Consumes video processing jobs from the RabbitMQ `video` queue, converts each MP4 to MP3 using MoviePy and ffmpeg, stores the result in MongoDB GridFS, then publishes a completion message to the `mp3` queue. + +**Logic (`consumer.py` + `convert/to_mp3.py`):** + +- `consumer.py`: + - Connects to MongoDB and creates two GridFS instances (`db_videos`, `db_mp3s`). + - Connects to RabbitMQ and calls `channel.basic_consume(queue="video", callback)`. + - On each message: calls `to_mp3.start()`. If it returns an error, calls `basic_nack()` (message goes back to queue). On success, calls `basic_ack()`. + +- `convert/to_mp3.py`: + 1. Deserializes the JSON message to get `video_fid`. + 2. Fetches the video binary from GridFS using `ObjectId(video_fid)`. + 3. Writes video bytes to a `NamedTemporaryFile`. + 4. Uses `moviepy.editor.VideoFileClip(tf.name).audio` to extract audio. + 5. Writes the audio to `{tmpdir}/{video_fid}.mp3`. + 6. Reads the MP3 file and stores it in `fs_mp3s` via `fs_mp3s.put(data)`. + 7. Publishes updated message `{video_fid, mp3_fid, username}` to the `mp3` queue as a durable message. + 8. Cleans up the temp file. + +**Environment Variables:** + +| Variable | Source | Value | +|----------|--------|-------| +| `VIDEO_QUEUE` | ConfigMap | `video` | +| `MP3_QUEUE` | ConfigMap | `mp3` | +| `MONGODB_URI` | ConfigMap | `mongodb://mongouser:MongoSecure2024@mongodb:27017/mp3s?authSource=admin` | + +**Dependencies:** MongoDB (`mongodb:27017`), RabbitMQ (`rabbitmq:5672`), `ffmpeg` (system package in container) + +--- + +### 4.4 Notification Service + +**Image:** `nasi101/notification` | **Replicas:** 2 | **No external port** + +**Purpose:** Consumes messages from the `mp3` RabbitMQ queue and sends an email to the user with the MP3 file ID so they can download it. + +**Logic (`consumer.py` + `send/email.py`):** + +- `consumer.py`: + - Connects to RabbitMQ and consumes from the `mp3` queue. + - On each message: calls `email.notification(body)`. Acks or nacks based on return value. + +- `send/email.py`: + 1. Deserializes message to get `mp3_fid` and `username` (the user's email address). + 2. Composes an `EmailMessage` with subject "MP3 Download" and body `"mp3 file_id: {mp3_fid} is now ready!"`. + 3. Opens an SMTP connection to `smtp.gmail.com:587`, calls `starttls()`, logs in with the Gmail App Password, and sends the message. + +**Environment Variables:** + +| Variable | Source | Value | +|----------|--------|-------| +| `MP3_QUEUE` | ConfigMap | `mp3` | +| `GMAIL_ADDRESS` | Secret | `baabalola@gmail.com` | +| `GMAIL_PASSWORD` | Secret | Gmail App Password (16 chars) | + +**Dependencies:** RabbitMQ (`rabbitmq:5672`), Gmail SMTP (`smtp.gmail.com:587`) + +--- + +## 5. Infrastructure Services (Helm Charts) + +### 5.1 MongoDB + +- **Image:** `mongo:4.0.8` +- **Type:** StatefulSet (1 replica) +- **Ports:** ClusterIP :27017, NodePort :30005 +- **Storage:** hostPath PV at `/mnt/data`, 10Gi capacity, 1Gi claimed +- **Databases:** `videos` (stores raw video GridFS), `mp3s` (stores converted MP3 GridFS) +- **Initialization:** `ensure-users.js` runs in `docker-entrypoint-initdb.d/` at first start. It authenticates as root, then iterates over `videos` and `mp3s` databases and creates the app user (`mongouser`) with `readWrite` role on each. +- **Credentials:** Injected via Kubernetes Secret as file mounts (root and app credentials stored separately). + +### 5.2 PostgreSQL + +- **Image:** `postgres` (latest) +- **Type:** Deployment (1 replica, **no PersistentVolume** — data lost on pod restart) +- **Ports:** ClusterIP :5432 (service name `db`), NodePort :30003 +- **Database:** `authdb` +- **Schema (init.sql):** + ```sql + CREATE TABLE auth_user ( + id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + email VARCHAR(255) NOT NULL, + password VARCHAR(255) NOT NULL + ); + INSERT INTO auth_user (email, password) VALUES ('johnbsignups@gmail.com', 'YourPassword123'); + ``` +- **Note:** `init.sql` is NOT automatically applied by the Helm chart. It must be run manually via `psql` after the pod starts (Phase 7 of deployment). +- **Credentials:** Passed as environment variables (`POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB`) from `values.yaml`. + +### 5.3 RabbitMQ + +- **Image:** `rabbitmq:3-management` (includes HTTP Management API) +- **Type:** StatefulSet (1 replica) +- **Ports:** + - ClusterIP :5672 (AMQP — used by all microservices) + - NodePort :30004 → :15672 (Management UI / HTTP API) +- **Storage:** hostPath PV at `/mnt/data`, 10Gi capacity, 1Gi claimed +- **Queues:** `video` and `mp3` (durable) — created manually via HTTP API in Phase 8 +- **Default credentials:** `guest:guest` + +--- + +## 6. Data Flow — Step by Step + +``` +Step 1: User POSTs /login with Basic Auth + → Gateway → Auth Service → PostgreSQL query + ← JWT token returned to client + +Step 2: User POSTs /upload with video file + Bearer JWT + → Gateway validates JWT (calls Auth Service /validate) + → File stored in MongoDB GridFS (videos DB) → returns video_fid + → Message published to RabbitMQ "video" queue: + { "video_fid": "<oid>", "mp3_fid": null, "username": "user@email.com" } + +Step 3: Converter Service (one of 4 replicas) picks up the message + → Reads video binary from MongoDB GridFS by video_fid + → Writes to temp file → MoviePy extracts audio → writes MP3 + → Stores MP3 in MongoDB GridFS (mp3s DB) → returns mp3_fid + → Publishes to RabbitMQ "mp3" queue: + { "video_fid": "<oid>", "mp3_fid": "<oid>", "username": "user@email.com" } + → Acks "video" message + +Step 4: Notification Service (one of 2 replicas) picks up the "mp3" message + → Sends email to username (user's email) via Gmail SMTP: + Subject: "MP3 Download" + Body: "mp3 file_id: <mp3_fid> is now ready!" + → Acks "mp3" message + +Step 5: User GETs /download?fid=<mp3_fid> with Bearer JWT + → Gateway validates JWT + → Retrieves MP3 binary from MongoDB GridFS by mp3_fid + → Streams file as attachment (saved as <fid>.mp3) +``` + +--- + +## 7. Kubernetes Configuration + +### Deployments Summary + +| Resource | Kind | Replicas | Image | Config Sources | +|----------|------|----------|-------|----------------| +| `auth` | Deployment | 2 | `nasi101/auth` | auth-configmap, auth-secret | +| `gateway` | Deployment | 2 | `nasi101/gateway` | gateway-configmap, gateway-secret | +| `converter` | Deployment | 4 | `nasi101/converter` | converter-configmap, converter-secret | +| `notification` | Deployment | 2 | `nasi101/notification` | notification-configmap, notification-secret | +| `mongodb` | StatefulSet | 1 | `mongo:4.0.8` | mongodb-configmap, mongodb-secret | +| `rabbitmq` | StatefulSet | 1 | `rabbitmq:3-management` | rabbitmq-configmap, rabbitmq-secret | +| `postgres-deploy` | Deployment | 1 | `postgres` | values.yaml inline env vars | + +### Rolling Update Strategy + +All deployments use `RollingUpdate` with `maxSurge` set generously (3–8) to allow quick rollouts. No `maxUnavailable` is set (defaults to 25%). No liveness or readiness probes are configured. + +### Persistent Storage + +| Service | PV Type | Capacity | Claim | Path | +|---------|---------|----------|-------|------| +| MongoDB | hostPath | 10Gi | 1Gi | `/mnt/data` | +| RabbitMQ | hostPath | 10Gi | 1Gi | `/mnt/data` | +| PostgreSQL | None | — | — | ephemeral | + +**Note:** Both MongoDB and RabbitMQ PVs use `/mnt/data` as the hostPath. This works with a single-node cluster but would conflict in a multi-node setup. + +--- + +## 8. Port Map + +| Port | Protocol | Service | Exposure | Purpose | +|------|----------|---------|----------|---------| +| 30002 | TCP | Gateway | NodePort (external) | Client API — login, upload, download | +| 30003 | TCP | PostgreSQL | NodePort (external) | Admin DB access, init.sql injection | +| 30004 | TCP | RabbitMQ | NodePort (external) | Management UI + HTTP API | +| 30005 | TCP | MongoDB | NodePort (external) | Admin DB access | +| 5000 | TCP | Auth Service | ClusterIP (internal) | JWT login + validation | +| 8080 | TCP | Gateway | ClusterIP (internal) | NodePort target | +| 5432 | TCP | PostgreSQL | ClusterIP (service: `db`) | Auth Service queries | +| 27017 | TCP | MongoDB | ClusterIP (service: `mongodb`) | Gateway + Converter GridFS | +| 5672 | TCP | RabbitMQ | ClusterIP | AMQP — Gateway, Converter, Notification | +| 15672 | TCP | RabbitMQ | ClusterIP (→ NodePort 30004) | Management UI | + +--- + +## 9. Configuration and Credentials + +All credentials are stamped into files by `customise.sh` using `sed`. The script reads from `DEPLOYMENT_CONFIG.md` and updates 8 files atomically, then validates no defaults remain. + +### Files Modified by `customise.sh` + +| File | What Changes | +|------|-------------| +| `Helm_charts/MongoDB/values.yaml` | MongoDB username + password | +| `Helm_charts/Postgres/values.yaml` | PostgreSQL user + password | +| `Helm_charts/Postgres/init.sql` | Login email + password inserted into auth_user | +| `src/auth-service/manifest/secret.yaml` | PSQL_PASSWORD + JWT_SECRET | +| `src/auth-service/manifest/configmap.yaml` | DATABASE_USER | +| `src/gateway-service/manifest/configmap.yaml` | MongoDB URIs (both databases) | +| `src/converter-service/manifest/configmap.yaml` | MONGODB_URI | +| `src/notification-service/manifest/secret.yaml` | GMAIL_ADDRESS + GMAIL_PASSWORD | + +### Secret Storage + +Secrets are stored in Kubernetes `Secret` objects using `stringData` (unencoded plaintext in YAML, base64 at rest in etcd). This is acceptable for a learning project but not production-ready — in production, use AWS Secrets Manager or Sealed Secrets. + +--- + +## 10. Known Issues and Applied Fixes + +| # | Severity | Issue | Location | Fix Applied | +|---|----------|-------|----------|-------------| +| 1 | **High** | `NameError: unauth_count` crashes Gateway pod on first unauthorized request | `gateway-service/server.py` lines 36, 60 | Removed `unauth_count.inc()` calls (Prometheus counter never defined) | +| 2 | **High** | JWT secret was "sarcasm" (default, trivially guessable) | `auth-service/manifest/secret.yaml` | Replaced with 34-char random string | +| 3 | **High** | Plaintext passwords in PostgreSQL (no hashing) | `init.sql`, `auth-service/server.py` | Not fixed — acceptable for learning; document only | +| 4 | **High** | Credentials in source YAML files | All `secret.yaml`, `values.yaml` | Not fixed — never push to a public repo | +| 5 | **Low** | `ffmpeg` installed in notification Dockerfile unnecessarily (+100MB) | `notification-service/Dockerfile` | Not fixed — acceptable; notification service doesn't use ffmpeg | +| 6 | **Medium** | No liveness/readiness probes on any deployment | All deployment manifests | Out of scope for this deployment | +| 7 | **Medium** | No resource limits/requests on any deployment | All deployment manifests | Out of scope for this deployment | +| 8 | **Medium** | PostgreSQL has no PersistentVolume (data lost on restart) | `Helm_charts/Postgres/` | Acceptable for learning; use RDS in production | +| 9 | **Low** | `prometheus-client` in gateway requirements.txt but unused | `gateway-service/requirements.txt` | Not fixed — dead dependency only | + +--- + +## 11. Deployment Summary + +### AWS Resources Created + +| Resource | ID / Value | +|----------|-----------| +| Region | `eu-west-2` | +| EKS Cluster | `cba-microservices` | +| Node Instance | `m7i-flex.large` (2 vCPU / 8 GB RAM) | +| Node Instance ID | `i-0d93e8c9a1ce8cfc8` | +| Node External IP | `13.42.28.15` | +| EKS Cluster Role | `eks-cluster-role` | +| EKS Node Role | `eks-node-role` | + +### Deployment Phases + +| Phase | Name | Status | +|-------|------|--------| +| 0 | Prerequisites | Complete | +| 1 | IAM Roles | Complete | +| 2 | VPC / Networking | Complete | +| 3 | EKS Cluster + Node Group | Complete | +| 4 | Security Group Rules | Complete | +| 5 | File Customisation + Bug Fixes | Complete | +| 6 | Helm Deployments (MongoDB, Postgres, RabbitMQ) | Complete | +| 7 | PostgreSQL Init (init.sql) | Complete | +| 8 | RabbitMQ Queue Creation | Complete | +| 9 | Docker Images (prebuilt nasi101/*) | Complete | +| 10 | Microservice Deployments | Complete | +| 11 | End-to-End Test | Complete — output.mp3 downloaded | +| 12 | Final Report | Complete | + +### Notable Deployment Challenge + +**T-type instance failure (~39 min lost):** +The initial t3.medium node group reached `CREATE_FAILED` with error `AsgInstanceLaunchFailures: InvalidParameterCombination`. Root cause: EKS auto-generates `CreditSpecification: unlimited` for T-type instances, which this AWS account's SCPs reject. Resolution: switched to `m7i-flex.large`. + +**Rule for this account:** Always use M/C/R-series instances. Never use T-type instances. + +### Live API Endpoints + +```bash +# Login +curl -X POST http://13.42.28.15:30002/login -u "johnbsignups@gmail.com:YourPassword123" + +# Upload (replace $JWT with token from login) +curl -X POST http://13.42.28.15:30002/upload \ + -F "file=@assets/video.mp4" \ + -H "Authorization: Bearer $JWT" + +# Download (replace FILE_ID from email notification) +curl -X GET "http://13.42.28.15:30002/download?fid=FILE_ID" \ + -H "Authorization: Bearer $JWT" -o output.mp3 + +# RabbitMQ Management UI +open http://13.42.28.15:30004 # guest:guest +``` + +--- + +## 12. Technology Stack + +| Layer | Technology | Version | Notes | +|-------|-----------|---------|-------| +| HTTP framework | Flask | 2.2.2 | All 4 microservices | +| JWT | PyJWT | 2.6.0 | HS256 signing | +| PostgreSQL driver | psycopg2 | 2.9.5 | Auth service only | +| MongoDB driver | PyMongo + Flask-PyMongo | 4.3.3 | Gateway + Converter | +| RabbitMQ client | Pika | 1.3.1 | Gateway, Converter, Notification | +| Video conversion | MoviePy | 1.0.3 | Converter service | +| Audio extraction | ffmpeg | system pkg | Converter container | +| Container runtime | Docker | — | python:3.10-slim-bullseye base | +| Orchestration | Kubernetes (AWS EKS) | 1.31 | Single node group | +| Helm | Helm | — | MongoDB, Postgres, RabbitMQ charts | +| Cloud | AWS | — | EKS, EC2 (m7i-flex.large) | +| Storage | AWS EBS / hostPath PV | — | MongoDB + RabbitMQ | +| Email | Gmail SMTP | TLS 587 | App Password auth | diff --git a/README.md b/README.md index 0ac9c72..8d65df7 100644 --- a/README.md +++ b/README.md @@ -1,274 +1,251 @@ -# Devops Project: video-converter -Converting mp4 videos to mp3 in a microservices architecture. +# VidCast — Video-to-Audio Microservices Platform -## Architecture - -<p align="center"> - <img src="./Project documentation/ProjectArchitecture.png" width="600" title="Architecture" alt="Architecture"> - </p> - -## Deploying a Python-based Microservice Application on AWS EKS - -### Introduction - -This document provides a step-by-step guide for deploying a Python-based microservice application on AWS Elastic Kubernetes Service (EKS). The application comprises four major microservices: `auth-server`, `converter-module`, `database-server` (PostgreSQL and MongoDB), and `notification-server`. - -### Prerequisites - -Before you begin, ensure that the following prerequisites are met: - -1. **Create an AWS Account:** If you do not have an AWS account, create one by following the steps [here](https://docs.aws.amazon.com/streams/latest/dev/setting-up.html). - -2. **Install Helm:** Helm is a Kubernetes package manager. Install Helm by following the instructions provided [here](https://helm.sh/docs/intro/install/). - -3. **Python:** Ensure that Python is installed on your system. You can download it from the [official Python website](https://www.python.org/downloads/). - -4. **AWS CLI:** Install the AWS Command Line Interface (CLI) following the official [installation guide](https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html). - -5. **Install kubectl:** Install the latest stable version of `kubectl` on your system. You can find installation instructions [here](https://kubernetes.io/docs/tasks/tools/). - -6. **Databases:** Set up PostgreSQL and MongoDB for your application. - -### High Level Flow of Application Deployment - -Follow these steps to deploy your microservice application: - -1. **MongoDB and PostgreSQL Setup:** Create databases and enable automatic connections to them. - -2. **RabbitMQ Deployment:** Deploy RabbitMQ for message queuing, which is required for the `converter-module`. - -3. **Create Queues in RabbitMQ:** Before deploying the `converter-module`, create two queues in RabbitMQ: `mp3` and `video`. - -4. **Deploy Microservices:** - - **auth-server:** Navigate to the `auth-server` manifest folder and apply the configuration. - - **gateway-server:** Deploy the `gateway-server`. - - **converter-module:** Deploy the `converter-module`. Make sure to provide your email and password in `converter/manifest/secret.yaml`. - - **notification-server:** Configure email for notifications and two-factor authentication (2FA). - -5. **Application Validation:** Verify the status of all components by running: - ```bash - kubectl get all - ``` - -6. **Destroying the Infrastructure** - - -### Low Level Steps - -#### Cluster Creation - -1. **Log in to AWS Console:** - - Access the AWS Management Console with your AWS account credentials. - -2. **Create eksCluster IAM Role** - - Follow the steps mentioned in [this](https://docs.aws.amazon.com/eks/latest/userguide/service_IAM_role.html) documentation using root user - - After creating it will look like this: - - <p align="center"> - <img src="./Project documentation/ekscluster_role.png" width="600" title="ekscluster_role" alt="ekscluster_role"> - </p> +**Turn video recordings into podcast-ready audio.** - - Please attach `AmazonEKS_CNI_Policy` explicitly if it is not attached by default +VidCast is a production-grade Python microservices platform running on AWS EKS. Upload an MP4, and the platform converts it to MP3 asynchronously — then emails you a download link. Built to demonstrate event-driven architecture, container security, CI/CD automation, and infrastructure as code. -3. **Create Node Role - AmazonEKSNodeRole** - - Follow the steps mentioned in [this](https://docs.aws.amazon.com/eks/latest/userguide/create-node-role.html#create-worker-node-role) documentation using root user - - Please note that you do NOT need to configure any VPC CNI policy mentioned after step 5.e under Creating the Amazon EKS node IAM role - - Simply attach the following policies to your role once you have created `AmazonEKS_CNI_Policy` , `AmazonEBSCSIDriverPolicy` , `AmazonEC2ContainerRegistryReadOnly` - incase it is not attached by default - - Your AmazonEKSNodeRole will look like this: +--- -<p align="center"> - <img src="./Project documentation/node_iam.png" width="600" title="Node_IAM" alt="Node_IAM"> - </p> +## What's Inside -4. **Open EKS Dashboard:** - - Navigate to the Amazon EKS service from the AWS Console dashboard. +| Component | Technology | What it does | +|-----------|-----------|--------------| +| Frontend | React 18 + nginx | Web interface — login, upload, download, monitoring dashboard | +| Gateway API | Flask + GridFS + Pika | Entry point — handles uploads, downloads, JWT validation | +| Auth Service | Flask + PyJWT + psycopg2 | Issues and validates JWT tokens against PostgreSQL | +| Converter | Pika + MoviePy + ffmpeg | 4 worker pods consuming RabbitMQ, converting MP4 → MP3 | +| Notification | Pika + smtplib | 2 worker pods sending email with download link | +| MongoDB | mongo:4.0.8 StatefulSet | Stores video and MP3 files via GridFS | +| PostgreSQL | postgres Deployment | User credentials for auth | +| RabbitMQ | rabbitmq:3-management | Message broker — video queue and mp3 queue | -5. **Create EKS Cluster:** - - Click "Create cluster." - - Choose a name for your cluster. - - Configure networking settings (VPC, subnets). - - Choose the `eksCluster` IAM role that was created above - - Review and create the cluster. - -6. **Cluster Creation:** - - Wait for the cluster to provision, which may take several minutes. - -7. **Cluster Ready:** - - Once the cluster status shows as "Active," you can now create node groups. - -#### Node Group Creation +## Architecture -1. In the "Compute" section, click on "Add node group." +``` +Browser + │ + ▼ +Frontend (React, NodePort :30006) + │ + ▼ +Gateway (Flask :8080, NodePort :30002) + ├── /login ──► Auth Service (:5000) ──► PostgreSQL (:5432) + ├── /upload ──► MongoDB GridFS ──► RabbitMQ "video" queue + └── /download ◄── MongoDB GridFS + │ + RabbitMQ "video" queue + │ + Converter ×4 (ffmpeg) + ├── fetch video from MongoDB + ├── convert to MP3 + ├── store MP3 in MongoDB + └── publish to RabbitMQ "mp3" queue + │ + Notification ×2 (smtplib) + └── email file ID to user +``` -2. Choose the AMI (default), instance type (e.g., t3.medium), and the number of nodes (attach a screenshot here). +--- -3. Click "Create node group." +## Infrastructure -#### Adding inbound rules in Security Group of Nodes +- **Platform:** AWS EKS eu-west-2 (London) +- **Node type:** m7i-flex.large — 2 vCPU / 8 GB RAM +- **IaC:** Terraform modules for VPC, IAM, EKS, security groups +- **Helm charts:** MongoDB, PostgreSQL, RabbitMQ +- **CI/CD:** GitHub Actions (lint → Trivy scan → build → push → EKS deploy) +- **Staging:** Docker Swarm on EC2 t2.micro (97% cheaper than a second EKS cluster) +- **Monitoring:** kube-prometheus-stack — Grafana :30007, Alertmanager :30008 -**NOTE:** Ensure that all the necessary ports are open in the node security group. +--- -<p align="center"> - <img src="./Project documentation/inbound_rules_sg.png" width="600" title="Inbound_rules_sg" alt="Inbound_rules_sg"> - </p> +## Quick Start — Deploy to AWS -#### Enable EBS CSI Addon -1. enable addon `ebs csi` this is for enabling pvcs once cluster is created +### Prerequisites -<p align="center"> - <img src="./Project documentation/ebs_addon.png" width="600" title="ebs_addon" alt="ebs_addon"> - </p> +```bash +# Tools required +aws --version # AWS CLI v2 +kubectl version # kubectl 1.31+ +helm version # Helm 3.x +terraform version # Terraform 1.5+ +``` -#### Deploying your application on EKS Cluster +### 1 — Provision infrastructure with Terraform -1. Clone the code from this repository. +```bash +cd terraform/environments/dev -2. Set the cluster context: - ``` - aws eks update-kubeconfig --name <cluster_name> --region <aws_region> - ``` +# Copy and fill in your values +cp terraform.tfvars.example terraform.tfvars +# Edit terraform.tfvars with your state bucket name etc. -### Commands +terraform init \ + -backend-config="bucket=YOUR_STATE_BUCKET" \ + -backend-config="key=vidcast/dev/terraform.tfstate" \ + -backend-config="region=eu-west-2" \ + -backend-config="dynamodb_table=vidcast-terraform-locks" -Here are some essential Kubernetes commands for managing your deployment: +terraform plan +terraform apply +``` +> **Note:** Never use T-type instances on this account. The Terraform EKS module includes a validation block that rejects them. Use `m7i-flex.large` or any M/C/R-series type. -### MongoDB +### 2 — Deploy infrastructure services -To install MongoDB, set the database username and password in `values.yaml`, then navigate to the MongoDB Helm chart folder and run: +```bash +# Connect kubectl to the new cluster +aws eks update-kubeconfig --name vidcast-cluster --region eu-west-2 -``` -cd Helm_charts/MongoDB -helm install mongo . +# Deploy MongoDB, PostgreSQL, RabbitMQ +cd Helm_charts/MongoDB && helm install mongodb . && cd ../.. +kubectl wait --for=condition=ready pod/mongodb-0 --timeout=120s +cd Helm_charts/Postgres && helm install postgres . && cd ../.. +cd Helm_charts/RabbitMQ && helm install rabbitmq . && cd ../.. ``` -Connect to the MongoDB instance using: +### 3 — Initialise PostgreSQL -``` -mongosh mongodb://<username>:<pwd>@<nodeip>:30005/mp3s?authSource=admin +```bash +NODE_IP=$(kubectl get nodes -o jsonpath='{.items[0].status.addresses[?(@.type=="ExternalIP")].address}') +PGPASSWORD=YOUR_POSTGRES_PASSWORD psql -h $NODE_IP -p 30003 \ + -U YOUR_POSTGRES_USERNAME -d authdb -f Helm_charts/Postgres/init.sql ``` -### PostgreSQL +### 4 — Create RabbitMQ queues -Set the database username and password in `values.yaml`. Install PostgreSQL from the PostgreSQL Helm chart folder and initialize it with the queries in `init.sql`. For PowerShell users: - -``` -cd .. -cd Postgres -helm install postgres . +```bash +curl -u guest:guest -X PUT http://$NODE_IP:30004/api/queues/%2F/video \ + -H "Content-Type: application/json" -d '{"durable":true}' +curl -u guest:guest -X PUT http://$NODE_IP:30004/api/queues/%2F/mp3 \ + -H "Content-Type: application/json" -d '{"durable":true}' ``` -Connect to the Postgres database and copy all the queries from the "init.sql" file. -``` -psql 'postgres://<username>:<pwd>@<nodeip>:30003/authdb' -``` - -### RabbitMQ +### 5 — Deploy microservices -Deploy RabbitMQ by running: - -``` -helm install rabbitmq . +```bash +kubectl apply -f src/auth-service/manifest/ +kubectl apply -f src/gateway-service/manifest/ +kubectl apply -f src/converter-service/manifest/ +kubectl apply -f src/notification-service/manifest/ +kubectl apply -f src/frontend/manifest/ +kubectl get pods # all should reach Running ``` -Ensure you have created two queues in RabbitMQ named `mp3` and `video`. To create queues, visit `<nodeIp>:30004>` and use default username `guest` and password `guest` +### 6 — Test end-to-end -**NOTE:** Ensure that all the necessary ports are open in the node security group. +```bash +# Login +TOKEN=$(curl -s -X POST http://$NODE_IP:30002/login -u "EMAIL:PASSWORD") -### Apply the manifest file for each microservice: +# Upload +curl -X POST http://$NODE_IP:30002/upload \ + -F "file=@assets/video.mp4" -H "Authorization: Bearer $TOKEN" -- **Auth Service:** - ``` - cd auth-service/manifest - kubectl apply -f . - ``` - -- **Gateway Service:** - ``` - cd gateway-service/manifest - kubectl apply -f . - ``` - -- **Converter Service:** - ``` - cd converter-service/manifest - kubectl apply -f . - ``` +# Download (use file_id from notification email) +curl -X GET "http://$NODE_IP:30002/download?fid=FILE_ID" \ + -H "Authorization: Bearer $TOKEN" -o output.mp3 +``` -- **Notification Service:** - ``` - cd notification-service/manifest - kubectl apply -f . - ``` +--- -### Application Validation +## CI/CD Pipeline -After deploying the microservices, verify the status of all components by running: +Push to `main` triggers the pipeline automatically: ``` -kubectl get all +push to main + └── GitHub Actions ci.yml + ├── ruff lint (Python) + ├── Docker build × 4 services (matrix) + ├── Trivy scan (CRITICAL + HIGH — fails build if found) + └── Push to Docker Hub (tagged with short git SHA) + └── GitHub Actions cd.yml + ├── aws eks update-kubeconfig + └── kubectl set image × 4 deployments ``` -### Notification Configuration +Jenkins pipeline (`Jenkinsfile`) mirrors the same stages for enterprise environments, adding a Docker Swarm staging deploy and a manual approval gate before production. +See `GITHUB_SECRETS_REQUIRED.md` for the secrets to configure. +--- -For configuring email notifications and two-factor authentication (2FA), follow these steps: +## Monitoring -1. Go to your Gmail account and click on your profile. +```bash +helm repo add prometheus-community https://prometheus-community.github.io/helm-charts +helm repo update +helm install monitoring prometheus-community/kube-prometheus-stack \ + -f monitoring/values.yaml -n monitoring --create-namespace -2. Click on "Manage Your Google Account." - -3. Navigate to the "Security" tab on the left side panel. - -4. Enable "2-Step Verification." +kubectl apply -f monitoring/alerts/vidcast-alerts.yaml +``` -5. Search for the application-specific passwords. You will find it in the settings. +| Dashboard | URL | Credentials | +|-----------|-----|-------------| +| Grafana — VidCast Operations | `http://NODE_IP:30007` | admin / vidcast-demo | +| Alertmanager | `http://NODE_IP:30008` | — | -6. Click on "Other" and provide your name. +--- -7. Click on "Generate" and copy the generated password. +## Security -8. Paste this generated password in `notification-service/manifest/secret.yaml` along with your email. +- All pods run as non-root (uid 1000), read-only root filesystem, capabilities dropped +- Resource limits on every container — converters can't starve gateway/auth +- HTTP health probes on auth + gateway; exec probes on converter + notification +- Secrets gitignored — never committed +- Images scanned with Trivy before push; tagged with git SHA (no `:latest` in production) -Run the application through the following API calls: +--- -# API Definition +## Teardown -- **Login Endpoint** - ```http request - POST http://nodeIP:30002/login - ``` +```bash +# Microservices +kubectl delete -f src/auth-service/manifest/ +kubectl delete -f src/gateway-service/manifest/ +kubectl delete -f src/converter-service/manifest/ +kubectl delete -f src/notification-service/manifest/ +kubectl delete -f src/frontend/manifest/ - ```console - curl -X POST http://nodeIP:30002/login -u <email>:<password> - ``` - Expected output: success! +# Helm +helm uninstall mongodb postgres rabbitmq +helm uninstall monitoring -n monitoring -- **Upload Endpoint** - ```http request - POST http://nodeIP:30002/upload - ``` +# Infrastructure +cd terraform/environments/dev +terraform destroy +``` - ```console - curl -X POST -F 'file=@./video.mp4' -H 'Authorization: Bearer <JWT Token>' http://nodeIP:30002/upload - ``` - - Check if you received the ID on your email. +--- -- **Download Endpoint** - ```http request - GET http://nodeIP:30002/download?fid=<Generated file identifier> - ``` - ```console - curl --output video.mp3 -X GET -H 'Authorization: Bearer <JWT Token>' "http://nodeIP:30002/download?fid=<Generated fid>" - ``` +## Bugs Fixed -## Destroying the Infrastructure +| # | Severity | Issue | Fix | +|---|----------|-------|-----| +| 1 | High | `unauth_count.inc()` NameError in gateway service crashes pod on any 401 response | Removed 2 stale Prometheus stub lines | +| 2 | High | JWT secret was `"sarcasm"` (base64) — trivially guessable | Replaced with 34-char random string | -To clean up the infrastructure, follow these steps: +--- -1. **Delete the Node Group:** Delete the node group associated with your EKS cluster. +## Repository Structure -2. **Delete the EKS Cluster:** Once the nodes are deleted, you can proceed to delete the EKS cluster itself. +``` +├── .github/workflows/ # CI (lint+scan+build+push) and CD (EKS deploy) +├── Helm_charts/ # MongoDB, PostgreSQL, RabbitMQ Helm charts +├── Jenkinsfile # Enterprise CI/CD pipeline with Swarm staging +├── docker-compose.swarm.yml # Docker Swarm staging environment +├── monitoring/ # kube-prometheus-stack values, dashboard, alerts +├── src/ +│ ├── auth-service/ +│ ├── converter-service/ +│ ├── frontend/ # React web app + nginx + Kubernetes manifests +│ ├── gateway-service/ +│ └── notification-service/ +└── terraform/ + ├── environments/dev/ # Root module (main, variables, outputs, backend) + └── modules/ # vpc, iam, eks, security-groups +``` +This is an edit to trigger CI, which builds the Docker images \ No newline at end of file diff --git a/VIDCAST_UPGRADE_PLAN.md b/VIDCAST_UPGRADE_PLAN.md new file mode 100644 index 0000000..953bc87 --- /dev/null +++ b/VIDCAST_UPGRADE_PLAN.md @@ -0,0 +1,634 @@ +# VidCast — Production Upgrade Plan + +**Project:** Video-to-Audio Microservices Platform on AWS EKS +**Product Name:** VidCast — "Turn video recordings into podcast-ready audio" +**Date:** May 2026 +**Status:** Base platform deployed and passing end-to-end tests. This document covers planned improvements. + +--- + +## How to Read This Document + +This document is for the team. It explains every improvement we plan to make, why it matters, what it costs (in time and money), and what the alternatives were. If you're picking up a phase to work on, read the relevant section fully before writing any code. If something isn't clear, ask — don't guess. + +Every improvement falls into one of three categories: + +- **Build It** — We will implement this. It goes into the repo and the demo. +- **Talk About It** — We understand this and can explain it in the presentation, but we're not implementing it. +- **Skip It** — Not relevant for this project at this stage. + +--- + +## Table of Contents + +1. [Current State — What We Have](#1-current-state--what-we-have) +2. [Product Concept — VidCast](#2-product-concept--vidcast) +3. [Phase 1 — Terraform Infrastructure as Code](#3-phase-1--terraform-infrastructure-as-code) +4. [Phase 2 — CI/CD Pipeline](#4-phase-2--cicd-pipeline) +5. [Phase 3 — Security Hardening](#5-phase-3--security-hardening) +6. [Phase 4 — Monitoring and Observability](#6-phase-4--monitoring-and-observability) +7. [Phase 5 — Frontend Web Application](#7-phase-5--frontend-web-application) +8. [Phase 6 — Documentation and Presentation](#8-phase-6--documentation-and-presentation) +9. [Things We Talk About But Don't Build](#9-things-we-talk-about-but-dont-build) +10. [Repository Structure](#10-repository-structure) +11. [Branch Strategy](#11-branch-strategy) +12. [Cost Breakdown](#12-cost-breakdown) +13. [Real-World Use Cases](#13-real-world-use-cases) +14. [Presentation Strategy](#14-presentation-strategy) + +--- + +## 1. Current State — What We Have + +The base platform is deployed on AWS EKS in eu-west-2. It consists of four Python microservices (auth, gateway, converter, notification) and three infrastructure services (MongoDB, PostgreSQL, RabbitMQ) deployed via Helm charts. The application accepts video uploads via HTTP, converts them to MP3 asynchronously using RabbitMQ as a message broker, and emails the user when the audio file is ready for download. + +What works: end-to-end flow (login, upload, convert, notify, download), JWT authentication, event-driven async processing, Helm-managed infrastructure services, multi-replica deployments. + +What's missing: no infrastructure as code (cluster built manually via console), no CI/CD pipeline (images built and deployed manually), no health checks or resource limits on pods, no monitoring or alerting, credentials stored in plaintext YAML committed to the repo, no web interface (API-only via curl), no documentation beyond the deployment guide. + +These gaps are normal for a first-pass learning project. The purpose of this upgrade plan is to close them systematically. + +--- + +## 2. Product Concept — VidCast + +Instead of presenting this as "a Kubernetes exercise," we're framing it as a product that solves a real problem. This makes the demo accessible to non-technical audiences and gives the architecture a business context. + +**The product story:** Content creators record video — Zoom interviews, webinars, conference talks. They need the audio as a standalone podcast episode. VidCast lets them upload the video, converts it automatically, and emails them when the MP3 is ready to download. + +**Why this framing matters:** Every architectural decision now has a business justification. "Why do we use a message queue?" becomes "Because the creator shouldn't have to wait 5 minutes staring at a loading screen — they upload and walk away." "Why do we have 4 converter replicas?" becomes "Because if 20 creators upload at once, we need parallel processing capacity." + +**Why not YouTube downloads:** Downloading from YouTube violates their Terms of Service, yt-dlp breaks regularly as YouTube fights it, and a failed download during a live demo would derail the presentation. Our demo uses locally-stored video files that we control. + +--- + +## 3. Phase 1 — Terraform Infrastructure as Code + +### What We're Building + +Terraform modules that create and manage all AWS infrastructure: VPC, subnets, internet gateway, route tables, security groups, IAM roles, EKS cluster, and managed node group. After this phase, the entire platform can be destroyed and recreated from a single `terraform apply` command. + +### Why This Matters + +Right now, if someone deletes the EKS cluster, we'd need to click through the AWS Console for 30-60 minutes to rebuild it, hoping we remember every setting. With Terraform, the infrastructure is version-controlled, reviewable, and repeatable. This is the single most impactful improvement for the CV and the demo. + +In industry, this is non-negotiable. Every company running cloud infrastructure uses some form of IaC — Terraform, CloudFormation, Pulumi, or CDK. "I can destroy and recreate this entire platform from scratch with one command" is a sentence that separates you from most bootcamp graduates. + +### What the Industry Calls This + +Infrastructure as Code (IaC). The practice comes from the DevOps principle that infrastructure should be treated like application code: version-controlled, peer-reviewed, tested, and reproducible. The term was popularised by tools like Chef and Puppet in the 2010s, and Terraform (by HashiCorp, now part of IBM) became the dominant multi-cloud IaC tool. + +### Trade-off Analysis + +| Dimension | Terraform (Chosen) | AWS CloudFormation | Pulumi | +|---|---|---|---| +| Multi-cloud support | Yes — works with AWS, Azure, GCP | AWS only | Yes | +| Language | HCL (domain-specific) | JSON/YAML | Python, TypeScript, Go | +| Industry adoption | Dominant in multi-cloud shops | Dominant in AWS-only shops | Growing but smaller | +| Learning curve | Moderate — HCL is readable | Low for simple stacks | Low if you know the language | +| State management | Remote state in S3 + DynamoDB lock | Managed by AWS automatically | Managed by Pulumi Cloud or self-hosted | +| Bootcamp relevance | Taught in most DevOps curricula | Less commonly taught | Rarely taught in bootcamps | + +**Why Terraform:** It's what we learned, it's what most job postings list, and it works across cloud providers. CloudFormation would also be fine for an AWS-only project, but Terraform demonstrates a transferable skill. + +### What We're Creating + +``` +terraform/ +├── environments/ +│ └── dev/ +│ ├── main.tf # Root module — calls all child modules +│ ├── variables.tf # Input variables (region, instance type, etc.) +│ ├── outputs.tf # Cluster endpoint, node IP, kubeconfig command +│ └── terraform.tfvars # Actual values (gitignored — never committed) +└── modules/ + ├── vpc/ # VPC, subnets, IGW, route tables, NAT + ├── eks/ # EKS cluster, node group, OIDC provider + ├── iam/ # Cluster role, node role, policies + └── security-groups/ # NodePort rules (30002-30005) +``` + +### Key Decisions + +**Remote state in S3 with DynamoDB locking.** Local state files are not acceptable for any shared project. If two people run `terraform apply` simultaneously with local state, one of them will corrupt the infrastructure. S3 stores the state file, and DynamoDB prevents concurrent modifications. This is standard practice. + +**Module structure instead of a single flat file.** Each concern (networking, compute, identity) is a separate module with its own inputs and outputs. This means one person can modify the security groups without touching the VPC configuration. It also means modules can be reused across environments (dev, staging, prod) with different variable values. + +**terraform.tfvars is gitignored.** This file contains the actual values for your deployment — AWS account ID, region, instance type. It's environment-specific and must never be committed to the repo. Each team member creates their own from a template. + +### Estimated Effort + +4-6 hours to write and test all modules. Most of the time is in the EKS module (cluster creation takes 15 minutes per attempt, so iteration is slow). + +--- + +## 4. Phase 2 — CI/CD Pipeline + +### What We're Building + +A GitHub Actions workflow that automatically lints, scans, builds, and deploys the application whenever code is pushed. A Jenkinsfile that achieves the same pipeline for teams using Jenkins. + +### Why This Matters + +Right now, deploying a code change means: manually build a Docker image on your laptop, manually push it to Docker Hub, manually run `kubectl apply` against the cluster, and hope you didn't forget a step. This is error-prone, unreviewable, and unauditable. Nobody knows who deployed what, when, or from which commit. + +A CI/CD pipeline enforces a consistent process: every change goes through the same steps, every deployment is traceable to a specific commit, and security scanning happens automatically before any image reaches the cluster. + +### What the Industry Calls This + +Continuous Integration (CI) — automatically building and testing every change. Continuous Delivery/Deployment (CD) — automatically deploying validated changes to environments. Together, CI/CD. The practice originated in the early 2000s with tools like CruiseControl and Hudson (which became Jenkins). Modern implementations use GitHub Actions, GitLab CI, CircleCI, or Jenkins. + +### Trade-off Analysis + +| Dimension | GitHub Actions (Chosen) | Jenkins | GitLab CI | +|---|---|---|---| +| Infrastructure cost | Free for public repos, generous free tier | Must host and maintain Jenkins server | Free for public repos | +| Setup complexity | Zero — lives in the repo | High — needs a server, plugins, configuration | Low if using GitLab.com | +| Plugin ecosystem | Growing (Actions marketplace) | Massive (1800+ plugins) | Built-in features | +| Enterprise adoption | High and growing | Very high (legacy and current) | High in European companies | +| Pipeline as code | YAML in .github/workflows/ | Jenkinsfile in repo root | .gitlab-ci.yml in repo root | +| Demo-ability | Excellent — visible in GitHub UI | Requires Jenkins server running | Requires GitLab instance | + +**Why both:** GitHub Actions for the actual pipeline (easy to demo, no infrastructure needed). Jenkinsfile in the repo to show we can work in enterprise environments. During the presentation, we show GitHub Actions running; we mention Jenkins as "the enterprise alternative I also wrote." + +### Pipeline Stages + +``` +Push to any branch + │ + ├── Lint (ruff for Python) + ├── Trivy Scan (container vulnerability scanning) + │ + └── If main branch: + ├── Build Docker Image + ├── Tag with Git SHA (never :latest) + ├── Push to Docker Hub + ├── Configure kubectl for EKS + └── Deploy to cluster (kubectl apply or helm upgrade) +``` + +### Security Scanning — Where Trivy Fits + +Trivy is an open-source vulnerability scanner by Aqua Security. It scans container images for known CVEs (Common Vulnerabilities and Exposures) in OS packages and application dependencies. In our pipeline, Trivy runs after the Docker image is built but before it's pushed to the registry. If Trivy finds a CRITICAL or HIGH severity CVE, the pipeline fails and the image never reaches the cluster. + +This is the same concept as Docker Content Trust from Docker Swarm — ensuring that only verified, safe images run in your cluster. Trivy is the scanning step; Docker Content Trust (or Cosign/Sigstore in Kubernetes) is the signing step. We implement scanning; we talk about signing. + +In industry, this is called "shift-left security" — catching security issues early in the development process rather than discovering them in production. Most companies run Trivy, Snyk, or Grype as a CI pipeline gate. + +### Jenkins Pipeline + +The Jenkinsfile mirrors the GitHub Actions workflow exactly. Same stages, same tools, different syntax. This demonstrates that the pipeline logic is tool-agnostic — the stages (lint, scan, build, push, deploy) are the same regardless of whether you're using GitHub Actions, Jenkins, GitLab CI, or CircleCI. + +```groovy +// Jenkinsfile — same pipeline, different syntax +pipeline { + agent any + stages { + stage('Lint') { steps { sh 'ruff check src/' } } + stage('Scan') { steps { sh 'trivy image ...' } } + stage('Build') { steps { sh 'docker build ...' } } + stage('Push') { steps { sh 'docker push ...' } } + stage('Deploy') { steps { sh 'kubectl apply ...' } } + } +} +``` + +### Estimated Effort + +3-4 hours. The workflow files are straightforward; most time goes into configuring GitHub Secrets (Docker Hub credentials, AWS credentials, kubeconfig) and testing the pipeline end-to-end. + +--- + +## 5. Phase 3 — Security Hardening + +### What We're Building + +Four categories of security improvements applied to every Kubernetes deployment manifest. + +### 5a. Liveness and Readiness Probes + +**What they are:** Health checks that Kubernetes runs continuously to determine if a pod is alive (liveness) and ready to receive traffic (readiness). If a liveness probe fails, Kubernetes restarts the pod. If a readiness probe fails, Kubernetes stops sending traffic to that pod but doesn't restart it. + +**Why they matter:** Right now, Kubernetes has no way to know if our pods are actually healthy. It only knows they're running. If the Gateway loses its RabbitMQ connection, Kubernetes keeps routing traffic to it, and every upload silently fails. With probes, Kubernetes detects the failure and either restarts the pod or routes traffic to a healthy replica. + +**Where this concept comes from:** Health checks are a core Kubernetes primitive, inspired by process monitoring in traditional infrastructure (like systemd watchdog timers or Nagios checks). The distinction between liveness and readiness was introduced by Kubernetes to handle the common case where a service is alive but temporarily unable to serve (e.g., during startup or when a dependency is down). + +**What we're adding:** + +| Service | Probe Type | Check Method | What It Checks | +|---|---|---|---| +| Auth | HTTP GET /healthz | Liveness + Readiness | Flask is responding, PostgreSQL is reachable | +| Gateway | HTTP GET /healthz | Liveness + Readiness | Flask is responding, MongoDB and RabbitMQ are reachable | +| Converter | Exec command | Liveness | Process is alive, RabbitMQ connection is active | +| Notification | Exec command | Liveness | Process is alive, RabbitMQ connection is active | + +This requires adding a small `/healthz` endpoint to the Flask services (auth and gateway) — about 10 lines of Python each. + +### 5b. Resource Requests and Limits + +**What they are:** CPU and memory boundaries set on each pod. Requests are the guaranteed minimum — Kubernetes uses these for scheduling decisions. Limits are the hard ceiling — if a pod exceeds its memory limit, it gets killed (OOMKilled). + +**Why they matter:** The converter service runs ffmpeg, which is CPU-intensive. Without limits, four converter replicas could consume all 2 vCPUs on our m7i-flex.large node, starving the gateway and auth services. Users would be able to upload files but never log in, because the auth service can't get CPU time to process JWT validation. + +**What we're setting:** + +| Service | CPU Request | CPU Limit | Memory Request | Memory Limit | Rationale | +|---|---|---|---|---|---| +| Auth | 50m | 200m | 64Mi | 128Mi | Lightweight Flask app, small queries | +| Gateway | 100m | 300m | 128Mi | 256Mi | HTTP handling + GridFS uploads | +| Converter | 250m | 500m | 256Mi | 512Mi | ffmpeg is CPU and memory hungry | +| Notification | 50m | 100m | 64Mi | 128Mi | Sends emails — minimal resources | + +Total request across all replicas: approximately 1.5 vCPU and 1.5GB RAM, which fits comfortably on a 2 vCPU / 8GB node. + +### 5c. Security Contexts (Runtime Hardening) + +**What they are:** Linux-level security constraints applied to the container process. This is the direct Kubernetes equivalent of the Docker Swarm runtime hardening we learned in class. + +**Where this concept comes from:** The principle of least privilege — a container should have only the permissions it needs to do its job, nothing more. In Docker Swarm, we configured this through service spec options. In Kubernetes, the same concepts exist in the `securityContext` block of the pod spec. + +**What we're adding to every pod:** + +```yaml +securityContext: + runAsNonRoot: true # Container cannot run as root user + runAsUser: 1000 # Run as a non-privileged user + readOnlyRootFilesystem: true # Filesystem is read-only (prevents malware writing to disk) + allowPrivilegeEscalation: false # Cannot gain more privileges than it started with + capabilities: + drop: ["ALL"] # Drop all Linux capabilities (network raw, sys admin, etc.) +``` + +**Special case — Converter service:** The converter needs to write temporary files (the video input and MP3 output during conversion). We set `readOnlyRootFilesystem: true` but mount a writable `emptyDir` volume at `/tmp`. This means the converter can write temp files but cannot modify its own binaries, configuration, or any other part of the filesystem. If an attacker compromises the converter, they can write to /tmp but cannot install tools, modify the application, or persist across pod restarts. + +**Mapping from Docker Swarm to Kubernetes:** + +| Swarm Concept | Kubernetes Equivalent | +|---|---| +| `--user` flag | `securityContext.runAsUser` | +| `--read-only` flag | `securityContext.readOnlyRootFilesystem` | +| `--cap-drop ALL` | `securityContext.capabilities.drop: ["ALL"]` | +| `--no-new-privileges` | `securityContext.allowPrivilegeEscalation: false` | +| mTLS between services | Requires a service mesh (Istio/Linkerd) — Talk About It, don't build | +| Rotating join tokens | Managed by EKS automatically — Talk About It | +| Certificate management | ACM for external certs, EKS manages internal — Talk About It | + +### 5d. .gitignore and Secrets Audit + +**What we're adding:** A comprehensive .gitignore that prevents credentials, state files, and generated artifacts from being committed. We're also auditing every file in the repo for hardcoded secrets and documenting which files contain sensitive values. + +**Files that must never be committed:** + +``` +# Terraform +terraform.tfvars +*.tfstate +*.tfstate.backup +.terraform/ + +# Kubernetes secrets (generated by customise.sh) +**/secret.yaml + +# Credentials and state +deployment-ids.txt +DEPLOYMENT_CONFIG.md +DEPLOYMENT_HANDOVER.md +customise.sh + +# Build artifacts +*.mp3 +*.mp4 +node_modules/ +__pycache__/ +.env +``` + +### Estimated Effort + +2-3 hours for all four categories. Most of the work is YAML editing and adding small health endpoints to the Python services. + +--- + +## 6. Phase 4 — Monitoring and Observability + +### What We're Building + +A Prometheus + Grafana + Alertmanager monitoring stack deployed via the kube-prometheus-stack Helm chart, with one custom Grafana dashboard for the demo. + +### Why This Matters + +Right now, if the converter pods crash, if RabbitMQ fills up, if MongoDB runs out of disk — nobody knows until a user complains (or, more likely, until we notice during a demo that nothing is working). In industry, this is unacceptable for anything beyond a personal experiment. + +Monitoring answers three questions: Is the system healthy right now? Was it healthy over the past hour/day/week? When did it stop being healthy, and what changed? + +### What the Industry Calls This + +Observability — the ability to understand the internal state of a system by examining its outputs. The "three pillars of observability" are metrics (numerical measurements over time), logs (structured event records), and traces (request paths across services). We're implementing metrics and dashboards. We'll discuss logs and traces in the presentation. + +### Trade-off Analysis + +| Dimension | kube-prometheus-stack (Chosen) | AWS CloudWatch | Datadog | +|---|---|---|---| +| Cost | Free (self-hosted) | Pay per metric/log/alarm | $15-23/host/month | +| Setup complexity | One Helm install | Requires CloudWatch agent, IAM roles | Agent install + SaaS config | +| Kubernetes integration | Native — built for K8s | Good but requires extra config | Excellent | +| Dashboard quality | Grafana — highly customisable | Basic but functional | Excellent out of the box | +| Industry relevance | Prometheus is the CNCF standard | Common in AWS-heavy shops | Common in well-funded startups | +| Demo impact | High — Grafana looks impressive | Medium | High but costs money | + +**Why kube-prometheus-stack:** One Helm install gives us Prometheus (metrics collection), Grafana (dashboards), Alertmanager (alerts), kube-state-metrics (Kubernetes object metrics), and node-exporter (host-level metrics). It's free, it's the CNCF standard, and Grafana dashboards look professional in a demo. + +### What We Get + +**Out of the box (no extra configuration):** CPU and memory usage per pod, per node, and cluster-wide. Pod restart counts and crash loop detection. Network I/O. Disk usage. Kubernetes object status (deployments, statefulsets, pods). + +**Custom dashboard for the demo ("VidCast Operations"):** RabbitMQ queue depth (video queue and mp3 queue) — this is the most compelling visual during a demo. Pod status for all four microservices. Node resource utilisation. Converter processing rate (if we add custom metrics to the Python code). + +**Alerts:** + +| Alert | Condition | Severity | Why | +|---|---|---|---| +| Pod CrashLoopBackOff | Pod restarted 3+ times in 10 minutes | Critical | Service is broken | +| High Node Memory | Node memory > 85% for 5 minutes | Warning | Risk of OOMKill | +| RabbitMQ Queue Backlog | Video queue depth > 10 for 5 minutes | Warning | Conversions are backing up | +| RabbitMQ Unavailable | RabbitMQ pod not ready for 2 minutes | Critical | Entire pipeline is blocked | + +### Estimated Effort + +3-4 hours. The Helm install takes 5 minutes; building a good custom dashboard takes iteration. + +--- + +## 7. Phase 5 — Frontend Web Application + +### What We're Building + +A React web application that serves as the VidCast product interface. It communicates with the existing Gateway API and provides a visual way to interact with the platform during the demo. + +### Why This Matters + +Right now, the demo involves running curl commands in a terminal. This is fine for a technical audience, but for a bootcamp presentation where we need to explain the system to non-technical people, a visual interface makes the flow immediately understandable. The frontend also gives us a place to show the monitoring dashboard and the architecture diagram during the presentation. + +### Pages + +**Login Page:** Email and password form. Calls `/login` on the Gateway, stores the JWT in React state (not localStorage — that's not supported in artifacts/sandboxed environments, and it's a security consideration worth mentioning). Clean VidCast branding. + +**Upload Page:** Drag-and-drop file upload. Sends the video to `/upload` with the JWT. Shows a success confirmation: "Your file is being processed. You'll receive an email when it's ready." + +**Download Page:** Text input for the file ID (from the email notification). Calls `/download` with the JWT and file ID. Triggers a browser download of the MP3. + +**Dashboard Page:** Embedded Grafana panels showing RabbitMQ queue depth and pod health, or a simplified custom view. This is the "behind the scenes" view for the presentation. + +**Architecture Page:** An interactive system diagram showing the microservices and data flow. During the demo, this helps explain what happens when you upload a file — "the request hits the Gateway here, then the video goes into the queue here, then a converter worker picks it up here..." + +### Deployment + +The frontend gets its own Dockerfile (Node.js, nginx to serve the built React app), its own Kubernetes Deployment and Service (NodePort or Ingress), and its own entry in the CI/CD pipeline. It becomes the fifth microservice in the cluster. + +### Trade-off Analysis + +| Dimension | React SPA (Chosen) | Plain HTML/CSS/JS | Next.js | +|---|---|---|---| +| Complexity | Moderate | Low | High | +| State management | React hooks (useState) | Manual DOM manipulation | React + SSR complexity | +| Component reuse | Excellent | Poor | Excellent | +| Build step required | Yes (npm build) | No | Yes | +| Team familiarity | Depends | Everyone knows HTML | Fewer people know Next.js | +| Demo appearance | Professional | Can look professional | Professional | + +**Why React:** Component-based architecture makes the dashboard and architecture views easier to build. Tailwind CSS keeps styling consistent without custom CSS. The built app is served as static files by nginx, so it's lightweight and fast. + +### Estimated Effort + +6-8 hours. This is the most visible piece but not the most complex — the backend already works, so the frontend is mostly API calls and UI design. + +--- + +## 8. Phase 6 — Documentation and Presentation + +### What We're Producing + +An updated README.md that explains the project from the perspective of someone finding it on GitHub — what it does, how to deploy it, how to destroy it. Architecture diagrams. Presentation notes with talking points and analogies for non-technical audiences. + +### Analogies for Non-Technical Audiences + +**Microservices → Restaurant:** A monolith is one chef doing everything. Microservices are specialised roles: host, cook, runner, cashier. Each can be scaled independently. + +**Message Queue → Post Office:** You don't wait at the counter for your letter to be delivered. You drop it off, and the postal workers process it on their own schedule. + +**JWT Authentication → Security Badge:** You show your ID at reception once (login), get a badge (token), and swipe it for access to different rooms (upload, download) without going back to reception. + +**Containers → Shipping Containers:** Standardised boxes that work the same everywhere — your laptop, a data centre, the cloud. + +**Kubernetes → Port Authority:** Manages where containers go, replaces ones that fall off the ship, and adds more when demand increases. + +**Infrastructure as Code → Building Blueprints:** Instead of telling builders "make it like the last one," you hand them exact blueprints. Anyone can build the same building from the same plans. + +**CI/CD Pipeline → Factory Assembly Line:** Raw materials (code) go in one end, pass through quality checks, and a finished product (deployed application) comes out the other end. Every step is automated and inspected. + +--- + +## 9. Things We Talk About But Don't Build + +These are concepts we understand and can discuss in the presentation or interviews, but we're not implementing them in this project. For each one, the reason for not building it is included. + +### ArgoCD / GitOps + +**What it is:** A deployment model where Git is the single source of truth. Instead of running `kubectl apply` from a pipeline, ArgoCD watches the Git repo and automatically syncs the cluster state to match what's in Git. If someone manually changes something in the cluster, ArgoCD detects the drift and reverts it. + +**Why we're not building it:** ArgoCD adds significant operational complexity (it needs its own deployment, RBAC, and repository credentials). For a single-developer project, the CI/CD pipeline with `kubectl apply` achieves the same outcome. ArgoCD shines in multi-team environments where drift detection and audit trails matter. + +**What to say in an interview:** "For a single-developer project, I used direct deployment from the CI/CD pipeline. In a team environment, I'd introduce ArgoCD for drift detection and to enforce that all changes go through Git." + +### KEDA / Queue-Based Autoscaling + +**What it is:** Kubernetes Event-Driven Autoscaling. Instead of scaling based on CPU (which HPA does), KEDA scales based on external metrics — in our case, RabbitMQ queue depth. If 50 videos are in the queue, KEDA would scale the converter from 4 replicas to 20. When the queue drains, it scales back down. + +**Why we're not building it:** Our demo processes one video at a time. KEDA is impressive but meaningless without a load-testing scenario to demonstrate it. Implementing it without a visible demo adds complexity without presentation value. + +**What to say in an interview:** "The converter service would benefit from queue-based autoscaling with KEDA. Instead of a fixed 4 replicas, KEDA would watch the RabbitMQ queue depth and scale converter workers dynamically. This means we pay for compute only when there's work to do." + +### Service Mesh / mTLS + +**What it is:** A service mesh (Istio, Linkerd) adds a sidecar proxy to every pod that handles service-to-service communication. This enables mutual TLS (mTLS) — every connection between services is encrypted and both sides verify each other's identity. In Docker Swarm, mTLS is built in. In Kubernetes, it requires a service mesh. + +**Why we're not building it:** Installing Istio would triple the resource consumption on our single node and add significant operational complexity. For a four-service demo with no sensitive data, it's overkill. + +**What to say in an interview:** "In production, I'd add a service mesh like Istio or Linkerd for mTLS between services. Even if an attacker gets inside the cluster network, they can't intercept or modify traffic between the gateway and auth service. The same encryption that Docker Swarm provides built-in requires a service mesh in Kubernetes." + +### Managed Database Services (RDS, DocumentDB, Amazon MQ) + +**What it is:** Instead of running MongoDB, PostgreSQL, and RabbitMQ as containers in the cluster, use AWS managed services: RDS for PostgreSQL, DocumentDB or MongoDB Atlas for MongoDB, and Amazon MQ for RabbitMQ. AWS handles backups, patching, replication, and failover. + +**Why we're not building it:** Managed services cost $200-400/month for a project we run for demos. They also remove the Kubernetes operational experience (running StatefulSets, Helm charts) that makes the project valuable. The in-cluster approach demonstrates more skills. + +**What to say in an interview:** "In production, I'd migrate PostgreSQL to RDS and RabbitMQ to Amazon MQ. Managed services handle backups, patching, and replication — operational burden the platform team shouldn't own. I kept them as StatefulSets in this project to demonstrate Kubernetes data service management." + +### External Secrets Operator / AWS Secrets Manager + +**What it is:** Instead of storing secrets in Kubernetes Secret objects (which are just base64-encoded, not encrypted), store them in AWS Secrets Manager and use the External Secrets Operator to sync them into the cluster at runtime. + +**Why we might not build it:** It requires an OIDC provider configured on the EKS cluster and IRSA (IAM Roles for Service Accounts). This is achievable but adds 2-3 hours of work. If time permits, we'll add it. If not, we document the approach and explain it. + +**What to say in an interview:** "Credentials are currently in Kubernetes Secrets, which are base64-encoded but not encrypted at rest unless you enable EKS envelope encryption. In production, I'd use AWS Secrets Manager with the External Secrets Operator. Secrets are stored in Secrets Manager, retrieved at runtime via IRSA, and never exist in Git." + +### Network Policies + +**What it is:** Kubernetes NetworkPolicy resources that restrict which pods can communicate with each other. By default, every pod in a Kubernetes cluster can talk to every other pod. Network Policies implement the principle of least privilege at the network level. + +**Why we should try to build it (stretch goal):** It's a 20-minute task that demonstrates security awareness. The auth service should only accept traffic from the gateway. MongoDB should only accept traffic from the gateway and converter. + +**What to say in an interview:** "I implemented Network Policies to restrict east-west traffic. The auth service only accepts connections from the gateway — even if an attacker compromises the converter, they can't directly access the auth database." + +--- + +## 10. Repository Structure + +``` +vidcast/ (repo root) +│ +├── README.md # Public-facing: what, why, how to deploy, how to destroy +├── VIDCAST_UPGRADE_PLAN.md # This document +├── .gitignore # Comprehensive — secrets, state, artifacts +├── Jenkinsfile # Enterprise CI/CD alternative +│ +├── .github/ +│ └── workflows/ +│ ├── ci.yml # Lint + scan + build + push +│ └── cd.yml # Deploy to EKS +│ +├── terraform/ +│ ├── environments/ +│ │ └── dev/ +│ │ ├── main.tf +│ │ ├── variables.tf +│ │ ├── outputs.tf +│ │ ├── backend.tf # S3 + DynamoDB state config +│ │ └── terraform.tfvars # GITIGNORED — actual values +│ └── modules/ +│ ├── vpc/ +│ ├── eks/ +│ ├── iam/ +│ └── security-groups/ +│ +├── Helm_charts/ # Existing — unchanged +│ ├── MongoDB/ +│ ├── Postgres/ +│ └── RabbitMQ/ +│ +├── src/ +│ ├── auth-service/ # Existing + health endpoint + security context +│ ├── gateway-service/ # Existing + health endpoint + security context +│ ├── converter-service/ # Existing + security context + resource limits +│ ├── notification-service/ # Existing + security context +│ └── frontend/ # NEW — React web application +│ ├── Dockerfile +│ ├── nginx.conf +│ ├── package.json +│ ├── src/ +│ │ ├── App.jsx +│ │ ├── pages/ +│ │ │ ├── Login.jsx +│ │ │ ├── Upload.jsx +│ │ │ ├── Download.jsx +│ │ │ ├── Dashboard.jsx +│ │ │ └── Architecture.jsx +│ │ └── components/ +│ └── manifest/ +│ ├── deployment.yaml +│ ├── service.yaml +│ └── configmap.yaml +│ +├── monitoring/ +│ ├── values.yaml # Custom values for kube-prometheus-stack +│ ├── dashboards/ +│ │ └── vidcast-operations.json # Custom Grafana dashboard +│ └── alerts/ +│ └── vidcast-alerts.yaml # Custom alert rules +│ +├── docs/ +│ ├── architecture.md +│ ├── deployment-guide.md +│ └── presentation-notes.md +│ +└── assets/ + └── video.mp4 # Test video +``` + +--- + +## 11. Branch Strategy + +``` +main ← current working state (base project) + │ + ├── feature/terraform-infra ← Phase 1: all Terraform code + ├── feature/ci-cd-pipeline ← Phase 2: GitHub Actions + Jenkinsfile + ├── feature/security-harden ← Phase 3: probes, limits, security contexts, .gitignore + ├── feature/monitoring ← Phase 4: kube-prometheus-stack + dashboard + ├── feature/frontend ← Phase 5: React web application + └── feature/documentation ← Phase 6: README, arch docs, presentation notes +``` + +Each branch is merged to main via a Pull Request when complete and tested. This gives us a clean Git history where each PR represents a meaningful improvement. The PR descriptions become talking points: "Here's the PR where I added infrastructure as code. Here's where I introduced container security scanning." + +**Rules:** +- Never push directly to main. Always use a feature branch and PR. +- Each PR should have a description explaining what changed and why. +- Merge in order: Phase 1 → 2 → 3 → 4 → 5 → 6 (though 2 and 3 can be parallel). + +--- + +## 12. Cost Breakdown + +| Component | Monthly Cost | Notes | +|---|---|---| +| EKS cluster | ~$73 | $0.10/hour for the control plane | +| EC2 node (m7i-flex.large) | ~$70 on-demand | Could reduce with Spot (~$25) but not for a demo | +| EBS storage (30GB gp3) | ~$2.40 | Root volume for the node | +| S3 (Terraform state) | <$0.10 | A few KB of state files | +| DynamoDB (state lock) | <$0.10 | On-demand pricing, minimal usage | +| Data transfer | ~$5 | Minimal for a demo | +| Docker Hub | Free | Public repos, free tier | +| **Total (running 24/7)** | **~$150/month** | | +| **Total (8 hours/day, weekdays only)** | **~$40/month** | Stop the node group outside working hours | + +**Cost-saving tip:** The biggest expense is the EC2 node. If you're not actively using the cluster, delete the node group (`aws eks delete-nodegroup`) and recreate it when you need it. The EKS control plane still costs $73/month even with no nodes, so for extended breaks, destroy the whole cluster and recreate it from Terraform. + +--- + +## 13. Real-World Use Cases + +This architecture pattern — API gateway, async processing queue, worker services, notification — is used everywhere in industry. Here are concrete examples to reference during the presentation: + +**Media processing (YouTube, TikTok, Spotify):** When you upload a video, it goes through a processing pipeline: transcoding to multiple resolutions, thumbnail generation, audio extraction for captions, content moderation. Each step is a separate service consuming from a queue. Our project does the same thing at a smaller scale. + +**E-commerce order processing (Amazon, ASOS):** When you place an order, separate services handle payment, inventory, warehouse notification, shipping labels, and confirmation email. The queue absorbs traffic spikes (Black Friday) without dropping orders. + +**Banking document processing:** Mortgage applications, bank statements, and identity documents go through OCR, data extraction, fraud checks, and compliance verification — each as a separate service. + +**Healthcare imaging:** MRI and X-ray images are uploaded, converted to standard formats, analysed by AI, stored in archives, and the referring doctor is notified. Upload, queue, process, store, notify — same pattern. + +--- + +## 14. Presentation Strategy + +### Flow (12-15 minutes) + +**Open with the product (2 min):** "This is VidCast — a platform that converts video recordings into podcast-ready audio." Demo the upload through the web interface. Everyone understands what the system does. + +**Explain the architecture (3 min):** Switch to the architecture view. Use the restaurant analogy for microservices, the post office analogy for queues. Walk through the data flow. + +**Show the platform engineering (5 min):** Show Terraform creating infrastructure. Show the CI/CD pipeline deploying a change. Show the Grafana dashboard. Show the security contexts. Explain each in terms the audience can follow. + +**Talk about what you'd do next (2 min):** Managed databases, service mesh, KEDA, GitOps. Shows you see beyond what you built. + +**Close with real-world connection (1 min):** "This is the same pattern used by YouTube, Spotify, and every media processing platform. The scale is different, but the principles are identical." + +### Teaching Tips + +- Start with the problem, not the technology. +- One analogy per concept. Don't stack metaphors. +- If you're about to say a technical term, explain it immediately: "RabbitMQ — that's our post office sorting room — was showing a backlog." +- Show, don't tell. A live demo is worth ten slides. +- End each section with "and this is why it matters" before moving on. diff --git a/docker-compose.swarm.yml b/docker-compose.swarm.yml new file mode 100644 index 0000000..a18f759 --- /dev/null +++ b/docker-compose.swarm.yml @@ -0,0 +1,122 @@ +version: '3.8' + +services: + auth: + image: vidcast/auth:latest + ports: + - "5000:5000" + networks: + - vidcast-net + environment: + DATABASE_HOST: postgres + DATABASE_NAME: auth + DATABASE_USER: auth_user + DATABASE_PORT: "5432" + PSQL_PASSWORD: Auth123 + JWT_SECRET: staging-jwt-secret-change-in-production + AUTH_TABLE: auth_user + deploy: + replicas: 1 + update_config: + failure_action: rollback + restart_policy: + condition: on-failure + max_attempts: 3 + + gateway: + image: vidcast/gateway:latest + ports: + - "8080:8080" + networks: + - vidcast-net + environment: + MONGODB_VIDEOS_URI: mongodb://mongo:27017/videos + MONGODB_MP3S_URI: mongodb://mongo:27017/mp3s + RABBITMQ_HOST: rabbitmq + AUTH_SVC_ADDRESS: auth:5000 + deploy: + replicas: 2 + update_config: + failure_action: rollback + restart_policy: + condition: on-failure + max_attempts: 3 + + converter: + image: vidcast/converter:latest + networks: + - vidcast-net + environment: + MONGODB_URI: mongodb://mongo:27017 + RABBITMQ_HOST: rabbitmq + VIDEO_QUEUE: video + MP3_QUEUE: mp3 + deploy: + replicas: 4 + update_config: + failure_action: rollback + restart_policy: + condition: on-failure + max_attempts: 3 + + notification: + image: vidcast/notification:latest + networks: + - vidcast-net + environment: + RABBITMQ_HOST: rabbitmq + MP3_QUEUE: mp3 + GMAIL_ADDRESS: "" + GMAIL_PASSWORD: "" + deploy: + replicas: 1 + update_config: + failure_action: rollback + restart_policy: + condition: on-failure + max_attempts: 3 + + mongo: + image: mongo:4.0.8 + volumes: + - mongo-data:/data/db + networks: + - vidcast-net + deploy: + replicas: 1 + restart_policy: + condition: on-failure + + postgres: + image: postgres:14 + environment: + POSTGRES_DB: auth + POSTGRES_USER: auth_user + POSTGRES_PASSWORD: Auth123 + volumes: + - pg-data:/var/lib/postgresql/data + networks: + - vidcast-net + deploy: + replicas: 1 + restart_policy: + condition: on-failure + + rabbitmq: + image: rabbitmq:3-management + ports: + - "15672:15672" + networks: + - vidcast-net + deploy: + replicas: 1 + restart_policy: + condition: on-failure + +networks: + vidcast-net: + driver: overlay + +volumes: + mongo-data: + pg-data: diff --git a/docs/DECISIONS_MADE.md b/docs/DECISIONS_MADE.md new file mode 100644 index 0000000..8ebf283 --- /dev/null +++ b/docs/DECISIONS_MADE.md @@ -0,0 +1,165 @@ +# Architectural Decisions — RBAC / Notifications / Admin branch + +Trade-off documentation for the `feature/rbac-and-notifications` branch. Each +decision follows the same shape: **what we chose → the alternatives → the +trade-off we accepted → where it breaks → the real fix at scale.** + +--- + +## 1. bcrypt now, alongside RBAC (not deferred) + +We added bcrypt password hashing in the same change as the role model, rather than +shipping RBAC on the existing plaintext passwords and hashing "later." + +The alternative was to defer: keep the plaintext comparison, add only the `role` +column and JWT claim now. It's less code and avoids a coordinated DB+image +migration. + +The trade-off we accepted is a one-time migration cost: bcrypt seeds in `init.sql`, +a `checkpw` path in `/login`, and a merge-time reseed of live Postgres — all of +which must land together or logins break. + +This would be the wrong call if the password store were large and live (re-hashing +millions of users needs a dual-read "verify-then-upgrade-on-login" strategy, not a +reseed). Here the user set is two seeded admins plus dev sign-ups on a disposable +cluster, so a reseed is trivial. + +The deciding reason: "you added role-based access but didn't hash the passwords" is +the first thing an assessor asks. Doing RBAC on plaintext is a half-measure that +invites the question; doing both closes it, and the image rebuilds anyway. + +## 2. Polling, not SSE/WebSockets, for the download bubble + +The "your file is ready" badge polls `GET /notifications/unseen-count` every 5 +seconds rather than holding a server-push channel open. + +The alternatives were Server-Sent Events (one-way push, <1s latency) or WebSockets +(bidirectional). Both eliminate the poll and feel instant. + +The trade-off we accepted is up to ~5s of latency before the badge updates — which +is invisible when the conversion it's reporting on takes 5–30s anyway. + +This would be wrong at scale: thousands of concurrent browsers polling every 5s is +load the server feels, and at that point a push transport earns its complexity. + +For a single-user demo, polling is one endpoint, debuggable with `curl`, and +firewall-proof. The honest scaling note for the presentation is "we'd move to SSE +before WebSockets if push became necessary" — SSE is the right next rung, not WS. + +## 3. Skipping the admin stats panel (Grafana already covers it) + +Feature 4 ships the user table + role management but **not** the aggregate stats +panel (uploads today, bytes converted, queue depth) the spec sketched. + +The alternative was a `GET /admin/stats` endpoint aggregating Mongo + RabbitMQ and +a stats card on the page. + +The trade-off we accepted is that an admin reads operational metrics in Grafana +(already deployed on NodePort 30007), not inside the app. + +This would be wrong if the audience for the metrics were non-operators who never +open Grafana — then in-app stats earn their place. Our admin is also the cluster +operator, who already lives in Grafana. + +The deciding reason: building a second, thinner metrics surface duplicates what the +monitoring stack does properly (retention, alerting, dashboards). Don't rebuild +Grafana badly inside the app. + +## 4. Admin enforcement in the gateway only (in-cluster trust gap) + +Authorization for the admin endpoints is checked in the **gateway**; the +auth-service `/users` endpoints have no role check of their own and trust +in-cluster callers — the same trust model as the pre-existing `/login`/`/validate`. + +The alternative is defence in depth: every service validates the JWT and authorizes +independently, so no service is trusted purely by its network position. + +The trade-off we accepted is a real privilege boundary that sits at the **network** +layer (ClusterIP + "only the gateway should call auth") rather than the +**application** layer — an in-cluster pod could call `auth/users` directly. + +This is wrong the moment the cluster is multi-tenant or runs untrusted workloads: +network position is not identity, and "internal" is not "trusted." + +The real fix is one of: mTLS / a shared secret between gateway and auth; the auth +service validating the JWT itself; or a service mesh enforcing "only the gateway +may call auth" via NetworkPolicy + workload identity. Out of scope for a +single-tenant demo, but that's the next step. + +## 5. Audit trail to stdout (not an append-only store) + +Every role change prints `AUDIT admin_role_change admin=<caller> target=<email> +new_role=<role> result=<status>` to the gateway's stdout, captured by `kubectl +logs` and the monitoring stack. + +The alternative is a dedicated `audit_log` table (or an external SIEM sink) written +transactionally with the change. + +The trade-off we accepted is that the record is **mutable and ephemeral**: logs +rotate, pods are replaced, and the line vanishes if the code path changes. It +answers who/whom/what, but it is not tamper-evident. + +This is wrong anywhere with compliance or forensic requirements: "the logs say so" +is not an audit trail if the logs can be edited or lost. + +The real fix is an append-only store written in the **same transaction** as the +role change — immutable timestamps, ideally hash-chained so tampering is +detectable — or shipping to a write-once external system. A whole subsystem; +deliberately out of scope. + +## 6. Admin guardrails: self-demote (403) and last-admin (409) + +The `PATCH /admin/users/<email>` endpoint refuses to let an admin change their own +role (403) or demote the last remaining admin (409), in addition to 404 on an +unknown email and 400 on an invalid role. + +The alternative is to trust admins to not lock themselves out, or to handle lockout +reactively (a manual DB edit to restore an admin). + +The trade-off we accepted is a little extra server-side logic and one pre-check +query (counting admins) before a demotion — negligible cost. + +This is rarely wrong, but the guard is conservative: in a large org you might +legitimately want to demote yourself once another admin exists, which our blanket +self-demote block forbids. We chose the safe default over the flexible one. + +The deciding reason: admin lockout is a self-inflicted outage with no in-app +recovery path. Two cheap guards (plus disabling the self-row button in the UI) +remove the most common ways to cause it, and the 409 last-admin check catches the +case where demoting *someone else* would still empty the admin set. + +--- + +## Addenda — learnings from the post-merge integration test + +### A. The bcrypt migration is a forward-only constraint + +Once live Postgres is migrated to bcrypt hashes, you **cannot roll the auth image +back** to the pre-bcrypt version. The old image compares passwords with `==` +against the stored value; after migration that value is a bcrypt hash, so every +login fails. The clean rollback path (old plaintext image + old plaintext DB) +exists **only before the migration runs** — migration closes it. + +We hit exactly this live: the merge auto-deployed the bcrypt auth image *before* +the DB was migrated, so logins 500'd, and the only correct recovery was to roll +**forward** (run the migration), not back. The operational rule that falls out of +this: the bcrypt image and the schema/seed migration are a single atomic change — +deploy them together, and treat "rollback" post-migration as "fix forward," not +"revert the image." (A true revert would also require restoring a pre-bcrypt DB +snapshot, which a no-PV dev Postgres doesn't have.) + +### B. The 403 self-demote and 409 last-admin guards are complementary, not redundant + +At first glance the 409 looks unreachable: in normal operation the only admin +demoting the only admin is caught by the 403 self-demote check first, so 409 never +fires. That's true — for *non-stale* tokens. + +The 409 exists for the **stale-token** case. An admin whose role was revoked in the +DB but who still holds an unexpired admin JWT would pass the gateway's `admin` +claim check, and could then demote the last *real* admin — emptying the admin set +without ever demoting "themselves" (their token's identity is already a non-admin +in the DB). The 403 guards **identity** ("you can't change your own role"); the 409 +guards a **system invariant** ("never zero admins"). Different questions, different +failure modes — together they cover both "don't shoot yourself" and "don't empty +the admin set, even with a token that out-lived its privileges." This is why the +integration test could only trigger 409 by deliberately staling a token. diff --git a/docs/MERGE_RUNBOOK_RBAC.md b/docs/MERGE_RUNBOOK_RBAC.md new file mode 100644 index 0000000..168c301 --- /dev/null +++ b/docs/MERGE_RUNBOOK_RBAC.md @@ -0,0 +1,97 @@ +# Merge-time runbook — RBAC + bcrypt (Fix 1) + +**Run this WITH John, at the moment the `feature/rbac-and-notifications` branch is +merged to `main` and CI builds the new auth image.** It is the operational +counterpart to commit `6fd3b83`. + +> This is a *tracked* operational doc (unlike the `*_EXPLAINED.md` study aids, +> which are deliberately gitignored). It contains **no credentials** — the +> Postgres password is read from the environment. Export it first from the +> gitignored `DEPLOYMENT_CONFIG.md` (`POSTGRES_PASSWORD`), never paste it here. + +## Why this is needed + +The new auth image (bcrypt) and the new DB schema/seed **must land together**. If +the bcrypt image rolls while live Postgres still holds the old *plaintext* row, +`bcrypt.checkpw` fails to verify against a non-hash value and **every login +fails**. (As of the F1-F hardening, a malformed stored hash now returns 401 rather +than 500 — but it's still a failed login until the DB is migrated.) + +`init.sql` is **not** run by CD — it's a manual `psql`. Live Postgres has no +PersistentVolume, so re-seeding is safe and non-destructive to anything we care +about. + +## Pre-flight + +```bash +# Postgres password from the gitignored config — do NOT hardcode it. +export PGPASSWORD="$(grep -E '^POSTGRES_PASSWORD:' DEPLOYMENT_CONFIG.md | cut -d'"' -f2)" +# App-login plaintext (for the smoke test only), same source: +export APP_PW="$(grep -E '^APP_LOGIN_PASSWORD:' DEPLOYMENT_CONFIG.md | cut -d'"' -f2)" + +kubectl config current-context # expect arn:...:cluster/vidcast-cluster +NODE_IP=$(kubectl get nodes -o jsonpath='{.items[0].status.addresses[?(@.type=="ExternalIP")].address}') +echo "node: $NODE_IP" +``` + +## 1. Migrate the schema (idempotent, additive) + +```bash +psql -h "$NODE_IP" -p 30003 -U pguser -d authdb <<'SQL' +ALTER TABLE auth_user ADD COLUMN IF NOT EXISTS role VARCHAR(32) NOT NULL DEFAULT 'user'; +ALTER TABLE auth_user ADD COLUMN IF NOT EXISTS created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP; +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'auth_user_email_key') THEN + ALTER TABLE auth_user ADD CONSTRAINT auth_user_email_key UNIQUE (email); + END IF; +END $$; +SQL +``` + +## 2. Re-seed admins with bcrypt hashes (idempotent via ON CONFLICT) + +```bash +psql -h "$NODE_IP" -p 30003 -U pguser -d authdb -f Helm_charts/Postgres/init.sql +``` + +> `init.sql` uses `CREATE TABLE IF NOT EXISTS` + `ON CONFLICT (email) DO UPDATE`, +> so running it against the now-migrated table only refreshes the two seeded +> admins' role + bcrypt hash. Any self-registered `user` rows are left untouched. + +## 3. Verify the seed + +```bash +psql -h "$NODE_IP" -p 30003 -U pguser -d authdb \ + -c "SELECT email, role, left(password,7) AS pw_prefix FROM auth_user;" +# expect baabalola@ and johnbsignups@ as admin, pw_prefix = '$2b$12$' +``` + +## 4. Roll the auth image (CD normally does this on merge) + +```bash +kubectl rollout status deployment/auth --timeout=120s +``` + +## 5. Smoke test — admin login carries role=admin + +```bash +JWT=$(curl -s -X POST "http://$NODE_IP:30002/login" -u "baabalola@gmail.com:$APP_PW") +echo "$JWT" | cut -d. -f2 | base64 -d 2>/dev/null; echo +# expect: {"username":"baabalola@gmail.com",...,"admin":true,"role":"admin"} +``` + +## 6. Negative test — a new sign-up is role=user, never admin + +```bash +curl -s -X POST "http://$NODE_IP:30002/register" \ + -H 'Content-Type: application/json' \ + -d '{"email":"rbac-test@example.com","password":"testpass123"}' \ + | cut -d. -f2 | base64 -d 2>/dev/null; echo +# expect: ...,"admin":false,"role":"user" +``` + +## Rollback + +If login misbehaves: `kubectl rollout undo deployment/auth` returns the previous +(plaintext) auth image, which matches the pre-migration DB. Re-running `init.sql` +is always safe (`ON CONFLICT`). When done, `unset PGPASSWORD APP_PW`. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..a3418de --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,160 @@ +# VidCast — Architecture Reference + +## System Overview + +VidCast is an event-driven microservices platform. When a user uploads a video, it is stored immediately and a message is published to a queue. Worker pods pick up the message asynchronously, convert the video to MP3, and trigger an email notification. The user never waits for conversion — they get a notification when it's ready. + +This pattern (store-and-queue instead of store-and-block) is the same one used by YouTube, TikTok, Spotify, and every media processing platform at scale. + +--- + +## Service Inventory + +### Frontend Service + +- **Technology:** React 18 + Vite + Tailwind CSS, served by nginx +- **Image:** `johnbaabalola/frontend` +- **Port:** NodePort 30006 +- **Replicas:** 1 +- **Purpose:** Web interface — login, upload, download, monitoring dashboard, architecture diagram +- **Build:** Multi-stage Dockerfile (Node.js build → nginx serve) +- **Security:** Runs as non-root uid 1001, HTTP liveness/readiness probes + +### Gateway Service + +- **Technology:** Flask 2.2, PyMongo, Pika +- **Image:** `nasi101/gateway` +- **Port:** NodePort 30002 (8080 in-cluster) +- **Replicas:** 2 +- **Purpose:** The single external entry point. Handles authentication delegation, file storage, and queue publishing. +- **Routes:** + - `POST /login` → delegates to Auth Service → returns JWT + - `POST /upload` → validates JWT → stores video in MongoDB GridFS → publishes file ID to RabbitMQ video queue + - `GET /download?fid=` → validates JWT → retrieves MP3 from MongoDB GridFS → streams to client + - `GET /healthz` → checks MongoDB + RabbitMQ → 200 ok / 503 degraded +- **Security:** CORS enabled, readOnlyRootFilesystem, resource limits 100m-300m CPU / 128Mi-256Mi RAM + +### Auth Service + +- **Technology:** Flask 2.2, PyJWT, psycopg2 +- **Image:** `nasi101/auth` +- **Port:** ClusterIP 5000 (internal only — not accessible outside the cluster) +- **Replicas:** 2 +- **Purpose:** Issues and validates JWT tokens. Reads user credentials from PostgreSQL. +- **Routes:** + - `POST /login` → queries PostgreSQL for email/password → returns JWT (1-day expiry) + - `POST /validate` → decodes and verifies JWT → returns claims + - `GET /healthz` → checks PostgreSQL connectivity → 200 ok / 503 error +- **Security:** ClusterIP only, readOnlyRootFilesystem, resource limits 50m-200m CPU / 64Mi-128Mi RAM + +### Converter Service + +- **Technology:** Python, Pika, PyMongo, MoviePy, ffmpeg +- **Image:** `nasi101/converter` +- **Port:** None (queue consumer only — no HTTP interface) +- **Replicas:** 4 +- **Purpose:** Processes the video queue. For each message, fetches the video from MongoDB, runs ffmpeg to extract audio, stores the MP3 back in MongoDB, acknowledges the message, publishes the MP3 file ID to the mp3 queue, and touches `/tmp/healthy`. +- **Security:** emptyDir volume at /tmp (needed for temp files during conversion), readOnlyRootFilesystem, resource limits 250m-500m CPU / 256Mi-512Mi RAM + +### Notification Service + +- **Technology:** Python, Pika, smtplib +- **Image:** `nasi101/notification` +- **Port:** None (queue consumer only — no HTTP interface) +- **Replicas:** 2 +- **Purpose:** Processes the mp3 queue. For each message, sends an email via Gmail SMTP containing the file ID for download. +- **Security:** emptyDir volume at /tmp, readOnlyRootFilesystem, resource limits 50m-100m CPU / 64Mi-128Mi RAM + +--- + +## Infrastructure Services + +### MongoDB (StatefulSet) + +- **Image:** mongo:4.0.8 +- **Port:** NodePort 30005 (27017 in-cluster) +- **Storage:** GridFS — stores binary files (video and MP3) chunked into 255KB pieces +- **Databases:** `videos` (uploaded MP4s), `mp3s` (converted MP3s) +- **Note:** No PersistentVolume — data is lost if the pod is deleted. Acceptable for demo; use Atlas or DocumentDB in production. + +### PostgreSQL (Deployment) + +- **Port:** NodePort 30003 (5432 in-cluster) +- **Database:** `authdb` +- **Table:** `auth_user` (email, password) +- **Note:** No PersistentVolume. Use RDS for production. + +### RabbitMQ (StatefulSet) + +- **Image:** rabbitmq:3-management +- **Ports:** NodePort 30004 (management UI), 5672 (AMQP in-cluster) +- **Queues:** `video` (durable), `mp3` (durable) +- **Durability:** Messages survive RabbitMQ restarts + +--- + +## Data Flow — Upload + +``` +1. User POSTs MP4 to Gateway :30002/upload with JWT +2. Gateway validates JWT with Auth Service +3. Gateway stores MP4 binary in MongoDB GridFS → receives file_id +4. Gateway publishes file_id to RabbitMQ "video" queue +5. Gateway returns "success!" to user immediately +6. (Asynchronously) Converter pod picks up file_id from "video" queue +7. Converter fetches MP4 bytes from MongoDB by file_id +8. Converter runs ffmpeg to extract audio as MP3 +9. Converter stores MP3 binary in MongoDB GridFS → receives mp3_id +10. Converter publishes mp3_id to RabbitMQ "mp3" queue +11. (Asynchronously) Notification pod picks up mp3_id from "mp3" queue +12. Notification sends email with mp3_id to user +13. User GETs /download?fid=mp3_id → Gateway streams MP3 from MongoDB +``` + +--- + +## Port Map + +| Port | Service | Access | +|------|---------|--------| +| 30002 | Gateway API | Public — client entry point | +| 30003 | PostgreSQL | Admin only | +| 30004 | RabbitMQ Management | Admin only | +| 30005 | MongoDB | Admin only | +| 30006 | Frontend | Public — web interface | +| 30007 | Grafana | Admin only | +| 30008 | Alertmanager | Admin only | + +--- + +## Security Architecture + +### What's implemented + +- **Non-root containers:** All pods run as uid 1000 (or 1001 for frontend nginx) +- **Read-only root filesystem:** Containers cannot modify their own binaries or config files at runtime. Converter and notification mount an `emptyDir` at `/tmp` for temporary files. +- **Capability dropping:** All Linux capabilities dropped (`capabilities.drop: ["ALL"]`) +- **No privilege escalation:** `allowPrivilegeEscalation: false` on all containers +- **Resource limits:** Prevents one service from starving others on the shared node +- **Health probes:** Kubernetes detects and restarts unhealthy pods automatically +- **Secrets not in Git:** `**/secret.yaml` is gitignored; secrets are applied via `kubectl apply` outside of version control +- **Image scanning:** Trivy scans every image build for CRITICAL and HIGH CVEs before push + +### What's discussed but not implemented + +- **mTLS between services:** Requires a service mesh (Istio, Linkerd). Docker Swarm provides mTLS built-in; Kubernetes requires explicit setup. +- **Network Policies:** Currently all pods can talk to all other pods. Network Policies would restrict Auth to only accept traffic from Gateway, etc. +- **External Secrets Operator:** Secrets currently stored in Kubernetes Secret objects (base64, not encrypted). External Secrets + AWS Secrets Manager would fetch secrets at runtime via IRSA. +- **Image signing:** Trivy scans for known CVEs; Cosign/Sigstore would add cryptographic signing so only verified images can run. + +--- + +## Environments + +| Environment | Platform | Purpose | Cost | +|-------------|----------|---------|------| +| Production | AWS EKS eu-west-2 (m7i-flex.large) | Live traffic | ~$150/month | +| Staging | Docker Swarm (t2.micro EC2) | Pre-production via Jenkins | ~$10/month | +| Local | Docker Compose | Developer testing | Free | + +Staging uses Docker Swarm rather than a second EKS cluster — a 97% cost reduction with equivalent functionality for integration testing. diff --git a/docs/deployment-guide.md b/docs/deployment-guide.md new file mode 100644 index 0000000..cf050bc --- /dev/null +++ b/docs/deployment-guide.md @@ -0,0 +1,304 @@ +# VidCast — Deployment Guide + +Complete step-by-step instructions for deploying, operating, and destroying the VidCast platform. + +--- + +## Prerequisites + +```bash +# Check all tools are installed +aws --version # AWS CLI v2+ +kubectl version # 1.31+ +helm version # 3.x +terraform version # 1.5+ +psql --version # PostgreSQL client +docker --version # Docker 20+ +``` + +Configure AWS credentials: +```bash +aws configure +# Or export AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_DEFAULT_REGION +aws sts get-caller-identity # Verify +``` + +--- + +## Phase 1 — Infrastructure (Terraform) + +Create the S3 bucket and DynamoDB table for Terraform remote state first (one-time): + +```bash +# State bucket +aws s3 mb s3://YOUR-STATE-BUCKET --region eu-west-2 +aws s3api put-bucket-versioning --bucket YOUR-STATE-BUCKET \ + --versioning-configuration Status=Enabled + +# State lock table +aws dynamodb create-table \ + --table-name vidcast-terraform-locks \ + --attribute-definitions AttributeName=LockID,AttributeType=S \ + --key-schema AttributeName=LockID,KeyType=HASH \ + --billing-mode PAY_PER_REQUEST \ + --region eu-west-2 +``` + +Then apply Terraform: + +```bash +cd terraform/environments/dev +cp terraform.tfvars.example terraform.tfvars +# Edit terraform.tfvars — set state_bucket to YOUR-STATE-BUCKET + +terraform init \ + -backend-config="bucket=YOUR-STATE-BUCKET" \ + -backend-config="key=vidcast/dev/terraform.tfstate" \ + -backend-config="region=eu-west-2" \ + -backend-config="dynamodb_table=vidcast-terraform-locks" + +terraform validate +terraform plan +terraform apply # Takes ~20 minutes for EKS cluster creation +``` + +Get the kubeconfig update command from outputs: +```bash +terraform output kubeconfig_command +# Run the command it prints +kubectl get nodes -o wide # Capture EXTERNAL-IP as NODE_IP +``` + +--- + +## Phase 2 — Infrastructure Services (Helm) + +```bash +cd Helm_charts/MongoDB +helm install mongodb . +kubectl wait --for=condition=ready pod/mongodb-0 --timeout=180s + +cd ../Postgres +helm install postgres . +kubectl wait --for=condition=ready pod -l app=postgres --timeout=120s + +cd ../RabbitMQ +helm install rabbitmq . +kubectl wait --for=condition=ready pod/rabbitmq-0 --timeout=120s +cd ../.. +``` + +--- + +## Phase 3 — Initialise PostgreSQL + +```bash +NODE_IP=$(kubectl get nodes -o jsonpath='{.items[0].status.addresses[?(@.type=="ExternalIP")].address}') + +PGPASSWORD=YOUR_POSTGRES_PASSWORD psql \ + -h $NODE_IP -p 30003 \ + -U YOUR_POSTGRES_USERNAME -d authdb \ + -f Helm_charts/Postgres/init.sql + +# Verify +PGPASSWORD=YOUR_POSTGRES_PASSWORD psql \ + -h $NODE_IP -p 30003 \ + -U YOUR_POSTGRES_USERNAME -d authdb \ + -c "SELECT email FROM auth_user;" +``` + +--- + +## Phase 4 — Create RabbitMQ Queues + +```bash +curl -u guest:guest -X PUT http://$NODE_IP:30004/api/queues/%2F/video \ + -H "Content-Type: application/json" -d '{"durable":true}' + +curl -u guest:guest -X PUT http://$NODE_IP:30004/api/queues/%2F/mp3 \ + -H "Content-Type: application/json" -d '{"durable":true}' + +# Verify +curl -s -u guest:guest http://$NODE_IP:30004/api/queues | \ + python3 -c "import json,sys; [print(q['name']) for q in json.load(sys.stdin)]" +``` + +--- + +## Phase 5 — Create Kubernetes Secrets + +Secrets are gitignored (`**/secret.yaml`). A `secret.yaml.example` template sits +beside each service's manifests — copy it to `secret.yaml`, fill in real values, +and it will be picked up by `kubectl apply -f <service>/manifest/`. Or create +them imperatively: + +```bash +# Auth service +kubectl create secret generic auth-secret \ + --from-literal=PSQL_PASSWORD=YOUR_POSTGRES_PASSWORD \ + --from-literal=JWT_SECRET=YOUR_JWT_SECRET + +# Gateway service — MongoDB URIs now live in the Secret, not the ConfigMap +kubectl create secret generic gateway-secret \ + --from-literal=MONGODB_VIDEOS_URI="mongodb://USER:PASS@mongodb:27017/videos?authSource=admin" \ + --from-literal=MONGODB_MP3S_URI="mongodb://USER:PASS@mongodb:27017/mp3s?authSource=admin" + +# Converter service — MongoDB URI now lives in the Secret, not the ConfigMap +kubectl create secret generic converter-secret \ + --from-literal=MONGODB_URI="mongodb://USER:PASS@mongodb:27017/mp3s?authSource=admin" + +# Notification service +kubectl create secret generic notification-secret \ + --from-literal=GMAIL_ADDRESS=YOUR_GMAIL \ + --from-literal=GMAIL_PASSWORD=YOUR_GMAIL_APP_PASSWORD +``` + +--- + +## Phase 6 — Deploy Microservices + +```bash +kubectl apply -f src/auth-service/manifest/ +kubectl rollout status deployment/auth --timeout=120s + +kubectl apply -f src/gateway-service/manifest/ +kubectl rollout status deployment/gateway --timeout=120s + +kubectl apply -f src/converter-service/manifest/ +kubectl rollout status deployment/converter --timeout=120s + +kubectl apply -f src/notification-service/manifest/ +kubectl rollout status deployment/notification --timeout=120s + +kubectl apply -f src/frontend/manifest/ +kubectl rollout status deployment/frontend --timeout=120s + +kubectl get pods # All should be Running +``` + +--- + +## Phase 7 — End-to-End Test + +```bash +NODE_IP=$(kubectl get nodes -o jsonpath='{.items[0].status.addresses[?(@.type=="ExternalIP")].address}') + +# Login +TOKEN=$(curl -s -X POST http://$NODE_IP:30002/login -u "EMAIL:PASSWORD") +echo "Token: ${TOKEN:0:30}..." + +# Upload +curl -X POST http://$NODE_IP:30002/upload \ + -F "file=@assets/video.mp4" \ + -H "Authorization: Bearer $TOKEN" +# Expected: "success!" + +# Monitor conversion +sleep 10 +curl -s -u guest:guest http://$NODE_IP:30004/api/queues/%2F/video | \ + python3 -c "import json,sys; q=json.load(sys.stdin); print('video queue:', q.get('messages', 0), 'messages')" + +# Download (file_id from notification email) +curl -X GET "http://$NODE_IP:30002/download?fid=FILE_ID" \ + -H "Authorization: Bearer $TOKEN" \ + -o output.mp3 +ls -lh output.mp3 +``` + +--- + +## Phase 8 — Monitoring (Optional) + +```bash +helm repo add prometheus-community https://prometheus-community.github.io/helm-charts +helm repo update +helm install monitoring prometheus-community/kube-prometheus-stack \ + -f monitoring/values.yaml -n monitoring --create-namespace + +kubectl wait --for=condition=ready pod -l app.kubernetes.io/name=grafana -n monitoring --timeout=180s + +kubectl apply -f monitoring/alerts/vidcast-alerts.yaml + +echo "Grafana: http://$NODE_IP:30007 (admin / vidcast-demo)" +echo "Alertmanager: http://$NODE_IP:30008" +``` + +--- + +## Operational Commands + +```bash +# Pod status +kubectl get pods -o wide + +# Logs +kubectl logs -l app=gateway --tail=50 +kubectl logs -l app=converter --tail=50 -c converter + +# Restart a deployment +kubectl rollout restart deployment/gateway + +# Scale converters for heavy load +kubectl scale deployment/converter --replicas=8 + +# Watch RabbitMQ queue depths +watch -n5 "curl -s -u guest:guest http://$NODE_IP:30004/api/queues/%2F | \ + python3 -c \"import json,sys; [print(q['name'], q.get('messages',0)) for q in json.load(sys.stdin)]\"" + +# Check health endpoints +curl http://$NODE_IP:30002/healthz # Gateway +``` + +--- + +## Cost Management + +Stop/start the node group to pause costs (saves ~$70/month when not in use): + +```bash +# Stop (scale to 0 nodes) +aws eks update-nodegroup-config \ + --cluster-name vidcast-cluster \ + --nodegroup-name vidcast-nodes \ + --scaling-config minSize=0,maxSize=2,desiredSize=0 \ + --region eu-west-2 + +# Start (scale back up) +aws eks update-nodegroup-config \ + --cluster-name vidcast-cluster \ + --nodegroup-name vidcast-nodes \ + --scaling-config minSize=1,maxSize=2,desiredSize=1 \ + --region eu-west-2 +``` + +Note: The EKS control plane still costs ~$73/month even with 0 nodes. For extended breaks, run `terraform destroy`. + +--- + +## Teardown (Full Destroy) + +```bash +# 1. Microservices +kubectl delete -f src/frontend/manifest/ +kubectl delete -f src/auth-service/manifest/ +kubectl delete -f src/gateway-service/manifest/ +kubectl delete -f src/converter-service/manifest/ +kubectl delete -f src/notification-service/manifest/ + +# 2. Monitoring +helm uninstall monitoring -n monitoring +kubectl delete namespace monitoring + +# 3. Infrastructure services +helm uninstall mongodb +helm uninstall postgres +helm uninstall rabbitmq + +# 4. EKS + VPC + IAM via Terraform +cd terraform/environments/dev +terraform destroy # Takes ~15 minutes + +# 5. Delete Terraform state bucket (optional) +aws s3 rb s3://YOUR-STATE-BUCKET --force +aws dynamodb delete-table --table-name vidcast-terraform-locks --region eu-west-2 +``` diff --git a/docs/presentation-notes.md b/docs/presentation-notes.md new file mode 100644 index 0000000..f6cc21c --- /dev/null +++ b/docs/presentation-notes.md @@ -0,0 +1,115 @@ +# VidCast — Presentation Notes + +## Timing Guide (12–15 minutes total) + +| Section | Time | What to show | +|---------|------|--------------| +| Open with the product | 2 min | Live demo via web interface | +| Architecture walkthrough | 3 min | Architecture page in the frontend | +| Platform engineering | 5 min | Terraform, CI/CD pipeline, Grafana | +| What I'd do next | 2 min | Whiteboard / verbal | +| Real-world connection | 1 min | Verbal close | + +--- + +## Opening (2 minutes) + +**Don't start with "I built a Kubernetes cluster." Start with the problem.** + +"Content creators record videos — Zoom calls, webinars, conference talks. They need the audio as a standalone podcast. Right now they have to download the video, find a converter tool, wait, re-upload. VidCast does it in one step: upload the video, we email you when the MP3 is ready." + +Then open the web interface and do the upload live. + +--- + +## Architecture Walkthrough (3 minutes) + +Switch to the Architecture page in the frontend. + +**Microservices → Restaurant analogy:** +"In a traditional monolith, one chef does everything — takes the order, cooks, plates, brings it to you. That chef gets overwhelmed at rush hour. VidCast uses specialised roles: the gateway is the host taking orders, the converter is the kitchen, the notification service is the runner bringing the food. Each role can be scaled independently — we run 4 converter workers because conversion is the slow part." + +**Message queue → Post office analogy:** +"When you drop a letter at the post office, you don't wait at the counter for it to be delivered. You hand it over and walk away. RabbitMQ is our post office sorting room. You upload a video, it goes into the queue, and you get on with your day. The converter workers process it on their own schedule." + +**JWT authentication → Security badge analogy:** +"You show your ID at reception once — that's the login. You get a badge — that's the JWT token. You swipe the badge at each door — that's the authorization header on every request. The auth service is reception; the gateway is the building with all the doors." + +--- + +## Platform Engineering Walkthrough (5 minutes) + +### Terraform (~1 minute) +Show the `terraform/` directory structure. + +"Before this project, if someone deleted the cluster, I'd spend an hour clicking through the AWS console trying to remember every setting. Now: `terraform apply` recreates the entire platform in 20 minutes from version-controlled code. VPC, subnets, IAM roles, EKS cluster, security groups — all defined as code, reviewable, reproducible. This is the difference between an experiment and a production system." + +**One important detail:** On this AWS account, T-type instances fail during EKS node group creation because EKS auto-generates a `CreditSpecification: unlimited` parameter that the account's SCP rejects. The Terraform EKS module includes a validation block that catches this immediately rather than failing after 15 minutes. That's a lesson in defensive infrastructure — encoding known constraints in the code rather than the documentation. + +### CI/CD Pipeline (~2 minutes) +Show the GitHub Actions UI (or the `.github/workflows/ci.yml` file). + +"Every push to main runs this pipeline automatically. Ruff lints all four Python services. Docker builds all four images in parallel. Trivy scans each image for critical vulnerabilities before any image reaches the registry. If Trivy fails, the pipeline stops — nothing gets pushed to Docker Hub, nothing gets deployed to the cluster. + +This is called shift-left security — catching problems early in development rather than discovering them in production. + +After CI passes, the CD pipeline runs automatically: configures kubectl for EKS, and deploys the new images with `kubectl set image`. Rolling deployment, zero downtime. + +I also wrote a Jenkinsfile for teams using Jenkins — same stages, different syntax. It adds a Docker Swarm staging environment and a manual approval gate before production. A CI/CD pipeline is tool-agnostic; the concepts are the same whether you're using GitHub Actions, Jenkins, or GitLab CI." + +### Grafana Dashboard (~2 minutes) +Open Grafana, navigate to VidCast Operations. + +"This is what the on-call engineer sees. Pod status — are all 4 converters running? Restart count — has anything crashed in the last hour? Node CPU and memory — is the node being saturated? And this is the one I find most interesting for a demo: RabbitMQ queue depth. Watch what happens when I upload a video..." + +[Upload a video and watch the video queue tick up, then back down as the converters process it.] + +"That spike is real. You can see the video enter the queue, the converters pick it up, and the queue drain. This is what observability looks like — not just 'is it running,' but 'is it doing what it's supposed to do.'" + +--- + +## Security Hardening (if time permits) + +"Every pod runs as a non-root user — even nginx runs as uid 1001. The root filesystem is read-only, so even if an attacker compromises the converter, they can't modify the application binaries. We mount a writable `/tmp` directory as a separate volume so the ffmpeg conversion has somewhere to write temporary files without compromising the rest of the filesystem. + +Every capability is dropped — no raw sockets, no sys_admin, no process injection. This is the principle of least privilege applied at the kernel level." + +--- + +## What I'd Do Next (2 minutes) + +"Three things I'd add with more time: + +**KEDA — queue-based autoscaling.** Right now I have 4 converter replicas. With KEDA, the converter would watch the RabbitMQ queue depth and scale automatically — 4 replicas for 4 videos waiting, 20 replicas for 20 videos. You pay for compute only when there's work to do. + +**Service mesh for mTLS.** Docker Swarm gives you mutual TLS between services built-in — every connection is encrypted and authenticated. In Kubernetes, you need a service mesh like Istio or Linkerd to get the same thing. For a demo, it's not worth the operational overhead. For production handling sensitive content, it's non-negotiable. + +**External Secrets Operator.** Right now credentials are in Kubernetes Secrets — which are base64-encoded, not encrypted. The right approach is to store them in AWS Secrets Manager and fetch them at runtime via IRSA. The secrets never exist in the cluster YAML files at all." + +--- + +## Closing (1 minute) + +"Every media processing platform uses this pattern. YouTube when you upload a video. Spotify when they transcode your podcast. Companies processing mortgage documents, medical images, satellite data. The scale is different, but the architecture is the same: upload, queue, process, store, notify. VidCast is a production-quality implementation of that pattern on real AWS infrastructure." + +--- + +## Common Interview Questions — With Answers + +**"Why microservices instead of a monolith?"** +"For this use case, the converter is the bottleneck — ffmpeg is CPU-intensive and variable in duration. By separating it into its own service, we can scale it independently (4 replicas) without scaling the gateway or auth service. A monolith would require scaling everything together." + +**"Why RabbitMQ instead of SQS or Kafka?"** +"RabbitMQ fits our scale — durable queues, simple consumer model, built-in management UI. SQS would be equally valid and easier to operate in AWS (no StatefulSet needed). Kafka would be overkill for this throughput; it shines at millions of messages per second with multiple consumer groups. For a production system I'd use SQS to reduce operational overhead." + +**"What happens if a converter pod crashes mid-conversion?"** +"The RabbitMQ `basic_ack` is sent only after successful conversion. If the converter crashes before acknowledging, RabbitMQ redelivers the message to another converter. The video gets processed exactly once (at-least-once delivery). The MP3 might be stored twice if the pod crashes after storing but before acking — in production I'd add idempotency via a unique conversion ID." + +**"Why Docker Swarm for staging instead of a second EKS cluster?"** +"A second EKS cluster costs ~$290/month. A Swarm EC2 instance costs ~$8/month. 97% cost reduction for functionally equivalent pre-production testing. The Jenkins pipeline deploys to Swarm first, runs a smoke test against the /healthz endpoint, waits for human approval, then deploys to EKS." + +**"How would you handle secrets in production?"** +"Currently they're in Kubernetes Secrets — base64, not encrypted. In production: AWS Secrets Manager + External Secrets Operator + IRSA. Secrets are stored in Secrets Manager, fetched at runtime by the pod's service account, never in any YAML file. If EKS envelope encryption is enabled, the Secret objects in etcd are also encrypted at rest." + +**"What is Trivy and why is it in the pipeline?"** +"Trivy is an open-source vulnerability scanner by Aqua Security. It scans container images for known CVEs in OS packages and application dependencies. In our pipeline, it runs after Docker build but before Docker push. If Trivy finds a CRITICAL or HIGH vulnerability that has a fix available, the pipeline fails — the image never reaches the registry. This is shift-left security: catching problems in CI rather than discovering them in production." diff --git a/install_prerequisites.sh b/install_prerequisites.sh new file mode 100644 index 0000000..4c2e938 --- /dev/null +++ b/install_prerequisites.sh @@ -0,0 +1,130 @@ +#!/bin/bash +# DevOps Project Prerequisites Installation Guide for WSL2 +# This script installs: kubectl, Helm, Python 3, psql, mongosh +# Already installed: AWS CLI, Docker + +set -e # Exit on any error + +echo "==========================================" +echo "DevOps Project Prerequisites Installation" +echo "WSL2 Ubuntu Setup" +echo "==========================================" +echo "" + +# ═══════════════════════════════════════════════════════════════ +# 1. UPDATE PACKAGE MANAGER +# ═══════════════════════════════════════════════════════════════ +echo "[1/6] Updating package manager..." +sudo apt-get update +echo "✓ Package manager updated" +echo "" + +# ═══════════════════════════════════════════════════════════════ +# 2. INSTALL KUBECTL +# ═══════════════════════════════════════════════════════════════ +echo "[2/6] Installing kubectl..." +echo " → Downloading kubectl binary" +curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl" +echo " → Making executable" +chmod +x kubectl +echo " → Installing to /usr/local/bin" +sudo mv kubectl /usr/local/bin/kubectl +echo " → Verifying installation" +kubectl version --client +echo "✓ kubectl installed successfully" +echo "" + +# ═══════════════════════════════════════════════════════════════ +# 3. INSTALL HELM +# ═══════════════════════════════════════════════════════════════ +echo "[3/6] Installing Helm..." +echo " → Downloading Helm installation script" +curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash +echo " → Verifying installation" +helm version +echo "✓ Helm installed successfully" +echo "" + +# ═══════════════════════════════════════════════════════════════ +# 4. INSTALL PYTHON 3 +# ═══════════════════════════════════════════════════════════════ +echo "[4/6] Installing Python 3..." +echo " → Installing python3 and pip" +sudo apt-get install -y python3 python3-pip python3-venv +echo " → Verifying Python installation" +python3 --version +echo " → Verifying pip installation" +pip3 --version +echo "✓ Python 3 installed successfully" +echo "" + +# ═══════════════════════════════════════════════════════════════ +# 5. INSTALL POSTGRESQL CLIENT (psql) +# ═══════════════════════════════════════════════════════════════ +echo "[5/6] Installing PostgreSQL client (psql)..." +echo " → Installing postgresql-client" +sudo apt-get install -y postgresql-client +echo " → Verifying installation" +psql --version +echo "✓ PostgreSQL client installed successfully" +echo "" + +# ═══════════════════════════════════════════════════════════════ +# 6. INSTALL MONGODB CLIENT (mongosh) +# ═══════════════════════════════════════════════════════════════ +echo "[6/6] Installing MongoDB client (mongosh)..." +echo " → Adding MongoDB repository" +curl https://www.mongodb.org/static/pgp/server-7.0.asc | sudo apt-key add - +echo "deb [ arch=amd64,arm64 ] https://repo.mongodb.org/apt/ubuntu focal/mongodb-org/7.0 multiverse" | sudo tee /etc/apt/sources.list.d/mongodb-org-7.0.list +echo " → Updating package manager" +sudo apt-get update +echo " → Installing mongosh" +sudo apt-get install -y mongosh +echo " → Verifying installation" +mongosh --version +echo "✓ MongoDB client installed successfully" +echo "" + +# ═══════════════════════════════════════════════════════════════ +# FINAL VERIFICATION +# ═══════════════════════════════════════════════════════════════ +echo "==========================================" +echo "Installation Complete!" +echo "==========================================" +echo "" +echo "Verification of all tools:" +echo "" +echo "kubectl:" +kubectl version --client --short +echo "" +echo "Helm:" +helm version --short +echo "" +echo "Python:" +python3 --version +echo "" +echo "pip:" +pip3 --version +echo "" +echo "psql (PostgreSQL client):" +psql --version +echo "" +echo "mongosh (MongoDB client):" +mongosh --version +echo "" +echo "✓ All prerequisites installed successfully!" +echo "" +echo "Next steps:" +echo "1. Clone the repository:" +echo " git clone https://github.com/N4si/K8s-video-converter.git" +echo " cd K8s-video-converter" +echo "" +echo "2. Verify AWS CLI:" +echo " aws --version" +echo "" +echo "3. Verify Docker:" +echo " docker --version" +echo "" +echo "4. Configure AWS credentials (if not already done):" +echo " aws configure" +echo "" diff --git a/monitoring/README.md b/monitoring/README.md new file mode 100644 index 0000000..46ca02b --- /dev/null +++ b/monitoring/README.md @@ -0,0 +1,48 @@ +# VidCast Monitoring Stack + +Prometheus + Grafana + Alertmanager deployed via kube-prometheus-stack. + +## Install + +```bash +helm repo add prometheus-community https://prometheus-community.github.io/helm-charts +helm repo update +helm install monitoring prometheus-community/kube-prometheus-stack \ + -f monitoring/values.yaml \ + -n monitoring \ + --create-namespace +``` + +Wait for all pods to start: +```bash +kubectl get pods -n monitoring -w +``` + +## Access + +| Service | URL | Credentials | +|---------|-----|-------------| +| Grafana | http://NODE_IP:30007 | admin / vidcast-demo | +| Alertmanager | http://NODE_IP:30008 | none | + +Replace `NODE_IP` with the output of `kubectl get nodes -o wide`. + +## Apply Custom Dashboard + +The `dashboards/vidcast-operations.json` file is loaded automatically via the Grafana sidecar when the release is installed with the values in `values.yaml`. To load manually: + +1. Open Grafana → Dashboards → Import +2. Upload `monitoring/dashboards/vidcast-operations.json` + +## Apply Custom Alert Rules + +```bash +kubectl apply -f monitoring/alerts/vidcast-alerts.yaml +``` + +## Uninstall + +```bash +helm uninstall monitoring -n monitoring +kubectl delete namespace monitoring +``` diff --git a/monitoring/alerts/vidcast-alerts.yaml b/monitoring/alerts/vidcast-alerts.yaml new file mode 100644 index 0000000..9776cc1 --- /dev/null +++ b/monitoring/alerts/vidcast-alerts.yaml @@ -0,0 +1,67 @@ +apiVersion: monitoring.coreos.com/v1 +kind: PrometheusRule +metadata: + name: vidcast-alerts + namespace: monitoring + labels: + release: monitoring +spec: + groups: + - name: vidcast.pods + interval: 1m + rules: + - alert: PodCrashLoopBackOff + expr: | + rate(kube_pod_container_status_restarts_total{namespace="default"}[10m]) * 60 > 0.5 + for: 5m + labels: + severity: critical + annotations: + summary: "Pod {{ $labels.pod }} is crash-looping" + description: "Pod {{ $labels.pod }} in namespace {{ $labels.namespace }} has restarted more than 3 times in 10 minutes. Investigate with: kubectl logs {{ $labels.pod }} --previous" + + - name: vidcast.resources + interval: 1m + rules: + - alert: HighNodeMemoryUsage + expr: | + 100 * (1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)) > 85 + for: 5m + labels: + severity: warning + annotations: + summary: "Node memory usage above 85%" + description: "Node memory is {{ $value | humanize }}% used. Risk of OOMKill for converter pods. Consider scaling down or upgrading the node." + + - alert: HighNodeCPUUsage + expr: | + 100 - (avg(rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100) > 85 + for: 5m + labels: + severity: warning + annotations: + summary: "Node CPU usage above 85%" + description: "Node CPU is {{ $value | humanize }}% used. Converter replicas may be saturating the node." + + - name: vidcast.queues + interval: 1m + rules: + - alert: RabbitMQQueueBacklog + expr: | + rabbitmq_queue_messages{queue="video"} > 10 + for: 5m + labels: + severity: warning + annotations: + summary: "Video queue backlog: {{ $value }} messages" + description: "More than 10 videos are waiting for conversion. Converter workers may be overwhelmed or crashed." + + - alert: RabbitMQUnavailable + expr: | + up{job="rabbitmq"} == 0 + for: 2m + labels: + severity: critical + annotations: + summary: "RabbitMQ is unreachable" + description: "RabbitMQ has been down for 2 minutes. The entire upload/convert pipeline is blocked. Check: kubectl describe pod rabbitmq-0" diff --git a/monitoring/dashboards/vidcast-operations.json b/monitoring/dashboards/vidcast-operations.json new file mode 100644 index 0000000..5b5619b --- /dev/null +++ b/monitoring/dashboards/vidcast-operations.json @@ -0,0 +1,139 @@ +{ + "title": "VidCast Operations", + "uid": "vidcast-ops", + "tags": ["vidcast"], + "timezone": "browser", + "refresh": "30s", + "schemaVersion": 36, + "panels": [ + { + "id": 1, + "title": "Pod Status — All Services", + "type": "stat", + "gridPos": {"h": 4, "w": 12, "x": 0, "y": 0}, + "targets": [ + { + "expr": "sum by (pod) (kube_pod_status_phase{namespace='default', phase='Running'})", + "legendFormat": "{{pod}}" + } + ], + "options": { + "colorMode": "background", + "graphMode": "none", + "reduceOptions": {"calcs": ["last"]} + } + }, + { + "id": 2, + "title": "Pod Restarts (last 1h)", + "type": "stat", + "gridPos": {"h": 4, "w": 12, "x": 12, "y": 0}, + "targets": [ + { + "expr": "sum by (pod) (increase(kube_pod_container_status_restarts_total{namespace='default'}[1h]))", + "legendFormat": "{{pod}}" + } + ], + "options": { + "colorMode": "background", + "thresholds": { + "steps": [ + {"color": "green", "value": 0}, + {"color": "yellow", "value": 1}, + {"color": "red", "value": 3} + ] + } + } + }, + { + "id": 3, + "title": "Node CPU Usage %", + "type": "gauge", + "gridPos": {"h": 6, "w": 8, "x": 0, "y": 4}, + "targets": [ + { + "expr": "100 - (avg(rate(node_cpu_seconds_total{mode='idle'}[5m])) * 100)", + "legendFormat": "CPU %" + } + ], + "options": { + "reduceOptions": {"calcs": ["lastNotNull"]}, + "thresholds": { + "steps": [ + {"color": "green", "value": 0}, + {"color": "yellow", "value": 70}, + {"color": "red", "value": 85} + ] + } + } + }, + { + "id": 4, + "title": "Node Memory Usage %", + "type": "gauge", + "gridPos": {"h": 6, "w": 8, "x": 8, "y": 4}, + "targets": [ + { + "expr": "100 * (1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes))", + "legendFormat": "Memory %" + } + ], + "options": { + "reduceOptions": {"calcs": ["lastNotNull"]}, + "thresholds": { + "steps": [ + {"color": "green", "value": 0}, + {"color": "yellow", "value": 70}, + {"color": "red", "value": 85} + ] + } + } + }, + { + "id": 5, + "title": "RabbitMQ Queue Depth", + "type": "timeseries", + "gridPos": {"h": 6, "w": 8, "x": 16, "y": 4}, + "description": "Messages waiting in video and mp3 queues. Rising video queue = converter backlog.", + "targets": [ + { + "expr": "rabbitmq_queue_messages{queue='video'}", + "legendFormat": "video queue" + }, + { + "expr": "rabbitmq_queue_messages{queue='mp3'}", + "legendFormat": "mp3 queue" + } + ] + }, + { + "id": 6, + "title": "CPU Usage per Pod", + "type": "timeseries", + "gridPos": {"h": 6, "w": 12, "x": 0, "y": 10}, + "targets": [ + { + "expr": "sum by (pod) (rate(container_cpu_usage_seconds_total{namespace='default', pod!=''}[5m]))", + "legendFormat": "{{pod}}" + } + ] + }, + { + "id": 7, + "title": "Memory Usage per Pod", + "type": "timeseries", + "gridPos": {"h": 6, "w": 12, "x": 12, "y": 10}, + "targets": [ + { + "expr": "sum by (pod) (container_memory_working_set_bytes{namespace='default', pod!=''})", + "legendFormat": "{{pod}}" + } + ], + "fieldConfig": { + "defaults": { + "unit": "bytes" + } + } + } + ] +} diff --git a/monitoring/values.yaml b/monitoring/values.yaml new file mode 100644 index 0000000..2926366 --- /dev/null +++ b/monitoring/values.yaml @@ -0,0 +1,69 @@ +# kube-prometheus-stack Helm values for VidCast +# Install: helm install monitoring prometheus-community/kube-prometheus-stack \ +# -f monitoring/values.yaml -n monitoring --create-namespace + +grafana: + adminPassword: vidcast-demo + service: + type: NodePort + nodePort: 30007 + persistence: + enabled: true + size: 2Gi + sidecar: + dashboards: + enabled: true + searchNamespace: monitoring + grafana.ini: + server: + root_url: "%(protocol)s://%(domain)s:30007" + +alertmanager: + service: + type: NodePort + nodePort: 30008 + alertmanagerSpec: + storage: + volumeClaimTemplate: + spec: + accessModes: ["ReadWriteOnce"] + resources: + requests: + storage: 2Gi + +prometheus: + prometheusSpec: + retention: 7d + storageSpec: + volumeClaimTemplate: + spec: + accessModes: ["ReadWriteOnce"] + resources: + requests: + storage: 10Gi + # EKS manages etcd, scheduler, controller-manager — disable scraping + kubeEtcd: + enabled: false + kubeScheduler: + enabled: false + kubeControllerManager: + enabled: false + additionalScrapeConfigs: + - job_name: 'vidcast-gateway' + static_configs: + - targets: ['gateway:8080'] + metrics_path: /metrics + +# Disable components EKS manages internally +kubeEtcd: + enabled: false +kubeScheduler: + enabled: false +kubeControllerManager: + enabled: false + +# Keep these enabled — node exporter and kube-state-metrics provide pod/node metrics +nodeExporter: + enabled: true +kubeStateMetrics: + enabled: true diff --git a/src/auth-service/.dockerignore b/src/auth-service/.dockerignore new file mode 100644 index 0000000..00a08c6 --- /dev/null +++ b/src/auth-service/.dockerignore @@ -0,0 +1,18 @@ +# Keep the build context small and free of anything the image doesn't need. +__pycache__/ +*.pyc +*.pyo +*.pyd +.Python +.pytest_cache/ +.git/ +.gitignore + +# Kubernetes manifests and secrets must never enter the image build context. +manifest/ +*secret*.yaml + +# Docs / study material +*_EXPLAINED.md +README.md +*.md diff --git a/src/auth-service/Dockerfile b/src/auth-service/Dockerfile index 0314d0d..7ddf3fe 100644 --- a/src/auth-service/Dockerfile +++ b/src/auth-service/Dockerfile @@ -1,6 +1,12 @@ -FROM python:3.10-slim-bullseye +FROM python:3.10-slim-bookworm -RUN apt-get update && apt-get install -y --no-install-recommends --no-install-suggests build-essential libpq-dev python3-dev && pip install --no-cache-dir --upgrade pip +# apt-get upgrade pulls patched OS packages (libgnutls30, libkrb5*) that the base +# image predates. pip upgrade of setuptools/wheel clears toolchain CVEs +# (CVE-2026-24049 wheel, CVE-2026-23949 jaraco.context vendored in setuptools). +RUN apt-get update && apt-get upgrade -y \ + && apt-get install -y --no-install-recommends --no-install-suggests build-essential libpq-dev python3-dev \ + && rm -rf /var/lib/apt/lists/* \ + && pip install --no-cache-dir --upgrade pip setuptools wheel WORKDIR /app COPY ./requirements.txt /app @@ -10,4 +16,8 @@ COPY . /app EXPOSE 5000 +# Run as a non-root uid (matches the Kubernetes securityContext runAsUser: 1000). +# Port 5000 is >1024 so no privileged binding is required; /app is world-readable. +USER 1000 + CMD ["python", "server.py"] \ No newline at end of file diff --git a/src/auth-service/manifest/configmap.yaml b/src/auth-service/manifest/configmap.yaml index c34dacc..980594d 100644 --- a/src/auth-service/manifest/configmap.yaml +++ b/src/auth-service/manifest/configmap.yaml @@ -5,5 +5,5 @@ metadata: data: DATABASE_HOST: db DATABASE_NAME: authdb - DATABASE_USER: nasi + DATABASE_USER: pguser AUTH_TABLE: auth_user diff --git a/src/auth-service/manifest/deployment.yaml b/src/auth-service/manifest/deployment.yaml index f3767e7..d174bd7 100644 --- a/src/auth-service/manifest/deployment.yaml +++ b/src/auth-service/manifest/deployment.yaml @@ -18,9 +18,13 @@ spec: labels: app: auth spec: + securityContext: + runAsNonRoot: true + runAsUser: 1000 containers: - name: auth - image: nasi101/auth + image: johnbaabalola/auth-service:16f49a0 + imagePullPolicy: IfNotPresent ports: - containerPort: 5000 envFrom: @@ -28,3 +32,29 @@ spec: name: auth-configmap - secretRef: name: auth-secret + resources: + requests: + cpu: "50m" + memory: "64Mi" + limits: + cpu: "200m" + memory: "128Mi" + securityContext: + readOnlyRootFilesystem: true + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] + livenessProbe: + httpGet: + path: /healthz + port: 5000 + initialDelaySeconds: 15 + periodSeconds: 10 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /healthz + port: 5000 + initialDelaySeconds: 5 + periodSeconds: 5 + failureThreshold: 3 diff --git a/src/auth-service/manifest/secret.yaml b/src/auth-service/manifest/secret.yaml deleted file mode 100644 index a662735..0000000 --- a/src/auth-service/manifest/secret.yaml +++ /dev/null @@ -1,9 +0,0 @@ -apiVersion: v1 -kind: Secret -metadata: - name: auth-secret -stringData: - PSQL_PASSWORD: nasi1234 - JWT_SECRET: sarcasm -type: Opaque - diff --git a/src/auth-service/manifest/secret.yaml.example b/src/auth-service/manifest/secret.yaml.example new file mode 100644 index 0000000..0529255 --- /dev/null +++ b/src/auth-service/manifest/secret.yaml.example @@ -0,0 +1,11 @@ +# Template for auth-secret. Copy to secret.yaml (gitignored) and fill in. +# WARNING: Replace before production use — back this with an external secret +# manager (AWS Secrets Manager + External Secrets Operator), not a committed file. +apiVersion: v1 +kind: Secret +metadata: + name: auth-secret +type: Opaque +stringData: + PSQL_PASSWORD: "<postgres-password>" + JWT_SECRET: "<random-32-plus-char-string>" diff --git a/src/auth-service/requirements.txt b/src/auth-service/requirements.txt index 15bf23b..d9520dd 100644 --- a/src/auth-service/requirements.txt +++ b/src/auth-service/requirements.txt @@ -1,26 +1,21 @@ -astroid==2.12.13 -cffi==1.15.1 -click==8.1.3 -cryptography==38.0.3 -dill==0.3.6 -Flask==2.2.2 -importlib-metadata==5.0.0 -isort==5.10.1 -itsdangerous==2.1.2 -jedi==0.18.1 -Jinja2==3.1.2 -lazy-object-proxy==1.8.0 -MarkupSafe==2.1.1 -mccabe==0.7.0 -parso==0.8.3 -platformdirs==2.5.4 -psycopg2==2.9.5 -pycparser==2.21 -PyJWT==2.6.0 -pylint==2.15.6 -tomli==2.0.1 -tomlkit==0.11.6 -typing-extensions==4.4.0 -Werkzeug==2.2.2 -wrapt==1.14.1 -zipp==3.10.0 +# Pinned to minimum CVE-free versions. pip resolves patched transitive deps +# (Jinja2, MarkupSafe, click, itsdangerous, blinker) from these floors. +# Dropped from the old frozen list: pylint/astroid/jedi/isort/dill/etc. (dev-only +# linting tools never imported at runtime) and cryptography (the service signs +# JWTs with HS256, which uses stdlib hmac — cryptography is only needed for RS256). +# Flask/Werkzeug are on 3.x: Werkzeug CVE-2024-34069 (debugger RCE, HIGH) is only +# fixed in 3.0.3, and Werkzeug 3 requires Flask 3. The service uses only basic +# Flask APIs (route/request/jsonify), which are unchanged across the 2.x->3.x line. +flask>=3.0.3 +werkzeug>=3.0.3 +psycopg2-binary>=2.9.5 +pyjwt>=2.6.0 +# bcrypt: password hashing. Logins are verified with bcrypt.checkpw (constant-time) +# and sign-ups hashed with bcrypt.hashpw — replaces the old plaintext comparison. +bcrypt>=4.1.2 +certifi>=2023.7.22 +# urllib3 must be >=2.6.0: the latest 1.26.x (1.26.20) still carries 4 fixable +# HIGH CVEs (e.g. CVE-2025-66418) that are only patched in the 2.x line. Safe +# here — the only consumer is requests, which supports urllib3 2.x, and no app +# code uses urllib3 directly. +urllib3>=2.6.0 diff --git a/src/auth-service/server.py b/src/auth-service/server.py index 2355a90..f5d5092 100644 --- a/src/auth-service/server.py +++ b/src/auth-service/server.py @@ -1,6 +1,10 @@ -import jwt, datetime, os +import datetime +import os + +import bcrypt +import jwt import psycopg2 -from flask import Flask, request +from flask import Flask, jsonify, request server = Flask(__name__) @@ -13,6 +17,15 @@ def get_db_connection(): return conn +@server.route('/healthz', methods=['GET']) +def healthz(): + try: + conn = get_db_connection() + conn.close() + return jsonify({"status": "ok"}), 200 + except Exception as e: + return jsonify({"status": "error", "detail": str(e)}), 503 + @server.route('/login', methods=['POST']) def login(): auth_table_name = os.getenv('AUTH_TABLE') @@ -22,28 +35,82 @@ def login(): conn = get_db_connection() cur = conn.cursor() - query = f"SELECT email, password FROM {auth_table_name} WHERE email = %s" - res = cur.execute(query, (auth.username,)) - - if res is None: + try: + # NOTE: psycopg2's cur.execute() always returns None (it does not return a + # rowcount like some drivers), so we decide on the fetched row, not on the + # return value of execute(). The old code branched on `res is None` and so + # 500'd for unknown users instead of returning 401. + query = f"SELECT email, password, role FROM {auth_table_name} WHERE email = %s" + cur.execute(query, (auth.username,)) user_row = cur.fetchone() - email = user_row[0] - password = user_row[1] - - if auth.username != email or auth.password != password: - return 'Could not verify', 401, {'WWW-Authenticate': 'Basic realm="Login required!"'} - else: - return CreateJWT(auth.username, os.environ['JWT_SECRET'], True) - else: + finally: + cur.close() + conn.close() + + if user_row is None: return 'Could not verify', 401, {'WWW-Authenticate': 'Basic realm="Login required!"'} -def CreateJWT(username, secret, authz): + email, password_hash, role = user_row[0], user_row[1], user_row[2] + + # Constant-time verification against the stored bcrypt hash (see init.sql). + # checkpw raises ValueError if the stored value is not a valid bcrypt hash + # (e.g. a legacy plaintext row from before the bcrypt migration). Treat that + # as an auth failure (401), never a 500 — /login must not leak a stack trace. + try: + password_ok = bcrypt.checkpw(auth.password.encode('utf-8'), password_hash.encode('utf-8')) + except (ValueError, TypeError) as err: + print(f"login: stored credential for {email} is not a valid bcrypt hash: {err}") + password_ok = False + if not password_ok: + return 'Could not verify', 401, {'WWW-Authenticate': 'Basic realm="Login required!"'} + + return CreateJWT(email, os.environ['JWT_SECRET'], role) + +@server.route('/register', methods=['POST']) +def register(): + auth_table_name = os.getenv('AUTH_TABLE') + data = request.get_json(silent=True) or {} + email = data.get('email') + password = data.get('password') + if not email or not password: + return 'email and password are required', 400 + if len(password) < 8: + return 'password must be at least 8 characters', 400 + + conn = get_db_connection() + cur = conn.cursor() + try: + cur.execute(f"SELECT 1 FROM {auth_table_name} WHERE email = %s", (email,)) + if cur.fetchone() is not None: + return 'an account with that email already exists', 409 + # Store a bcrypt hash, never the plaintext. New sign-ups are always role + # 'user' — self-registration must NOT be able to mint an admin account + # (the old code returned an admin JWT here, a privilege-escalation hole). + hashed = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt(rounds=12)).decode('utf-8') + cur.execute( + f"INSERT INTO {auth_table_name} (email, password, role) VALUES (%s, %s, 'user')", + (email, hashed), + ) + conn.commit() + finally: + cur.close() + conn.close() + + # Auto-login: return a JWT so the new user is signed in immediately, as a + # regular (non-admin) user. + return CreateJWT(email, os.environ['JWT_SECRET'], 'user'), 201 + +def CreateJWT(username, secret, role): return jwt.encode( { "username": username, "exp": datetime.datetime.now(tz=datetime.timezone.utc) + datetime.timedelta(days=1), "iat": datetime.datetime.now(tz=datetime.timezone.utc), - "admin": authz, + # 'admin' (boolean) is kept for backward-compatibility with the gateway + # and frontend that read it; 'role' (string) is the forward-compatible + # claim that supports more roles later (auditor, support, ...). + "admin": role == "admin", + "role": role, }, secret, algorithm="HS256", @@ -59,10 +126,66 @@ def validate(): encoded_jwt = encoded_jwt.split(' ')[1] try: decoded_jwt = jwt.decode(encoded_jwt, os.environ['JWT_SECRET'], algorithms=["HS256"]) - except: + except Exception: return 'Unauthorized', 401, {'WWW-Authenticate': 'Basic realm="Login required!"'} return decoded_jwt, 200 +# --- User administration (internal, ClusterIP) --- +# These endpoints are NOT exposed via NodePort and carry NO role check of their +# own — they trust in-cluster callers, exactly like /login and /validate. The +# gateway is the component that enforces "admin only" before calling them. See +# ADMIN_USERS_EXPLAINED.md for the trust gap this implies and the real fix. + +@server.route('/users', methods=['GET']) +def list_users(): + auth_table_name = os.getenv('AUTH_TABLE') + conn = get_db_connection() + cur = conn.cursor() + try: + cur.execute( + f"SELECT email, role, created_at FROM {auth_table_name} ORDER BY created_at" + ) + rows = cur.fetchall() + finally: + cur.close() + conn.close() + + users = [ + { + "email": r[0], + "role": r[1], + "created_at": r[2].isoformat() if r[2] else None, + } + for r in rows + ] + return jsonify(users), 200 + +@server.route('/users/<email>', methods=['PATCH']) +def update_user_role(email): + auth_table_name = os.getenv('AUTH_TABLE') + data = request.get_json(silent=True) or {} + role = data.get('role') + if role not in ('user', 'admin'): + return "role must be 'user' or 'admin'", 400 + + conn = get_db_connection() + cur = conn.cursor() + try: + cur.execute( + f"UPDATE {auth_table_name} SET role = %s WHERE email = %s RETURNING email, role", + (role, email), + ) + updated = cur.fetchone() + conn.commit() + finally: + cur.close() + conn.close() + + if updated is None: + return 'no account with that email', 404 + + return jsonify({"email": updated[0], "role": updated[1]}), 200 + if __name__ == '__main__': server.run(host='0.0.0.0', port=5000) diff --git a/src/converter-service/.dockerignore b/src/converter-service/.dockerignore new file mode 100644 index 0000000..00a08c6 --- /dev/null +++ b/src/converter-service/.dockerignore @@ -0,0 +1,18 @@ +# Keep the build context small and free of anything the image doesn't need. +__pycache__/ +*.pyc +*.pyo +*.pyd +.Python +.pytest_cache/ +.git/ +.gitignore + +# Kubernetes manifests and secrets must never enter the image build context. +manifest/ +*secret*.yaml + +# Docs / study material +*_EXPLAINED.md +README.md +*.md diff --git a/src/converter-service/Dockerfile b/src/converter-service/Dockerfile index 6edab37..8dab9e7 100644 --- a/src/converter-service/Dockerfile +++ b/src/converter-service/Dockerfile @@ -1,6 +1,12 @@ -FROM python:3.10-slim-bullseye +FROM python:3.10-slim-bookworm -RUN apt-get update && apt-get install -y --no-install-recommends --no-install-suggests build-essential libpq-dev python3-dev ffmpeg && pip install --no-cache-dir --upgrade pip +# apt-get upgrade pulls patched OS packages (libgnutls30, libkrb5*) that the base +# image predates. pip upgrade of setuptools/wheel clears toolchain CVEs +# (CVE-2026-24049 wheel, CVE-2026-23949 jaraco.context vendored in setuptools). +RUN apt-get update && apt-get upgrade -y \ + && apt-get install -y --no-install-recommends --no-install-suggests build-essential libpq-dev python3-dev ffmpeg \ + && rm -rf /var/lib/apt/lists/* \ + && pip install --no-cache-dir --upgrade pip setuptools wheel WORKDIR /app COPY ./requirements.txt /app @@ -8,4 +14,9 @@ COPY ./requirements.txt /app RUN pip install --no-cache-dir --requirement /app/requirements.txt COPY . /app +# Run as a non-root uid (matches the Kubernetes securityContext runAsUser: 1000). +# The consumer writes ffmpeg temp files and the /tmp/healthy heartbeat to /tmp, +# which is world-writable (mode 1777) and backed by a writable emptyDir in k8s. +USER 1000 + CMD ["python", "consumer.py"] \ No newline at end of file diff --git a/src/converter-service/consumer.py b/src/converter-service/consumer.py index b4fd31f..f1c1df9 100644 --- a/src/converter-service/consumer.py +++ b/src/converter-service/consumer.py @@ -1,4 +1,8 @@ -import pika, sys, os, time +import os +import pathlib +import sys + +import pika from pymongo import MongoClient import gridfs from convert import to_mp3 @@ -12,17 +16,28 @@ def main(): fs_mp3s = gridfs.GridFS(db_mp3s) # rabbitmq connection + credentials = pika.PlainCredentials( + os.environ.get("RABBITMQ_DEFAULT_USER", "guest"), + os.environ.get("RABBITMQ_DEFAULT_PASS", "guest"), + ) connection = pika.BlockingConnection( - pika.ConnectionParameters(host='rabbitmq',heartbeat=0) + pika.ConnectionParameters(host='rabbitmq', credentials=credentials, heartbeat=0) ) channel = connection.channel() + # Signal readiness as soon as we are connected and ready to consume. The + # liveness probe checks for this file; without an initial touch an idle + # consumer (no messages yet) would never create it and crash-loop on the + # probe. Each successfully processed message refreshes it below. + pathlib.Path("/tmp/healthy").touch() + def callback(ch, method, properties, body): err = to_mp3.start(body, fs_videos, fs_mp3s, ch) if err: ch.basic_nack(delivery_tag=method.delivery_tag) else: ch.basic_ack(delivery_tag=method.delivery_tag) + pathlib.Path("/tmp/healthy").touch() channel.basic_consume( queue=os.environ.get("VIDEO_QUEUE"), on_message_callback=callback diff --git a/src/converter-service/convert/to_mp3.py b/src/converter-service/convert/to_mp3.py index 8cbf121..7c90b43 100644 --- a/src/converter-service/convert/to_mp3.py +++ b/src/converter-service/convert/to_mp3.py @@ -1,4 +1,8 @@ -import pika, json, tempfile, os +import json +import os +import tempfile + +import pika from bson.objectid import ObjectId import moviepy.editor @@ -19,10 +23,16 @@ def start(message, fs_videos, fs_mp3s, channel): tf_path = tempfile.gettempdir() + f"/{message['video_fid']}.mp3" audio.write_audiofile(tf_path) - # save the file to the mongodb database + # save the file to the mongodb database. Copy the owner tag from the video + # message onto the mp3 so /my-files and the unseen-count badge can find it; + # .get() keeps backward-compat with old messages that have no username. f = open(tf_path, "rb") data = f.read() - fid = fs_mp3s.put(data) + fid = fs_mp3s.put( + data, + filename=f"{message['video_fid']}.mp3", + metadata={"owner_email": message.get("username")}, + ) f.close() os.remove(tf_path) @@ -37,6 +47,6 @@ def start(message, fs_videos, fs_mp3s, channel): delivery_mode=pika.spec.PERSISTENT_DELIVERY_MODE ), ) - except Exception as err: + except Exception: fs_mp3s.delete(fid) return "failed to publish message" diff --git a/src/converter-service/manifest/configmap.yaml b/src/converter-service/manifest/configmap.yaml index 9674f3e..a3bc97b 100644 --- a/src/converter-service/manifest/configmap.yaml +++ b/src/converter-service/manifest/configmap.yaml @@ -5,4 +5,7 @@ metadata: data: MP3_QUEUE: "mp3" VIDEO_QUEUE: "video" - MONGODB_URI: "mongodb://nasi:nasi1234@mongodb:27017/mp3s?authSource=admin" #nodeip:nodeport + # MONGODB_URI moved to the converter-secret Secret — it embeds the MongoDB + # username/password and must not live in a ConfigMap. The env var name is + # unchanged; envFrom pulls it from the Secret instead. See + # converter-service/manifest/secret.yaml.example. diff --git a/src/converter-service/manifest/converter-deploy.yaml b/src/converter-service/manifest/converter-deploy.yaml index b48b1ae..50f501d 100644 --- a/src/converter-service/manifest/converter-deploy.yaml +++ b/src/converter-service/manifest/converter-deploy.yaml @@ -5,26 +5,63 @@ metadata: labels: app: converter spec: - replicas: 4 + # 2 replicas, not 4: the single m7i-flex.large node (2 vCPU) cannot schedule + # 4 converters @ 250m CPU request alongside the other services — they sat + # Pending with "Insufficient cpu". 2 replicas is enough for demo throughput; + # scale up by adding nodes (raise the node group desired_size) if needed. + replicas: 2 selector: matchLabels: app: converter strategy: type: RollingUpdate rollingUpdate: - maxSurge: 8 + maxSurge: 1 template: metadata: labels: app: converter spec: + securityContext: + runAsNonRoot: true + runAsUser: 1000 + volumes: + - name: tmp-volume + emptyDir: {} containers: - name: converter - image: nasi101/converter + image: johnbaabalola/converter-service:16f49a0 + imagePullPolicy: IfNotPresent envFrom: - configMapRef: name: converter-configmap - secretRef: name: converter-secret - - + - secretRef: + name: rabbitmq-secret + env: + # Unbuffered stdout so print() diagnostics reach kubectl logs + # immediately, not on a block-buffer flush. + - name: PYTHONUNBUFFERED + value: "1" + volumeMounts: + - name: tmp-volume + mountPath: /tmp + resources: + requests: + cpu: "250m" + memory: "256Mi" + limits: + cpu: "500m" + memory: "512Mi" + securityContext: + readOnlyRootFilesystem: true + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] + livenessProbe: + exec: + command: ["test", "-f", "/tmp/healthy"] + initialDelaySeconds: 15 + periodSeconds: 10 + failureThreshold: 3 diff --git a/src/converter-service/manifest/secret.yaml b/src/converter-service/manifest/secret.yaml deleted file mode 100644 index 18a8217..0000000 --- a/src/converter-service/manifest/secret.yaml +++ /dev/null @@ -1,7 +0,0 @@ -apiVersion: v1 -kind: Secret -metadata: - name: converter-secret -stringData: - PLACEHOLDER: "NONE" -type: Opaque \ No newline at end of file diff --git a/src/converter-service/manifest/secret.yaml.example b/src/converter-service/manifest/secret.yaml.example new file mode 100644 index 0000000..3dc887f --- /dev/null +++ b/src/converter-service/manifest/secret.yaml.example @@ -0,0 +1,10 @@ +# Template for converter-secret. Copy to secret.yaml (gitignored) and fill in. +# WARNING: Replace before production use — back this with an external secret +# manager (AWS Secrets Manager + External Secrets Operator), not a committed file. +apiVersion: v1 +kind: Secret +metadata: + name: converter-secret +type: Opaque +stringData: + MONGODB_URI: "mongodb://<user>:<password>@mongodb:27017/mp3s?authSource=admin" diff --git a/src/converter-service/requirements.txt b/src/converter-service/requirements.txt index 88832c6..ee4b459 100644 --- a/src/converter-service/requirements.txt +++ b/src/converter-service/requirements.txt @@ -1,29 +1,17 @@ -astroid==2.12.13 -certifi==2022.9.24 -charset-normalizer==2.1.1 -decorator==4.4.2 -dill==0.3.6 -dnspython==2.2.1 -idna==3.4 -imageio==2.22.4 -imageio-ffmpeg==0.4.7 -isort==5.10.1 -jedi==0.18.2 -lazy-object-proxy==1.8.0 -mccabe==0.7.0 -moviepy==1.0.3 -numpy==1.23.5 -parso==0.8.3 -pika==1.3.1 -Pillow==9.3.0 -platformdirs==2.5.4 -proglog==0.1.10 -pylint==2.15.6 -pymongo==4.3.3 -requests==2.28.1 -tomli==2.0.1 -tomlkit==0.11.6 -tqdm==4.64.1 -typing-extensions==4.4.0 -urllib3==1.26.12 -wrapt==1.14.1 +# Pinned to minimum CVE-free versions. pip resolves moviepy's transitive stack +# (imageio, imageio-ffmpeg, decorator, proglog, tqdm) from these floors. +# numpy capped <2.0: moviepy 1.0.3 is not compatible with the numpy 2.x API. +# Pillow floored at 10.3.0 to clear CVE-2023-44271 / CVE-2023-50447 (CRITICAL); +# the service only extracts audio, so it never touches Pillow's removed +# Image.ANTIALIAS resize path. +# Dropped from the old frozen list: pylint/astroid/jedi/isort (dev-only tools). +pika>=1.3.1 +pymongo>=4.3.3 +moviepy>=1.0.3,<2.0 +numpy>=1.26.0,<2.0 +Pillow>=10.3.0 +certifi>=2023.7.22 +# urllib3 must be >=2.6.0: the latest 1.26.x (1.26.20) still carries 4 fixable +# HIGH CVEs (e.g. CVE-2025-66418) that are only patched in the 2.x line. Safe +# here — the only consumer is requests (via imageio), which supports urllib3 2.x. +urllib3>=2.6.0 diff --git a/src/frontend/Dockerfile b/src/frontend/Dockerfile new file mode 100644 index 0000000..9a3ae05 --- /dev/null +++ b/src/frontend/Dockerfile @@ -0,0 +1,21 @@ +# Stage 1 — Build React app +FROM node:18-alpine AS builder +WORKDIR /app +COPY package.json ./ +RUN npm install +COPY . . +RUN npm run build + +# Stage 2 — Serve with nginx as non-root +FROM nginx:1.25-alpine +RUN addgroup -g 1001 appgroup && adduser -u 1001 -G appgroup -D appuser +COPY --from=builder /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +RUN chown -R appuser:appgroup /usr/share/nginx/html \ + && chown -R appuser:appgroup /var/cache/nginx \ + && chown -R appuser:appgroup /var/log/nginx \ + && touch /var/run/nginx.pid \ + && chown appuser:appgroup /var/run/nginx.pid +USER appuser +EXPOSE 8080 +CMD ["nginx", "-g", "daemon off;"] diff --git a/src/frontend/index.html b/src/frontend/index.html new file mode 100644 index 0000000..47044fe --- /dev/null +++ b/src/frontend/index.html @@ -0,0 +1,12 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>VidCast — Video to Podcast Audio</title> + </head> + <body> + <div id="root"></div> + <script type="module" src="/src/main.jsx"></script> + </body> +</html> diff --git a/src/frontend/manifest/configmap.yaml b/src/frontend/manifest/configmap.yaml new file mode 100644 index 0000000..a6e9fb2 --- /dev/null +++ b/src/frontend/manifest/configmap.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: frontend-configmap +data: + VITE_API_URL: "/api" + VITE_GRAFANA_URL: "" diff --git a/src/frontend/manifest/deployment.yaml b/src/frontend/manifest/deployment.yaml new file mode 100644 index 0000000..002e192 --- /dev/null +++ b/src/frontend/manifest/deployment.yaml @@ -0,0 +1,57 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: frontend + labels: + app: frontend +spec: + replicas: 1 + selector: + matchLabels: + app: frontend + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 + template: + metadata: + labels: + app: frontend + spec: + securityContext: + runAsNonRoot: true + runAsUser: 1001 + containers: + - name: frontend + # Hosted in this account's ECR (the node IAM role can pull it); CI + # does not build the frontend, so it is not on Docker Hub like the + # backend services. SHA-pinned to the repo commit it was built from. + image: 501562869470.dkr.ecr.eu-west-2.amazonaws.com/vidcast-frontend:8582bf1 + ports: + - containerPort: 8080 + resources: + requests: + cpu: "50m" + memory: "64Mi" + limits: + cpu: "200m" + memory: "128Mi" + securityContext: + readOnlyRootFilesystem: false + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] + livenessProbe: + httpGet: + path: / + port: 8080 + initialDelaySeconds: 10 + periodSeconds: 10 + failureThreshold: 3 + readinessProbe: + httpGet: + path: / + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 5 + failureThreshold: 3 diff --git a/src/frontend/manifest/service.yaml b/src/frontend/manifest/service.yaml new file mode 100644 index 0000000..3d63cdc --- /dev/null +++ b/src/frontend/manifest/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: frontend + labels: + app: frontend +spec: + type: NodePort + selector: + app: frontend + ports: + - port: 8080 + targetPort: 8080 + nodePort: 30006 + protocol: TCP diff --git a/src/frontend/nginx.conf b/src/frontend/nginx.conf new file mode 100644 index 0000000..0474e60 --- /dev/null +++ b/src/frontend/nginx.conf @@ -0,0 +1,31 @@ +server { + listen 8080; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + # Allow video uploads through the proxy. nginx defaults to 1m, which + # rejected MP4 uploads with 413 before they ever reached the gateway. + client_max_body_size 256m; + + # Proxy API calls to the gateway service + location /api/ { + proxy_pass http://gateway:8080/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_connect_timeout 30s; + proxy_read_timeout 120s; + } + + # React SPA routing — send all unknown paths to index.html + location / { + try_files $uri $uri/ /index.html; + } + + # Security headers + add_header X-Frame-Options DENY; + add_header X-Content-Type-Options nosniff; + add_header X-XSS-Protection "1; mode=block"; +} diff --git a/src/frontend/package-lock.json b/src/frontend/package-lock.json new file mode 100644 index 0000000..d222bf2 --- /dev/null +++ b/src/frontend/package-lock.json @@ -0,0 +1,2537 @@ +{ + "name": "vidcast-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "vidcast-frontend", + "version": "1.0.0", + "dependencies": { + "axios": "^1.5.1", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.16.0" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.1.0", + "autoprefixer": "^10.4.16", + "postcss": "^8.4.31", + "tailwindcss": "^3.3.5", + "vite": "^4.4.11" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz", + "integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz", + "integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz", + "integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz", + "integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz", + "integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz", + "integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz", + "integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz", + "integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz", + "integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz", + "integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz", + "integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz", + "integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz", + "integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz", + "integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz", + "integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz", + "integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz", + "integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz", + "integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz", + "integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz", + "integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz", + "integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz", + "integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.3", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz", + "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", + "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.2", + "caniuse-lite": "^1.0.30001787", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axios": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.17.0.tgz", + "integrity": "sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.33", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.33.tgz", + "integrity": "sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001793", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", + "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.366", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.366.tgz", + "integrity": "sha512-OlRuhb688YTCzzU3gXPLn6nGyd+F+53INE1qaKKlu6kETErE8FYsyDh0XqXEU+uBRn0MpCzz2vfNwORhkap8qg==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", + "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.18.20", + "@esbuild/android-arm64": "0.18.20", + "@esbuild/android-x64": "0.18.20", + "@esbuild/darwin-arm64": "0.18.20", + "@esbuild/darwin-x64": "0.18.20", + "@esbuild/freebsd-arm64": "0.18.20", + "@esbuild/freebsd-x64": "0.18.20", + "@esbuild/linux-arm": "0.18.20", + "@esbuild/linux-arm64": "0.18.20", + "@esbuild/linux-ia32": "0.18.20", + "@esbuild/linux-loong64": "0.18.20", + "@esbuild/linux-mips64el": "0.18.20", + "@esbuild/linux-ppc64": "0.18.20", + "@esbuild/linux-riscv64": "0.18.20", + "@esbuild/linux-s390x": "0.18.20", + "@esbuild/linux-x64": "0.18.20", + "@esbuild/netbsd-x64": "0.18.20", + "@esbuild/openbsd-x64": "0.18.20", + "@esbuild/sunos-x64": "0.18.20", + "@esbuild/win32-arm64": "0.18.20", + "@esbuild/win32-ia32": "0.18.20", + "@esbuild/win32-x64": "0.18.20" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.47", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", + "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz", + "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz", + "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.3", + "react-router": "6.30.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "3.30.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.30.0.tgz", + "integrity": "sha512-kQvGasUgN+AlWGliFn2POSajRQEsULVYFGTvOZmK06d7vCD+YhZztt70kGk3qaeAXeWYL5eO7zx+rAubBc55eA==", + "dev": true, + "license": "MIT", + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=14.18.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "4.5.14", + "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.14.tgz", + "integrity": "sha512-+v57oAaoYNnO3hIu5Z/tJRZjq5aHM2zDve9YZ8HngVHbhk66RStobhb1sqPMIPEleV6cNKYK4eGrAbE9Ulbl2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.18.10", + "postcss": "^8.4.27", + "rollup": "^3.27.1" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + }, + "peerDependencies": { + "@types/node": ">= 14", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/src/frontend/package.json b/src/frontend/package.json new file mode 100644 index 0000000..0f736c4 --- /dev/null +++ b/src/frontend/package.json @@ -0,0 +1,23 @@ +{ + "name": "vidcast-frontend", + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.16.0", + "axios": "^1.5.1" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.1.0", + "autoprefixer": "^10.4.16", + "postcss": "^8.4.31", + "tailwindcss": "^3.3.5", + "vite": "^4.4.11" + } +} diff --git a/src/frontend/postcss.config.js b/src/frontend/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/src/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/src/frontend/src/App.jsx b/src/frontend/src/App.jsx new file mode 100644 index 0000000..34ad2ff --- /dev/null +++ b/src/frontend/src/App.jsx @@ -0,0 +1,94 @@ +import React, { useState } from 'react' +import { Routes, Route, NavLink, Navigate } from 'react-router-dom' +import Login from './pages/Login' +import Upload from './pages/Upload' +import Download from './pages/Download' +import MyConversions from './pages/MyConversions' +import Dashboard from './pages/Dashboard' +import Architecture from './pages/Architecture' +import AdminUsers from './pages/AdminUsers' +import { userFromToken } from './auth' +import { useUnseenCount } from './hooks/useUnseenCount' + +export default function App() { + const [token, setToken] = useState(null) + + // `since` marks the last time the user "saw" their downloads. New conversions + // completed after this timestamp drive the bubble badge. It resets on login + // and whenever the user visits the Download tab (marking everything as seen). + const [since, setSince] = useState(() => new Date().toISOString()) + const markDownloadsSeen = () => setSince(new Date().toISOString()) + + const handleLogin = (t) => { + markDownloadsSeen() + setToken(t) + } + + // Derive the user's role from the JWT. isAdmin gates the privileged tabs and + // routes below. This is UX-only — the real control is the backend role check; + // the frontend hiding just keeps the experience clean. + const { isAdmin } = userFromToken(token) + + // Polled count of conversions ready since `since` — shown as the Download badge. + const unseen = useUnseenCount(token, since) + + const nav = 'px-4 py-2 rounded hover:bg-purple-800 transition-colors' + const active = 'bg-purple-700' + + return ( + <div className="min-h-screen flex flex-col"> + <header className="bg-indigo-950 border-b border-indigo-800 px-6 py-3 flex items-center justify-between"> + <span className="text-xl font-bold text-purple-400">🎙 VidCast</span> + {token && ( + <nav className="flex gap-2 text-sm"> + <NavLink to="/upload" className={({ isActive }) => `${nav} ${isActive ? active : ''}`}>Upload</NavLink> + <NavLink + to="/download" + onClick={markDownloadsSeen} + className={({ isActive }) => `relative ${nav} ${isActive ? active : ''}`} + > + Download + {unseen > 0 && ( + <span className="absolute -top-1 -right-1 bg-red-500 text-white text-xs font-bold rounded-full px-1.5 min-w-[18px] text-center leading-tight"> + {unseen} + </span> + )} + </NavLink> + <NavLink to="/my-files" className={({ isActive }) => `${nav} ${isActive ? active : ''}`}>My Conversions</NavLink> + {isAdmin && <NavLink to="/dashboard" className={({ isActive }) => `${nav} ${isActive ? active : ''}`}>Dashboard</NavLink>} + {isAdmin && <NavLink to="/architecture" className={({ isActive }) => `${nav} ${isActive ? active : ''}`}>Architecture</NavLink>} + {isAdmin && <NavLink to="/admin/users" className={({ isActive }) => `${nav} ${isActive ? active : ''}`}>Users</NavLink>} + <button onClick={() => setToken(null)} className={`${nav} text-red-400`}>Logout</button> + </nav> + )} + </header> + + <main className="flex-1 p-6"> + <Routes> + <Route path="/" element={token ? <Navigate to="/upload" /> : <Login onLogin={handleLogin} />} /> + <Route path="/upload" element={token ? <Upload token={token} /> : <Navigate to="/" />} /> + <Route path="/download" element={token ? <Download token={token} /> : <Navigate to="/" />} /> + <Route path="/my-files" element={token ? <MyConversions token={token} /> : <Navigate to="/" />} /> + {/* Admin-only routes. Guarded even against direct URL entry: a non-admin + who types /dashboard is bounced to /upload, an unauth user to /. */} + <Route + path="/dashboard" + element={!token ? <Navigate to="/" /> : isAdmin ? <Dashboard /> : <Navigate to="/upload" />} + /> + <Route + path="/architecture" + element={!token ? <Navigate to="/" /> : isAdmin ? <Architecture /> : <Navigate to="/upload" />} + /> + <Route + path="/admin/users" + element={!token ? <Navigate to="/" /> : isAdmin ? <AdminUsers token={token} /> : <Navigate to="/upload" />} + /> + </Routes> + </main> + + <footer className="text-center text-xs text-gray-600 py-3"> + VidCast — built on AWS EKS · React + Flask + RabbitMQ + MongoDB + </footer> + </div> + ) +} diff --git a/src/frontend/src/api.js b/src/frontend/src/api.js new file mode 100644 index 0000000..fbc3077 --- /dev/null +++ b/src/frontend/src/api.js @@ -0,0 +1,69 @@ +import axios from 'axios' + +const BASE = import.meta.env.VITE_API_URL || '/api' + +export async function login(email, password) { + const res = await axios.post(`${BASE}/login`, null, { + auth: { username: email, password } + }) + return res.data +} + +export async function register(email, password) { + const res = await axios.post(`${BASE}/register`, { email, password }) + return res.data +} + +export async function uploadVideo(file, token) { + const form = new FormData() + form.append('file', file) + const res = await axios.post(`${BASE}/upload`, form, { + headers: { Authorization: `Bearer ${token}` } + }) + return res.data +} + +export async function downloadMp3(fid, token) { + const res = await axios.get(`${BASE}/download`, { + params: { fid }, + headers: { Authorization: `Bearer ${token}` }, + responseType: 'blob' + }) + return res.data +} + +// Count of this user's conversions completed since `since` (ISO-8601 string). +// Used by the Download bubble badge. +export async function unseenCount(token, since) { + const res = await axios.get(`${BASE}/notifications/unseen-count`, { + params: { since }, + headers: { Authorization: `Bearer ${token}` } + }) + return res.data // { count } +} + +// This user's converted files, newest first. Used by the My Conversions page. +export async function myFiles(token) { + const res = await axios.get(`${BASE}/my-files`, { + headers: { Authorization: `Bearer ${token}` } + }) + return res.data // { files: [...] } +} + +// Admin only: all users with role, signup date, and conversion count. +export async function adminUsers(token) { + const res = await axios.get(`${BASE}/admin/users`, { + headers: { Authorization: `Bearer ${token}` } + }) + return res.data // [{ email, role, created_at, conversions }] +} + +// Admin only: promote/demote a user between 'user' and 'admin'. +export async function setUserRole(token, email, role) { + const res = await axios.patch( + `${BASE}/admin/users/${encodeURIComponent(email)}`, + { role }, + { headers: { Authorization: `Bearer ${token}` } } + ) + return res.data +} diff --git a/src/frontend/src/auth.js b/src/frontend/src/auth.js new file mode 100644 index 0000000..5d2cc38 --- /dev/null +++ b/src/frontend/src/auth.js @@ -0,0 +1,33 @@ +// Decode a JWT payload WITHOUT verifying the signature. +// The gateway (via the auth service /validate) is the real authority — it +// cryptographically verifies the token on every protected request. The frontend +// only needs to *read* claims to decide what to show, so an unverified decode is +// fine here: a tampered token buys nothing because the backend rejects it anyway. +export function decodeJwt(token) { + if (!token) return null + try { + const payload = token.split('.')[1] + const base64 = payload.replace(/-/g, '+').replace(/_/g, '/') + const json = decodeURIComponent( + atob(base64) + .split('') + .map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)) + .join('') + ) + return JSON.parse(json) + } catch { + return null + } +} + +// Convenience: derive the user view-model from a raw token string. +export function userFromToken(token) { + const claims = decodeJwt(token) + return { + email: claims?.username || null, + role: claims?.role || 'anonymous', + // Read the backward-compatible boolean; fall back to role string. + isAdmin: claims?.admin === true || claims?.role === 'admin', + isAuthenticated: Boolean(claims), + } +} diff --git a/src/frontend/src/hooks/useUnseenCount.js b/src/frontend/src/hooks/useUnseenCount.js new file mode 100644 index 0000000..f6bd67c --- /dev/null +++ b/src/frontend/src/hooks/useUnseenCount.js @@ -0,0 +1,38 @@ +import { useState, useEffect } from 'react' +import { unseenCount } from '../api' + +// Polls the gateway for the number of conversions completed since `since`. +// Polling (not WebSockets/SSE) is the deliberate choice for a single-user demo: +// trivially debuggable, works through any firewall, one endpoint. The few-second +// latency is irrelevant when conversion itself takes 5-30s. (If we ever needed +// thousands of concurrent users we'd switch to SSE to avoid the poll load.) +export function useUnseenCount(token, since, pollIntervalMs = 5000) { + const [count, setCount] = useState(0) + + useEffect(() => { + if (!token) { + setCount(0) + return + } + let cancelled = false + + const poll = async () => { + try { + const data = await unseenCount(token, since) + if (!cancelled) setCount(data?.count || 0) + } catch { + // Silent — the next tick retries. A transient gateway blip shouldn't + // surface an error in the navbar. + } + } + + poll() // immediate first read, don't wait a full interval + const id = setInterval(poll, pollIntervalMs) + return () => { + cancelled = true + clearInterval(id) + } + }, [token, since, pollIntervalMs]) + + return count +} diff --git a/src/frontend/src/index.css b/src/frontend/src/index.css new file mode 100644 index 0000000..d6446ad --- /dev/null +++ b/src/frontend/src/index.css @@ -0,0 +1,7 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +body { + @apply bg-gray-950 text-white min-h-screen; +} diff --git a/src/frontend/src/main.jsx b/src/frontend/src/main.jsx new file mode 100644 index 0000000..8901eca --- /dev/null +++ b/src/frontend/src/main.jsx @@ -0,0 +1,11 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { BrowserRouter } from 'react-router-dom' +import App from './App' +import './index.css' + +ReactDOM.createRoot(document.getElementById('root')).render( + <BrowserRouter> + <App /> + </BrowserRouter> +) diff --git a/src/frontend/src/pages/AdminUsers.jsx b/src/frontend/src/pages/AdminUsers.jsx new file mode 100644 index 0000000..26385e9 --- /dev/null +++ b/src/frontend/src/pages/AdminUsers.jsx @@ -0,0 +1,115 @@ +import React, { useState, useEffect } from 'react' +import { adminUsers, setUserRole } from '../api' +import { userFromToken } from '../auth' + +function formatDate(iso) { + if (!iso) return '—' + const d = new Date(iso) + return Number.isNaN(d.getTime()) ? '—' : d.toLocaleDateString() +} + +export default function AdminUsers({ token }) { + const me = userFromToken(token).email + const [users, setUsers] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + const [busy, setBusy] = useState(null) // email currently being changed + + async function load() { + setError('') + try { + const data = await adminUsers(token) + setUsers(Array.isArray(data) ? data : []) + } catch { + setError('Could not load users.') + } finally { + setLoading(false) + } + } + + useEffect(() => { + let cancelled = false + setLoading(true) + adminUsers(token) + .then((data) => { if (!cancelled) setUsers(Array.isArray(data) ? data : []) }) + .catch(() => { if (!cancelled) setError('Could not load users.') }) + .finally(() => { if (!cancelled) setLoading(false) }) + return () => { cancelled = true } + }, [token]) + + async function changeRole(email, nextRole) { + setBusy(email) + setError('') + try { + await setUserRole(token, email, nextRole) + await load() + } catch (err) { + const status = err?.response?.status + const msg = + status === 403 ? 'You cannot change your own role.' + : status === 409 ? 'Cannot demote the last remaining admin.' + : status === 404 ? 'That account no longer exists.' + : 'Could not update role.' + setError(msg) + } finally { + setBusy(null) + } + } + + return ( + <div className="max-w-4xl mx-auto mt-10"> + <h2 className="text-2xl font-bold text-purple-400 mb-2">Users</h2> + <p className="text-gray-400 mb-6">Manage roles. Admins can access the Dashboard, Architecture, and this page.</p> + + {loading && <p className="text-gray-400">Loading…</p>} + {error && <p className="text-red-400 text-sm mb-4">{error}</p>} + + {!loading && ( + <div className="bg-indigo-950 border border-indigo-800 rounded-xl overflow-hidden"> + <table className="w-full text-sm"> + <thead> + <tr className="text-left text-gray-400 border-b border-indigo-800"> + <th className="px-4 py-3 font-medium">Email</th> + <th className="px-4 py-3 font-medium">Role</th> + <th className="px-4 py-3 font-medium">Signed up</th> + <th className="px-4 py-3 font-medium">Conversions</th> + <th className="px-4 py-3 font-medium text-right">Action</th> + </tr> + </thead> + <tbody> + {users.map((u) => { + const isMe = u.email === me + const isAdmin = u.role === 'admin' + const nextRole = isAdmin ? 'user' : 'admin' + return ( + <tr key={u.email} className="border-b border-indigo-900 last:border-0 hover:bg-indigo-900/40"> + <td className="px-4 py-3 text-gray-200"> + {u.email}{isMe && <span className="text-gray-500"> (you)</span>} + </td> + <td className="px-4 py-3"> + <span className={`rounded-full px-2 py-0.5 text-xs font-semibold ${isAdmin ? 'bg-purple-700 text-white' : 'bg-gray-700 text-gray-200'}`}> + {u.role} + </span> + </td> + <td className="px-4 py-3 text-gray-400">{formatDate(u.created_at)}</td> + <td className="px-4 py-3 text-gray-400">{u.conversions ?? 0}</td> + <td className="px-4 py-3 text-right"> + <button + onClick={() => changeRole(u.email, nextRole)} + disabled={isMe || busy === u.email} + title={isMe ? "You can't change your own role" : ''} + className="bg-purple-700 hover:bg-purple-600 disabled:opacity-40 disabled:cursor-not-allowed rounded-lg px-3 py-1.5 font-semibold transition-colors" + > + {busy === u.email ? '…' : isAdmin ? 'Demote to user' : 'Promote to admin'} + </button> + </td> + </tr> + ) + })} + </tbody> + </table> + </div> + )} + </div> + ) +} diff --git a/src/frontend/src/pages/Architecture.jsx b/src/frontend/src/pages/Architecture.jsx new file mode 100644 index 0000000..97b1401 --- /dev/null +++ b/src/frontend/src/pages/Architecture.jsx @@ -0,0 +1,92 @@ +import React, { useState } from 'react' + +const services = [ + { id: 'client', label: 'Browser / curl', color: 'bg-gray-700', desc: 'The client — uploads videos, downloads MP3s via HTTP.' }, + { id: 'frontend', label: 'Frontend (React)', color: 'bg-blue-800', desc: 'This web app. Served as static files by nginx on NodePort 30006. Proxies API calls to the Gateway.' }, + { id: 'gateway', label: 'Gateway (Flask)', color: 'bg-purple-800', desc: 'The entry point. Handles /login, /upload, /download. Stores video in MongoDB GridFS and publishes to the video RabbitMQ queue. NodePort 30002.' }, + { id: 'auth', label: 'Auth (Flask)', color: 'bg-indigo-800', desc: 'Issues and validates JWT tokens. Reads user credentials from PostgreSQL. ClusterIP only — not publicly accessible.' }, + { id: 'rabbit', label: 'RabbitMQ', color: 'bg-orange-800', desc: 'The message broker. Two durable queues: "video" (uploaded videos waiting to convert) and "mp3" (converted files waiting to notify). NodePort 30004 for management UI.' }, + { id: 'converter',label: 'Converter (×4)', color: 'bg-green-800', desc: '4 worker pods. Each reads a video file ID from the video queue, fetches the video from MongoDB, runs ffmpeg/MoviePy to extract audio, stores the MP3 back to MongoDB, then publishes to the mp3 queue.' }, + { id: 'notify', label: 'Notification (×2)', color: 'bg-yellow-800', desc: '2 worker pods. Each reads from the mp3 queue and sends an email via Gmail SMTP with the file ID for download.' }, + { id: 'mongo', label: 'MongoDB (GridFS)', color: 'bg-red-900', desc: 'Stores video and MP3 files as GridFS chunks. StatefulSet for stable storage. NodePort 30005 for admin access.' }, + { id: 'postgres', label: 'PostgreSQL', color: 'bg-blue-900', desc: 'Stores user credentials (email + password). Used only by the Auth service. NodePort 30003 for admin access.' }, +] + +const arrows = [ + { from: 'client', to: 'frontend', label: 'HTTP :30006' }, + { from: 'frontend', to: 'gateway', label: 'HTTP :30002' }, + { from: 'gateway', to: 'auth', label: 'validate JWT' }, + { from: 'auth', to: 'postgres', label: 'SQL query' }, + { from: 'gateway', to: 'mongo', label: 'store video' }, + { from: 'gateway', to: 'rabbit', label: 'publish fid' }, + { from: 'rabbit', to: 'converter', label: 'consume video queue' }, + { from: 'converter', to: 'mongo', label: 'fetch video / store MP3' }, + { from: 'converter', to: 'rabbit', label: 'publish to mp3 queue' }, + { from: 'rabbit', to: 'notify', label: 'consume mp3 queue' }, + { from: 'notify', to: 'client', label: 'email with file ID' }, +] + +export default function Architecture() { + const [selected, setSelected] = useState(null) + const current = services.find(s => s.id === selected) + + return ( + <div> + <h2 className="text-2xl font-bold text-purple-400 mb-2">System Architecture</h2> + <p className="text-gray-400 mb-6">Click any service to learn what it does and how it connects to the rest of the system.</p> + + <div className="flex flex-wrap gap-3 mb-6"> + {services.map(s => ( + <button + key={s.id} + onClick={() => setSelected(s.id === selected ? null : s.id)} + className={`px-4 py-2 rounded-lg border text-sm font-medium transition-all ${s.color} + ${selected === s.id ? 'ring-2 ring-purple-400 scale-105' : 'border-gray-700 hover:scale-105'}`} + > + {s.label} + </button> + ))} + </div> + + {current && ( + <div className="bg-indigo-950 border border-purple-700 rounded-xl p-5 mb-6"> + <h3 className="text-lg font-bold text-purple-300 mb-1">{current.label}</h3> + <p className="text-gray-300">{current.desc}</p> + </div> + )} + + <div className="bg-gray-900 rounded-xl p-6 font-mono text-sm"> + <pre className="text-gray-300 whitespace-pre">{` +Client ──────────────────────────────────► Frontend :30006 + │ + ▼ + Gateway :30002 + / | \\ + Auth MongoDB RabbitMQ + :5000 ──► GridFS "video" queue + │ :30005 │ + PostgreSQL Converter ×4 + :30003 (reads video, writes MP3) + │ + RabbitMQ + "mp3" queue + │ + Notification ×2 + │ + Email → Client +`}</pre> + </div> + + <div className="mt-6 grid grid-cols-1 md:grid-cols-2 gap-3"> + {arrows.map((a, i) => ( + <div key={i} className="bg-gray-900 rounded-lg px-4 py-2 text-sm"> + <span className="text-purple-400">{a.from}</span> + <span className="text-gray-500 mx-2">→</span> + <span className="text-green-400">{a.to}</span> + <span className="text-gray-600 ml-2 text-xs">{a.label}</span> + </div> + ))} + </div> + </div> + ) +} diff --git a/src/frontend/src/pages/Dashboard.jsx b/src/frontend/src/pages/Dashboard.jsx new file mode 100644 index 0000000..bb48018 --- /dev/null +++ b/src/frontend/src/pages/Dashboard.jsx @@ -0,0 +1,50 @@ +import React from 'react' + +const GRAFANA_URL = import.meta.env.VITE_GRAFANA_URL || 'http://localhost:30007' + +export default function Dashboard() { + return ( + <div> + <h2 className="text-2xl font-bold text-purple-400 mb-2">Operations Dashboard</h2> + <p className="text-gray-400 mb-6"> + Live Grafana dashboard showing pod health, node resources, and RabbitMQ queue depth. + </p> + + <div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6"> + <div className="bg-indigo-950 border border-indigo-800 rounded-xl p-4"> + <h3 className="text-purple-400 font-semibold mb-1">Access Grafana</h3> + <p className="text-gray-400 text-sm mb-2">Full dashboard with all metrics</p> + <a + href={`${GRAFANA_URL}/d/vidcast-ops`} + target="_blank" + rel="noopener noreferrer" + className="text-purple-400 underline text-sm hover:text-purple-300" + > + Open Grafana → VidCast Operations + </a> + <p className="text-gray-600 text-xs mt-1">Credentials: admin / vidcast-demo</p> + </div> + <div className="bg-indigo-950 border border-indigo-800 rounded-xl p-4"> + <h3 className="text-purple-400 font-semibold mb-1">Access Alertmanager</h3> + <p className="text-gray-400 text-sm mb-2">View active alerts</p> + <a + href={GRAFANA_URL.replace('30007', '30008')} + target="_blank" + rel="noopener noreferrer" + className="text-purple-400 underline text-sm hover:text-purple-300" + > + Open Alertmanager + </a> + </div> + </div> + + <div className="bg-indigo-950 border border-indigo-800 rounded-xl overflow-hidden"> + <iframe + src={`${GRAFANA_URL}/d/vidcast-ops?orgId=1&kiosk=tv`} + className="w-full h-96" + title="VidCast Operations Dashboard" + /> + </div> + </div> + ) +} diff --git a/src/frontend/src/pages/Download.jsx b/src/frontend/src/pages/Download.jsx new file mode 100644 index 0000000..fe384e7 --- /dev/null +++ b/src/frontend/src/pages/Download.jsx @@ -0,0 +1,56 @@ +import React, { useState } from 'react' +import { downloadMp3 } from '../api' + +export default function Download({ token }) { + const [fid, setFid] = useState('') + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + + async function handleDownload(e) { + e.preventDefault() + setError('') + setLoading(true) + try { + const blob = await downloadMp3(fid.trim(), token) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `${fid.trim()}.mp3` + a.click() + URL.revokeObjectURL(url) + } catch { + setError('File not found or not yet converted. Check your email for the correct file ID.') + } finally { + setLoading(false) + } + } + + return ( + <div className="max-w-xl mx-auto mt-10"> + <h2 className="text-2xl font-bold text-purple-400 mb-2">Download MP3</h2> + <p className="text-gray-400 mb-6">Enter the file ID from your notification email to download your converted audio.</p> + + <form onSubmit={handleDownload} className="space-y-4"> + <div> + <label className="block text-sm text-gray-400 mb-1">File ID</label> + <input + type="text" + value={fid} + onChange={e => setFid(e.target.value)} + placeholder="e.g. 6a1a19f08025aee51e1d4073" + className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white font-mono focus:outline-none focus:border-purple-500" + required + /> + </div> + {error && <p className="text-red-400 text-sm">{error}</p>} + <button + type="submit" + disabled={loading || !fid.trim()} + className="w-full bg-purple-700 hover:bg-purple-600 disabled:opacity-50 rounded-lg py-3 font-semibold transition-colors" + > + {loading ? 'Downloading...' : '⬇ Download MP3'} + </button> + </form> + </div> + ) +} diff --git a/src/frontend/src/pages/Login.jsx b/src/frontend/src/pages/Login.jsx new file mode 100644 index 0000000..6bf1ac9 --- /dev/null +++ b/src/frontend/src/pages/Login.jsx @@ -0,0 +1,131 @@ +import React, { useState } from 'react' +import { login, register } from '../api' + +export default function Login({ onLogin }) { + const [mode, setMode] = useState('signin') // 'signin' | 'signup' + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [confirm, setConfirm] = useState('') + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + + const isSignup = mode === 'signup' + + function switchMode() { + setMode(isSignup ? 'signin' : 'signup') + setError('') + setConfirm('') + } + + async function handleSubmit(e) { + e.preventDefault() + setError('') + + if (isSignup && password.length < 8) { + setError('Password must be at least 8 characters.') + return + } + + if (isSignup && password !== confirm) { + setError('Passwords do not match.') + return + } + + setLoading(true) + try { + const token = isSignup + ? await register(email, password) + : await login(email, password) + onLogin(token) + } catch (err) { + const status = err?.response?.status + if (isSignup && status === 409) { + setError('An account with that email already exists.') + } else if (isSignup) { + setError('Could not create account. Please try again.') + } else { + setError('Invalid credentials. Please try again.') + } + } finally { + setLoading(false) + } + } + + return ( + <div className="max-w-md mx-auto mt-20"> + <div className="bg-indigo-950 border border-indigo-800 rounded-xl p-8"> + <h1 className="text-3xl font-bold text-purple-400 mb-2">VidCast</h1> + <p className="text-gray-400 mb-6">Turn video recordings into podcast-ready audio</p> + + <form onSubmit={handleSubmit} className="space-y-4"> + <div> + <label className="block text-sm text-gray-400 mb-1">Email</label> + <input + type="email" + value={email} + onChange={e => setEmail(e.target.value)} + className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:border-purple-500" + required + /> + </div> + <div> + <label className="block text-sm text-gray-400 mb-1">Password</label> + <input + type="password" + value={password} + onChange={e => setPassword(e.target.value)} + className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:border-purple-500" + required + /> + {isSignup && <p className="text-xs text-gray-500 mt-1">At least 8 characters.</p>} + </div> + {isSignup && ( + <div> + <label className="block text-sm text-gray-400 mb-1">Confirm password</label> + <input + type="password" + value={confirm} + onChange={e => setConfirm(e.target.value)} + className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:border-purple-500" + required + /> + </div> + )} + {error && <p className="text-red-400 text-sm">{error}</p>} + <button + type="submit" + disabled={loading} + className="w-full bg-purple-700 hover:bg-purple-600 disabled:opacity-50 rounded-lg py-2 font-semibold transition-colors" + > + {loading + ? (isSignup ? 'Creating account...' : 'Signing in...') + : (isSignup ? 'Sign Up' : 'Sign In')} + </button> + </form> + + {isSignup && ( + <div className="mt-6 bg-indigo-900/40 border border-indigo-800 rounded-lg p-4 text-xs text-gray-400"> + <p className="font-semibold text-gray-300 mb-1">About email notifications</p> + <p> + When your audio conversion finishes, we'll email a download link to the + address you sign up with — you don't need to configure anything on your + end. Add our notification address to your contacts so it doesn't land in + your spam folder. + </p> + </div> + )} + + <p className="text-gray-400 text-sm mt-6 text-center"> + {isSignup ? 'Already have an account?' : "Don't have an account?"}{' '} + <button + type="button" + onClick={switchMode} + className="text-purple-400 hover:text-purple-300 font-semibold" + > + {isSignup ? 'Sign in' : 'Sign up'} + </button> + </p> + </div> + </div> + ) +} diff --git a/src/frontend/src/pages/MyConversions.jsx b/src/frontend/src/pages/MyConversions.jsx new file mode 100644 index 0000000..ab20a2d --- /dev/null +++ b/src/frontend/src/pages/MyConversions.jsx @@ -0,0 +1,107 @@ +import React, { useState, useEffect } from 'react' +import { myFiles, downloadMp3 } from '../api' + +function formatSize(bytes) { + if (!bytes && bytes !== 0) return '—' + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` + return `${(bytes / (1024 * 1024)).toFixed(1)} MB` +} + +function formatDate(iso) { + if (!iso) return '—' + const d = new Date(iso) + return Number.isNaN(d.getTime()) ? '—' : d.toLocaleString() +} + +export default function MyConversions({ token }) { + const [files, setFiles] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + const [downloading, setDownloading] = useState(null) + + useEffect(() => { + let cancelled = false + async function load() { + setLoading(true) + setError('') + try { + const data = await myFiles(token) + if (!cancelled) setFiles(data?.files || []) + } catch { + if (!cancelled) setError('Could not load your conversions. Please try again.') + } finally { + if (!cancelled) setLoading(false) + } + } + load() + return () => { cancelled = true } + }, [token]) + + async function handleDownload(fid) { + setDownloading(fid) + try { + const blob = await downloadMp3(fid, token) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `${fid}.mp3` + a.click() + URL.revokeObjectURL(url) + } catch { + setError('Download failed. The file may still be converting.') + } finally { + setDownloading(null) + } + } + + return ( + <div className="max-w-3xl mx-auto mt-10"> + <h2 className="text-2xl font-bold text-purple-400 mb-2">My Conversions</h2> + <p className="text-gray-400 mb-6">Every video you've converted, newest first. Click a row to download its MP3.</p> + + {loading && <p className="text-gray-400">Loading…</p>} + {error && <p className="text-red-400 text-sm mb-4">{error}</p>} + + {!loading && !error && files.length === 0 && ( + <div className="bg-indigo-950 border border-indigo-800 rounded-xl p-8 text-center text-gray-400"> + <p className="mb-2">No conversions yet.</p> + <p className="text-sm">Head to <span className="text-purple-400">Upload</span> to convert your first video.</p> + </div> + )} + + {!loading && !error && files.length > 0 && ( + <div className="bg-indigo-950 border border-indigo-800 rounded-xl overflow-hidden"> + <table className="w-full text-sm"> + <thead> + <tr className="text-left text-gray-400 border-b border-indigo-800"> + <th className="px-4 py-3 font-medium">File</th> + <th className="px-4 py-3 font-medium">Converted</th> + <th className="px-4 py-3 font-medium">Size</th> + <th className="px-4 py-3 font-medium text-right">Download</th> + </tr> + </thead> + <tbody> + {files.map((f) => ( + <tr key={f.fid} className="border-b border-indigo-900 last:border-0 hover:bg-indigo-900/40"> + <td className="px-4 py-3 font-mono text-gray-200">{f.filename || f.fid}</td> + <td className="px-4 py-3 text-gray-400">{formatDate(f.created)}</td> + <td className="px-4 py-3 text-gray-400">{formatSize(f.size)}</td> + <td className="px-4 py-3 text-right"> + <button + onClick={() => handleDownload(f.fid)} + disabled={downloading === f.fid} + className="bg-purple-700 hover:bg-purple-600 disabled:opacity-50 rounded-lg px-3 py-1.5 font-semibold transition-colors" + > + {downloading === f.fid ? 'Downloading…' : '⬇ MP3'} + </button> + </td> + </tr> + ))} + </tbody> + </table> + </div> + )} + </div> + ) +} diff --git a/src/frontend/src/pages/Upload.jsx b/src/frontend/src/pages/Upload.jsx new file mode 100644 index 0000000..69c1ebb --- /dev/null +++ b/src/frontend/src/pages/Upload.jsx @@ -0,0 +1,70 @@ +import React, { useState, useRef } from 'react' +import { uploadVideo } from '../api' + +export default function Upload({ token }) { + const [file, setFile] = useState(null) + const [status, setStatus] = useState(null) + const [loading, setLoading] = useState(false) + const [dragging, setDragging] = useState(false) + const inputRef = useRef() + + function handleDrop(e) { + e.preventDefault() + setDragging(false) + const f = e.dataTransfer.files[0] + if (f && f.type.startsWith('video/')) setFile(f) + } + + async function handleUpload() { + if (!file) return + setLoading(true) + setStatus(null) + try { + await uploadVideo(file, token) + setStatus({ type: 'success', message: "Your video is being processed. You'll receive an email when the MP3 is ready to download." }) + setFile(null) + } catch (err) { + setStatus({ type: 'error', message: err.response?.data || 'Upload failed. Please try again.' }) + } finally { + setLoading(false) + } + } + + return ( + <div className="max-w-xl mx-auto mt-10"> + <h2 className="text-2xl font-bold text-purple-400 mb-2">Upload Video</h2> + <p className="text-gray-400 mb-6">Upload an MP4 file. We'll extract the audio and email you a download link.</p> + + <div + onDragOver={e => { e.preventDefault(); setDragging(true) }} + onDragLeave={() => setDragging(false)} + onDrop={handleDrop} + onClick={() => inputRef.current?.click()} + className={`border-2 border-dashed rounded-xl p-12 text-center cursor-pointer transition-colors + ${dragging ? 'border-purple-400 bg-purple-900/20' : 'border-gray-700 hover:border-gray-500'}`} + > + <input ref={inputRef} type="file" accept="video/*" className="hidden" onChange={e => setFile(e.target.files[0])} /> + {file + ? <p className="text-purple-300">📹 {file.name} ({(file.size / 1e6).toFixed(1)} MB)</p> + : <p className="text-gray-500">Drag & drop a video file, or click to browse</p> + } + </div> + + {file && ( + <button + onClick={handleUpload} + disabled={loading} + className="mt-4 w-full bg-purple-700 hover:bg-purple-600 disabled:opacity-50 rounded-lg py-3 font-semibold transition-colors" + > + {loading ? 'Uploading...' : 'Convert to MP3'} + </button> + )} + + {status && ( + <div className={`mt-4 p-4 rounded-lg ${status.type === 'success' ? 'bg-green-900/40 text-green-300' : 'bg-red-900/40 text-red-300'}`}> + {status.message} + </div> + )} + </div> + ) +} diff --git a/src/frontend/tailwind.config.js b/src/frontend/tailwind.config.js new file mode 100644 index 0000000..6480320 --- /dev/null +++ b/src/frontend/tailwind.config.js @@ -0,0 +1,15 @@ +export default { + content: ['./index.html', './src/**/*.{js,jsx}'], + theme: { + extend: { + colors: { + vidcast: { + purple: '#6D28D9', + dark: '#1E1B4B', + accent: '#A78BFA', + } + } + } + }, + plugins: [] +} diff --git a/src/frontend/vite.config.js b/src/frontend/vite.config.js new file mode 100644 index 0000000..829e075 --- /dev/null +++ b/src/frontend/vite.config.js @@ -0,0 +1,15 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + server: { + port: 3000, + proxy: { + '/api': { + target: 'http://localhost:8080', + rewrite: (path) => path.replace(/^\/api/, '') + } + } + } +}) diff --git a/src/gateway-service/.dockerignore b/src/gateway-service/.dockerignore new file mode 100644 index 0000000..00a08c6 --- /dev/null +++ b/src/gateway-service/.dockerignore @@ -0,0 +1,18 @@ +# Keep the build context small and free of anything the image doesn't need. +__pycache__/ +*.pyc +*.pyo +*.pyd +.Python +.pytest_cache/ +.git/ +.gitignore + +# Kubernetes manifests and secrets must never enter the image build context. +manifest/ +*secret*.yaml + +# Docs / study material +*_EXPLAINED.md +README.md +*.md diff --git a/src/gateway-service/Dockerfile b/src/gateway-service/Dockerfile index 0018d3b..589b79c 100644 --- a/src/gateway-service/Dockerfile +++ b/src/gateway-service/Dockerfile @@ -1,6 +1,12 @@ -FROM python:3.10-slim-bullseye +FROM python:3.10-slim-bookworm -RUN apt-get update && apt-get install -y --no-install-recommends --no-install-suggests build-essential libpq-dev python3-dev && pip install --no-cache-dir --upgrade pip +# apt-get upgrade pulls patched OS packages (libgnutls30, libkrb5*) that the base +# image predates. pip upgrade of setuptools/wheel clears toolchain CVEs +# (CVE-2026-24049 wheel, CVE-2026-23949 jaraco.context vendored in setuptools). +RUN apt-get update && apt-get upgrade -y \ + && apt-get install -y --no-install-recommends --no-install-suggests build-essential libpq-dev python3-dev \ + && rm -rf /var/lib/apt/lists/* \ + && pip install --no-cache-dir --upgrade pip setuptools wheel WORKDIR /app COPY ./requirements.txt /app @@ -10,4 +16,8 @@ COPY . /app EXPOSE 8080 +# Run as a non-root uid (matches the Kubernetes securityContext runAsUser: 1000). +# Port 8080 is >1024 so no privileged binding is required; /app is world-readable. +USER 1000 + CMD ["python", "server.py"] \ No newline at end of file diff --git a/src/gateway-service/auth/validate.py b/src/gateway-service/auth/validate.py index 245a669..40c5d91 100644 --- a/src/gateway-service/auth/validate.py +++ b/src/gateway-service/auth/validate.py @@ -1,8 +1,10 @@ -import os, requests +import os + +import requests def token(request): - if not "Authorization" in request.headers: + if "Authorization" not in request.headers: return None, ("missing credentials", 401) token = request.headers["Authorization"] diff --git a/src/gateway-service/auth_svc/access.py b/src/gateway-service/auth_svc/access.py index fd8b10f..c7647f3 100644 --- a/src/gateway-service/auth_svc/access.py +++ b/src/gateway-service/auth_svc/access.py @@ -1,4 +1,6 @@ -import os, requests +import os + +import requests def login(request): @@ -16,3 +18,16 @@ def login(request): return response.text, None else: return None, (response.text, response.status_code) + + +def register(request): + data = request.get_json(silent=True) or {} + + response = requests.post( + f"http://{os.environ.get('AUTH_SVC_ADDRESS')}/register", json=data + ) + + if response.status_code in (200, 201): + return response.text, None + else: + return None, (response.text, response.status_code) diff --git a/src/gateway-service/manifest/configmap.yaml b/src/gateway-service/manifest/configmap.yaml index 8bc592c..097b964 100644 --- a/src/gateway-service/manifest/configmap.yaml +++ b/src/gateway-service/manifest/configmap.yaml @@ -4,6 +4,9 @@ metadata: name: gateway-configmap data: AUTH_SVC_ADDRESS: "auth:5000" - MONGODB_VIDEOS_URI: "mongodb://nasi:nasi1234@mongodb:27017/videos?authSource=admin" - MONGODB_MP3S_URI: "mongodb://nasi:nasi1234@mongodb:27017/mp3s?authSource=admin" + # MONGODB_VIDEOS_URI and MONGODB_MP3S_URI moved to the gateway-secret Secret — + # they embed the MongoDB username/password and must not live in a ConfigMap + # (ConfigMaps are not treated as sensitive and are easy to dump). The env var + # names are unchanged; envFrom pulls them from the Secret instead. See + # gateway-service/manifest/secret.yaml.example. diff --git a/src/gateway-service/manifest/gateway-deploy.yaml b/src/gateway-service/manifest/gateway-deploy.yaml index a67dc56..29b22fc 100644 --- a/src/gateway-service/manifest/gateway-deploy.yaml +++ b/src/gateway-service/manifest/gateway-deploy.yaml @@ -10,6 +10,7 @@ spec: matchLabels: app: gateway strategy: + type: RollingUpdate rollingUpdate: maxSurge: 3 template: @@ -17,11 +18,59 @@ spec: labels: app: gateway spec: + securityContext: + runAsNonRoot: true + runAsUser: 1000 + volumes: + # Writable scratch dir. readOnlyRootFilesystem is true, but Werkzeug + # buffers multipart file uploads to a temp directory; without this the + # /upload handler fails with "No usable temporary directory found". + - name: tmp-volume + emptyDir: {} containers: - name: gateway - image: nasi101/gateway + image: johnbaabalola/gateway-service:16f49a0 + imagePullPolicy: IfNotPresent + ports: + - containerPort: 8080 envFrom: - configMapRef: name: gateway-configmap - secretRef: name: gateway-secret + - secretRef: + name: rabbitmq-secret + env: + # Unbuffered stdout so print() (e.g. the admin role-change audit log) + # reaches kubectl logs immediately, not on a block-buffer flush. + - name: PYTHONUNBUFFERED + value: "1" + volumeMounts: + - name: tmp-volume + mountPath: /tmp + resources: + requests: + cpu: "100m" + memory: "128Mi" + limits: + cpu: "300m" + memory: "256Mi" + securityContext: + readOnlyRootFilesystem: true + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] + livenessProbe: + httpGet: + path: /healthz + port: 8080 + initialDelaySeconds: 15 + periodSeconds: 10 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /healthz + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 5 + failureThreshold: 3 diff --git a/src/gateway-service/manifest/secret.yaml b/src/gateway-service/manifest/secret.yaml deleted file mode 100644 index f9582f4..0000000 --- a/src/gateway-service/manifest/secret.yaml +++ /dev/null @@ -1,7 +0,0 @@ -apiVersion: v1 -kind: Secret -metadata: - name: gateway-secret -stringData: - PLACEHOLDER: nothing -type: Opaque \ No newline at end of file diff --git a/src/gateway-service/manifest/secret.yaml.example b/src/gateway-service/manifest/secret.yaml.example new file mode 100644 index 0000000..f41ff80 --- /dev/null +++ b/src/gateway-service/manifest/secret.yaml.example @@ -0,0 +1,12 @@ +# Template for gateway-secret. Copy to secret.yaml (gitignored) and fill in real +# values, or create out-of-band with `kubectl create secret generic`. +# WARNING: Replace before production use — back this with an external secret +# manager (AWS Secrets Manager + External Secrets Operator), not a committed file. +apiVersion: v1 +kind: Secret +metadata: + name: gateway-secret +type: Opaque +stringData: + MONGODB_VIDEOS_URI: "mongodb://<user>:<password>@mongodb:27017/videos?authSource=admin" + MONGODB_MP3S_URI: "mongodb://<user>:<password>@mongodb:27017/mp3s?authSource=admin" diff --git a/src/gateway-service/requirements.txt b/src/gateway-service/requirements.txt index 389b405..cf70aa2 100644 --- a/src/gateway-service/requirements.txt +++ b/src/gateway-service/requirements.txt @@ -1,31 +1,22 @@ -astroid==2.12.13 -certifi==2022.9.24 -charset-normalizer==2.1.1 -click==8.1.3 -dill==0.3.6 -dnspython==2.2.1 -Flask==2.2.2 -Flask-PyMongo==2.3.0 -idna==3.4 -importlib-metadata==5.0.0 -isort==5.10.1 -itsdangerous==2.1.2 -jedi==0.18.2 -Jinja2==3.1.2 -lazy-object-proxy==1.8.0 -prometheus-client==0.15.0 -MarkupSafe==2.1.1 -mccabe==0.7.0 -parso==0.8.3 -pika==1.3.1 -platformdirs==2.5.4 -pylint==2.15.6 -pymongo==4.3.3 -requests==2.28.1 -tomli==2.0.1 -tomlkit==0.11.6 -typing-extensions==4.4.0 -urllib3==1.26.12 -Werkzeug==2.2.2 -wrapt==1.14.1 -zipp==3.10.0 +# Pinned to minimum CVE-free versions. pip resolves patched transitive deps +# (Jinja2, MarkupSafe, idna, charset-normalizer, dnspython) from these floors. +# Dropped from the old frozen list: pylint/astroid/jedi/isort/dill/etc. (dev-only +# linting tools never imported at runtime) and prometheus-client (declared but +# never imported; its only call sites, unauth_count.inc(), were already removed). +# Flask/Werkzeug are on 3.x: Werkzeug CVE-2024-34069 (debugger RCE, HIGH) is only +# fixed in 3.0.3, and Werkzeug 3 requires Flask 3. flask-pymongo bumped to its +# Flask-3-compatible 3.x line; the .db API the gateway uses is unchanged. +flask>=3.0.3 +werkzeug>=3.0.3 +flask-cors>=4.0.2 +flask-pymongo>=3.0.1 +pymongo>=4.3.3 +pyjwt>=2.6.0 +pika>=1.3.1 +requests>=2.31.0 +certifi>=2023.7.22 +# urllib3 must be >=2.6.0: the latest 1.26.x (1.26.20) still carries 4 fixable +# HIGH CVEs (e.g. CVE-2025-66418) that are only patched in the 2.x line. Safe +# here — the only consumer is requests, which supports urllib3 2.x, and no app +# code uses urllib3 directly. +urllib3>=2.6.0 diff --git a/src/gateway-service/server.py b/src/gateway-service/server.py index a78373a..eeb9580 100644 --- a/src/gateway-service/server.py +++ b/src/gateway-service/server.py @@ -1,13 +1,21 @@ -import os, gridfs, pika, json -from flask import Flask, request, send_file +import datetime +import gridfs +import json +import os + +import pika +import requests +from bson.objectid import ObjectId +from flask import Flask, jsonify, request, send_file +from flask_cors import CORS from flask_pymongo import PyMongo + from auth import validate from auth_svc import access from storage import util -from bson.objectid import ObjectId -from werkzeug.middleware.dispatcher import DispatcherMiddleware server = Flask(__name__) +CORS(server) mongo_video = PyMongo(server, uri=os.environ.get('MONGODB_VIDEOS_URI')) @@ -16,9 +24,40 @@ fs_videos = gridfs.GridFS(mongo_video.db) fs_mp3s = gridfs.GridFS(mongo_mp3.db) -connection = pika.BlockingConnection(pika.ConnectionParameters(host="rabbitmq", heartbeat=0)) +rabbitmq_credentials = pika.PlainCredentials( + os.environ.get("RABBITMQ_DEFAULT_USER", "guest"), + os.environ.get("RABBITMQ_DEFAULT_PASS", "guest"), +) +connection = pika.BlockingConnection( + pika.ConnectionParameters(host="rabbitmq", credentials=rabbitmq_credentials, heartbeat=0) +) channel = connection.channel() +@server.route("/healthz", methods=["GET"]) +def healthz(): + checks = {} + status_code = 200 + try: + mongo_video.db.command("ping") + checks["mongodb"] = "ok" + except Exception as e: + checks["mongodb"] = str(e) + status_code = 503 + try: + conn = pika.BlockingConnection( + pika.ConnectionParameters( + host=os.environ.get("RABBITMQ_HOST", "rabbitmq"), + credentials=rabbitmq_credentials, + heartbeat=0, + ) + ) + conn.close() + checks["rabbitmq"] = "ok" + except Exception as e: + checks["rabbitmq"] = str(e) + status_code = 503 + return jsonify({"status": "ok" if status_code == 200 else "degraded", "checks": checks}), status_code + @server.route("/login", methods=["POST"]) def login(): token, err = access.login(request) @@ -28,54 +67,215 @@ def login(): else: return err +@server.route("/register", methods=["POST"]) +def register(): + token, err = access.register(request) + + if not err: + return token, 201 + else: + return err + @server.route("/upload", methods=["POST"]) def upload(): access, err = validate.token(request) if err: - unauth_count.inc() return err access = json.loads(access) - if access["admin"]: - if len(request.files) > 1 or len(request.files) < 1: - return "exactly 1 file required", 400 + # AUTHORIZATION: uploading is a core action available to ANY authenticated + # user, not just admins. We previously gated on access["admin"], which only + # worked because every JWT claimed admin=true. With real RBAC, admin is + # reserved for privileged views (Dashboard/Architecture/Users); a valid token + # is all that's required to upload. + if not access: + return "not authorized", 401 - for _, f in request.files.items(): - err = util.upload(f, fs_videos, channel, access) + if len(request.files) > 1 or len(request.files) < 1: + return "exactly 1 file required", 400 - if err: - return err + for _, f in request.files.items(): + err = util.upload(f, fs_videos, channel, access) - return "success!", 200 - else: - return "not authorized", 401 + if err: + return err + + return "success!", 200 @server.route("/download", methods=["GET"]) def download(): access, err = validate.token(request) if err: - unauth_count.inc() return err access = json.loads(access) - if access["admin"]: - fid_string = request.args.get("fid") + # AUTHORIZATION: downloading is available to any authenticated user (same + # rationale as /upload). Per-user ownership scoping of downloads is layered on + # in Fix 2 via GridFS owner_email metadata; here we only require a valid token. + if not access: + return "not authorized", 401 + + fid_string = request.args.get("fid") + + if not fid_string: + return "fid is required", 400 + + try: + out = fs_mp3s.get(ObjectId(fid_string)) + return send_file(out, download_name=f"{fid_string}.mp3") + except Exception as err: + print(err) + return "internal server error", 500 + + +@server.route("/my-files", methods=["GET"]) +def my_files(): + """List the converted mp3s owned by the current user, newest first. + + Ownership is the metadata.owner_email tag written on the GridFS object at + conversion time (converter) — set from the uploader's JWT username. Files + uploaded before per-user ownership existed have no tag and simply don't + appear here (correct: they predate the concept; no backfill needed). + """ + access, err = validate.token(request) + if err: + return err + access = json.loads(access) + if not access: + return "not authorized", 401 + + owner = access["username"] + files = [] + for f in fs_mp3s.find({"metadata.owner_email": owner}).sort("uploadDate", -1): + files.append({ + "fid": str(f._id), + "filename": f.filename, + "size": f.length, + "created": f.upload_date.isoformat() if f.upload_date else None, + }) + return jsonify({"files": files}), 200 + - if not fid_string: - return "fid is required", 400 +@server.route("/notifications/unseen-count", methods=["GET"]) +def unseen_count(): + """Count this user's completed mp3s created since `since` (ISO-8601). + The frontend polls this for the Download bubble badge and passes the + timestamp of the user's last visit to the Download page as `since`, so the + badge reflects only conversions completed since they last looked. + """ + access, err = validate.token(request) + if err: + return err + access = json.loads(access) + if not access: + return "not authorized", 401 + + since = request.args.get("since", "1970-01-01T00:00:00") + try: + since_dt = datetime.datetime.fromisoformat(since) + except ValueError: + since_dt = datetime.datetime(1970, 1, 1) + + # count_documents on the GridFS files collection — PyMongo 4 removed + # Cursor.count(), and counting server-side avoids streaming file docs. + count = mongo_mp3.db["fs.files"].count_documents({ + "metadata.owner_email": access["username"], + "uploadDate": {"$gt": since_dt}, + }) + return jsonify({"count": count}), 200 + + +def _require_admin(request): + """Validate the JWT and require the admin role. Returns (claims, None) on + success or (None, (body, status)) to return directly. This is where admin + authorization is enforced — the auth-service /users endpoints trust it.""" + raw, err = validate.token(request) + if err: + return None, err + claims = json.loads(raw) + if not claims or not claims.get("admin"): + return None, ("admin only", 403) + return claims, None + + +def _conversion_counts(): + """Map of owner_email -> number of converted mp3s, from a Mongo aggregation.""" + pipeline = [{"$group": {"_id": "$metadata.owner_email", "count": {"$sum": 1}}}] + return { + doc["_id"]: doc["count"] + for doc in mongo_mp3.db["fs.files"].aggregate(pipeline) + if doc["_id"] + } + + +@server.route("/admin/users", methods=["GET"]) +def admin_users(): + claims, err = _require_admin(request) + if err: + return err + + auth_addr = os.environ.get("AUTH_SVC_ADDRESS") + try: + resp = requests.get(f"http://{auth_addr}/users", timeout=5) + except Exception as e: + return f"auth service unreachable: {e}", 502 + if resp.status_code != 200: + return resp.text, resp.status_code + + users = resp.json() + counts = _conversion_counts() + for u in users: + u["conversions"] = counts.get(u["email"], 0) + return jsonify(users), 200 + + +@server.route("/admin/users/<email>", methods=["PATCH"]) +def admin_update_user(email): + claims, err = _require_admin(request) + if err: + return err + + data = request.get_json(silent=True) or {} + role = data.get("role") + if role not in ("user", "admin"): + return "role must be 'user' or 'admin'", 400 + + caller = claims.get("username") + auth_addr = os.environ.get("AUTH_SVC_ADDRESS") + + # Guardrail 1: an admin cannot change their own role (no accidental self-lockout). + if email == caller: + return "cannot change your own role", 403 + + # Guardrail 2: refuse a demotion that would leave zero admins (cluster lockout). + if role == "user": try: - out = fs_mp3s.get(ObjectId(fid_string)) - return send_file(out, download_name=f"{fid_string}.mp3") - except Exception as err: - print(err) - return "internal server error", 500 + resp = requests.get(f"http://{auth_addr}/users", timeout=5) + resp.raise_for_status() + admin_emails = {u["email"] for u in resp.json() if u.get("role") == "admin"} + except Exception as e: + return f"auth service unreachable: {e}", 502 + if admin_emails == {email}: + return "cannot demote the last remaining admin", 409 + + try: + resp = requests.patch( + f"http://{auth_addr}/users/{email}", json={"role": role}, timeout=5 + ) + except Exception as e: + return f"auth service unreachable: {e}", 502 - return "not authorized", 401 + # Audit trail (captured in gateway pod logs): who changed whom, to what role. + print( + f"AUDIT admin_role_change admin={caller} target={email} " + f"new_role={role} result={resp.status_code}" + ) + return resp.text, resp.status_code if __name__ == "__main__": diff --git a/src/gateway-service/storage/util.py b/src/gateway-service/storage/util.py index a9283fe..ff3cf05 100644 --- a/src/gateway-service/storage/util.py +++ b/src/gateway-service/storage/util.py @@ -1,9 +1,18 @@ -import pika, json +import json + +import pika def upload(f, fs, channel, access): try: - fid = fs.put(f) + # Tag the stored video with its owner (the uploader's JWT email) and a + # filename. owner_email is what /my-files and the unseen-count badge + # query on; the converter copies the same tag onto the resulting mp3. + fid = fs.put( + f, + filename=getattr(f, "filename", None), + metadata={"owner_email": access["username"]}, + ) except Exception as err: print(err) return "internal server error, fs level", 500 diff --git a/src/notification-service/.dockerignore b/src/notification-service/.dockerignore new file mode 100644 index 0000000..00a08c6 --- /dev/null +++ b/src/notification-service/.dockerignore @@ -0,0 +1,18 @@ +# Keep the build context small and free of anything the image doesn't need. +__pycache__/ +*.pyc +*.pyo +*.pyd +.Python +.pytest_cache/ +.git/ +.gitignore + +# Kubernetes manifests and secrets must never enter the image build context. +manifest/ +*secret*.yaml + +# Docs / study material +*_EXPLAINED.md +README.md +*.md diff --git a/src/notification-service/Dockerfile b/src/notification-service/Dockerfile index 6edab37..d072b34 100644 --- a/src/notification-service/Dockerfile +++ b/src/notification-service/Dockerfile @@ -1,6 +1,16 @@ -FROM python:3.10-slim-bullseye +FROM python:3.10-slim-bookworm -RUN apt-get update && apt-get install -y --no-install-recommends --no-install-suggests build-essential libpq-dev python3-dev ffmpeg && pip install --no-cache-dir --upgrade pip +# ffmpeg removed: the notification service only consumes the mp3 queue and sends +# email via SMTP. It never invokes ffmpeg/moviepy, so the ~100MB media toolchain +# was dead weight inherited by copy-paste from the converter Dockerfile. +# build-essential/libpq-dev/python3-dev also dropped: the only deps (pika, +# certifi, urllib3) are pure-Python wheels needing no compilation. +# apt-get upgrade pulls patched OS packages (libgnutls30, libkrb5*); the pip +# upgrade of setuptools/wheel clears toolchain CVEs (CVE-2026-24049 wheel, +# CVE-2026-23949 jaraco.context vendored in setuptools). +RUN apt-get update && apt-get upgrade -y \ + && rm -rf /var/lib/apt/lists/* \ + && pip install --no-cache-dir --upgrade pip setuptools wheel WORKDIR /app COPY ./requirements.txt /app @@ -8,4 +18,9 @@ COPY ./requirements.txt /app RUN pip install --no-cache-dir --requirement /app/requirements.txt COPY . /app +# Run as a non-root uid (matches the Kubernetes securityContext runAsUser: 1000). +# The consumer writes the /tmp/healthy heartbeat to /tmp, which is world-writable +# (mode 1777) and backed by a writable emptyDir in k8s. +USER 1000 + CMD ["python", "consumer.py"] \ No newline at end of file diff --git a/src/notification-service/consumer.py b/src/notification-service/consumer.py index 0762ba2..b2bb3d0 100644 --- a/src/notification-service/consumer.py +++ b/src/notification-service/consumer.py @@ -1,17 +1,36 @@ -import pika, sys, os +import os +import pathlib +import sys + +import pika from send import email def main(): # rabbitmq connection - connection = pika.BlockingConnection(pika.ConnectionParameters(host="rabbitmq",heartbeat=0)) + credentials = pika.PlainCredentials( + os.environ.get("RABBITMQ_DEFAULT_USER", "guest"), + os.environ.get("RABBITMQ_DEFAULT_PASS", "guest"), + ) + connection = pika.BlockingConnection( + pika.ConnectionParameters(host="rabbitmq", credentials=credentials, heartbeat=0) + ) channel = connection.channel() + # Signal readiness as soon as we are connected and ready to consume. The + # liveness probe checks for this file; without an initial touch an idle + # consumer would never create it and crash-loop on the probe. This matters + # especially here: if email delivery fails (e.g. placeholder Gmail + # password), the per-message touch below never runs, so the startup touch + # is the only thing keeping the pod alive. + pathlib.Path("/tmp/healthy").touch() + def callback(ch, method, properties, body): err = email.notification(body) if err: ch.basic_nack(delivery_tag=method.delivery_tag) else: ch.basic_ack(delivery_tag=method.delivery_tag) + pathlib.Path("/tmp/healthy").touch() channel.basic_consume( queue=os.environ.get("MP3_QUEUE"), on_message_callback=callback diff --git a/src/notification-service/manifest/configmap.yaml b/src/notification-service/manifest/configmap.yaml index 51a93f9..fb54aec 100644 --- a/src/notification-service/manifest/configmap.yaml +++ b/src/notification-service/manifest/configmap.yaml @@ -4,4 +4,6 @@ metadata: name: notification-configmap data: MP3_QUEUE: "mp3" - VIDEO_QUEUE: "video" \ No newline at end of file + # VIDEO_QUEUE removed: the notification consumer only reads MP3_QUEUE + # (consumer.py consumes os.environ.get("MP3_QUEUE")). The video queue is + # consumed exclusively by the converter service, so this value was never read. \ No newline at end of file diff --git a/src/notification-service/manifest/notification-deploy.yaml b/src/notification-service/manifest/notification-deploy.yaml index c739c73..817abc4 100644 --- a/src/notification-service/manifest/notification-deploy.yaml +++ b/src/notification-service/manifest/notification-deploy.yaml @@ -18,11 +18,46 @@ spec: labels: app: notification spec: + securityContext: + runAsNonRoot: true + runAsUser: 1000 + volumes: + - name: tmp-volume + emptyDir: {} containers: - name: notification - image: nasi101/notification + image: johnbaabalola/notification-service:16f49a0 + imagePullPolicy: IfNotPresent envFrom: - configMapRef: name: notification-configmap - secretRef: name: notification-secret + - secretRef: + name: rabbitmq-secret + env: + # Unbuffered stdout so print() diagnostics reach kubectl logs + # immediately, not on a block-buffer flush. + - name: PYTHONUNBUFFERED + value: "1" + volumeMounts: + - name: tmp-volume + mountPath: /tmp + resources: + requests: + cpu: "50m" + memory: "64Mi" + limits: + cpu: "100m" + memory: "128Mi" + securityContext: + readOnlyRootFilesystem: true + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] + livenessProbe: + exec: + command: ["test", "-f", "/tmp/healthy"] + initialDelaySeconds: 15 + periodSeconds: 10 + failureThreshold: 3 diff --git a/src/notification-service/manifest/secret.yaml b/src/notification-service/manifest/secret.yaml deleted file mode 100644 index 011b22b..0000000 --- a/src/notification-service/manifest/secret.yaml +++ /dev/null @@ -1,10 +0,0 @@ -apiVersion: v1 -kind: Secret -metadata: - name: notification-secret -stringData: - GMAIL_ADDRESS: "iambatmanthegoat@gmail.com" #enter your email to get the id - GMAIL_PASSWORD: "gkxk acif rhgv erjr" -type: Opaque - -# Passw0rd@1234 \ No newline at end of file diff --git a/src/notification-service/manifest/secret.yaml.example b/src/notification-service/manifest/secret.yaml.example new file mode 100644 index 0000000..f939d6e --- /dev/null +++ b/src/notification-service/manifest/secret.yaml.example @@ -0,0 +1,11 @@ +# Template for notification-secret. Copy to secret.yaml (gitignored) and fill in. +# WARNING: Replace before production use — back this with an external secret +# manager (AWS Secrets Manager + External Secrets Operator), not a committed file. +apiVersion: v1 +kind: Secret +metadata: + name: notification-secret +type: Opaque +stringData: + GMAIL_ADDRESS: "<your-gmail-address>" + GMAIL_PASSWORD: "<16-char-gmail-app-password>" diff --git a/src/notification-service/requirements.txt b/src/notification-service/requirements.txt index af32496..eb0f44a 100644 --- a/src/notification-service/requirements.txt +++ b/src/notification-service/requirements.txt @@ -1,11 +1,9 @@ -astroid==2.9.3 -isort==5.10.1 -jedi==0.18.1 -lazy-object-proxy==1.7.1 -mccabe==0.6.1 -parso==0.8.3 -pika==1.2.0 -platformdirs==2.5.1 -pylint==2.12.2 -toml==0.10.2 -wrapt==1.13.3 \ No newline at end of file +# The notification service only imports pika (RabbitMQ) plus the stdlib +# (smtplib, email, json, os). certifi/urllib3 are floored as patched versions in +# case a future transitive pulls them; they clear CVE-2023-37920 / CVE-2023-43804. +# Dropped from the old frozen list: pylint/astroid/jedi/isort (dev-only tools). +pika>=1.3.1 +certifi>=2023.7.22 +# urllib3 must be >=2.6.0: the latest 1.26.x (1.26.20) still carries 4 fixable +# HIGH CVEs (e.g. CVE-2025-66418) that are only patched in the 2.x line. +urllib3>=2.6.0 diff --git a/src/notification-service/send/email.py b/src/notification-service/send/email.py index 7e58435..0ffce80 100644 --- a/src/notification-service/send/email.py +++ b/src/notification-service/send/email.py @@ -1,22 +1,65 @@ -import smtplib, os, json +import json +import os +import smtplib from email.message import EmailMessage + def notification(message): - message = json.loads(message) - mp3_fid = message["mp3_fid"] + """Send the "your audio is ready" email to the user who uploaded the video. + + Returns None on success OR on a deliberate skip (the caller ACKs and moves + on); returns a truthy error string only for a *retryable* failure (the caller + NACKs). It never raises — an unhandled exception here crashes the consumer + pod, which is exactly the CrashLoopBackOff this hardening removes. + + Recipient routing: the message carries `username` (the uploader's email, put + there by the gateway from the validated JWT and forwarded through the + converter). This is the standard SaaS "notify the user who triggered the + action" pattern — the address never comes from a hardcoded value. + """ + try: + message = json.loads(message) + except (ValueError, TypeError) as err: + # Unparseable body — it will never succeed on retry, so drop it (ACK). + print(f"notification: dropping unparseable message: {err}") + return None + + mp3_fid = message.get("mp3_fid") + receiver_address = message.get("username") + + # Backward compatibility: messages published before per-user routing existed + # have no `username`. Skip (ACK) rather than crash or loop forever on them. + if not receiver_address: + print(f"notification: mp3 {mp3_fid} has no username, skipping email") + return None + sender_address = os.environ.get("GMAIL_ADDRESS") sender_password = os.environ.get("GMAIL_PASSWORD") - receiver_address = message["username"] msg = EmailMessage() - msg.set_content(f"mp3 file_id: {mp3_fid} is now ready!") - msg["Subject"] = "MP3 Download" + msg.set_content( + "Your VidCast audio is ready.\n\n" + f"File ID: {mp3_fid}\n\n" + "Download it from the VidCast app using this file ID." + ) + msg["Subject"] = "Your VidCast audio is ready" msg["From"] = sender_address msg["To"] = receiver_address - session = smtplib.SMTP("smtp.gmail.com", 587) - session.starttls() - session.login(sender_address, sender_password) - session.send_message(msg, sender_address, receiver_address) - session.quit() - print("Mail Sent") \ No newline at end of file + try: + session = smtplib.SMTP("smtp.gmail.com", 587) + session.starttls() + session.login(sender_address, sender_password) + session.send_message(msg, sender_address, receiver_address) + session.quit() + except Exception as err: + # Retryable (transient network, or a bad credential that may be fixed by + # rotating the secret). Returning an error makes the consumer NACK so the + # message is requeued. NOTE: a *permanently* bad credential will requeue + # in a loop — in production we'd bound that with a dead-letter queue and a + # max-retry policy. Deliberately out of scope here (no new infra). + print(f"notification: failed to send mail for mp3 {mp3_fid}: {err}") + return f"email send failed: {err}" + + print(f"notification: mail sent to {receiver_address} for mp3 {mp3_fid}") + return None diff --git a/terraform/environments/dev/backend.tf b/terraform/environments/dev/backend.tf new file mode 100644 index 0000000..f5b4d93 --- /dev/null +++ b/terraform/environments/dev/backend.tf @@ -0,0 +1,30 @@ +terraform { + required_version = ">= 1.5" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.0" + } + tls = { + source = "hashicorp/tls" + version = "~> 4.0" + } + } + + backend "s3" { + # Values are provided at init time: + # terraform init -backend-config="bucket=YOUR_BUCKET" \ + # -backend-config="key=vidcast/dev/terraform.tfstate" \ + # -backend-config="region=eu-west-2" \ + # -backend-config="dynamodb_table=vidcast-terraform-locks" + # + # Or configure in terraform.tfvars (gitignored). + key = "vidcast/dev/terraform.tfstate" + region = "eu-west-2" + } +} + +provider "aws" { + region = var.aws_region +} diff --git a/terraform/environments/dev/main.tf b/terraform/environments/dev/main.tf new file mode 100644 index 0000000..9444800 --- /dev/null +++ b/terraform/environments/dev/main.tf @@ -0,0 +1,80 @@ +locals { + common_tags = { + Project = "vidcast" + ManagedBy = "terraform" + Environment = "dev" + Region = var.aws_region + } +} + +module "vpc" { + source = "../../modules/vpc" + + cluster_name = var.cluster_name + vpc_cidr = var.vpc_cidr + availability_zones = var.availability_zones + tags = local.common_tags +} + +module "iam" { + source = "../../modules/iam" + + cluster_name = var.cluster_name + tags = local.common_tags +} + +module "eks" { + source = "../../modules/eks" + + cluster_name = var.cluster_name + kubernetes_version = var.kubernetes_version + cluster_role_arn = module.iam.cluster_role_arn + node_role_arn = module.iam.node_role_arn + subnet_ids = module.vpc.public_subnet_ids + node_instance_type = var.node_instance_type + node_min_count = var.node_min_count + node_max_count = var.node_max_count + node_desired_count = var.node_desired_count + tags = local.common_tags +} + +module "security_groups" { + source = "../../modules/security-groups" + + cluster_name = var.cluster_name + vpc_id = module.vpc.vpc_id + nodeport_ports = [30002, 30003, 30004, 30005, 30006, 30007, 30008] + tags = local.common_tags +} + +module "github_oidc" { + source = "../../modules/github-oidc" + + cluster_name = var.cluster_name + aws_region = var.aws_region + github_org = var.github_org + github_repo = var.github_repo + tags = local.common_tags +} + +# Grant the GitHub Actions deploy role Kubernetes-level permissions on the +# cluster. The IAM role policy (eks:DescribeCluster) only gets it a kubeconfig; +# this access entry is what lets `kubectl set image` actually work. EKSEditPolicy +# allows patching deployments and reading pods — enough for the CD workflow. +resource "aws_eks_access_entry" "github_deploy" { + cluster_name = module.eks.cluster_name + principal_arn = module.github_oidc.deploy_role_arn + type = "STANDARD" +} + +resource "aws_eks_access_policy_association" "github_deploy" { + cluster_name = module.eks.cluster_name + principal_arn = module.github_oidc.deploy_role_arn + policy_arn = "arn:aws:eks::aws:cluster-access-policy/AmazonEKSEditPolicy" + + access_scope { + type = "cluster" + } + + depends_on = [aws_eks_access_entry.github_deploy] +} diff --git a/terraform/environments/dev/outputs.tf b/terraform/environments/dev/outputs.tf new file mode 100644 index 0000000..bd70efe --- /dev/null +++ b/terraform/environments/dev/outputs.tf @@ -0,0 +1,39 @@ +output "cluster_endpoint" { + description = "EKS cluster API endpoint" + value = module.eks.cluster_endpoint +} + +output "cluster_name" { + description = "EKS cluster name" + value = module.eks.cluster_name +} + +output "vpc_id" { + description = "VPC ID" + value = module.vpc.vpc_id +} + +output "public_subnet_ids" { + description = "Public subnet IDs" + value = module.vpc.public_subnet_ids +} + +output "node_security_group_id" { + description = "NodePort security group ID" + value = module.security_groups.security_group_id +} + +output "kubeconfig_command" { + description = "Run this command to configure kubectl" + value = module.eks.kubeconfig_command +} + +output "oidc_provider_arn" { + description = "OIDC provider ARN for IRSA setup" + value = module.eks.oidc_provider_arn +} + +output "github_actions_role_arn" { + description = "Set this as the AWS_DEPLOY_ROLE_ARN secret in GitHub for OIDC-based CD" + value = module.github_oidc.deploy_role_arn +} diff --git a/terraform/environments/dev/terraform.tfvars.example b/terraform/environments/dev/terraform.tfvars.example new file mode 100644 index 0000000..8fea421 --- /dev/null +++ b/terraform/environments/dev/terraform.tfvars.example @@ -0,0 +1,19 @@ +# Copy this file to terraform.tfvars and fill in your values. +# NEVER commit terraform.tfvars — it is gitignored. + +aws_region = "eu-west-2" +cluster_name = "vidcast-cluster" +node_instance_type = "m7i-flex.large" +node_min_count = 1 +node_max_count = 2 +node_desired_count = 1 +kubernetes_version = "1.31" + +# Leave blank to create a new VPC, or provide an existing VPC ID +vpc_id = "" + +# S3 bucket for Terraform remote state (must exist before terraform init) +state_bucket = "your-terraform-state-bucket" + +# DynamoDB table for state locking (must exist before terraform init) +state_lock_table = "vidcast-terraform-locks" diff --git a/terraform/environments/dev/variables.tf b/terraform/environments/dev/variables.tf new file mode 100644 index 0000000..502f7d1 --- /dev/null +++ b/terraform/environments/dev/variables.tf @@ -0,0 +1,76 @@ +variable "aws_region" { + description = "AWS region for all resources" + type = string + default = "eu-west-2" +} + +variable "cluster_name" { + description = "EKS cluster name" + type = string + default = "vidcast-cluster" +} + +variable "vpc_cidr" { + description = "CIDR block for the VPC" + type = string + default = "10.0.0.0/16" +} + +variable "availability_zones" { + description = "Availability zones for public subnets" + type = list(string) + default = ["eu-west-2a", "eu-west-2b"] +} + +variable "kubernetes_version" { + description = "Kubernetes version for the EKS cluster" + type = string + default = "1.31" +} + +variable "node_instance_type" { + description = "EC2 instance type for worker nodes. Must be M/C/R-series — T-type is blocked by SCP." + type = string + default = "m7i-flex.large" +} + +variable "node_min_count" { + description = "Minimum node count" + type = number + default = 1 +} + +variable "node_max_count" { + description = "Maximum node count" + type = number + default = 2 +} + +variable "node_desired_count" { + description = "Desired node count" + type = number + default = 1 +} + +variable "state_bucket" { + description = "S3 bucket name for Terraform remote state" + type = string +} + +variable "state_lock_table" { + description = "DynamoDB table name for Terraform state locking" + type = string + default = "vidcast-terraform-locks" +} + +variable "github_org" { + description = "GitHub org/user that owns the repo (for the OIDC deploy role trust policy)" + type = string + default = "johnnybabs" +} + +variable "github_repo" { + description = "GitHub repository name (for the OIDC deploy role trust policy)" + type = string + default = "microservices-python-app" +} diff --git a/terraform/modules/eks/main.tf b/terraform/modules/eks/main.tf new file mode 100644 index 0000000..ac62cc9 --- /dev/null +++ b/terraform/modules/eks/main.tf @@ -0,0 +1,55 @@ +resource "aws_eks_cluster" "this" { + name = var.cluster_name + version = var.kubernetes_version + role_arn = var.cluster_role_arn + + # API_AND_CONFIG_MAP enables EKS access entries (used to grant the GitHub + # Actions deploy role kubectl permissions) while keeping aws-auth working. + # The principal that creates the cluster is auto-granted cluster admin. + access_config { + authentication_mode = var.authentication_mode + bootstrap_cluster_creator_admin_permissions = true + } + + vpc_config { + subnet_ids = var.subnet_ids + endpoint_public_access = true + endpoint_private_access = false + } + + tags = var.tags + + depends_on = [var.cluster_role_arn] +} + +resource "aws_eks_node_group" "this" { + cluster_name = aws_eks_cluster.this.name + node_group_name = "${var.cluster_name}-nodes" + node_role_arn = var.node_role_arn + subnet_ids = var.subnet_ids + instance_types = [var.node_instance_type] + ami_type = "AL2_x86_64" + + scaling_config { + min_size = var.node_min_count + max_size = var.node_max_count + desired_size = var.node_desired_count + } + + tags = var.tags + + depends_on = [aws_eks_cluster.this] +} + +# OIDC provider — required for IRSA (IAM Roles for Service Accounts) +data "tls_certificate" "eks_oidc" { + url = aws_eks_cluster.this.identity[0].oidc[0].issuer +} + +resource "aws_iam_openid_connect_provider" "eks" { + client_id_list = ["sts.amazonaws.com"] + thumbprint_list = [data.tls_certificate.eks_oidc.certificates[0].sha1_fingerprint] + url = aws_eks_cluster.this.identity[0].oidc[0].issuer + + tags = var.tags +} diff --git a/terraform/modules/eks/outputs.tf b/terraform/modules/eks/outputs.tf new file mode 100644 index 0000000..0698374 --- /dev/null +++ b/terraform/modules/eks/outputs.tf @@ -0,0 +1,29 @@ +output "cluster_endpoint" { + description = "Endpoint URL of the EKS cluster API server" + value = aws_eks_cluster.this.endpoint +} + +output "cluster_name" { + description = "Name of the EKS cluster" + value = aws_eks_cluster.this.name +} + +output "cluster_ca_certificate" { + description = "Base64-encoded certificate authority data for the cluster" + value = aws_eks_cluster.this.certificate_authority[0].data +} + +output "oidc_provider_arn" { + description = "ARN of the OIDC provider (needed for IRSA)" + value = aws_iam_openid_connect_provider.eks.arn +} + +output "oidc_provider_url" { + description = "URL of the OIDC provider" + value = aws_iam_openid_connect_provider.eks.url +} + +output "kubeconfig_command" { + description = "Command to update local kubeconfig for this cluster" + value = "aws eks update-kubeconfig --name ${aws_eks_cluster.this.name} --region ${var.tags["Region"] != null ? var.tags["Region"] : "eu-west-2"}" +} diff --git a/terraform/modules/eks/variables.tf b/terraform/modules/eks/variables.tf new file mode 100644 index 0000000..4de470a --- /dev/null +++ b/terraform/modules/eks/variables.tf @@ -0,0 +1,66 @@ +variable "cluster_name" { + description = "EKS cluster name" + type = string +} + +variable "kubernetes_version" { + description = "Kubernetes version for the EKS cluster" + type = string + default = "1.31" +} + +variable "authentication_mode" { + description = "EKS cluster authentication mode. API_AND_CONFIG_MAP supports both access entries and the aws-auth ConfigMap." + type = string + default = "API_AND_CONFIG_MAP" +} + +variable "cluster_role_arn" { + description = "ARN of the IAM role for the EKS cluster" + type = string +} + +variable "node_role_arn" { + description = "ARN of the IAM role for the EKS node group" + type = string +} + +variable "subnet_ids" { + description = "List of subnet IDs for the EKS cluster and node group" + type = list(string) +} + +variable "node_instance_type" { + description = "EC2 instance type for EKS worker nodes. Must NOT be a T-type — SCPs on this account reject CreditSpecification:unlimited which EKS auto-generates for T-type instances." + type = string + default = "m7i-flex.large" + + validation { + condition = !startswith(var.node_instance_type, "t") + error_message = "T-type instances (t2, t3, t4g, etc.) are blocked by SCP on this AWS account. Use m7i-flex.large or another M/C/R-series instance." + } +} + +variable "node_min_count" { + description = "Minimum number of nodes in the node group" + type = number + default = 1 +} + +variable "node_max_count" { + description = "Maximum number of nodes in the node group" + type = number + default = 2 +} + +variable "node_desired_count" { + description = "Desired number of nodes in the node group" + type = number + default = 1 +} + +variable "tags" { + description = "Common tags applied to all resources" + type = map(string) + default = {} +} diff --git a/terraform/modules/github-oidc/main.tf b/terraform/modules/github-oidc/main.tf new file mode 100644 index 0000000..db78f7d --- /dev/null +++ b/terraform/modules/github-oidc/main.tf @@ -0,0 +1,69 @@ +# GitHub Actions OIDC identity provider + deploy role. +# Lets the CD workflow assume a short-lived role via OIDC instead of storing +# long-lived AWS access keys as GitHub secrets. + +data "aws_caller_identity" "current" {} + +# GitHub's OIDC issuer. The thumbprint is derived dynamically from the issuer's +# TLS certificate so it stays correct if GitHub rotates its CA. +data "tls_certificate" "github" { + url = "https://token.actions.githubusercontent.com" +} + +resource "aws_iam_openid_connect_provider" "github" { + url = "https://token.actions.githubusercontent.com" + client_id_list = ["sts.amazonaws.com"] + thumbprint_list = [data.tls_certificate.github.certificates[0].sha1_fingerprint] + tags = var.tags +} + +# Trust policy: only the GitHub OIDC provider may assume this role, and only for +# workflows running in this specific repo (any branch/ref). Tighten the sub +# condition to a specific ref (e.g. :ref:refs/heads/main) to lock it to main. +data "aws_iam_policy_document" "assume" { + statement { + actions = ["sts:AssumeRoleWithWebIdentity"] + effect = "Allow" + + principals { + type = "Federated" + identifiers = [aws_iam_openid_connect_provider.github.arn] + } + + condition { + test = "StringEquals" + variable = "token.actions.githubusercontent.com:aud" + values = ["sts.amazonaws.com"] + } + + condition { + test = "StringLike" + variable = "token.actions.githubusercontent.com:sub" + values = ["repo:${var.github_org}/${var.github_repo}:*"] + } + } +} + +resource "aws_iam_role" "deploy" { + name = "${var.cluster_name}-github-deploy" + assume_role_policy = data.aws_iam_policy_document.assume.json + tags = var.tags +} + +# The only AWS API the CD workflow calls is eks:DescribeCluster (for +# `aws eks update-kubeconfig`). Kubernetes-level authorization is granted +# separately via an EKS access entry in the root module. Scope the describe to +# this one cluster ARN (constructed — avoids a dependency cycle on the cluster). +data "aws_iam_policy_document" "deploy" { + statement { + actions = ["eks:DescribeCluster"] + effect = "Allow" + resources = ["arn:aws:eks:${var.aws_region}:${data.aws_caller_identity.current.account_id}:cluster/${var.cluster_name}"] + } +} + +resource "aws_iam_role_policy" "deploy" { + name = "eks-describe-cluster" + role = aws_iam_role.deploy.id + policy = data.aws_iam_policy_document.deploy.json +} diff --git a/terraform/modules/github-oidc/outputs.tf b/terraform/modules/github-oidc/outputs.tf new file mode 100644 index 0000000..d5d3f5f --- /dev/null +++ b/terraform/modules/github-oidc/outputs.tf @@ -0,0 +1,9 @@ +output "deploy_role_arn" { + description = "ARN of the IAM role GitHub Actions assumes via OIDC (set as the AWS_DEPLOY_ROLE_ARN GitHub secret)" + value = aws_iam_role.deploy.arn +} + +output "oidc_provider_arn" { + description = "ARN of the GitHub Actions OIDC identity provider" + value = aws_iam_openid_connect_provider.github.arn +} diff --git a/terraform/modules/github-oidc/variables.tf b/terraform/modules/github-oidc/variables.tf new file mode 100644 index 0000000..29f43d9 --- /dev/null +++ b/terraform/modules/github-oidc/variables.tf @@ -0,0 +1,25 @@ +variable "cluster_name" { + description = "EKS cluster name — used for role naming and the describe-cluster scope" + type = string +} + +variable "aws_region" { + description = "AWS region of the EKS cluster" + type = string +} + +variable "github_org" { + description = "GitHub organisation or user that owns the repo" + type = string +} + +variable "github_repo" { + description = "GitHub repository name (without the org prefix)" + type = string +} + +variable "tags" { + description = "Common tags applied to all resources" + type = map(string) + default = {} +} diff --git a/terraform/modules/iam/main.tf b/terraform/modules/iam/main.tf new file mode 100644 index 0000000..85486c8 --- /dev/null +++ b/terraform/modules/iam/main.tf @@ -0,0 +1,51 @@ +data "aws_iam_policy_document" "eks_cluster_assume_role" { + statement { + actions = ["sts:AssumeRole"] + principals { + type = "Service" + identifiers = ["eks.amazonaws.com"] + } + } +} + +data "aws_iam_policy_document" "eks_node_assume_role" { + statement { + actions = ["sts:AssumeRole"] + principals { + type = "Service" + identifiers = ["ec2.amazonaws.com"] + } + } +} + +resource "aws_iam_role" "cluster" { + name = "${var.cluster_name}-cluster-role" + assume_role_policy = data.aws_iam_policy_document.eks_cluster_assume_role.json + tags = var.tags +} + +resource "aws_iam_role_policy_attachment" "cluster_policy" { + role = aws_iam_role.cluster.name + policy_arn = "arn:aws:iam::aws:policy/AmazonEKSClusterPolicy" +} + +resource "aws_iam_role" "node" { + name = "${var.cluster_name}-node-role" + assume_role_policy = data.aws_iam_policy_document.eks_node_assume_role.json + tags = var.tags +} + +resource "aws_iam_role_policy_attachment" "node_worker_policy" { + role = aws_iam_role.node.name + policy_arn = "arn:aws:iam::aws:policy/AmazonEKSWorkerNodePolicy" +} + +resource "aws_iam_role_policy_attachment" "node_cni_policy" { + role = aws_iam_role.node.name + policy_arn = "arn:aws:iam::aws:policy/AmazonEKS_CNI_Policy" +} + +resource "aws_iam_role_policy_attachment" "node_ecr_readonly" { + role = aws_iam_role.node.name + policy_arn = "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly" +} diff --git a/terraform/modules/iam/outputs.tf b/terraform/modules/iam/outputs.tf new file mode 100644 index 0000000..3d02ddd --- /dev/null +++ b/terraform/modules/iam/outputs.tf @@ -0,0 +1,9 @@ +output "cluster_role_arn" { + description = "ARN of the EKS cluster IAM role" + value = aws_iam_role.cluster.arn +} + +output "node_role_arn" { + description = "ARN of the EKS node group IAM role" + value = aws_iam_role.node.arn +} diff --git a/terraform/modules/iam/variables.tf b/terraform/modules/iam/variables.tf new file mode 100644 index 0000000..dc4d1e1 --- /dev/null +++ b/terraform/modules/iam/variables.tf @@ -0,0 +1,10 @@ +variable "cluster_name" { + description = "EKS cluster name — used for role naming" + type = string +} + +variable "tags" { + description = "Common tags applied to all resources" + type = map(string) + default = {} +} diff --git a/terraform/modules/security-groups/main.tf b/terraform/modules/security-groups/main.tf new file mode 100644 index 0000000..096f36e --- /dev/null +++ b/terraform/modules/security-groups/main.tf @@ -0,0 +1,26 @@ +resource "aws_security_group" "node_ports" { + name = "${var.cluster_name}-nodeport-sg" + description = "Allow inbound traffic to Kubernetes NodePort services" + vpc_id = var.vpc_id + + dynamic "ingress" { + for_each = var.nodeport_ports + content { + from_port = ingress.value + to_port = ingress.value + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + description = "NodePort ${ingress.value}" + } + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + description = "Allow all outbound" + } + + tags = merge(var.tags, { Name = "${var.cluster_name}-nodeport-sg" }) +} diff --git a/terraform/modules/security-groups/outputs.tf b/terraform/modules/security-groups/outputs.tf new file mode 100644 index 0000000..7e158ac --- /dev/null +++ b/terraform/modules/security-groups/outputs.tf @@ -0,0 +1,4 @@ +output "security_group_id" { + description = "ID of the NodePort security group" + value = aws_security_group.node_ports.id +} diff --git a/terraform/modules/security-groups/variables.tf b/terraform/modules/security-groups/variables.tf new file mode 100644 index 0000000..e826d04 --- /dev/null +++ b/terraform/modules/security-groups/variables.tf @@ -0,0 +1,21 @@ +variable "cluster_name" { + description = "EKS cluster name — used for resource naming" + type = string +} + +variable "vpc_id" { + description = "VPC ID where the security group will be created" + type = string +} + +variable "nodeport_ports" { + description = "List of NodePort port numbers to open for inbound traffic" + type = list(number) + default = [30002, 30003, 30004, 30005, 30006, 30007, 30008] +} + +variable "tags" { + description = "Common tags applied to all resources" + type = map(string) + default = {} +} diff --git a/terraform/modules/vpc/main.tf b/terraform/modules/vpc/main.tf new file mode 100644 index 0000000..1e6fd55 --- /dev/null +++ b/terraform/modules/vpc/main.tf @@ -0,0 +1,44 @@ +resource "aws_vpc" "this" { + cidr_block = var.vpc_cidr + enable_dns_support = true + enable_dns_hostnames = true + + tags = merge(var.tags, { Name = "${var.cluster_name}-vpc" }) +} + +resource "aws_internet_gateway" "this" { + vpc_id = aws_vpc.this.id + tags = merge(var.tags, { Name = "${var.cluster_name}-igw" }) +} + +resource "aws_subnet" "public" { + count = length(var.availability_zones) + + vpc_id = aws_vpc.this.id + cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index + 1) + availability_zone = var.availability_zones[count.index] + map_public_ip_on_launch = true + + tags = merge(var.tags, { + Name = "${var.cluster_name}-public-${count.index + 1}" + "kubernetes.io/role/elb" = "1" + "kubernetes.io/cluster/${var.cluster_name}" = "shared" + }) +} + +resource "aws_route_table" "public" { + vpc_id = aws_vpc.this.id + + route { + cidr_block = "0.0.0.0/0" + gateway_id = aws_internet_gateway.this.id + } + + tags = merge(var.tags, { Name = "${var.cluster_name}-public-rt" }) +} + +resource "aws_route_table_association" "public" { + count = length(aws_subnet.public) + subnet_id = aws_subnet.public[count.index].id + route_table_id = aws_route_table.public.id +} diff --git a/terraform/modules/vpc/outputs.tf b/terraform/modules/vpc/outputs.tf new file mode 100644 index 0000000..b884b52 --- /dev/null +++ b/terraform/modules/vpc/outputs.tf @@ -0,0 +1,14 @@ +output "vpc_id" { + description = "ID of the VPC" + value = aws_vpc.this.id +} + +output "public_subnet_ids" { + description = "IDs of the public subnets" + value = aws_subnet.public[*].id +} + +output "internet_gateway_id" { + description = "ID of the internet gateway" + value = aws_internet_gateway.this.id +} diff --git a/terraform/modules/vpc/variables.tf b/terraform/modules/vpc/variables.tf new file mode 100644 index 0000000..b2c0ef0 --- /dev/null +++ b/terraform/modules/vpc/variables.tf @@ -0,0 +1,22 @@ +variable "cluster_name" { + description = "EKS cluster name — used for resource naming and tagging" + type = string +} + +variable "vpc_cidr" { + description = "CIDR block for the VPC" + type = string + default = "10.0.0.0/16" +} + +variable "availability_zones" { + description = "List of availability zones for public subnets" + type = list(string) + default = ["eu-west-2a", "eu-west-2b"] +} + +variable "tags" { + description = "Common tags applied to all resources" + type = map(string) + default = {} +}