diff --git a/.gitea/workflows/discovery-deploy.yml b/.gitea/workflows/discovery-deploy.yml new file mode 100644 index 0000000..1fffaff --- /dev/null +++ b/.gitea/workflows/discovery-deploy.yml @@ -0,0 +1,60 @@ +name: Build, Push and Deploy Discovery + +on: + push: + branches: [main] + paths: + - "discovery/**" + - ".gitea/workflows/discovery-deploy.yml" + +env: + AWS_REGION: us-east-1 + ECR_REPOSITORY: discovery + ECS_CLUSTER_ARN: arn:aws:ecs:us-east-1:887083795404:cluster/app-cluster-384acc4 + DISCOVERY_ECS_SERVICE_ARN: arn:aws:ecs:us-east-1:887083795404:service/app-cluster-384acc4/discovery-service-52aa4f6 + +jobs: + build-push-deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ env.AWS_REGION }} + + - name: Log in to Amazon ECR + id: login-ecr + uses: aws-actions/amazon-ecr-login@v2 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push image + uses: docker/build-push-action@v6 + with: + context: ./discovery + file: ./discovery/Dockerfile + push: true + tags: | + ${{ steps.login-ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:latest + ${{ steps.login-ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:${{ gitea.sha }} + + - name: Install AWS CLI + run: | + curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip" + unzip -q awscliv2.zip + sudo ./aws/install + rm -rf awscliv2.zip aws + + - name: Deploy to ECS + run: | + aws ecs update-service \ + --cluster "${{ env.ECS_CLUSTER_ARN }}" \ + --service "${{ env.DISCOVERY_ECS_SERVICE_ARN }}" \ + --force-new-deployment \ + --region "${{ env.AWS_REGION }}" diff --git a/CLAUDE.md b/CLAUDE.md index b9d0952..f81e345 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,15 +7,22 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ``` . ├── cms/ Go service: video ingestion/CMS (implemented) -├── discovery/ Go service: empty placeholder, no code yet +├── discovery/ Go service: catalogue discovery/read side (skeleton) ├── infrastructure/ Pulumi (Go) program provisioning all AWS resources ├── tests/ Go module: Gherkin/gobdd scenarios run against a live cms └── .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. +`discovery` is a skeleton, not a feature: it mirrors `cms`'s layout, boot +sequence and Postgres wiring, and serves `GET /health` plus the Swagger UI, but +it has no domain yet — `internal/models` and `internal/db/repositories` are +package doc comments, and migration `0001_init` creates no tables (it exists +only because `internal/db/migrate.go` embeds `migrations/*.sql`, which will not +compile against an empty directory). It has no `internal/services` and no +`internal/consumers`: those are AWS-only in `cms`, and `discovery` has no task +role, so it reaches nothing but its own database. It is deployable — its ECR +repo, ECS service and ALB have always existed in infra, and it now has an image +to put in them. ## Commands @@ -29,6 +36,28 @@ go build ./... go vet ./... ``` +### discovery (Go 1.25, module `thamanyah/discovery`) + +Same commands as `cms`, and the same two entry paths (`runServer`, +`runMigrate`) in `discovery/main.go` — only the port and the dependencies +differ. Note the module path carries no `/v2`: unlike `cms`, this module has +never had a v1. + +```bash +cd discovery +go run . # serves on :8080 (requires DB_* env vars only — no S3/MediaConvert) +go run . migrate # applies pending DB migrations, then exits (no HTTP server) +go build ./... +go vet ./... +``` + +Its OpenAPI spec is generated into `discovery/docs` by the same `swag init` +invocation as `cms`, run from `discovery/`. In `docker-compose.yml` it is a +service of its own on `127.0.0.1:8080`, wired to the `discovery` database and +role LocalStack provisions — and, like `cms`, started in server mode only, so a +freshly created local database needs `docker exec discovery ./discovery migrate` +once. + `cms`, `discovery` and `infrastructure` hold no test files of their own. The only tests in the repo are the black-box BDD scenarios in `tests/` — see below. @@ -246,7 +275,7 @@ on boot if any required var is empty. 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, + `runMigrations` (true for both services) adds a second, non-essential `-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 @@ -295,6 +324,11 @@ on boot if any required var is empty. 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). +- **`discovery-deploy.yml`**: the same pipeline for `discovery`, on pushes + touching `discovery/**`. One difference: the ECS service ARN is read from + the `DISCOVERY_ECS_SERVICE_ARN` repo variable rather than pinned in the + workflow, so it has to be set (from `pulumi stack output + discoveryServiceArn`) before the first deploy can succeed. - **`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`, diff --git a/discovery/.gitignore b/discovery/.gitignore new file mode 100644 index 0000000..ed5ad89 --- /dev/null +++ b/discovery/.gitignore @@ -0,0 +1,33 @@ +# Binaries +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool +*.out +coverage.txt + +# Dependency directories +vendor/ + +# Go workspace file +go.work +go.work.sum + +# Env files +.env +.env.local + +# Build output +/bin/ +/dist/ + +# IDE +.idea/ +.vscode/ +*.swp diff --git a/discovery/Dockerfile b/discovery/Dockerfile new file mode 100644 index 0000000..e193a43 --- /dev/null +++ b/discovery/Dockerfile @@ -0,0 +1,19 @@ +# syntax=docker/dockerfile:1 + +FROM golang:1.25-alpine AS builder +WORKDIR /src + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -o /out/discovery . + +FROM alpine:3.20 +RUN apk add --no-cache ca-certificates + +WORKDIR /app +COPY --from=builder /out/discovery ./discovery + +EXPOSE 8080 +ENTRYPOINT ["./discovery"] diff --git a/discovery/docs/docs.go b/discovery/docs/docs.go new file mode 100644 index 0000000..be9ed89 --- /dev/null +++ b/discovery/docs/docs.go @@ -0,0 +1,68 @@ +// Package docs Code generated by swaggo/swag. DO NOT EDIT +package docs + +import "github.com/swaggo/swag" + +const docTemplate = `{ + "schemes": {{ marshal .Schemes }}, + "swagger": "2.0", + "info": { + "description": "{{escape .Description}}", + "title": "{{.Title}}", + "contact": {}, + "version": "{{.Version}}" + }, + "host": "{{.Host}}", + "basePath": "{{.BasePath}}", + "paths": { + "/health": { + "get": { + "description": "Reports that the service is up and serving. Used as the ALB target group health check; it does not verify the database connection.", + "produces": [ + "application/json" + ], + "tags": [ + "system" + ], + "summary": "Health check", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.HealthResponse" + } + } + } + } + } + }, + "definitions": { + "api.HealthResponse": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "ok" + } + } + } + } +}` + +// SwaggerInfo holds exported Swagger Info so clients can modify it +var SwaggerInfo = &swag.Spec{ + Version: "1.0", + Host: "", + BasePath: "/", + Schemes: []string{}, + Title: "Thamanyah Discovery API", + Description: "JSON API for browsing the Thamanyah catalogue. Read-side counterpart to the CMS, which is what ingests videos.", + InfoInstanceName: "swagger", + SwaggerTemplate: docTemplate, + LeftDelim: "{{", + RightDelim: "}}", +} + +func init() { + swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo) +} diff --git a/discovery/docs/swagger.json b/discovery/docs/swagger.json new file mode 100644 index 0000000..1f75fba --- /dev/null +++ b/discovery/docs/swagger.json @@ -0,0 +1,43 @@ +{ + "swagger": "2.0", + "info": { + "description": "JSON API for browsing the Thamanyah catalogue. Read-side counterpart to the CMS, which is what ingests videos.", + "title": "Thamanyah Discovery API", + "contact": {}, + "version": "1.0" + }, + "basePath": "/", + "paths": { + "/health": { + "get": { + "description": "Reports that the service is up and serving. Used as the ALB target group health check; it does not verify the database connection.", + "produces": [ + "application/json" + ], + "tags": [ + "system" + ], + "summary": "Health check", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.HealthResponse" + } + } + } + } + } + }, + "definitions": { + "api.HealthResponse": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "ok" + } + } + } + } +} \ No newline at end of file diff --git a/discovery/docs/swagger.yaml b/discovery/docs/swagger.yaml new file mode 100644 index 0000000..7a5b1e2 --- /dev/null +++ b/discovery/docs/swagger.yaml @@ -0,0 +1,30 @@ +basePath: / +definitions: + api.HealthResponse: + properties: + status: + example: ok + type: string + type: object +info: + contact: {} + description: JSON API for browsing the Thamanyah catalogue. Read-side counterpart + to the CMS, which is what ingests videos. + title: Thamanyah Discovery API + version: "1.0" +paths: + /health: + get: + description: Reports that the service is up and serving. Used as the ALB target + group health check; it does not verify the database connection. + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/api.HealthResponse' + summary: Health check + tags: + - system +swagger: "2.0" diff --git a/discovery/go.mod b/discovery/go.mod new file mode 100644 index 0000000..7aca8c7 --- /dev/null +++ b/discovery/go.mod @@ -0,0 +1,25 @@ +module thamanyah/discovery + +go 1.25.12 + +require ( + github.com/golang-migrate/migrate/v4 v4.19.1 + github.com/lib/pq v1.12.3 + github.com/swaggo/http-swagger/v2 v2.0.2 + github.com/swaggo/swag v1.16.6 +) + +require ( + github.com/KyleBanks/depth v1.2.1 // indirect + github.com/go-openapi/jsonpointer v0.19.5 // indirect + github.com/go-openapi/jsonreference v0.20.0 // indirect + github.com/go-openapi/spec v0.20.6 // indirect + github.com/go-openapi/swag v0.19.15 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/mailru/easyjson v0.7.6 // indirect + github.com/swaggo/files/v2 v2.0.0 // indirect + golang.org/x/mod v0.29.0 // indirect + golang.org/x/sync v0.18.0 // indirect + golang.org/x/tools v0.38.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect +) diff --git a/discovery/go.sum b/discovery/go.sum new file mode 100644 index 0000000..d833e7f --- /dev/null +++ b/discovery/go.sum @@ -0,0 +1,117 @@ +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= +github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4= +github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI= +github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY= +github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonreference v0.20.0 h1:MYlu0sBgChmCfJxxUKZ8g1cPWFOB37YSZqewK7OKeyA= +github.com/go-openapi/jsonreference v0.20.0/go.mod h1:Ag74Ico3lPc+zR+qjn4XBUmXymS4zJbYVCZmcgkasdo= +github.com/go-openapi/spec v0.20.6 h1:ich1RQ3WDbfoeTqTAb+5EIxNmpKVJZWBNah9RAT0jIQ= +github.com/go-openapi/spec v0.20.6/go.mod h1:2OpW+JddWPrpXSCIX8eOx7lZ5iyuWj3RYR6VaaBKcWA= +github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= +github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= +github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= +github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= +github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= +github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA= +github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/swaggo/files/v2 v2.0.0 h1:hmAt8Dkynw7Ssz46F6pn8ok6YmGZqHSVLZ+HQM7i0kw= +github.com/swaggo/files/v2 v2.0.0/go.mod h1:24kk2Y9NYEJ5lHuCra6iVwkMjIekMCaFq/0JQj66kyM= +github.com/swaggo/http-swagger/v2 v2.0.2 h1:FKCdLsl+sFCx60KFsyM0rDarwiUSZ8DqbfSyIKC9OBg= +github.com/swaggo/http-swagger/v2 v2.0.2/go.mod h1:r7/GBkAWIfK6E/OLnE8fXnviHiDeAHmgIyooa4xm3AQ= +github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI= +github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= +go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= +go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= +go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= +go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= +go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= +go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/discovery/internal/api/api.go b/discovery/internal/api/api.go new file mode 100644 index 0000000..f6cf250 --- /dev/null +++ b/discovery/internal/api/api.go @@ -0,0 +1,23 @@ +// Package api holds the request and response bodies of the HTTP API — the +// wire contract, kept apart from the handlers that serve it. The swaggo +// annotations on the handlers reference these types by name +// (api.HealthResponse and so on), so renaming one changes the generated spec. +package api + +// HealthResponse is the body of GET /health. +type HealthResponse struct { + Status string `json:"status" example:"ok"` +} + +// ProblemDetails is an error body in the RFC 9457 "Problem Details for HTTP +// APIs" format. Type stays "about:blank" — the value RFC 9457 defines for +// problems with no dedicated documentation URI. Title is a short summary that +// stays identical for every occurrence of the same problem, so clients can +// branch on it; Detail explains this particular occurrence and is the only +// member that varies with request data. +type ProblemDetails struct { + Type string `json:"type" example:"about:blank"` + Title string `json:"title" example:"Something Went Wrong Loading The Catalogue"` + Status int `json:"status" example:"500"` + Detail string `json:"detail,omitempty" example:"The catalogue could not be read from the database. This is a server-side fault and the request was not processed; retrying in a few moments may succeed."` +} diff --git a/discovery/internal/db/client.go b/discovery/internal/db/client.go new file mode 100644 index 0000000..d99244f --- /dev/null +++ b/discovery/internal/db/client.go @@ -0,0 +1,30 @@ +package db + +import ( + "context" + "database/sql" + "fmt" + "log" +) + +func CreateDBConnection(connectionString string) *sql.DB { + sqlDB, err := sql.Open("postgres", connectionString) + if err != nil { + panic(fmt.Errorf("db: cannot open connection: %w", err)) + } + + return sqlDB +} + +func CloseConnection(SQLDB *sql.DB) { + err := SQLDB.Close() + if err != nil { + log.Fatalf("Error Closing DB Connection: %s", err) + } +} + +func AssertSuccessfulConnection(ctx context.Context, SQLDB *sql.DB) { + if err := SQLDB.PingContext(ctx); err != nil { + panic(fmt.Errorf("db: cannot connect: %w", err)) + } +} diff --git a/discovery/internal/db/migrate.go b/discovery/internal/db/migrate.go new file mode 100644 index 0000000..bc2fda2 --- /dev/null +++ b/discovery/internal/db/migrate.go @@ -0,0 +1,38 @@ +package db + +import ( + "database/sql" + "embed" + "errors" + "fmt" + + "github.com/golang-migrate/migrate/v4" + "github.com/golang-migrate/migrate/v4/database/postgres" + "github.com/golang-migrate/migrate/v4/source/iofs" +) + +//go:embed migrations/*.sql +var migrationsFS embed.FS + +func Migrate(sqlDB *sql.DB) error { + driver, err := postgres.WithInstance(sqlDB, &postgres.Config{}) + if err != nil { + return fmt.Errorf("db: creating migration driver: %w", err) + } + + source, err := iofs.New(migrationsFS, "migrations") + if err != nil { + return fmt.Errorf("db: loading embedded migrations: %w", err) + } + + m, err := migrate.NewWithInstance("iofs", source, "postgres", driver) + if err != nil { + return fmt.Errorf("db: initializing migrator: %w", err) + } + + if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) { + return fmt.Errorf("db: applying migrations: %w", err) + } + + return nil +} diff --git a/discovery/internal/db/migrations/0001_init.down.sql b/discovery/internal/db/migrations/0001_init.down.sql new file mode 100644 index 0000000..64d1bc6 --- /dev/null +++ b/discovery/internal/db/migrations/0001_init.down.sql @@ -0,0 +1,2 @@ +-- Nothing to undo: 0001 creates no objects. +SELECT 1; diff --git a/discovery/internal/db/migrations/0001_init.up.sql b/discovery/internal/db/migrations/0001_init.up.sql new file mode 100644 index 0000000..2735708 --- /dev/null +++ b/discovery/internal/db/migrations/0001_init.up.sql @@ -0,0 +1,6 @@ +-- Discovery's schema starts here. The service owns its own database — the +-- `discovery` role and same-named database provisioned by newServiceDatabase +-- in infrastructure/main.go — and shares no tables with cms, so nothing is +-- created yet. The file exists because internal/db/migrate.go embeds +-- migrations/*.sql: with no migration at all the package would not compile. +SELECT 1; diff --git a/discovery/internal/db/repositories/repositories.go b/discovery/internal/db/repositories/repositories.go new file mode 100644 index 0000000..90e2afe --- /dev/null +++ b/discovery/internal/db/repositories/repositories.go @@ -0,0 +1,7 @@ +// Package repositories holds every SQL statement the service runs. A +// repository is a struct carrying the *sql.DB opened by db.CreateDBConnection +// and is assigned to a package-level var in main, the way cms wires VideoRepo +// and CatagoriesRepo, so handlers call through something substitutable. +// +// Empty until discovery has tables to read. +package repositories diff --git a/discovery/internal/handlers/handlers.go b/discovery/internal/handlers/handlers.go new file mode 100644 index 0000000..1fdf8c5 --- /dev/null +++ b/discovery/internal/handlers/handlers.go @@ -0,0 +1,41 @@ +package handlers + +import ( + "encoding/json" + "log" + "net/http" + "thamanyah/discovery/internal/api" +) + +// Health is the liveness probe the ALB target group polls. +// +// @Summary Health check +// @Description Reports that the service is up and serving. Used as the ALB target group health check; it does not verify the database connection. +// @Tags system +// @Produce json +// @Success 200 {object} api.HealthResponse +// @Router /health [get] +func Health(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, api.HealthResponse{Status: "ok"}) +} + +func writeJSON(w http.ResponseWriter, status int, body any) { + writeJSONContent(w, "application/json", status, body) +} + +func writeProblem(w http.ResponseWriter, status int, title, detail string) { + writeJSONContent(w, "application/problem+json", status, api.ProblemDetails{ + Type: "about:blank", + Title: title, + Status: status, + Detail: detail, + }) +} + +func writeJSONContent(w http.ResponseWriter, contentType string, status int, body any) { + w.Header().Set("Content-Type", contentType) + w.WriteHeader(status) + if err := json.NewEncoder(w).Encode(body); err != nil { + log.Printf("Something Went Wrong Writing The Response: %s", err) + } +} diff --git a/discovery/internal/models/models.go b/discovery/internal/models/models.go new file mode 100644 index 0000000..dfd338f --- /dev/null +++ b/discovery/internal/models/models.go @@ -0,0 +1,8 @@ +// Package models holds the domain types the rest of the service passes +// around, the way cms holds Video and Category: plain Go structs with no JSON +// tags and no SQL, so that renaming a field here never moves the published +// API (that lives in internal/api) and never rewrites a query (that lives in +// internal/db/repositories). +// +// Empty until discovery has a domain to model. +package models diff --git a/discovery/main.go b/discovery/main.go new file mode 100644 index 0000000..97e2f09 --- /dev/null +++ b/discovery/main.go @@ -0,0 +1,97 @@ +package main + +import ( + "context" + "fmt" + "log" + "net/http" + "os" + "thamanyah/discovery/internal/db" + "thamanyah/discovery/internal/handlers" + + _ "github.com/lib/pq" + httpSwagger "github.com/swaggo/http-swagger/v2" + + _ "thamanyah/discovery/docs" +) + +// @title Thamanyah Discovery API +// @version 1.0 +// @description JSON API for browsing the Thamanyah catalogue. Read-side counterpart to the CMS, which is what ingests videos. +// @BasePath / + +func main() { + if len(os.Args) > 1 && os.Args[1] == "migrate" { + runMigrate() + return + } + runServer() +} + +func runMigrate() { + concreteDBClient := db.CreateDBConnection(requireDBConnectionString()) + defer db.CloseConnection(concreteDBClient) + if err := db.Migrate(concreteDBClient); err != nil { + panic(err) + } + log.Println("migrations applied successfully") +} + +func runServer() { + concreteDBClient := db.CreateDBConnection(requireDBConnectionString()) + defer db.CloseConnection(concreteDBClient) + db.AssertSuccessfulConnection(context.Background(), concreteDBClient) + + mux := http.NewServeMux() + + mux.HandleFunc("GET /health", handlers.Health) + + // Swagger UI and the generated spec. The UI assets are embedded in the + // binary by swaggo/files, so this needs no static directory on disk. + mux.Handle("GET /swagger/", httpSwagger.WrapHandler) + + addr := ":8080" + log.Printf("listening on %s", addr) + if err := http.ListenAndServe(addr, mux); err != nil { + log.Fatal(err) + } +} + +func requireDBConnectionString() string { + dbHost := os.Getenv("DB_HOST") + if dbHost == "" { + panic(fmt.Errorf("missing required env var: DB_HOST")) + } + + dbPort := os.Getenv("DB_PORT") + if dbPort == "" { + panic(fmt.Errorf("missing required env var: DB_PORT")) + } + + dbUser := os.Getenv("DB_USER") + if dbUser == "" { + panic(fmt.Errorf("missing required env var: DB_USER")) + } + + dbPassword := os.Getenv("DB_PASSWORD") + if dbPassword == "" { + panic(fmt.Errorf("missing required env var: DB_PASSWORD")) + } + + dbName := os.Getenv("DB_NAME") + if dbName == "" { + panic(fmt.Errorf("missing required env var: DB_NAME")) + } + + // Defaults to require: RDS terminates TLS, and only a local Postgres that + // serves none (LocalStack's RDS emulation) has any business overriding it. + dbSSLMode := os.Getenv("DB_SSLMODE") + if dbSSLMode == "" { + dbSSLMode = "require" + } + + return fmt.Sprintf( + "host=%s port=%s user=%s password=%s dbname=%s sslmode=%s", + dbHost, dbPort, dbUser, dbPassword, dbName, dbSSLMode, + ) +} diff --git a/docker-compose.yml b/docker-compose.yml index c46a813..bb0d6c0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -123,6 +123,34 @@ services: - action: rebuild path: ./cms + discovery: + container_name: "${DISCOVERY_DOCKER_NAME:-discovery}" + build: + context: ./discovery + dockerfile: Dockerfile + ports: + - "127.0.0.1:8080:8080" # JSON API + Swagger UI at /swagger/ + environment: + # Postgres only — discovery touches no AWS service, which is why it has + # no task role in infrastructure/main.go and no AWS_* wiring here. Its + # own role and database, provisioned alongside the cms ones by + # newServiceDatabase, so the two services share no credentials. + - DB_HOST=${DISCOVERY_DB_HOST:-localhost.localstack.cloud} + - DB_PORT=${DISCOVERY_DB_PORT:-4510} + - DB_NAME=${DISCOVERY_DB_NAME:-discovery} + - DB_USER=${DISCOVERY_DB_USER:-discovery} + - DB_PASSWORD=${DB_PASSWORD:?} + - DB_SSLMODE=${DISCOVERY_DB_SSLMODE:-disable} + depends_on: + infra: + condition: service_completed_successfully + develop: + watch: + # Same as cms: a compiled binary, so every change means a new image. + # Needs `docker compose up --watch` (or `docker compose watch`). + - action: rebuild + path: ./discovery + volumes: pulumi-state: pulumi-home: diff --git a/infrastructure/main.go b/infrastructure/main.go index 04fffda..0649aab 100644 --- a/infrastructure/main.go +++ b/infrastructure/main.go @@ -1089,9 +1089,13 @@ func main() { } if !localstack { + // runMigrations is true for the same reason it is for cms: + // discovery embeds its own golang-migrate migrations and applies + // them through `./discovery migrate`, so ECS runs that container to + // completion before the service accepts traffic. discoveryRepo, discoveryService, discoveryAlb, err = deployFargateService(ctx, "discovery", 8080, cluster, execRole, nil, nil, vpcID, subnetIDs, albSecurityGroup, serviceSecurityGroup, - db.Address, db.Port, discoveryPassword, false) + db.Address, db.Port, discoveryPassword, true) if err != nil { return err }