FEAT: Init Discovery Service
Build, Push and Deploy Discovery / build-push-deploy (push) Successful in 1m45s
Deploy Infrastructure / pulumi-up (push) Successful in 2s

This commit is contained in:
FahdShalhoub
2026-08-27 22:03:41 +03:00
parent 0cfd89f200
commit 98950aba20
20 changed files with 719 additions and 6 deletions
+60
View File
@@ -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 }}"
+39 -5
View File
@@ -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 `<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
@@ -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`,
+33
View File
@@ -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
+19
View File
@@ -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"]
+68
View File
@@ -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)
}
+43
View File
@@ -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"
}
}
}
}
}
+30
View File
@@ -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"
+25
View File
@@ -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
)
+117
View File
@@ -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=
+23
View File
@@ -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."`
}
+30
View File
@@ -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))
}
}
+38
View File
@@ -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
}
@@ -0,0 +1,2 @@
-- Nothing to undo: 0001 creates no objects.
SELECT 1;
@@ -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;
@@ -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
+41
View File
@@ -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)
}
}
+8
View File
@@ -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
+97
View File
@@ -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,
)
}
+28
View File
@@ -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:
+5 -1
View File
@@ -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
}