Files
thamanyah/CLAUDE.md
T
FahdShalhoub 27fd28d426
Build, Push and Deploy CMS / build-push-deploy (push) Successful in 2m11s
FEAT: Load Catagories from backend
2026-08-16 22:42:17 +03:00

11 KiB

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

Repo layout

.
├── cms/            Go service: video ingestion/CMS (implemented)
├── discovery/      Go service: empty placeholder, no code yet
├── infrastructure/ Pulumi (Go) program provisioning all AWS resources
└── .gitea/workflows/  Gitea Actions CI/CD pipelines

discovery is provisioned in infra (its own ECR repo, ECS service, ALB, Postgres database/role) but has no application code yet — don't assume it's deployable.

Commands

cms (Go 1.25, module thamanyah/cms/v2)

cd cms
go run .                    # serves on :8081 (requires DB_*/S3_*/MEDIACONVERT_* env vars — see below)
go run . migrate            # applies pending DB migrations, then exits (no HTTP server)
go build ./...
go vet ./...

There are no test files in this repo (cms, discovery, or infrastructure) — don't assume a test suite exists.

Views are written as .templ files (github.com/a-h/templ) and compiled to *_templ.go. If you edit a .templ file, regenerate its Go code with the templ generate CLI before building (not installed in this environment by default — install via go install github.com/a-h/templ/cmd/templ@v0.3.1020 to match go.mod, or check for an existing binary first).

infrastructure (Go, Pulumi, module thamanyah)

cd infrastructure
pulumi preview               # plan changes against stack "main"
pulumi up                    # apply — this touches real AWS resources, confirm with the user first
pulumi stack output          # e.g. ecsClusterArn, cmsServiceArn

Deploys run in CI (.gitea/workflows/infrastructure-deploy.yml) on push to main touching infrastructure/**. Treat local pulumi up as something to confirm with the user, not a routine dev command — it mutates shared cloud state and Pulumi state isn't safe to update concurrently with CI.

Architecture

cms service

Plain net/http (Go 1.22+ pattern-based ServeMux), no framework. Entry point cms/main.go wires up AWS SDK v2 clients (S3, MediaConvert) and a Postgres connection from env vars and assigns them to package-level interface vars in internal/services (services.S3Client, services.MediaConvertClient, services.DB) — handlers call through these interfaces, and S3Concrete/MediaConvertConcrete/DBConcrete are the only implementations, which is what makes the handlers testable even though no tests exist yet. On boot, S3Concrete.AssertSuccessfulConnection proactively exercises head/put/get/presign against the bucket, and DBConcrete.AssertSuccessfulConnection pings Postgres — both panic on failure rather than letting the service come up in a broken state.

cms/main.go has two entry paths, dispatched on os.Args[1]: the default path (runServer) boots the HTTP server; ./cms migrate (runMigrate) only opens the DB connection, applies pending migrations via cms/internal/db.Migrate (golang-migrate, iofs source, SQL files embedded from cms/internal/db/migrations/*.sql), and exits — it does not touch S3/MediaConvert or start the server. This is run as its own ECS container before the main container starts (see infrastructure below), so runServer never runs migrations itself, only AssertSuccessfulConnection.

Routes (cms/main.go): GET /, GET /health, GET /videos/new, POST /videos/presign, POST /videos, static files under /static/.

Views live in cms/internal/views (templ components) with a shared layouts.Layout wrapper; types.go holds view-model structs like VideoMetadata used by the upload-success page.

Data model (Postgres, cms/internal/db/migrations/)

  • videos — one row per uploaded video: title, description, tags (free-text, comma-separated — not normalized), file_name, storage_key (the S3 key, unique), mediaconvert_job_id, status (written once as "processing" on insert — see Known gaps), size_bytes, timestamps. id is a UUID generated application-side as a UUIDv7 (uuid.NewV7() in services.DBConcrete.CreateVideo) rather than via the column's own DEFAULT gen_random_uuid() (which generates v4 and is never actually relied on) — v7 keeps primary-key inserts roughly time-ordered, avoiding B-tree fragmentation as the table grows.
  • categories — a small fixed lookup table (documentary, news, entertainment, podcast, other), seeded by migration 0001.
  • video_categories — join table (video_id, category_id, composite PK, ON DELETE CASCADE) added in migration 0002: a video can belong to multiple categories, not just one. CreateVideo inserts the videos row and its video_categories links inside a single transaction.

Video upload → transcode pipeline

  1. Browser calls POST /videos/presign → cms returns a presigned S3 PUT URL for raw-uploads-bucket, key videos/<random-hex>.<ext>.
  2. Browser PUTs the file directly to S3 (requires the bucket's CORS rule, set up in infrastructure/main.go).
  3. Browser calls POST /videos with the metadata + key → cms calls MediaConvertClient.QueueEncodingJob(key), submitting a MediaConvert job s3://raw-uploads-bucket/<key>s3://encoded-bucket/<key> (H.264/AAC → MP4, QVBR rate control — QVBR requires MaxBitrate to be set explicitly), then services.DB.CreateVideo persists the videos row (status "processing") and its video_categories links.
  4. Finished output lands in encoded-bucket, served via CloudFront.

Config wiring: cms reads S3_BUCKET, MEDIACONVERT_INPUT_BUCKET, MEDIACONVERT_OUTPUT_BUCKET, MEDIACONVERT_ROLE_ARN, AWS_REGION, DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASSWORD from env vars injected by the ECS task definition (extraEnv and the unconditional DB_* vars in deployFargateService, infrastructure/main.go) — main.go panics on boot if any required var is empty.

infrastructure (infrastructure/main.go, single Pulumi Go program, region us-east-1, stack main)

  • Postgres: one shared RDS instance (db.t3.micro, single-AZ, no backups — intentionally minimal). Each app (cms, discovery) gets its own login role and same-named database via the postgresql provider (newServiceDatabase), so services never share DB credentials.
  • ECS Fargate: one cluster (app-cluster), one ALB per service (each gets its own DNS name rather than sharing a load balancer on different ports). deployFargateService(...) is the shared helper building a service's ECR repo, CloudWatch log group, task definition, ECS service, and ALB. taskRole is optional (nil = no AWS identity beyond the shared execution role); extraEnv appends container env vars beyond the DB_* set; runMigrations (true for cms, false for discovery) adds a second, non-essential <name>-migrate container to the task — same image, command: ["migrate"] — with the main container's dependsOn set to condition: "COMPLETE" on it. This is ECS's container-dependency mechanism, the Fargate equivalent of a Kubernetes init container: ECS runs the migrate container to completion (exit 0) before starting the main container, so schema migrations always finish before the service accepts traffic. No separate CI/Docker migration step exists — Dockerfile's ENTRYPOINT ["./cms"] plus the container's command override composes to ./cms migrate.
  • S3 + CloudFront: encoded-bucket holds finished transcoded output, served publicly via CloudFront using Origin Access Control (OAC) — the bucket itself blocks all public access; only CloudFront's OAC principal can read it.
  • S3 raw uploads: raw-uploads-bucket is a separate, private bucket for pre-transcode uploads — deliberately kept apart from encoded-bucket so raw source video is never reachable through the public CDN. CORS is scoped to PUT only, from the cms ALB's own origin (browser uploads directly via presigned URL).
  • IAM roles — four distinct roles/users, each scoped narrowly, don't conflate them:
    • ecs-task-execution-role — shared by both services' ECS agent (image pull, log write, Secrets Manager read for DB password). Not usable by application code inside the container.
    • cms-task-role — the cms container's own AWS identity: S3 ListBucket/PutObject/GetObject on raw-uploads-bucket only, mediaconvert:CreateJob, and iam:PassRole scoped to the MediaConvert service role (iam:PassedToService condition). discovery has no task role — it doesn't touch S3 or MediaConvert.
    • mediaconvert-service-role — trusted by mediaconvert.amazonaws.com, not by ECS; the role MediaConvert itself assumes (passed as CreateJobInput.Role) to read raw-uploads-bucket and write encoded-bucket. Distinct from cms-task-role by design: one is "cms calling AWS", the other is "AWS calling AWS on cms's behalf".
    • gitea-ci-user — an IAM user (static access keys, not OIDC — the Gitea Actions runner doesn't support instance-profile auth) scoped to just ECR push (cms/discovery repos) and ecs:UpdateService/ecs:DescribeServices on the two ECS services. Never broaden this to ecr:*/ecs:*.

CI/CD (.gitea/workflows/)

  • cms-deploy.yml: push to main touching cms/**. Builds/pushes the Docker image to ECR using gitea-ci-user, installs the AWS CLI (not preinstalled on the runner image — via AWS's official install script, not a third-party action), then aws ecs update-service --force-new-deployment. Cluster/service ARNs come from pulumi stack output and are set as repo variables (not secrets — ARNs aren't sensitive).
  • infrastructure-deploy.yml: push to main touching infrastructure/**. Runs pulumi up using a separate, broader AWS credential (PULUMI_AWS_ACCESS_KEY_ID/SECRET) than gitea-ci-user, since provisioning IAM/RDS/ECS/CloudFront needs wider permissions than pushing images and forcing deployments. Guarded with a concurrency group since Pulumi state isn't safe to update concurrently.
  • The runner's ubuntu-latest label maps to a docker image configured on the runner host (outside this repo) — currently minimal, lacking the AWS CLI, hence the manual install step in cms-deploy.yml.

Known gaps

  • cms/internal/services/mediaconvert.go never calls DescribeEndpoints and configures no custom MediaConvert endpoint — relies on the SDK's default regional endpoint.
  • No MediaConvert completion webhook or poller exists — a videos.status row is written once as "processing" in CreateVideo and never updated, even after the transcode job actually finishes or fails.
  • discovery has infra provisioned (ECR repo, ECS service, ALB, Postgres DB/role) but no application code — it doesn't touch its database at all.
  • No automated tests exist for cms, discovery, or infrastructure.