From 76c24782c8c461db285a0c579760f1c311ef2ab1 Mon Sep 17 00:00:00 2001 From: MangoPig Date: Tue, 16 Jun 2026 07:34:34 +0100 Subject: [PATCH] Feat: Backend scaffolding and local dev stack --- .gitignore | 4 + Backend/.air.api.toml | 52 ++++++++++ Backend/.air.web.toml | 52 ++++++++++ Backend/.air.worker.toml | 52 ++++++++++ Backend/.dockerignore | 4 + Backend/.gitkeep | 0 Backend/Dockerfile | 36 +++++++ Backend/cmd/api/main.go | 35 +++++++ Backend/cmd/migrate/main.go | 49 +++++++++ Backend/cmd/web/main.go | 35 +++++++ Backend/cmd/worker/main.go | 27 +++++ Backend/db/embed.go | 6 ++ Backend/db/migrations/000001_init.sql | 26 +++++ Backend/db/queries/organizations.sql | 15 +++ Backend/docker-bake.hcl | 36 +++++++ Backend/go.mod | 24 +++++ Backend/go.sum | 70 +++++++++++++ Backend/internal/bootstrap/bootstrap.go | 85 ++++++++++++++++ Backend/internal/buildinfo/buildinfo.go | 23 +++++ Backend/internal/cache/valkey.go | 40 ++++++++ Backend/internal/config/config.go | 79 +++++++++++++++ Backend/internal/database/postgres.go | 95 ++++++++++++++++++ Backend/internal/httpx/api_routes.go | 53 ++++++++++ Backend/internal/httpx/middleware.go | 80 +++++++++++++++ Backend/internal/httpx/response.go | 33 +++++++ Backend/internal/httpx/router.go | 70 +++++++++++++ Backend/internal/httpx/shared_routes.go | 69 +++++++++++++ Backend/internal/httpx/web_routes.go | 51 ++++++++++ Backend/internal/process/process.go | 65 ++++++++++++ Backend/sqlc.yaml | 12 +++ Commands/Local/Dev/backend.just | 26 +++++ Commands/Local/Dev/frontend.just | 14 +++ Commands/Local/Dev/mod.just | 39 ++++++++ Commands/Local/Dev/scripts/backend-stack.sh | 91 +++++++++++++++++ Commands/Local/Dev/scripts/dev-stack.sh | 102 +++++++++++++++++++ Commands/Local/Dev/scripts/docker.sh | 20 ++++ Commands/Local/Dev/scripts/env.sh | 21 ++++ Commands/Local/dev.just | 36 ------- Commands/Local/mod.just | 2 +- Commands/Local/prod.just | 23 +++-- Commands/Prod/.gitkeep | 0 Docker/docker-compose.local.dev.yaml | 104 +++++++++++++++++--- Documentation/CONTRIBUTING | 18 +++- Env/.env.example | 13 +++ Justfile | 2 +- 45 files changed, 1726 insertions(+), 63 deletions(-) create mode 100644 Backend/.air.api.toml create mode 100644 Backend/.air.web.toml create mode 100644 Backend/.air.worker.toml create mode 100644 Backend/.dockerignore delete mode 100644 Backend/.gitkeep create mode 100644 Backend/Dockerfile create mode 100644 Backend/cmd/api/main.go create mode 100644 Backend/cmd/migrate/main.go create mode 100644 Backend/cmd/web/main.go create mode 100644 Backend/cmd/worker/main.go create mode 100644 Backend/db/embed.go create mode 100644 Backend/db/migrations/000001_init.sql create mode 100644 Backend/db/queries/organizations.sql create mode 100644 Backend/docker-bake.hcl create mode 100644 Backend/go.mod create mode 100644 Backend/go.sum create mode 100644 Backend/internal/bootstrap/bootstrap.go create mode 100644 Backend/internal/buildinfo/buildinfo.go create mode 100644 Backend/internal/cache/valkey.go create mode 100644 Backend/internal/config/config.go create mode 100644 Backend/internal/database/postgres.go create mode 100644 Backend/internal/httpx/api_routes.go create mode 100644 Backend/internal/httpx/middleware.go create mode 100644 Backend/internal/httpx/response.go create mode 100644 Backend/internal/httpx/router.go create mode 100644 Backend/internal/httpx/shared_routes.go create mode 100644 Backend/internal/httpx/web_routes.go create mode 100644 Backend/internal/process/process.go create mode 100644 Backend/sqlc.yaml create mode 100644 Commands/Local/Dev/backend.just create mode 100644 Commands/Local/Dev/frontend.just create mode 100644 Commands/Local/Dev/mod.just create mode 100644 Commands/Local/Dev/scripts/backend-stack.sh create mode 100644 Commands/Local/Dev/scripts/dev-stack.sh create mode 100644 Commands/Local/Dev/scripts/docker.sh create mode 100644 Commands/Local/Dev/scripts/env.sh delete mode 100644 Commands/Local/dev.just delete mode 100644 Commands/Prod/.gitkeep create mode 100644 Env/.env.example diff --git a/.gitignore b/.gitignore index 20d1f5d..2f35ea1 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,7 @@ pnpm-debug.log* # OS / editor files .DS_Store .idea/ + +# Go build output +tmp/ +bin/ diff --git a/Backend/.air.api.toml b/Backend/.air.api.toml new file mode 100644 index 0000000..1de2b66 --- /dev/null +++ b/Backend/.air.api.toml @@ -0,0 +1,52 @@ +root = "." +testdata_dir = "testdata" +tmp_dir = "tmp/dev" + +[build] +args_bin = [] +bin = "./tmp/dev/bin/api" +cmd = "go build -o ./tmp/dev/bin/api ./cmd/api" +delay = 1000 +exclude_dir = ["tmp", "vendor", "testdata"] +exclude_file = [] +exclude_regex = ["_test.go"] +exclude_unchanged = false +follow_symlink = false +full_bin = "" +include_dir = [] +include_ext = ["go", "sql"] +include_file = [] +kill_delay = "0s" +log = "tmp/dev/logs/build-errors.api.log" +poll = false +poll_interval = 0 +post_cmd = [] +pre_cmd = [] +rerun = false +rerun_delay = 500 +send_interrupt = false +stop_on_error = true + +[color] +app = "" +build = "yellow" +main = "magenta" +runner = "green" +watcher = "cyan" + +[log] +main_only = false +silent = false +time = true + +[misc] +clean_on_exit = false + +[proxy] +app_port = 0 +enabled = false +proxy_port = 0 + +[screen] +clear_on_rebuild = true +keep_scroll = true diff --git a/Backend/.air.web.toml b/Backend/.air.web.toml new file mode 100644 index 0000000..607d9ae --- /dev/null +++ b/Backend/.air.web.toml @@ -0,0 +1,52 @@ +root = "." +testdata_dir = "testdata" +tmp_dir = "tmp/dev" + +[build] +args_bin = [] +bin = "./tmp/dev/bin/web" +cmd = "go build -o ./tmp/dev/bin/web ./cmd/web" +delay = 1000 +exclude_dir = ["tmp", "vendor", "testdata"] +exclude_file = [] +exclude_regex = ["_test.go"] +exclude_unchanged = false +follow_symlink = false +full_bin = "" +include_dir = [] +include_ext = ["go", "sql"] +include_file = [] +kill_delay = "0s" +log = "tmp/dev/logs/build-errors.web.log" +poll = false +poll_interval = 0 +post_cmd = [] +pre_cmd = [] +rerun = false +rerun_delay = 500 +send_interrupt = false +stop_on_error = true + +[color] +app = "" +build = "yellow" +main = "magenta" +runner = "green" +watcher = "cyan" + +[log] +main_only = false +silent = false +time = true + +[misc] +clean_on_exit = false + +[proxy] +app_port = 0 +enabled = false +proxy_port = 0 + +[screen] +clear_on_rebuild = true +keep_scroll = true diff --git a/Backend/.air.worker.toml b/Backend/.air.worker.toml new file mode 100644 index 0000000..b45db7e --- /dev/null +++ b/Backend/.air.worker.toml @@ -0,0 +1,52 @@ +root = "." +testdata_dir = "testdata" +tmp_dir = "tmp/dev" + +[build] +args_bin = [] +bin = "./tmp/dev/bin/worker" +cmd = "go build -o ./tmp/dev/bin/worker ./cmd/worker" +delay = 1000 +exclude_dir = ["tmp", "vendor", "testdata"] +exclude_file = [] +exclude_regex = ["_test.go"] +exclude_unchanged = false +follow_symlink = false +full_bin = "" +include_dir = [] +include_ext = ["go", "sql"] +include_file = [] +kill_delay = "0s" +log = "tmp/dev/logs/build-errors.worker.log" +poll = false +poll_interval = 0 +post_cmd = [] +pre_cmd = [] +rerun = false +rerun_delay = 500 +send_interrupt = false +stop_on_error = true + +[color] +app = "" +build = "yellow" +main = "magenta" +runner = "green" +watcher = "cyan" + +[log] +main_only = false +silent = false +time = true + +[misc] +clean_on_exit = false + +[proxy] +app_port = 0 +enabled = false +proxy_port = 0 + +[screen] +clear_on_rebuild = true +keep_scroll = true diff --git a/Backend/.dockerignore b/Backend/.dockerignore new file mode 100644 index 0000000..d6fb6f6 --- /dev/null +++ b/Backend/.dockerignore @@ -0,0 +1,4 @@ +.git +.gitignore +tmp +testdata diff --git a/Backend/.gitkeep b/Backend/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/Backend/Dockerfile b/Backend/Dockerfile new file mode 100644 index 0000000..e6e9d94 --- /dev/null +++ b/Backend/Dockerfile @@ -0,0 +1,36 @@ +# syntax=docker/dockerfile:1.7 + +FROM golang:1.25.7-alpine AS base + +WORKDIR /app + +RUN apk add --no-cache ca-certificates curl git tzdata && update-ca-certificates + +COPY go.mod go.sum ./ +RUN go mod download + +FROM base AS development + +RUN curl -fsSL https://raw.githubusercontent.com/air-verse/air/master/install.sh | sh -s -- -b /usr/local/bin + +COPY . . + +CMD ["air", "-c", ".air.web.toml"] + +FROM base AS builder + +ARG SERVICE_NAME=web + +COPY . . + +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/app ./cmd/${SERVICE_NAME} + +FROM alpine:3.22 AS runtime + +RUN apk add --no-cache ca-certificates tzdata && update-ca-certificates + +WORKDIR /app + +COPY --from=builder /out/app /usr/local/bin/app + +CMD ["/usr/local/bin/app"] diff --git a/Backend/cmd/api/main.go b/Backend/cmd/api/main.go new file mode 100644 index 0000000..6d7389f --- /dev/null +++ b/Backend/cmd/api/main.go @@ -0,0 +1,35 @@ +package main + +import ( + "log" + + "moku-backend/internal/bootstrap" + "moku-backend/internal/httpx" + "moku-backend/internal/process" +) + +func main() { + app, err := bootstrap.New("api") + if err != nil { + log.Fatalf("bootstrap api service: %v", err) + } + defer func() { + if closeErr := app.Close(); closeErr != nil { + app.Logger.Error("close api service", "error", closeErr) + } + }() + + handler := httpx.NewRouter(httpx.RouterConfig{ + ServiceName: app.ServiceName, + Config: app.Config, + Logger: app.Logger, + BuildInfo: app.BuildInfo, + Database: app.Database, + Cache: app.Cache, + }) + + if err := process.RunHTTPServer(app.ServiceName, app.Config.Address(app.ServiceName), handler, app.Logger, app.Config.ShutdownTimeout); err != nil { + app.Logger.Error("api service stopped", "error", err) + log.Fatal(err) + } +} diff --git a/Backend/cmd/migrate/main.go b/Backend/cmd/migrate/main.go new file mode 100644 index 0000000..18d8072 --- /dev/null +++ b/Backend/cmd/migrate/main.go @@ -0,0 +1,49 @@ +package main + +import ( + "fmt" + "log" + "os" + + "moku-backend/internal/config" + "moku-backend/internal/database" +) + +func main() { + cfg := config.Load() + + db, err := database.NewPostgres(cfg.PostgresURL) + if err != nil { + log.Fatalf("connect database for migrations: %v", err) + } + defer db.Close() + + command := "up" + if len(os.Args) > 1 { + command = os.Args[1] + } + + switch command { + case "up": + if err := db.MigrateUp(); err != nil { + log.Fatalf("run migrations: %v", err) + } + fmt.Println("migrations applied") + case "down": + if err := db.MigrateDown(); err != nil { + log.Fatalf("roll back migration: %v", err) + } + fmt.Println("latest migration rolled back") + case "reset": + if err := db.MigrateReset(); err != nil { + log.Fatalf("reset migrations: %v", err) + } + fmt.Println("migrations reset") + case "status": + if err := db.MigrateStatus(); err != nil { + log.Fatalf("show migration status: %v", err) + } + default: + log.Fatalf("unsupported migrate command %q (supported: up, down, reset, status)", command) + } +} diff --git a/Backend/cmd/web/main.go b/Backend/cmd/web/main.go new file mode 100644 index 0000000..e9f6c4f --- /dev/null +++ b/Backend/cmd/web/main.go @@ -0,0 +1,35 @@ +package main + +import ( + "log" + + "moku-backend/internal/bootstrap" + "moku-backend/internal/httpx" + "moku-backend/internal/process" +) + +func main() { + app, err := bootstrap.New("web") + if err != nil { + log.Fatalf("bootstrap web service: %v", err) + } + defer func() { + if closeErr := app.Close(); closeErr != nil { + app.Logger.Error("close web service", "error", closeErr) + } + }() + + handler := httpx.NewRouter(httpx.RouterConfig{ + ServiceName: app.ServiceName, + Config: app.Config, + Logger: app.Logger, + BuildInfo: app.BuildInfo, + Database: app.Database, + Cache: app.Cache, + }) + + if err := process.RunHTTPServer(app.ServiceName, app.Config.Address(app.ServiceName), handler, app.Logger, app.Config.ShutdownTimeout); err != nil { + app.Logger.Error("web service stopped", "error", err) + log.Fatal(err) + } +} diff --git a/Backend/cmd/worker/main.go b/Backend/cmd/worker/main.go new file mode 100644 index 0000000..55363f8 --- /dev/null +++ b/Backend/cmd/worker/main.go @@ -0,0 +1,27 @@ +package main + +import ( + "log" + + "moku-backend/internal/bootstrap" + "moku-backend/internal/process" +) + +func main() { + app, err := bootstrap.New("worker") + if err != nil { + log.Fatalf("bootstrap worker service: %v", err) + } + defer func() { + if closeErr := app.Close(); closeErr != nil { + app.Logger.Error("close worker service", "error", closeErr) + } + }() + + app.Logger.Info("worker ready", "service", app.ServiceName, "environment", app.Config.Environment) + + if err := process.WaitForShutdown(app.ServiceName, app.Logger); err != nil { + app.Logger.Error("worker stopped", "error", err) + log.Fatal(err) + } +} diff --git a/Backend/db/embed.go b/Backend/db/embed.go new file mode 100644 index 0000000..e0a4e0b --- /dev/null +++ b/Backend/db/embed.go @@ -0,0 +1,6 @@ +package db + +import "embed" + +//go:embed migrations/*.sql +var Migrations embed.FS diff --git a/Backend/db/migrations/000001_init.sql b/Backend/db/migrations/000001_init.sql new file mode 100644 index 0000000..0d42195 --- /dev/null +++ b/Backend/db/migrations/000001_init.sql @@ -0,0 +1,26 @@ +-- +goose Up +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; + +CREATE TABLE IF NOT EXISTS organizations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + slug TEXT NOT NULL UNIQUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS workspaces ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + name TEXT NOT NULL, + slug TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (organization_id, slug) +); + +CREATE INDEX IF NOT EXISTS idx_workspaces_organization_id ON workspaces (organization_id); + +-- +goose Down +DROP TABLE IF EXISTS workspaces; +DROP TABLE IF EXISTS organizations; diff --git a/Backend/db/queries/organizations.sql b/Backend/db/queries/organizations.sql new file mode 100644 index 0000000..86ddbb0 --- /dev/null +++ b/Backend/db/queries/organizations.sql @@ -0,0 +1,15 @@ +-- name: ListOrganizations :many +SELECT id, name, slug, created_at, updated_at +FROM organizations +ORDER BY created_at DESC; + +-- name: CreateOrganization :one +INSERT INTO organizations (name, slug) +VALUES ($1, $2) +RETURNING id, name, slug, created_at, updated_at; + +-- name: ListWorkspacesByOrganization :many +SELECT id, organization_id, name, slug, created_at, updated_at +FROM workspaces +WHERE organization_id = $1 +ORDER BY created_at DESC; diff --git a/Backend/docker-bake.hcl b/Backend/docker-bake.hcl new file mode 100644 index 0000000..d848cab --- /dev/null +++ b/Backend/docker-bake.hcl @@ -0,0 +1,36 @@ +variable "REGISTRY" { + default = "registry.example.com" +} + +variable "TAG" { + default = "latest" +} + +target "_app" { + context = "." + dockerfile = "Dockerfile" +} + +target "dev" { + inherits = ["_app"] + target = "development" + tags = ["moku/work-backend:dev"] +} + +target "dev-image" { + inherits = ["_app"] + target = "development" + tags = ["${REGISTRY}/moku/work-backend:dev-${TAG}"] +} + +group "local" { + targets = ["dev"] +} + +group "registry" { + targets = ["dev-image"] +} + +group "default" { + targets = ["dev"] +} diff --git a/Backend/go.mod b/Backend/go.mod new file mode 100644 index 0000000..710c502 --- /dev/null +++ b/Backend/go.mod @@ -0,0 +1,24 @@ +module moku-backend + +go 1.25.7 + +require ( + github.com/go-chi/chi/v5 v5.3.0 + github.com/google/uuid v1.6.0 + github.com/jackc/pgx/v5 v5.10.0 + github.com/pressly/goose/v3 v3.27.1 + github.com/redis/go-redis/v9 v9.20.1 +) + +require ( + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/mfridman/interpolate v0.0.2 // indirect + github.com/sethvargo/go-retry v0.3.0 // indirect + go.uber.org/atomic v1.11.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/text v0.36.0 // indirect +) diff --git a/Backend/go.sum b/Backend/go.sum new file mode 100644 index 0000000..e94fe05 --- /dev/null +++ b/Backend/go.sum @@ -0,0 +1,70 @@ +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM= +github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= +github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= +github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= +github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= +github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5sh4= +github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76csUV7+7KYoM= +github.com/redis/go-redis/v9 v9.20.1 h1:sfCU6A8P3dXbKyWes02uxA2baehGux9dZHfEKtsTB1w= +github.com/redis/go-redis/v9 v9.20.1/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= +github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= +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.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/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= +modernc.org/libc v1.72.1 h1:db1xwJ6u1kE3KHTFTTbe2GCrczHPKzlURP0aDC4NGD0= +modernc.org/libc v1.72.1/go.mod h1:HRMiC/PhPGLIPM7GzAFCbI+oSgE3dhZ8FWftmRrHVlY= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.49.1 h1:dYGHTKcX1sJ+EQDnUzvz4TJ5GbuvhNJa8Fg6ElGx73U= +modernc.org/sqlite v1.49.1/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew= diff --git a/Backend/internal/bootstrap/bootstrap.go b/Backend/internal/bootstrap/bootstrap.go new file mode 100644 index 0000000..1d9ed21 --- /dev/null +++ b/Backend/internal/bootstrap/bootstrap.go @@ -0,0 +1,85 @@ +// Path: Backend/internal/bootstrap/bootstrap.go + +package bootstrap + +import ( + "log/slog" + "os" + "strings" + + "moku-backend/internal/buildinfo" + "moku-backend/internal/cache" + "moku-backend/internal/config" + "moku-backend/internal/database" +) + +type App struct { + ServiceName string + Config *config.Config + Logger *slog.Logger + BuildInfo buildinfo.Info + Database *database.DB + Cache *cache.Client +} + +func New(serviceName string) (*App, error) { + cfg := config.Load() + logger := newLogger(cfg) + + db, err := database.NewPostgres(cfg.PostgresURL) + if err != nil { + return nil, err + } + + valkey, err := cache.NewValkey(cfg.ValkeyURL) + if err != nil { + db.Close() + return nil, err + } + + return &App{ + ServiceName: serviceName, + Config: cfg, + Logger: logger.With("service", serviceName), + BuildInfo: buildinfo.Current(), + Database: db, + Cache: valkey, + }, nil +} + +func (a *App) Close() error { + var closeErr error + + if a.Cache != nil { + if err := a.Cache.Close(); err != nil { + closeErr = err + } + } + + if a.Database != nil { + a.Database.Close() + } + + return closeErr +} + +func newLogger(cfg *config.Config) *slog.Logger { + level := slog.LevelInfo + + switch strings.ToLower(cfg.LogLevel) { + case "debug": + level = slog.LevelDebug + case "warn": + level = slog.LevelWarn + case "error": + level = slog.LevelError + } + + options := &slog.HandlerOptions{Level: level} + + if cfg.IsDevelopment() { + return slog.New(slog.NewTextHandler(os.Stdout, options)) + } + + return slog.New(slog.NewJSONHandler(os.Stdout, options)) +} diff --git a/Backend/internal/buildinfo/buildinfo.go b/Backend/internal/buildinfo/buildinfo.go new file mode 100644 index 0000000..2b2265b --- /dev/null +++ b/Backend/internal/buildinfo/buildinfo.go @@ -0,0 +1,23 @@ +// Path: Backend/internal/buildinfo/buildinfo.go + +package buildinfo + +var ( + Version = "dev" + Commit = "unknown" + BuiltAt = "unknown" +) + +type Info struct { + Version string `json:"version"` + Commit string `json:"commit"` + BuiltAt string `json:"builtAt"` +} + +func Current() Info { + return Info{ + Version: Version, + Commit: Commit, + BuiltAt: BuiltAt, + } +} diff --git a/Backend/internal/cache/valkey.go b/Backend/internal/cache/valkey.go new file mode 100644 index 0000000..94b7cdb --- /dev/null +++ b/Backend/internal/cache/valkey.go @@ -0,0 +1,40 @@ +// Path: Backend/internal/cache/valkey.go + +package cache + +import ( + "context" + + "github.com/redis/go-redis/v9" +) + +type Client struct { + Redis *redis.Client +} + +func NewValkey(valkeyURL string) (*Client, error) { + options, err := redis.ParseURL(valkeyURL) + if err != nil { + return nil, err + } + + client := redis.NewClient(options) + + return &Client{Redis: client}, nil +} + +func (c *Client) Health(ctx context.Context) error { + if c == nil || c.Redis == nil { + return nil + } + + return c.Redis.Ping(ctx).Err() +} + +func (c *Client) Close() error { + if c == nil || c.Redis == nil { + return nil + } + + return c.Redis.Close() +} diff --git a/Backend/internal/config/config.go b/Backend/internal/config/config.go new file mode 100644 index 0000000..a5f3b09 --- /dev/null +++ b/Backend/internal/config/config.go @@ -0,0 +1,79 @@ +// Path: Backend/internal/config/config.go + +package config + +import ( + "fmt" + "os" + "strings" + "time" +) + +type Config struct { + AppName string + Environment string + LogLevel string + WebPort string + APIPort string + WorkerPort string + PostgresURL string + ValkeyURL string + ShutdownTimeout time.Duration +} + +func Load() *Config { + return &Config{ + AppName: getEnv("APP_NAME", "moku"), + Environment: getEnv("GO_ENV", "development"), + LogLevel: getEnv("LOG_LEVEL", "debug"), + WebPort: getEnv("BACKEND_WEB_PORT", "8080"), + APIPort: getEnv("BACKEND_API_PORT", "8081"), + WorkerPort: getEnv("BACKEND_WORKER_PORT", "8082"), + PostgresURL: getEnv("DATABASE_URL", "postgres://moku:moku_dev_password@localhost:5432/moku?sslmode=disable"), + ValkeyURL: getEnv("VALKEY_URL", "redis://localhost:6379/0"), + ShutdownTimeout: getDurationEnv("BACKEND_SHUTDOWN_TIMEOUT", 10*time.Second), + } +} + +func (c *Config) Address(serviceName string) string { + var port string + + switch strings.ToLower(serviceName) { + case "web": + port = c.WebPort + case "api": + port = c.APIPort + case "worker": + port = c.WorkerPort + default: + port = c.WebPort + } + + return fmt.Sprintf(":%s", port) +} + +func (c *Config) IsDevelopment() bool { + return strings.EqualFold(c.Environment, "development") +} + +func getEnv(key, fallback string) string { + if value, exists := os.LookupEnv(key); exists { + return value + } + + return fallback +} + +func getDurationEnv(key string, fallback time.Duration) time.Duration { + value, exists := os.LookupEnv(key) + if !exists { + return fallback + } + + parsed, err := time.ParseDuration(value) + if err != nil { + return fallback + } + + return parsed +} diff --git a/Backend/internal/database/postgres.go b/Backend/internal/database/postgres.go new file mode 100644 index 0000000..0d616ce --- /dev/null +++ b/Backend/internal/database/postgres.go @@ -0,0 +1,95 @@ +// Path: Backend/internal/database/postgres.go + +package database + +import ( + "context" + "database/sql" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/jackc/pgx/v5/stdlib" + "github.com/pressly/goose/v3" + + "moku-backend/db" +) + +type DB struct { + Pool *pgxpool.Pool +} + +func NewPostgres(databaseURL string) (*DB, error) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + config, err := pgxpool.ParseConfig(databaseURL) + if err != nil { + return nil, err + } + + config.MaxConns = 25 + config.MinConns = 5 + config.MaxConnLifetime = time.Hour + config.MaxConnIdleTime = 30 * time.Minute + + pool, err := pgxpool.NewWithConfig(ctx, config) + if err != nil { + return nil, err + } + + if err := pool.Ping(ctx); err != nil { + pool.Close() + return nil, err + } + + return &DB{Pool: pool}, nil +} + +func (d *DB) MigrateUp() error { + return d.runGoose(goose.Up) +} + +func (d *DB) MigrateDown() error { + return d.runGoose(goose.Down) +} + +func (d *DB) MigrateReset() error { + return d.runGoose(goose.Reset) +} + +func (d *DB) MigrateStatus() error { + return d.runGoose(goose.Status) +} + +func (d *DB) runGoose(command func(*sql.DB, string, ...goose.OptionsFunc) error) error { + if d == nil || d.Pool == nil { + return nil + } + + sqlDB := stdlib.OpenDBFromPool(d.Pool) + defer sqlDB.Close() + + goose.SetBaseFS(db.Migrations) + + if err := goose.SetDialect("postgres"); err != nil { + return err + } + + return command(sqlDB, "migrations") +} + +func (d *DB) Health(ctx context.Context) error { + if d == nil || d.Pool == nil { + return nil + } + + return d.Pool.Ping(ctx) +} + +func (d *DB) Close() { + if d == nil || d.Pool == nil { + return + } + + d.Pool.Close() +} diff --git a/Backend/internal/httpx/api_routes.go b/Backend/internal/httpx/api_routes.go new file mode 100644 index 0000000..c99d72f --- /dev/null +++ b/Backend/internal/httpx/api_routes.go @@ -0,0 +1,53 @@ +// Path: Backend/internal/httpx/api_routes.go + +package httpx + +import ( + "net/http" + + "github.com/go-chi/chi/v5" +) + +type apiRoutes struct { + cfg RouterConfig +} + +func newAPIRoutes(cfg RouterConfig) routeRegistrar { + return apiRoutes{cfg: cfg} +} + +func (routes apiRoutes) Register(router chi.Router) { + router.Route("/v1", func(apiRouter chi.Router) { + apiRouter.Get("/", routes.handleIndex) + apiRouter.Get("/organizations", routes.handleOrganizations) + apiRouter.Get("/workspaces", routes.handleWorkspaces) + }) +} + +func (routes apiRoutes) handleIndex(w http.ResponseWriter, _ *http.Request) { + WriteJSON(w, http.StatusOK, map[string]any{ + "service": routes.cfg.ServiceName, + "version": "v1", + "status": "scaffolded", + }) +} + +func (routes apiRoutes) handleOrganizations(w http.ResponseWriter, _ *http.Request) { + WriteJSON(w, http.StatusOK, map[string]any{ + "data": []any{}, + "meta": map[string]any{ + "resource": "organizations", + "count": 0, + }, + }) +} + +func (routes apiRoutes) handleWorkspaces(w http.ResponseWriter, _ *http.Request) { + WriteJSON(w, http.StatusOK, map[string]any{ + "data": []any{}, + "meta": map[string]any{ + "resource": "workspaces", + "count": 0, + }, + }) +} diff --git a/Backend/internal/httpx/middleware.go b/Backend/internal/httpx/middleware.go new file mode 100644 index 0000000..6516623 --- /dev/null +++ b/Backend/internal/httpx/middleware.go @@ -0,0 +1,80 @@ +// Path: Backend/internal/httpx/middleware.go + +package httpx + +import ( + "context" + "log/slog" + "net/http" + "time" + + "github.com/google/uuid" +) + +type contextKey string + +const requestIDContextKey contextKey = "requestID" + +type statusRecorder struct { + http.ResponseWriter + statusCode int +} + +func (r *statusRecorder) WriteHeader(statusCode int) { + r.statusCode = statusCode + r.ResponseWriter.WriteHeader(statusCode) +} + +func RequestID(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestID := uuid.NewString() + ctx := context.WithValue(r.Context(), requestIDContextKey, requestID) + w.Header().Set("X-Request-ID", requestID) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +func Recoverer(logger *slog.Logger) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if recovered := recover(); recovered != nil { + requestID := RequestIDFromContext(r.Context()) + logger.Error("panic recovered", "request_id", requestID, "panic", recovered, "path", r.URL.Path) + WriteError(w, http.StatusInternalServerError, requestID, "internal_error", "An unexpected error occurred.") + } + }() + + next.ServeHTTP(w, r) + }) + } +} + +func RequestLogger(logger *slog.Logger) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + startedAt := time.Now() + recorder := &statusRecorder{ResponseWriter: w, statusCode: http.StatusOK} + + next.ServeHTTP(recorder, r) + + logger.Info( + "http request", + "request_id", RequestIDFromContext(r.Context()), + "method", r.Method, + "path", r.URL.Path, + "status", recorder.statusCode, + "duration", time.Since(startedAt).String(), + ) + }) + } +} + +func RequestIDFromContext(ctx context.Context) string { + requestID, ok := ctx.Value(requestIDContextKey).(string) + if !ok { + return "" + } + + return requestID +} diff --git a/Backend/internal/httpx/response.go b/Backend/internal/httpx/response.go new file mode 100644 index 0000000..cb6d0a1 --- /dev/null +++ b/Backend/internal/httpx/response.go @@ -0,0 +1,33 @@ +// Path: Backend/internal/httpx/response.go + +package httpx + +import ( + "encoding/json" + "net/http" +) + +type ErrorResponse struct { + Error string `json:"error"` + Message string `json:"message"` + RequestID string `json:"requestId,omitempty"` +} + +func WriteJSON(w http.ResponseWriter, status int, payload any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + + if payload == nil { + return + } + + _ = json.NewEncoder(w).Encode(payload) +} + +func WriteError(w http.ResponseWriter, status int, requestID, code, message string) { + WriteJSON(w, status, ErrorResponse{ + Error: code, + Message: message, + RequestID: requestID, + }) +} diff --git a/Backend/internal/httpx/router.go b/Backend/internal/httpx/router.go new file mode 100644 index 0000000..9f20d43 --- /dev/null +++ b/Backend/internal/httpx/router.go @@ -0,0 +1,70 @@ +// Path: Backend/internal/httpx/router.go + +package httpx + +import ( + "log/slog" + "net/http" + "strings" + + "github.com/go-chi/chi/v5" + chimiddleware "github.com/go-chi/chi/v5/middleware" + + "moku-backend/internal/buildinfo" + "moku-backend/internal/cache" + "moku-backend/internal/config" + "moku-backend/internal/database" +) + +type RouterConfig struct { + ServiceName string + Config *config.Config + Logger *slog.Logger + BuildInfo buildinfo.Info + Database *database.DB + Cache *cache.Client +} + +type routeRegistrar interface { + Register(chi.Router) +} + +func NewRouter(cfg RouterConfig) http.Handler { + router := chi.NewRouter() + + registerMiddleware(router, cfg) + registerRoutes(router, routesForService(cfg)...) + router.NotFound(notFoundHandler) + + return router +} + +func registerMiddleware(router chi.Router, cfg RouterConfig) { + router.Use(chimiddleware.RealIP) + router.Use(RequestID) + router.Use(Recoverer(cfg.Logger)) + router.Use(RequestLogger(cfg.Logger)) +} + +func registerRoutes(router chi.Router, registrars ...routeRegistrar) { + for _, registrar := range registrars { + registrar.Register(router) + } +} + +func routesForService(cfg RouterConfig) []routeRegistrar { + registrars := []routeRegistrar{newSharedRoutes(cfg)} + + switch strings.ToLower(cfg.ServiceName) { + case "web": + registrars = append(registrars, newWebRoutes(cfg)) + case "api": + registrars = append(registrars, newAPIRoutes(cfg)) + } + + return registrars +} + +func notFoundHandler(w http.ResponseWriter, r *http.Request) { + WriteError(w, http.StatusNotFound, RequestIDFromContext(r.Context()), "not_found", "The requested endpoint does not exist.") +} diff --git a/Backend/internal/httpx/shared_routes.go b/Backend/internal/httpx/shared_routes.go new file mode 100644 index 0000000..335d434 --- /dev/null +++ b/Backend/internal/httpx/shared_routes.go @@ -0,0 +1,69 @@ +// Path: Backend/internal/httpx/shared_routes.go + +package httpx + +import ( + "context" + "net/http" + "time" + + "github.com/go-chi/chi/v5" +) + +type sharedRoutes struct { + cfg RouterConfig +} + +func newSharedRoutes(cfg RouterConfig) routeRegistrar { + return sharedRoutes{cfg: cfg} +} + +func (routes sharedRoutes) Register(router chi.Router) { + router.Get("/", routes.handleIndex) + router.Get("/health", routes.handleHealth) + router.Get("/version", routes.handleVersion) +} + +func (routes sharedRoutes) handleIndex(w http.ResponseWriter, _ *http.Request) { + WriteJSON(w, http.StatusOK, map[string]string{ + "service": routes.cfg.ServiceName, + "status": "ok", + }) +} + +func (routes sharedRoutes) handleHealth(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) + defer cancel() + + databaseStatus := "ok" + if err := routes.cfg.Database.Health(ctx); err != nil { + databaseStatus = err.Error() + } + + cacheStatus := "ok" + if err := routes.cfg.Cache.Health(ctx); err != nil { + cacheStatus = err.Error() + } + + statusCode := http.StatusOK + if databaseStatus != "ok" || cacheStatus != "ok" { + statusCode = http.StatusServiceUnavailable + } + + WriteJSON(w, statusCode, map[string]any{ + "service": routes.cfg.ServiceName, + "status": map[string]string{ + "database": databaseStatus, + "cache": cacheStatus, + }, + }) +} + +func (routes sharedRoutes) handleVersion(w http.ResponseWriter, _ *http.Request) { + WriteJSON(w, http.StatusOK, map[string]any{ + "service": routes.cfg.ServiceName, + "app": routes.cfg.Config.AppName, + "environment": routes.cfg.Config.Environment, + "build": routes.cfg.BuildInfo, + }) +} diff --git a/Backend/internal/httpx/web_routes.go b/Backend/internal/httpx/web_routes.go new file mode 100644 index 0000000..68eef49 --- /dev/null +++ b/Backend/internal/httpx/web_routes.go @@ -0,0 +1,51 @@ +// Path: Backend/internal/httpx/web_routes.go + +package httpx + +import ( + "net/http" + + "github.com/go-chi/chi/v5" +) + +type webRoutes struct { + cfg RouterConfig +} + +func newWebRoutes(cfg RouterConfig) routeRegistrar { + return webRoutes{cfg: cfg} +} + +func (routes webRoutes) Register(router chi.Router) { + router.Get("/session", routes.handleSession) + router.Get("/bootstrap", routes.handleBootstrap) + router.Get("/me", routes.handleCurrentUser) +} + +func (routes webRoutes) handleSession(w http.ResponseWriter, _ *http.Request) { + WriteJSON(w, http.StatusOK, map[string]any{ + "service": routes.cfg.ServiceName, + "session": map[string]any{ + "authenticated": false, + "mode": "cookie", + }, + }) +} + +func (routes webRoutes) handleBootstrap(w http.ResponseWriter, _ *http.Request) { + WriteJSON(w, http.StatusOK, map[string]any{ + "service": routes.cfg.ServiceName, + "app": map[string]any{ + "name": routes.cfg.Config.AppName, + "environment": routes.cfg.Config.Environment, + }, + "features": map[string]bool{ + "auth": false, + "workspaces": false, + }, + }) +} + +func (routes webRoutes) handleCurrentUser(w http.ResponseWriter, r *http.Request) { + WriteError(w, http.StatusNotImplemented, RequestIDFromContext(r.Context()), "not_implemented", "The current user endpoint is scaffolded but not implemented yet.") +} diff --git a/Backend/internal/process/process.go b/Backend/internal/process/process.go new file mode 100644 index 0000000..238e09d --- /dev/null +++ b/Backend/internal/process/process.go @@ -0,0 +1,65 @@ +// Path: Backend/internal/process/process.go + +package process + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" +) + +func RunHTTPServer(serviceName, address string, handler http.Handler, logger *slog.Logger, shutdownTimeout time.Duration) error { + server := &http.Server{ + Addr: address, + Handler: handler, + ReadHeaderTimeout: 5 * time.Second, + } + + serverErr := make(chan error, 1) + + go func() { + logger.Info("http server starting", "service", serviceName, "address", address) + serverErr <- server.ListenAndServe() + }() + + signalCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + select { + case err := <-serverErr: + if err != nil && !errors.Is(err, http.ErrServerClosed) { + return err + } + return nil + case <-signalCtx.Done(): + logger.Info("shutdown requested", "service", serviceName) + } + + shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) + defer cancel() + + if err := server.Shutdown(shutdownCtx); err != nil { + return fmt.Errorf("shutdown %s server: %w", serviceName, err) + } + + logger.Info("http server stopped", "service", serviceName) + + return nil +} + +func WaitForShutdown(serviceName string, logger *slog.Logger) error { + signalCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + logger.Info("process running", "service", serviceName) + <-signalCtx.Done() + logger.Info("shutdown requested", "service", serviceName) + + return nil +} diff --git a/Backend/sqlc.yaml b/Backend/sqlc.yaml new file mode 100644 index 0000000..a1ab17d --- /dev/null +++ b/Backend/sqlc.yaml @@ -0,0 +1,12 @@ +version: "2" +sql: + - engine: "postgresql" + schema: "db/migrations" + queries: "db/queries" + gen: + go: + package: "sqlc" + out: "internal/sqlc" + sql_package: "pgx/v5" + emit_json_tags: true + emit_empty_slices: true diff --git a/Commands/Local/Dev/backend.just b/Commands/Local/Dev/backend.just new file mode 100644 index 0000000..fcad40d --- /dev/null +++ b/Commands/Local/Dev/backend.just @@ -0,0 +1,26 @@ +project_root := justfile_directory() +backend_dir := project_root + "/Backend" + +# Apply embedded database migrations. +migrate-up: + cd '{{backend_dir}}' && go run ./cmd/migrate up + +# Roll back the most recent embedded database migration. +migrate-down: + cd '{{backend_dir}}' && go run ./cmd/migrate down + +# Reset all embedded database migrations and reapply from scratch. +migrate-reset: + cd '{{backend_dir}}' && go run ./cmd/migrate reset + +# Show the embedded database migration status. +migrate-status: + cd '{{backend_dir}}' && go run ./cmd/migrate status + +# Format backend Go source files. +fmt: + cd '{{backend_dir}}' && gofmt -w ./cmd ./db ./internal + +# Run backend test suite. +test: + cd '{{backend_dir}}' && go test ./... diff --git a/Commands/Local/Dev/frontend.just b/Commands/Local/Dev/frontend.just new file mode 100644 index 0000000..46ba1b6 --- /dev/null +++ b/Commands/Local/Dev/frontend.just @@ -0,0 +1,14 @@ +project_root := justfile_directory() +local_compose := project_root + "/Docker/docker-compose.local.dev.yaml" +frontend_dir := project_root + "/Frontend" +node_modules_volume := "moku_work_frontend_node_modules" + +# Recreate the frontend node_modules Docker volume. +node_modules: + docker compose -f '{{local_compose}}' rm -sf frontend >/dev/null 2>&1 || true + docker volume rm -f '{{node_modules_volume}}' >/dev/null 2>&1 || true + docker compose -f '{{local_compose}}' up -d --remove-orphans --force-recreate frontend + +# Run the frontend TypeScript check. +tsc: + cd '{{frontend_dir}}' && pnpm typecheck diff --git a/Commands/Local/Dev/mod.just b/Commands/Local/Dev/mod.just new file mode 100644 index 0000000..a4f0add --- /dev/null +++ b/Commands/Local/Dev/mod.just @@ -0,0 +1,39 @@ +stack_runner := justfile_directory() + "/Commands/Local/Dev/scripts/dev-stack.sh" + +mod frontend +mod backend + +# Build the combined local development stack assets. +build: + bash '{{stack_runner}}' build + +# Start the full local development stack in the background. +up: + bash '{{stack_runner}}' up + +# Build first, then start the full local development stack in the background. +start: + bash '{{stack_runner}}' start + +# Alias for the main full local development flow. +dev: up + +# Stop and remove the local development stack. +down: + bash '{{stack_runner}}' down + +# Rebuild the full local development stack. +rebuild: + bash '{{stack_runner}}' rebuild + +# Follow logs for the full local development stack. +logs: + bash '{{stack_runner}}' logs + +# Restart the full local development stack. +restart: + bash '{{stack_runner}}' restart + +# Stop the local development stack and remove local images, volumes, and backend dev state. +clean: + bash '{{stack_runner}}' clean diff --git a/Commands/Local/Dev/scripts/backend-stack.sh b/Commands/Local/Dev/scripts/backend-stack.sh new file mode 100644 index 0000000..3d1ed09 --- /dev/null +++ b/Commands/Local/Dev/scripts/backend-stack.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash + +# Path: Commands/Local/Dev/scripts/backend-stack.sh +set -euo pipefail + +action=${1:-up} + +script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +project_root=$(cd -- "$script_dir/../../../.." && pwd) +backend_dir="$project_root/Backend" +backend_bake="$backend_dir/docker-bake.hcl" +env_dir="$project_root/Env" +compose_file="$project_root/Docker/docker-compose.local.dev.yaml" +runtime_dir="$backend_dir/tmp/dev" +backend_image="moku/work-backend:dev" +backend_go_pkg_volume="moku_work_backend_go_pkg" +backend_go_build_volume="moku_work_backend_go_build" + +services=(web api worker) + +source "$script_dir/docker.sh" +source "$script_dir/env.sh" + +build_backend() { + cd "$backend_dir" + docker buildx bake -f "$backend_bake" dev +} + +up_backend() { + docker compose -f "$compose_file" up -d --remove-orphans --force-recreate "${services[@]}" +} + +down_backend() { + docker compose -f "$compose_file" stop "${services[@]}" >/dev/null 2>&1 || true + docker compose -f "$compose_file" rm -f "${services[@]}" >/dev/null 2>&1 || true +} + +restart_backend() { + docker compose -f "$compose_file" restart "${services[@]}" +} + +follow_logs() { + docker compose -f "$compose_file" logs -f "${services[@]}" +} + +clean_runtime() { + rm -rf "$runtime_dir" +} + +case "$action" in + check) + ensure_docker 'docker is required for the local backend dev runtime. Install Docker first.' + ;; + build) + ensure_docker 'docker is required for the local backend dev runtime. Install Docker first.' + build_backend + ;; + up) + ensure_docker 'docker is required for the local backend dev runtime. Install Docker first.' + ensure_local_env_file "$env_dir" + up_backend + ;; + down) + ensure_docker 'docker is required for the local backend dev runtime. Install Docker first.' + ensure_local_env_file "$env_dir" + down_backend + ;; + restart) + ensure_docker 'docker is required for the local backend dev runtime. Install Docker first.' + ensure_local_env_file "$env_dir" + restart_backend + ;; + logs) + ensure_docker 'docker is required for the local backend dev runtime. Install Docker first.' + ensure_local_env_file "$env_dir" + follow_logs + ;; + clean) + ensure_docker 'docker is required for the local backend dev runtime. Install Docker first.' + ensure_local_env_file "$env_dir" + down_backend + remove_docker_image_if_present "$backend_image" + remove_docker_volume_if_present "$backend_go_pkg_volume" + remove_docker_volume_if_present "$backend_go_build_volume" + clean_runtime + ;; + *) + printf 'Unsupported backend stack action: %s\n' "$action" >&2 + exit 1 + ;; +esac diff --git a/Commands/Local/Dev/scripts/dev-stack.sh b/Commands/Local/Dev/scripts/dev-stack.sh new file mode 100644 index 0000000..5f3e7a4 --- /dev/null +++ b/Commands/Local/Dev/scripts/dev-stack.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash + +# Path: Commands/Local/Dev/scripts/dev-stack.sh +set -euo pipefail + +action=${1:-up} + +script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +project_root=$(cd -- "$script_dir/../../../.." && pwd) +frontend_dir="$project_root/Frontend" +frontend_bake="$frontend_dir/docker-bake.hcl" +backend_dir="$project_root/Backend" +backend_bake="$backend_dir/docker-bake.hcl" +env_dir="$project_root/Env" +compose_file="$project_root/Docker/docker-compose.local.dev.yaml" +frontend_image="moku/work-frontend:dev" +backend_image="moku/work-backend:dev" +frontend_volume="moku_work_frontend_node_modules" +backend_go_pkg_volume="moku_work_backend_go_pkg" +backend_go_build_volume="moku_work_backend_go_build" +backend_runtime_dir="$backend_dir/tmp/dev" + +source "$script_dir/docker.sh" +source "$script_dir/env.sh" + +build_frontend() { + cd "$frontend_dir" + docker buildx bake -f "$frontend_bake" dev +} + +build_backend() { + cd "$backend_dir" + docker buildx bake -f "$backend_bake" dev +} + +build_images() { + build_frontend + build_backend +} + +up_stack() { + docker compose -f "$compose_file" up -d --remove-orphans --force-recreate +} + +down_stack() { + docker compose -f "$compose_file" down --remove-orphans --volumes +} + +follow_logs() { + docker compose -f "$compose_file" logs -f +} + +clean_stack() { + docker compose -f "$compose_file" down --remove-orphans --volumes >/dev/null 2>&1 || true + remove_docker_image_if_present "$frontend_image" + remove_docker_image_if_present "$backend_image" + remove_docker_volume_if_present "$frontend_volume" + remove_docker_volume_if_present "$backend_go_pkg_volume" + remove_docker_volume_if_present "$backend_go_build_volume" + rm -rf "$backend_runtime_dir" +} + +start_stack() { + build_images + up_stack +} + +case "$action" in + build) + ensure_docker 'docker is required for the local development stack. Install Docker first.' + build_images + ;; + up|start|rebuild) + ensure_docker 'docker is required for the local development stack. Install Docker first.' + ensure_local_env_file "$env_dir" + start_stack + ;; + down) + ensure_docker 'docker is required for the local development stack. Install Docker first.' + ensure_local_env_file "$env_dir" + down_stack + ;; + restart) + ensure_docker 'docker is required for the local development stack. Install Docker first.' + ensure_local_env_file "$env_dir" + docker compose -f "$compose_file" restart + ;; + logs) + ensure_docker 'docker is required for the local development stack. Install Docker first.' + ensure_local_env_file "$env_dir" + follow_logs + ;; + clean) + ensure_docker 'docker is required for the local development stack. Install Docker first.' + ensure_local_env_file "$env_dir" + clean_stack + ;; + *) + printf 'Unsupported dev stack action: %s\n' "$action" >&2 + exit 1 + ;; +esac diff --git a/Commands/Local/Dev/scripts/docker.sh b/Commands/Local/Dev/scripts/docker.sh new file mode 100644 index 0000000..d7e21c4 --- /dev/null +++ b/Commands/Local/Dev/scripts/docker.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ensure_docker() { + local error_message=${1:-docker is required. Install Docker first.} + + if ! command -v docker >/dev/null 2>&1; then + printf '%s\n' "$error_message" >&2 + exit 1 + fi +} + +remove_docker_image_if_present() { + docker image rm -f "$1" >/dev/null 2>&1 || true +} + +remove_docker_volume_if_present() { + docker volume rm -f "$1" >/dev/null 2>&1 || true +} diff --git a/Commands/Local/Dev/scripts/env.sh b/Commands/Local/Dev/scripts/env.sh new file mode 100644 index 0000000..f21e36f --- /dev/null +++ b/Commands/Local/Dev/scripts/env.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ensure_local_env_file() { + local env_dir=$1 + local example_env_file="$env_dir/.env.example" + local local_env_file="$env_dir/.env.local" + + if [[ -f "$local_env_file" ]]; then + return + fi + + if [[ ! -f "$example_env_file" ]]; then + printf 'Missing env template: %s\n' "$example_env_file" >&2 + exit 1 + fi + + cp "$example_env_file" "$local_env_file" + printf 'Created %s from %s\n' "$local_env_file" "$example_env_file" +} diff --git a/Commands/Local/dev.just b/Commands/Local/dev.just deleted file mode 100644 index 2f363cc..0000000 --- a/Commands/Local/dev.just +++ /dev/null @@ -1,36 +0,0 @@ -project_root := justfile_directory() -frontend_dir := project_root + "/Frontend" -frontend_bake := project_root + "/Frontend/docker-bake.hcl" -local_compose := project_root + "/Docker/docker-compose.local.dev.yaml" - -# Build the Frontend development image. -build: - cd '{{frontend_dir}}' && docker buildx bake -f '{{frontend_bake}}' dev - -# Start the local development stack in the background using the current image. -up: - docker compose -f '{{local_compose}}' up -d --remove-orphans --force-recreate - -# Build first, then start the local development stack in the background. -start: build - docker compose -f '{{local_compose}}' up -d --remove-orphans --force-recreate - -# Alias for the main local development flow. -dev: start - -# Stop and remove the local development stack. -down: - docker compose -f '{{local_compose}}' down --remove-orphans --volumes - -# Rebuild the Frontend development image and recreate the stack. -rebuild: - cd '{{frontend_dir}}' && docker buildx bake -f '{{frontend_bake}}' dev - docker compose -f '{{local_compose}}' up -d --remove-orphans --force-recreate - -# Follow logs for the local development stack. -logs: - docker compose -f '{{local_compose}}' logs -f - -# Restart the local development stack. -restart: - docker compose -f '{{local_compose}}' restart diff --git a/Commands/Local/mod.just b/Commands/Local/mod.just index 296865a..fa8ae0f 100644 --- a/Commands/Local/mod.just +++ b/Commands/Local/mod.just @@ -1,2 +1,2 @@ -mod dev +mod dev "Dev" mod prod diff --git a/Commands/Local/prod.just b/Commands/Local/prod.just index 1bb5842..03a6819 100644 --- a/Commands/Local/prod.just +++ b/Commands/Local/prod.just @@ -1,32 +1,37 @@ project_root := justfile_directory() proxy_bake := project_root + "/Proxy/docker-bake.hcl" local_compose := project_root + "/Docker/docker-compose.local.prod.yaml" +proxy_image := "moku/work-proxy:local-prod" # Build the local production proxy image locally. build: - cd '{{project_root}}' && docker buildx bake -f '{{proxy_bake}}' prod + cd '{{project_root}}' && docker buildx bake -f '{{proxy_bake}}' prod # Start the local production stack in the background using the current image. up: - docker compose -f '{{local_compose}}' up -d --remove-orphans --force-recreate + docker compose -f '{{local_compose}}' up -d --remove-orphans --force-recreate # Build first, then start the local production stack in the background. -start: build - docker compose -f '{{local_compose}}' up -d --remove-orphans --force-recreate +start: build up # Rebuild the local production proxy image locally. rebuild: - cd '{{project_root}}' && docker buildx bake -f '{{proxy_bake}}' --set '*.no-cache=true' prod - docker compose -f '{{local_compose}}' up -d --remove-orphans --force-recreate + cd '{{project_root}}' && docker buildx bake -f '{{proxy_bake}}' --set '*.no-cache=true' prod + docker compose -f '{{local_compose}}' up -d --remove-orphans --force-recreate # Stop and remove the local production stack. down: - docker compose -f '{{local_compose}}' down --remove-orphans --volumes + docker compose -f '{{local_compose}}' down --remove-orphans --volumes # Follow logs for the local production stack. logs: - docker compose -f '{{local_compose}}' logs -f + docker compose -f '{{local_compose}}' logs -f # Restart the local production stack. restart: - docker compose -f '{{local_compose}}' restart + docker compose -f '{{local_compose}}' restart + +# Stop the local production stack and remove local images. +clean: + docker compose -f '{{local_compose}}' down --remove-orphans --volumes + docker image rm -f '{{proxy_image}}' >/dev/null 2>&1 || true diff --git a/Commands/Prod/.gitkeep b/Commands/Prod/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/Docker/docker-compose.local.dev.yaml b/Docker/docker-compose.local.dev.yaml index 8470fad..c504edb 100644 --- a/Docker/docker-compose.local.dev.yaml +++ b/Docker/docker-compose.local.dev.yaml @@ -1,15 +1,95 @@ +x-backend-service: &backend-service + image: moku/work-backend:dev + restart: unless-stopped + env_file: + - ../Env/.env.local + environment: + DATABASE_URL: postgres://moku:moku_dev_password@postgres:5432/moku?sslmode=disable + VALKEY_URL: redis://valkey:6379/0 + depends_on: + postgres: + condition: service_healthy + valkey: + condition: service_healthy + volumes: + - ../Backend:/app + - moku_work_backend_go_pkg:/go/pkg/mod + - moku_work_backend_go_build:/root/.cache/go-build + services: - frontend: - image: moku/work-frontend:dev - container_name: moku-work-frontend - restart: unless-stopped - env_file: - - ../Env/.env.local - ports: - - "3333:3333" - volumes: - - ../Frontend:/app - - moku_work_frontend_node_modules:/app/node_modules + postgres: + image: postgres:17-alpine + container_name: moku-work-postgres + restart: unless-stopped + environment: + POSTGRES_DB: moku + POSTGRES_USER: moku + POSTGRES_PASSWORD: moku_dev_password + ports: + - "5432:5432" + volumes: + - moku_work_postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U moku -d moku"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + + valkey: + image: valkey/valkey:8-alpine + container_name: moku-work-valkey + restart: unless-stopped + ports: + - "6379:6379" + volumes: + - moku_work_valkey_data:/data + healthcheck: + test: ["CMD", "valkey-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 5s + + frontend: + image: moku/work-frontend:dev + container_name: moku-work-frontend + restart: unless-stopped + env_file: + - ../Env/.env.local + depends_on: + postgres: + condition: service_healthy + valkey: + condition: service_healthy + ports: + - "3333:3333" + volumes: + - ../Frontend:/app + - moku_work_frontend_node_modules:/app/node_modules + + web: + <<: *backend-service + container_name: moku-work-backend-web + command: ["air", "-c", ".air.web.toml"] + ports: + - "8080:8080" + + api: + <<: *backend-service + container_name: moku-work-backend-api + command: ["air", "-c", ".air.api.toml"] + ports: + - "8081:8081" + + worker: + <<: *backend-service + container_name: moku-work-backend-worker + command: ["air", "-c", ".air.worker.toml"] volumes: - moku_work_frontend_node_modules: + moku_work_postgres_data: + moku_work_valkey_data: + moku_work_frontend_node_modules: + moku_work_backend_go_pkg: + moku_work_backend_go_build: diff --git a/Documentation/CONTRIBUTING b/Documentation/CONTRIBUTING index 10447e4..1210a82 100644 --- a/Documentation/CONTRIBUTING +++ b/Documentation/CONTRIBUTING @@ -9,8 +9,8 @@ This project is still in an early scaffold stage, so the goal is to keep changes ### Project structure - `Frontend/` — SolidStart frontend workspace -- `Backend/` — backend placeholder -- `Proxy/` — proxy placeholder +- `Backend/` — Go backend services (`web`, `api`, `worker`) +- `Proxy/` — local production proxy/runtime assets - `Docker/` — local Docker Compose files - `Env/` — local environment files - `Commands/` — Just command modules and entrypoints @@ -39,7 +39,8 @@ Main local development flow: just local dev ``` -This command builds the frontend development image and starts the local development stack. +This command builds the frontend and backend development images, then starts the +local development stack for Postgres, Valkey, frontend, and backend services. ### Local environment @@ -49,7 +50,16 @@ Local development uses: Env/.env.local ``` -If local environment values are missing, create or update that file before starting the stack. +The template lives at: + +```bash +Env/.env.example +``` + +When you start the local Docker stack, the dev scripts will create `Env/.env.local` +from `Env/.env.example` automatically if it does not already exist. + +If you need custom local values, edit `Env/.env.local` after it is created. ## Commit Naming Convention diff --git a/Env/.env.example b/Env/.env.example new file mode 100644 index 0000000..0420468 --- /dev/null +++ b/Env/.env.example @@ -0,0 +1,13 @@ +ALLOWED_HOSTS= + +APP_NAME=moku +GO_ENV=development +LOG_LEVEL=debug + +BACKEND_WEB_PORT=8080 +BACKEND_API_PORT=8081 +BACKEND_WORKER_PORT=8082 +BACKEND_SHUTDOWN_TIMEOUT=10s + +DATABASE_URL=postgres://moku:moku_dev_password@localhost:5432/moku?sslmode=disable +VALKEY_URL=redis://localhost:6379/0 diff --git a/Justfile b/Justfile index 9b2d2d6..1aff8b2 100644 --- a/Justfile +++ b/Justfile @@ -4,4 +4,4 @@ mod local "Commands/Local" [default] help: - just --list --list-submodules + just --list --list-submodules