Files
thamanyah/CLAUDE.md
T
FahdShalhoub 56a2992206
Build, Push and Deploy CMS / build-push-deploy (push) Successful in 2m19s
REFACTOR: Optimized Catagory IDs
2026-08-17 00:51:11 +03:00

15 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.

cms is a JSON API only — it serves no HTML and has no static assets. The OpenAPI spec is generated from swaggo annotations on the handlers into cms/docs, a committed, compiled-in Go package. If you add or change a handler, its annotation comments, or a request/response struct, regenerate it:

go install github.com/swaggo/swag/cmd/swag@v1.16.6   # match go.mod; not installed by default
swag init --generalInfo main.go --dir ./ --parseInternal --output ./docs

--parseInternal is required — the handlers live under internal/, which swag skips without it. Keep the swaggo/swag version in go.mod and the CLI in lockstep: http-swagger/v2 transitively pulls a much older swag whose swag.Spec struct lacks the LeftDelim/RightDelim fields newer generators emit, and the build breaks outright if the two drift. (The CLI's --version misreports itself as v1.16.4; go version -m $(go env GOPATH)/bin/swag gives the real one.)

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 /health, GET /api/categories, POST /api/videos/presign, POST /api/videos, plus Swagger UI at GET /swagger/ (/swagger/doc.json serves the spec). The UI assets are embedded in the binary by swaggo/files, so nothing is read from disk and nothing is fetched from a CDN at runtime.

Handlers live in cms/internal/handlershandlers.go holds Health and the shared response writers, videos.go the categories and upload endpoints. The request/response bodies they serve are not in that package: every wire struct lives in cms/internal/api (api.go for HealthResponse and ProblemDetails, videos.go for the rest), exported and referenced by the handlers' swaggo annotations as api.VideoResponse and so on — so the generated spec's definition names track that package, and renaming a type there changes the published schema names.

There is no view layer: the internal/views templ package, the static/ directory, and the htmx frontend were all removed when the service became a JSON API, along with the templ dependency.

Error responses are RFC 9457 Problem Details objects (application/problem+json), written by writeProblem(w, status, title, detail). type is always "about:blank"; title is a short summary held identical across every occurrence of a given problem, so clients can branch on it; detail is the only member that varies with request data. Success responses go through writeJSON (application/json). Both share writeJSONContent. Note this deviates slightly from RFC 9457, which pairs an about:blank type with a title that is just the HTTP status phrase — meaningful titles like these are supposed to carry a real type URI. Adding per-problem type URIs is the conforming fix if it ever matters.

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. status is an enum on both sides: the services.VideoStatus string type in cms/internal/services/status.go (processing, ready, failed) and the videos_status_check CHECK constraint added by migration 0003. Extending it means adding a constant there, widening the constraint in a new migration, and extending the enums annotation on api.VideoResponse.Status before regenerating the spec. 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. Its SMALLSERIAL ids are part of the public API: GET /api/categories returns {id, name} pairs and POST /api/videos takes categoryIds, so the seed order in migration 0001 is what fixes which id means which name — never renumber it.
  • 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, taking the category ids as given — handlers.resolveCategoryIDs is what checks them against ListCategories (422 on an unknown id) and drops duplicates before the transcode job is queued, leaving the foreign key as a backstop.

Video upload → transcode pipeline

  1. Client calls POST /api/videos/presign → cms returns a presigned S3 PUT URL for raw-uploads-bucket, key videos/<random-hex>.<ext>.
  2. Client PUTs the file directly to S3 (from a browser this requires the bucket's CORS rule, set up in infrastructure/main.go — see Known gaps, its allowed origin is now stale). The file never passes through cms.
  3. Client calls POST /api/videos with the metadata (categories given as categoryIds from GET /api/categories) + 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 — correct back when cms served the upload page itself, but stale now that it serves no UI (see Known gaps).
  • 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 VideoStatusProcessing in CreateVideo and never updated, even after the transcode job actually finishes or fails. The ready and failed states are defined but nothing sets them yet.
  • 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.
  • No browser client can reach the API yet. Two separate CORS gaps, both left over from cms dropping its own UI: cms sends no CORS headers of its own, and raw-uploads-bucket's CORS rule in infrastructure/main.go still allows only the cms ALB origin, so a frontend served from anywhere else fails preflight on the direct S3 PUT. Both need the real frontend origin before a browser client works end to end.
  • videos.size_bytes is hardcoded to 60 in CompleteVideoUpload — the request body carries no size field, so every row stores 60 and the API hands that back in sizeBytes. Fixing it means adding sizeBytes to the POST /api/videos request contract.
  • The generated spec is Swagger 2.0, which has no per-response media type, so error responses are documented as application/json even though they are actually sent as application/problem+json. The schemas themselves are right. swag init --v3.1 would emit OpenAPI 3.1 and resolve it.
  • cms/uploads/ holds a stray .mov from an older local-disk upload path. It is untracked, unreferenced by any code, and safe to delete.