From 8282d60c69a0c288fb7a30998116db17de0311d2 Mon Sep 17 00:00:00 2001 From: jordan Date: Sat, 31 Jan 2026 19:11:42 -0700 Subject: [PATCH] feat: implement composable monorepo template system with component architecture Adds the composable monorepo template system that generates project skeletons with pluggable components (service, worker, app-react, app-astro, cli). Key changes: - Monorepo skeleton templates with shared pkg/, scripts/, and git hooks - Component templates (service, worker, app-react, app-astro, cli) with Dockerfiles, CI steps, and component.yaml manifests - Component domain model with validation and dependency resolution - Component handler endpoints for CRUD and composition - Template provider extended with BuildComposableProject and component assembly - Deployer extended with composable project deployment support - Handler timeout constants (TimeoutFastLookup through TimeoutLongRunning) - envutil package for centralized env var reads with defaults - api.DecodeJSON helper for standardized request body decoding - Standardized response helpers (WriteBadRequest, WriteNotFound, etc.) - Replaced fullstack-app cookbook with composable-app cookbook - Hardened handler timeouts, logging, and error responses across all handlers Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 7 + ai-lookup/features/composable-monorepo.md | 7 + cmd/rdev-api/config.go | 87 +-- cmd/rdev-api/main.go | 103 +--- cookbooks/composable-app.md | 423 +++++++++++++ cookbooks/fullstack-app.md | 383 ------------ cookbooks/landing-page.md | 211 ++++--- cookbooks/scripts/composable-test.sh | 194 ++++++ cookbooks/scripts/fullstack-test.sh | 202 ------- cookbooks/scripts/landing-test.sh | 565 ++++-------------- .../adapter/deployer/deployer_components.go | 303 ++++++++++ internal/adapter/deployer/resources.go | 83 ++- internal/adapter/gitea/bulk_files.go | 55 +- internal/adapter/templates/provider.go | 238 ++++++++ internal/adapter/templates/provider_test.go | 45 ++ .../app-astro/.woodpecker.step.yml.tmpl | 18 + .../components/app-astro/Dockerfile.tmpl | 27 + .../app-astro/astro.config.mjs.tmpl | 10 + .../components/app-astro/component.yaml.tmpl | 6 + .../components/app-astro/nginx.conf.tmpl | 26 + .../components/app-astro/package.json.tmpl | 23 + .../components/app-astro/public/favicon.svg | 4 + .../app-astro/src/layouts/Layout.astro.tmpl | 25 + .../app-astro/src/lib/logger.ts.tmpl | 13 + .../app-astro/src/pages/index.astro.tmpl | 37 ++ .../app-astro/tailwind.config.mjs.tmpl | 8 + .../app-react/.woodpecker.step.yml.tmpl | 18 + .../components/app-react/Dockerfile.tmpl | 27 + .../components/app-react/component.yaml.tmpl | 6 + .../components/app-react/index.html.tmpl | 13 + .../components/app-react/nginx.conf.tmpl | 26 + .../components/app-react/package.json.tmpl | 34 ++ .../components/app-react/postcss.config.js | 6 + .../components/app-react/public/vite.svg | 1 + .../components/app-react/src/App.tsx.tmpl | 46 ++ .../components/app-react/src/index.css | 17 + .../app-react/src/lib/logger.ts.tmpl | 11 + .../components/app-react/src/main.tsx.tmpl | 11 + .../components/app-react/src/vite-env.d.ts | 1 + .../components/app-react/tailwind.config.js | 8 + .../components/app-react/tsconfig.json | 25 + .../components/app-react/tsconfig.node.json | 11 + .../components/app-react/vite.config.ts.tmpl | 13 + .../components/cli/.woodpecker.step.yml.tmpl | 16 + .../templates/components/cli/Makefile.tmpl | 46 ++ .../templates/components/cli/cmd/main.go.tmpl | 14 + .../components/cli/component.yaml.tmpl | 4 + .../templates/components/cli/go.mod.tmpl | 31 + .../components/cli/internal/cmd/root.go.tmpl | 64 ++ .../cli/internal/cmd/version.go.tmpl | 28 + .../components/service/.env.example.tmpl | 17 + .../service/.woodpecker.step.yml.tmpl | 18 + .../components/service/Dockerfile.tmpl | 32 + .../components/service/Makefile.tmpl | 34 ++ .../service/cmd/server/main.go.tmpl | 18 + .../components/service/component.yaml.tmpl | 9 + .../templates/components/service/go.mod.tmpl | 5 + .../internal/api/handlers/health.go.tmpl | 26 + .../service/internal/api/routes.go.tmpl | 21 + .../service/internal/config/config.go.tmpl | 32 + .../components/service/migrations/.gitkeep | 0 .../components/worker/.env.example.tmpl | 21 + .../worker/.woodpecker.step.yml.tmpl | 18 + .../components/worker/Dockerfile.tmpl | 30 + .../templates/components/worker/Makefile.tmpl | 34 ++ .../components/worker/cmd/worker/main.go.tmpl | 54 ++ .../components/worker/component.yaml.tmpl | 8 + .../templates/components/worker/go.mod.tmpl | 5 + .../worker/internal/config/config.go.tmpl | 55 ++ .../worker/internal/handlers/handler.go.tmpl | 53 ++ .../templates/skeleton/.githooks/commit-msg | 56 ++ .../templates/skeleton/.githooks/pre-commit | 135 +++++ .../templates/templates/skeleton/.gitignore | 48 ++ .../templates/skeleton/.golangci.yml.tmpl | 25 + .../templates/skeleton/.woodpecker.yml.tmpl | 19 + .../templates/skeleton/CLAUDE.md.tmpl | 49 ++ .../templates/skeleton/Procfile.tmpl | 2 + .../templates/skeleton/README.md.tmpl | 55 ++ .../templates/skeleton/apps/.gitkeep | 1 + .../templates/templates/skeleton/cli/.gitkeep | 1 + .../skeleton/docker-compose.yml.tmpl | 24 + .../templates/templates/skeleton/go.work.tmpl | 4 + .../templates/skeleton/packages/.gitkeep | 0 .../skeleton/packages/logger/package.json | 15 + .../skeleton/packages/logger/src/handlers.ts | 35 ++ .../skeleton/packages/logger/src/index.ts | 3 + .../skeleton/packages/logger/src/logger.ts | 170 ++++++ .../skeleton/packages/logger/src/types.ts | 40 ++ .../skeleton/packages/logger/tsconfig.json | 14 + .../templates/skeleton/pkg/README.md | 286 +++++++++ .../templates/skeleton/pkg/app/app.go.tmpl | 297 +++++++++ .../skeleton/pkg/config/config.go.tmpl | 235 ++++++++ .../templates/skeleton/pkg/go.mod.tmpl | 39 ++ .../skeleton/pkg/httpclient/client.go.tmpl | 253 ++++++++ .../skeleton/pkg/httpcontext/keys.go.tmpl | 186 ++++++ .../pkg/httpresponse/envelope.go.tmpl | 75 +++ .../pkg/httpresponse/response.go.tmpl | 192 ++++++ .../pkg/httpvalidation/validator.go.tmpl | 308 ++++++++++ .../skeleton/pkg/logging/context.go.tmpl | 99 +++ .../skeleton/pkg/logging/logger.go.tmpl | 245 ++++++++ .../skeleton/pkg/logging/worker.go.tmpl | 37 ++ .../skeleton/pkg/middleware/cors.go.tmpl | 98 +++ .../skeleton/pkg/middleware/logger.go.tmpl | 105 ++++ .../skeleton/pkg/middleware/recovery.go.tmpl | 54 ++ .../pkg/middleware/request_id.go.tmpl | 75 +++ .../skeleton/pkg/middleware/tracing.go.tmpl | 74 +++ .../templates/skeleton/scripts/dev.sh.tmpl | 19 + .../skeleton/scripts/discover.sh.tmpl | 138 +++++ .../skeleton/scripts/install.sh.tmpl | 32 + .../skeleton/scripts/quality.sh.tmpl | 26 + .../skeleton/scripts/setup-hooks.sh.tmpl | 44 ++ .../templates/skeleton/services/.gitkeep | 1 + .../templates/skeleton/workers/.gitkeep | 1 + internal/auth/middleware.go | 11 +- internal/domain/component.go | 103 ++++ internal/domain/component_test.go | 176 ++++++ internal/domain/deployment.go | 69 ++- internal/domain/errors.go | 6 + internal/envutil/envutil.go | 50 ++ internal/handlers/agents.go | 3 +- internal/handlers/builds.go | 3 +- internal/handlers/claude_config.go | 12 +- internal/handlers/components.go | 216 +++++++ internal/handlers/components_test.go | 436 ++++++++++++++ internal/handlers/create_and_build.go | 18 +- internal/handlers/credentials.go | 17 +- internal/handlers/health.go | 2 +- internal/handlers/infrastructure.go | 17 +- internal/handlers/infrastructure_deploy.go | 113 ++-- internal/handlers/infrastructure_domains.go | 9 +- .../handlers/infrastructure_mocks_test.go | 289 +++++++++ internal/handlers/infrastructure_pipelines.go | 4 +- internal/handlers/infrastructure_test.go | 250 -------- internal/handlers/keys.go | 5 +- internal/handlers/project_management.go | 18 +- internal/handlers/projects.go | 4 +- internal/handlers/projects_commands.go | 9 +- internal/handlers/queue.go | 2 +- internal/handlers/timeouts.go | 40 ++ internal/handlers/webhooks.go | 5 +- internal/handlers/woodpecker_webhook.go | 7 +- internal/handlers/work.go | 20 +- internal/port/component.go | 28 + internal/port/deployer.go | 21 + internal/port/template_provider.go | 56 ++ internal/service/component.go | 499 ++++++++++++++++ internal/service/component_updates.go | 171 ++++++ internal/service/project_infra.go | 2 +- internal/service/project_infra_crud.go | 8 +- internal/telemetry/telemetry.go | 32 +- internal/telemetry/telemetry_test.go | 6 +- pkg/api/decode.go | 63 ++ pkg/api/response.go | 10 + 153 files changed, 8764 insertions(+), 1731 deletions(-) create mode 100644 cookbooks/composable-app.md delete mode 100644 cookbooks/fullstack-app.md create mode 100755 cookbooks/scripts/composable-test.sh delete mode 100755 cookbooks/scripts/fullstack-test.sh create mode 100644 internal/adapter/deployer/deployer_components.go create mode 100644 internal/adapter/templates/templates/components/app-astro/.woodpecker.step.yml.tmpl create mode 100644 internal/adapter/templates/templates/components/app-astro/Dockerfile.tmpl create mode 100644 internal/adapter/templates/templates/components/app-astro/astro.config.mjs.tmpl create mode 100644 internal/adapter/templates/templates/components/app-astro/component.yaml.tmpl create mode 100644 internal/adapter/templates/templates/components/app-astro/nginx.conf.tmpl create mode 100644 internal/adapter/templates/templates/components/app-astro/package.json.tmpl create mode 100644 internal/adapter/templates/templates/components/app-astro/public/favicon.svg create mode 100644 internal/adapter/templates/templates/components/app-astro/src/layouts/Layout.astro.tmpl create mode 100644 internal/adapter/templates/templates/components/app-astro/src/lib/logger.ts.tmpl create mode 100644 internal/adapter/templates/templates/components/app-astro/src/pages/index.astro.tmpl create mode 100644 internal/adapter/templates/templates/components/app-astro/tailwind.config.mjs.tmpl create mode 100644 internal/adapter/templates/templates/components/app-react/.woodpecker.step.yml.tmpl create mode 100644 internal/adapter/templates/templates/components/app-react/Dockerfile.tmpl create mode 100644 internal/adapter/templates/templates/components/app-react/component.yaml.tmpl create mode 100644 internal/adapter/templates/templates/components/app-react/index.html.tmpl create mode 100644 internal/adapter/templates/templates/components/app-react/nginx.conf.tmpl create mode 100644 internal/adapter/templates/templates/components/app-react/package.json.tmpl create mode 100644 internal/adapter/templates/templates/components/app-react/postcss.config.js create mode 100644 internal/adapter/templates/templates/components/app-react/public/vite.svg create mode 100644 internal/adapter/templates/templates/components/app-react/src/App.tsx.tmpl create mode 100644 internal/adapter/templates/templates/components/app-react/src/index.css create mode 100644 internal/adapter/templates/templates/components/app-react/src/lib/logger.ts.tmpl create mode 100644 internal/adapter/templates/templates/components/app-react/src/main.tsx.tmpl create mode 100644 internal/adapter/templates/templates/components/app-react/src/vite-env.d.ts create mode 100644 internal/adapter/templates/templates/components/app-react/tailwind.config.js create mode 100644 internal/adapter/templates/templates/components/app-react/tsconfig.json create mode 100644 internal/adapter/templates/templates/components/app-react/tsconfig.node.json create mode 100644 internal/adapter/templates/templates/components/app-react/vite.config.ts.tmpl create mode 100644 internal/adapter/templates/templates/components/cli/.woodpecker.step.yml.tmpl create mode 100644 internal/adapter/templates/templates/components/cli/Makefile.tmpl create mode 100644 internal/adapter/templates/templates/components/cli/cmd/main.go.tmpl create mode 100644 internal/adapter/templates/templates/components/cli/component.yaml.tmpl create mode 100644 internal/adapter/templates/templates/components/cli/go.mod.tmpl create mode 100644 internal/adapter/templates/templates/components/cli/internal/cmd/root.go.tmpl create mode 100644 internal/adapter/templates/templates/components/cli/internal/cmd/version.go.tmpl create mode 100644 internal/adapter/templates/templates/components/service/.env.example.tmpl create mode 100644 internal/adapter/templates/templates/components/service/.woodpecker.step.yml.tmpl create mode 100644 internal/adapter/templates/templates/components/service/Dockerfile.tmpl create mode 100644 internal/adapter/templates/templates/components/service/Makefile.tmpl create mode 100644 internal/adapter/templates/templates/components/service/cmd/server/main.go.tmpl create mode 100644 internal/adapter/templates/templates/components/service/component.yaml.tmpl create mode 100644 internal/adapter/templates/templates/components/service/go.mod.tmpl create mode 100644 internal/adapter/templates/templates/components/service/internal/api/handlers/health.go.tmpl create mode 100644 internal/adapter/templates/templates/components/service/internal/api/routes.go.tmpl create mode 100644 internal/adapter/templates/templates/components/service/internal/config/config.go.tmpl create mode 100644 internal/adapter/templates/templates/components/service/migrations/.gitkeep create mode 100644 internal/adapter/templates/templates/components/worker/.env.example.tmpl create mode 100644 internal/adapter/templates/templates/components/worker/.woodpecker.step.yml.tmpl create mode 100644 internal/adapter/templates/templates/components/worker/Dockerfile.tmpl create mode 100644 internal/adapter/templates/templates/components/worker/Makefile.tmpl create mode 100644 internal/adapter/templates/templates/components/worker/cmd/worker/main.go.tmpl create mode 100644 internal/adapter/templates/templates/components/worker/component.yaml.tmpl create mode 100644 internal/adapter/templates/templates/components/worker/go.mod.tmpl create mode 100644 internal/adapter/templates/templates/components/worker/internal/config/config.go.tmpl create mode 100644 internal/adapter/templates/templates/components/worker/internal/handlers/handler.go.tmpl create mode 100644 internal/adapter/templates/templates/skeleton/.githooks/commit-msg create mode 100644 internal/adapter/templates/templates/skeleton/.githooks/pre-commit create mode 100644 internal/adapter/templates/templates/skeleton/.gitignore create mode 100644 internal/adapter/templates/templates/skeleton/.golangci.yml.tmpl create mode 100644 internal/adapter/templates/templates/skeleton/.woodpecker.yml.tmpl create mode 100644 internal/adapter/templates/templates/skeleton/CLAUDE.md.tmpl create mode 100644 internal/adapter/templates/templates/skeleton/Procfile.tmpl create mode 100644 internal/adapter/templates/templates/skeleton/README.md.tmpl create mode 100644 internal/adapter/templates/templates/skeleton/apps/.gitkeep create mode 100644 internal/adapter/templates/templates/skeleton/cli/.gitkeep create mode 100644 internal/adapter/templates/templates/skeleton/docker-compose.yml.tmpl create mode 100644 internal/adapter/templates/templates/skeleton/go.work.tmpl create mode 100644 internal/adapter/templates/templates/skeleton/packages/.gitkeep create mode 100644 internal/adapter/templates/templates/skeleton/packages/logger/package.json create mode 100644 internal/adapter/templates/templates/skeleton/packages/logger/src/handlers.ts create mode 100644 internal/adapter/templates/templates/skeleton/packages/logger/src/index.ts create mode 100644 internal/adapter/templates/templates/skeleton/packages/logger/src/logger.ts create mode 100644 internal/adapter/templates/templates/skeleton/packages/logger/src/types.ts create mode 100644 internal/adapter/templates/templates/skeleton/packages/logger/tsconfig.json create mode 100644 internal/adapter/templates/templates/skeleton/pkg/README.md create mode 100644 internal/adapter/templates/templates/skeleton/pkg/app/app.go.tmpl create mode 100644 internal/adapter/templates/templates/skeleton/pkg/config/config.go.tmpl create mode 100644 internal/adapter/templates/templates/skeleton/pkg/go.mod.tmpl create mode 100644 internal/adapter/templates/templates/skeleton/pkg/httpclient/client.go.tmpl create mode 100644 internal/adapter/templates/templates/skeleton/pkg/httpcontext/keys.go.tmpl create mode 100644 internal/adapter/templates/templates/skeleton/pkg/httpresponse/envelope.go.tmpl create mode 100644 internal/adapter/templates/templates/skeleton/pkg/httpresponse/response.go.tmpl create mode 100644 internal/adapter/templates/templates/skeleton/pkg/httpvalidation/validator.go.tmpl create mode 100644 internal/adapter/templates/templates/skeleton/pkg/logging/context.go.tmpl create mode 100644 internal/adapter/templates/templates/skeleton/pkg/logging/logger.go.tmpl create mode 100644 internal/adapter/templates/templates/skeleton/pkg/logging/worker.go.tmpl create mode 100644 internal/adapter/templates/templates/skeleton/pkg/middleware/cors.go.tmpl create mode 100644 internal/adapter/templates/templates/skeleton/pkg/middleware/logger.go.tmpl create mode 100644 internal/adapter/templates/templates/skeleton/pkg/middleware/recovery.go.tmpl create mode 100644 internal/adapter/templates/templates/skeleton/pkg/middleware/request_id.go.tmpl create mode 100644 internal/adapter/templates/templates/skeleton/pkg/middleware/tracing.go.tmpl create mode 100644 internal/adapter/templates/templates/skeleton/scripts/dev.sh.tmpl create mode 100644 internal/adapter/templates/templates/skeleton/scripts/discover.sh.tmpl create mode 100644 internal/adapter/templates/templates/skeleton/scripts/install.sh.tmpl create mode 100644 internal/adapter/templates/templates/skeleton/scripts/quality.sh.tmpl create mode 100644 internal/adapter/templates/templates/skeleton/scripts/setup-hooks.sh.tmpl create mode 100644 internal/adapter/templates/templates/skeleton/services/.gitkeep create mode 100644 internal/adapter/templates/templates/skeleton/workers/.gitkeep create mode 100644 internal/domain/component.go create mode 100644 internal/domain/component_test.go create mode 100644 internal/envutil/envutil.go create mode 100644 internal/handlers/components.go create mode 100644 internal/handlers/components_test.go create mode 100644 internal/handlers/infrastructure_mocks_test.go create mode 100644 internal/handlers/timeouts.go create mode 100644 internal/port/component.go create mode 100644 internal/service/component.go create mode 100644 internal/service/component_updates.go create mode 100644 pkg/api/decode.go diff --git a/CLAUDE.md b/CLAUDE.md index 12fdd30..67df9bf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,6 +44,13 @@ Run Claude Code instances in isolated Kubernetes pods with REST API control. Ena - **500-line limit:** Files exceeding 500 lines must be split - **Tests:** All handlers and services require tests - **Multi-step ops:** NEVER log-and-continue after partial failure. Rollback or document partial state. +- **Logging:** Use injected `*slog.Logger` only. NEVER `fmt.Println`, `log.Fatal`, `log.Printf`, or bare `slog.Info()`. Error key is ALWAYS `"error"` (not `"err"`). Log once at boundary (handlers/workers log, services return errors). +- **HTTP clients:** NEVER create `&http.Client{}` without a `Timeout` field. All HTTP clients must have explicit timeouts (30s standard, 5s for health checks). A bare client can hang indefinitely. +- **Config:** Use `envutil.GetEnv()` / `GetEnvInt()` / `GetEnvBool()` from `internal/envutil` for all env var reads with defaults. NEVER define local `getEnv` helpers — they duplicate and drift. Raw `os.Getenv()` is fine for required values with no default (secrets, passwords). +- **Handler timeouts:** NEVER use inline `time.Duration` in `context.WithTimeout` inside handlers. Use constants from `internal/handlers/timeouts.go`: `TimeoutFastLookup` (5s), `TimeoutLookup` (10s), `TimeoutStandard` (30s), `TimeoutHeavyWrite` (60s), `TimeoutOrchestration` (90s), `TimeoutLongRunning` (10m). +- **Response helpers:** Use `api.WriteUnauthorized`, `api.WriteForbidden`, `api.WriteBadRequest`, `api.WriteNotFound`, `api.WriteInternalError` instead of bare `api.WriteError` with status codes. Only use `api.WriteError` directly for custom error codes (e.g., KEY_REVOKED, IP_NOT_ALLOWED). +- **JSON decoding:** ALWAYS use `api.DecodeJSON(r, &req)` to decode request bodies. NEVER use raw `json.NewDecoder(r.Body).Decode()`. The helper handles nil body, EOF, and returns typed errors. Decode error message is always `"invalid request body"`. +- **Validation:** Use `validate.New()` accumulator for 2+ field checks in handlers: `v := validate.New(); v.Required(req.Name, "name"); v.Required(req.Type, "type"); if err := v.Error() { ... }`. Single-field checks can stay inline. NEVER duplicate validation logic that exists in `internal/validate`. ## Quick Reference diff --git a/ai-lookup/features/composable-monorepo.md b/ai-lookup/features/composable-monorepo.md index cb35f68..005c371 100644 --- a/ai-lookup/features/composable-monorepo.md +++ b/ai-lookup/features/composable-monorepo.md @@ -14,6 +14,11 @@ Composable Monorepo Templates evolve rdev's project scaffolding from single temp - Optional `component.yaml` per component for ports, dependencies, build order - Shared `pkg/` from Aeries chassis + Colix patterns (8 packages) - Deployment supports whole-monorepo or individual-component targets +- **CI is template-provided** - skeleton has `.woodpecker.yml.tmpl`, components have `.woodpecker.step.yml.tmpl` + +**Critical Design Decision:** +CI/CD configuration MUST come from templates, never AI-generated. Claude Code produces invalid +Woodpecker YAML when generating from scratch (broken YAML anchor syntax). **File Pointers:** - Plan: `tmp/template-monorepo-plan.md` @@ -30,6 +35,7 @@ POST /projects {"name": "acme"} Creates monorepo skeleton: - CLAUDE.md, README.md, Procfile - docker-compose.yml, go.work, .golangci.yml + - .woodpecker.yml (template-provided CI) - scripts/ (discover, install, quality, dev) - pkg/ (8 shared packages from Aeries + Colix) - .claude/ (guides, skills, commands) @@ -49,6 +55,7 @@ Auto-updates: - Procfile (add service entry) - go.work (add module) - CLAUDE.md (add routing) + - .woodpecker.yml (add build step for component) ``` ### Monorepo Structure diff --git a/cmd/rdev-api/config.go b/cmd/rdev-api/config.go index ad092d2..e964c95 100644 --- a/cmd/rdev-api/config.go +++ b/cmd/rdev-api/config.go @@ -4,9 +4,9 @@ import ( "context" "log/slog" "os" - "strconv" "github.com/orchard9/rdev/internal/domain" + "github.com/orchard9/rdev/internal/envutil" "github.com/orchard9/rdev/internal/port" ) @@ -75,28 +75,14 @@ type InfraConfig struct { } func loadConfig() Config { - port := 8080 - if v := os.Getenv("PORT"); v != "" { - if p, err := strconv.Atoi(v); err == nil { - port = p - } - } - - dbPort := 5432 - if v := os.Getenv("DB_PORT"); v != "" { - if p, err := strconv.Atoi(v); err == nil { - dbPort = p - } - } - return Config{ - Port: port, - DBHost: getEnv("DB_HOST", "postgres.databases.svc"), - DBPort: dbPort, - DBUser: getEnv("DB_USER", "appuser"), + Port: envutil.GetEnvInt("PORT", 8080), + DBHost: envutil.GetEnv("DB_HOST", "postgres.databases.svc"), + DBPort: envutil.GetEnvInt("DB_PORT", 5432), + DBUser: envutil.GetEnv("DB_USER", "appuser"), DBPassword: os.Getenv("DB_PASSWORD"), - DBName: getEnv("DB_NAME", "rdev"), - DBSSLMode: getEnv("DB_SSL_MODE", "disable"), + DBName: envutil.GetEnv("DB_NAME", "rdev"), + DBSSLMode: envutil.GetEnv("DB_SSL_MODE", "disable"), AdminKey: os.Getenv("RDEV_ADMIN_KEY"), // Encryption key for credential store (generate with: openssl rand -base64 32) @@ -105,33 +91,26 @@ func loadConfig() Config { // OpenCode (optional alternative code agent) OpenCodeURL: os.Getenv("OPENCODE_URL"), // e.g., "http://opencode:4096" - OpenCodeUsername: getEnv("OPENCODE_USERNAME", "opencode"), + OpenCodeUsername: envutil.GetEnv("OPENCODE_USERNAME", "opencode"), OpenCodePassword: os.Getenv("OPENCODE_PASSWORD"), // Infrastructure adapters (fallback if not in credential store) - GiteaURL: getEnv("GITEA_URL", "https://git.threesix.ai"), + GiteaURL: envutil.GetEnv("GITEA_URL", "https://git.threesix.ai"), GiteaToken: os.Getenv("GITEA_TOKEN"), - GiteaDefaultOrg: getEnv("GITEA_DEFAULT_ORG", "jordan"), + GiteaDefaultOrg: envutil.GetEnv("GITEA_DEFAULT_ORG", "jordan"), CloudflareToken: os.Getenv("CLOUDFLARE_API_TOKEN"), CloudflareZoneID: os.Getenv("CLOUDFLARE_ZONE_ID"), - DefaultDomain: getEnv("DEFAULT_DOMAIN", "threesix.ai"), - DeployNamespace: getEnv("DEPLOY_NAMESPACE", "projects"), - DeployTLSIssuer: getEnv("DEPLOY_TLS_ISSUER", "letsencrypt-prod"), - ClusterIP: getEnv("CLUSTER_IP", "208.122.204.172"), - RegistryURL: getEnv("REGISTRY_URL", "zot.threesix.svc.cluster.local:5000"), - WoodpeckerURL: getEnv("WOODPECKER_URL", "https://ci.threesix.ai"), + DefaultDomain: envutil.GetEnv("DEFAULT_DOMAIN", "threesix.ai"), + DeployNamespace: envutil.GetEnv("DEPLOY_NAMESPACE", "projects"), + DeployTLSIssuer: envutil.GetEnv("DEPLOY_TLS_ISSUER", "letsencrypt-prod"), + ClusterIP: envutil.GetEnv("CLUSTER_IP", "208.122.204.172"), + RegistryURL: envutil.GetEnv("REGISTRY_URL", "zot.threesix.svc.cluster.local:5000"), + WoodpeckerURL: envutil.GetEnv("WOODPECKER_URL", "https://ci.threesix.ai"), WoodpeckerAPIToken: os.Getenv("WOODPECKER_API_TOKEN"), WoodpeckerWebhookSecret: os.Getenv("WOODPECKER_WEBHOOK_SECRET"), } } -func getEnv(key, defaultVal string) string { - if v := os.Getenv(key); v != "" { - return v - } - return defaultVal -} - // loadInfraConfig loads infrastructure configuration from credential store, // falling back to environment variables if not found in the store. func loadInfraConfig(ctx context.Context, store port.CredentialStore, cfg Config, logger *slog.Logger) InfraConfig { @@ -159,20 +138,6 @@ func loadInfraConfig(ctx context.Context, store port.CredentialStore, cfg Config return envFallback } - // Parse CRDB and Redis ports - crdbPort := 26257 - if v := os.Getenv("CRDB_PORT"); v != "" { - if p, err := strconv.Atoi(v); err == nil { - crdbPort = p - } - } - redisPort := 6379 - if v := os.Getenv("REDIS_PORT"); v != "" { - if p, err := strconv.Atoi(v); err == nil { - redisPort = p - } - } - infraCfg := InfraConfig{ GiteaURL: getOrFallback(domain.CredKeyGiteaURL, cfg.GiteaURL), GiteaToken: getOrFallback(domain.CredKeyGiteaToken, cfg.GiteaToken), @@ -190,11 +155,11 @@ func loadInfraConfig(ctx context.Context, store port.CredentialStore, cfg Config // CockroachDB and Redis provisioners (env-only for now) CRDBHost: os.Getenv("CRDB_HOST"), // e.g., "cockroachdb-public.databases.svc" - CRDBPort: crdbPort, - CRDBUser: getEnv("CRDB_USER", "root"), - CRDBSSLMode: getEnv("CRDB_SSL_MODE", "disable"), + CRDBPort: envutil.GetEnvInt("CRDB_PORT", 26257), + CRDBUser: envutil.GetEnv("CRDB_USER", "root"), + CRDBSSLMode: envutil.GetEnv("CRDB_SSL_MODE", "disable"), RedisHost: os.Getenv("REDIS_HOST"), // e.g., "redis.threesix.svc" - RedisPort: redisPort, + RedisPort: envutil.GetEnvInt("REDIS_PORT", 6379), RedisPassword: os.Getenv("REDIS_PASSWORD"), } @@ -211,3 +176,15 @@ func loadInfraConfig(ctx context.Context, store port.CredentialStore, cfg Config return infraCfg } + +// closeProvisioner attempts to close a provisioner that implements io.Closer. +func closeProvisioner(p any, name string, logger *slog.Logger) { + if p == nil { + return + } + if closer, ok := p.(interface{ Close() error }); ok { + if err := closer.Close(); err != nil { + logger.Warn("failed to close "+name+" provisioner", "error", err) + } + } +} diff --git a/cmd/rdev-api/main.go b/cmd/rdev-api/main.go index 140ac9a..d4a4415 100644 --- a/cmd/rdev-api/main.go +++ b/cmd/rdev-api/main.go @@ -1,36 +1,4 @@ // Package main provides the entry point for the rdev API server. -// -// rdev (Remote Developer) provides a REST API for controlling Claude Code -// instances running in Kubernetes pods. External clients (Discord bots, -// CLI tools, etc.) connect via this API. -// -// Authentication: -// - All endpoints (except /health, /ready, /docs) require X-API-Key header -// - Admin key from RDEV_ADMIN_KEY env var for key management -// - Create additional keys via POST /keys -// -// Endpoints: -// - GET /health - Health check (no auth) -// - GET /ready - Readiness check (no auth) -// - GET /docs - Scalar API documentation (no auth) -// - GET /openapi.json - OpenAPI 3.0 specification (no auth) -// - GET /keys - List API keys -// - POST /keys - Create API key -// - GET /keys/{id} - Get API key details -// - DELETE /keys/{id} - Revoke API key -// - GET /projects - List available projects -// - GET /projects/{id} - Get project details -// - POST /projects/{id}/claude - Run Claude command -// - POST /projects/{id}/shell - Run shell command -// - POST /projects/{id}/git - Run git command -// - GET /projects/{id}/events - SSE stream for output -// - GET /projects/{id}/claude-config - List commands/skills/agents -// - GET /projects/{id}/claude-config/commands - List commands -// - POST /projects/{id}/claude-config/commands - Create command -// - GET /projects/{id}/claude-config/commands/{name} - Get command -// - PUT /projects/{id}/claude-config/commands/{name} - Update command -// - DELETE /projects/{id}/claude-config/commands/{name} - Delete command -// (same pattern for /skills and /agents) package main import ( @@ -54,6 +22,7 @@ import ( "github.com/orchard9/rdev/internal/adapter/woodpecker" "github.com/orchard9/rdev/internal/auth" "github.com/orchard9/rdev/internal/db" + "github.com/orchard9/rdev/internal/envutil" "github.com/orchard9/rdev/internal/handlers" "github.com/orchard9/rdev/internal/metrics" "github.com/orchard9/rdev/internal/middleware" @@ -120,7 +89,7 @@ func main() { infraCfg := loadInfraConfig(context.Background(), credentialStore, cfg, logger) // Create adapters (dependency injection) - namespace := getEnv("K8S_NAMESPACE", "rdev") + namespace := envutil.GetEnv("K8S_NAMESPACE", "rdev") // Initialize K8s client for dynamic project discovery // Falls back gracefully if K8s is unavailable (e.g., local development) @@ -138,27 +107,18 @@ func main() { k8sExecutor := kubernetes.NewExecutor(namespace) streamPub := memory.NewStreamPublisher() - // Start watching for project pod changes if K8s client is available if k8sClient != nil { if err := projectRepo.StartWatching(context.Background()); err != nil { logger.Warn("failed to start project watcher", "error", err) } } - // Initialize audit logger auditLogger := postgres.NewAuditLogger(database.DB) - - // Initialize rate limiter rateLimiter := postgres.NewRateLimiter(database.DB) stopRateLimitCleanup := rateLimiter.StartCleanupWorker(context.Background(), 5*time.Minute) - - // Initialize command queue commandQueue := postgres.NewCommandQueueRepository(database.DB) - - // Initialize work queue (for worker pool tasks) workQueueRepo := postgres.NewWorkQueueRepository(database.DB) - // Initialize webhook repository and dispatcher webhookRepo := postgres.NewWebhookRepository(database.DB) webhookDispatcher := webhook.NewDispatcher(webhookRepo, &webhook.DispatcherConfig{ WorkerCount: 10, @@ -172,8 +132,7 @@ func main() { os.Exit(1) } - // Initialize infrastructure adapters (optional - only if configured) - // Uses infraCfg which loads from credential store with env var fallback + // Infrastructure adapters (optional - only if configured) var giteaClient *gitea.Client if infraCfg.GiteaToken != "" && infraCfg.GiteaURL != "" { var err error @@ -384,6 +343,23 @@ func main() { // Initialize project management handler projectMgmtHandler := handlers.NewProjectManagementHandler(projectInfraService, logger) + // Initialize component service and handler (for monorepo component management) + var componentsHandler *handlers.ComponentsHandler + if infraCfg.GiteaToken != "" && infraCfg.GiteaURL != "" && templateProvider != nil { + bulkFileClient := gitea.NewBulkFileClient(infraCfg.GiteaURL, infraCfg.GiteaToken) + componentService := service.NewComponentService( + database.DB, + templateProvider, + bulkFileClient, + service.ComponentServiceConfig{ + DefaultGitOwner: infraCfg.GiteaDefaultOrg, + Logger: logger, + }, + ) + componentsHandler = handlers.NewComponentsHandler(componentService, logger) + logger.Info("component service initialized") + } + // Initialize Woodpecker webhook handler (for CI/CD auto-deploy) woodpeckerHandler := handlers.NewWoodpeckerWebhookHandler( deployerAdapter, @@ -425,6 +401,9 @@ func main() { workHandler.Mount(app.Router()) infraHandler.Mount(app.Router()) projectMgmtHandler.Mount(app.Router()) + if componentsHandler != nil { + componentsHandler.Mount(app.Router()) + } woodpeckerHandler.Mount(app.Router()) credentialsHandler.Mount(app.Router()) agentsHandler.Mount(app.Router()) @@ -493,47 +472,18 @@ func main() { // Enable API documentation app.EnableDocs(buildOpenAPISpec()) - // Cleanup on shutdown app.OnShutdown(func(ctx context.Context) error { - // Stop work executor (deregisters worker) workExecutor.Stop() - - // Stop queue maintenance worker queueMaintenance.Stop() - - // Stop queue processor queueProcessor.Stop() - - // Stop webhook dispatcher webhookDispatcher.Stop() - - // Stop project watcher projectRepo.StopWatching() - - // Stop rate limit cleanup worker stopRateLimitCleanup() - - // Close database and cache provisioners - if dbProvisioner != nil { - if closer, ok := dbProvisioner.(interface{ Close() error }); ok { - if err := closer.Close(); err != nil { - logger.Warn("failed to close database provisioner", "error", err) - } - } - } - if cacheProvisioner != nil { - if closer, ok := cacheProvisioner.(interface{ Close() error }); ok { - if err := closer.Close(); err != nil { - logger.Warn("failed to close cache provisioner", "error", err) - } - } - } - - // Shutdown telemetry (flush pending traces) + closeProvisioner(dbProvisioner, "database", logger) + closeProvisioner(cacheProvisioner, "cache", logger) if err := tel.Shutdown(ctx); err != nil { logger.Error("telemetry shutdown error", "error", err) } - return database.Close() }) @@ -547,5 +497,4 @@ func main() { app.Run() } -// Config, InfraConfig, loadConfig, loadInfraConfig, and getEnv -// are defined in config.go. +// Config, InfraConfig, loadConfig, loadInfraConfig are defined in config.go. diff --git a/cookbooks/composable-app.md b/cookbooks/composable-app.md new file mode 100644 index 0000000..41a9ec9 --- /dev/null +++ b/cookbooks/composable-app.md @@ -0,0 +1,423 @@ +# Composable App Cookbook + +> Deploy a full-stack application with multiple components using composable monorepo templates. + +## Overview + +This cookbook creates a full-stack application by composing components: + +``` +POST /projects + ↓ +Creates: Monorepo skeleton with shared packages + ↓ +POST /projects/{id}/components (service) + ↓ +Adds: Go API backend with CI step + ↓ +POST /projects/{id}/components (app) + ↓ +Adds: React/Astro frontend with CI step + ↓ +POST /projects/{id}/deploy + ↓ +Deploys all components to K8s +``` + +**Composable. Template-driven. CI auto-configured.** + +--- + +## Prerequisites + +### API Access +```bash +export RDEV_API_URL="https://rdev.masq-ops.orchard9.ai" +export RDEV_API_KEY="" +``` + +### Infrastructure Required +- rdev-api running with embedded worker +- Gitea at https://git.threesix.ai +- Woodpecker CI at https://ci.threesix.ai + +--- + +## Step 1: Create Project (Monorepo Skeleton) + +```bash +curl -X POST "$RDEV_API_URL/projects" \ + -H "X-API-Key: $RDEV_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "taskapp", + "description": "Task management application" + }' +``` + +**Response:** +```json +{ + "data": { + "project_id": "taskapp", + "name": "taskapp", + "domain": "xyz789ab.threesix.ai", + "url": "https://xyz789ab.threesix.ai", + "git": { + "owner": "jordan", + "name": "taskapp", + "html_url": "https://git.threesix.ai/jordan/taskapp" + } + } +} +``` + +This creates: +``` +taskapp/ +├── CLAUDE.md # AI routing +├── README.md # Project docs +├── Procfile # Local dev (empty) +├── docker-compose.yml # Postgres, Redis +├── go.work # Go workspace +├── .woodpecker.yml # CI pipeline (template-provided) +├── .golangci.yml # Go linting +├── scripts/ # Discovery scripts +│ ├── discover.sh +│ ├── install.sh +│ ├── quality.sh +│ └── dev.sh +├── pkg/ # Shared Go packages +│ ├── app/ # Service bootstrapper +│ ├── middleware/ # HTTP middleware +│ ├── httpresponse/ # JSON responses +│ └── ... +├── services/ # (empty, ready for components) +├── apps/ # (empty, ready for components) +├── workers/ # (empty, ready for components) +└── cli/ # (empty, ready for components) +``` + +--- + +## Step 2: Add Backend Service + +```bash +curl -X POST "$RDEV_API_URL/projects/taskapp/components" \ + -H "X-API-Key: $RDEV_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "type": "service", + "name": "api", + "template": "service" + }' +``` + +**Response:** +```json +{ + "data": { + "type": "service", + "name": "api", + "path": "services/api", + "port": 8001, + "template": "service" + } +} +``` + +This adds: +``` +services/api/ +├── cmd/server/main.go # Entry point using pkg/app +├── internal/ +│ ├── api/routes.go # Chi router setup +│ ├── api/handlers/health.go # Health endpoints +│ └── config/config.go # Configuration +├── migrations/ # Database migrations +├── Makefile # Build targets +├── Dockerfile # Multi-stage Go build +├── component.yaml # Port, dependencies +└── .env.example # Environment template +``` + +And updates: +- `.woodpecker.yml` - adds `build-api` step +- `Procfile` - adds `api: make -C services/api run` +- `go.work` - adds `use ./services/api` + +--- + +## Step 3: Add Frontend App + +```bash +curl -X POST "$RDEV_API_URL/projects/taskapp/components" \ + -H "X-API-Key: $RDEV_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "type": "app", + "name": "dashboard", + "template": "app-react" + }' +``` + +**Response:** +```json +{ + "data": { + "type": "app", + "name": "dashboard", + "path": "apps/dashboard", + "port": 3001, + "template": "app-react" + } +} +``` + +This adds: +``` +apps/dashboard/ +├── src/ +│ ├── App.tsx +│ ├── main.tsx +│ └── components/ +├── package.json +├── vite.config.ts +├── tsconfig.json +├── Dockerfile # Multi-stage Node build +└── component.yaml # Port, dependencies +``` + +--- + +## Step 4: Customize with Claude (Optional) + +Submit a build task to customize the components: + +```bash +curl -X POST "$RDEV_API_URL/projects/taskapp/builds" \ + -H "X-API-Key: $RDEV_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "prompt": "Implement a task management system:\n\nBACKEND (services/api):\n- Add Task model with id, title, description, status, created_at\n- Endpoints: GET /api/tasks, POST /api/tasks, PATCH /api/tasks/{id}, DELETE /api/tasks/{id}\n- In-memory storage for now\n\nFRONTEND (apps/dashboard):\n- Task list with status badges\n- Add task form\n- Mark complete/delete buttons\n- Dark theme with Tailwind\n- Fetch from /api/tasks", + "auto_commit": true, + "auto_push": true + }' +``` + +Monitor the build: +```bash +curl -s "$RDEV_API_URL/builds/{task_id}" \ + -H "X-API-Key: $RDEV_API_KEY" | jq '.data.status' +``` + +--- + +## Step 5: Deploy All Components + +```bash +curl -X POST "$RDEV_API_URL/projects/taskapp/deploy" \ + -H "X-API-Key: $RDEV_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{}' +``` + +This deploys all components to K8s, or deploy a single component: + +```bash +curl -X POST "$RDEV_API_URL/projects/taskapp/deploy" \ + -H "X-API-Key: $RDEV_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"component": "services/api"}' +``` + +--- + +## Step 6: Monitor CI Pipeline + +```bash +curl -s "$RDEV_API_URL/projects/taskapp/pipelines" \ + -H "X-API-Key: $RDEV_API_KEY" | jq '.data[0]' +``` + +The pipeline builds both components in parallel then deploys. + +--- + +## Step 7: Verify Deployment + +```bash +# Check site is live +curl -I https://xyz789ab.threesix.ai + +# Test API +curl https://xyz789ab.threesix.ai/api/tasks | jq . + +# View the app +open https://xyz789ab.threesix.ai +``` + +--- + +## Adding More Components + +### Add a Background Worker + +```bash +curl -X POST "$RDEV_API_URL/projects/taskapp/components" \ + -H "X-API-Key: $RDEV_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "type": "worker", + "name": "notifications", + "template": "worker" + }' +``` + +### Add a CLI Tool + +```bash +curl -X POST "$RDEV_API_URL/projects/taskapp/components" \ + -H "X-API-Key: $RDEV_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "type": "cli", + "name": "taskctl", + "template": "cli" + }' +``` + +### List All Components + +```bash +curl -s "$RDEV_API_URL/projects/taskapp/components" \ + -H "X-API-Key: $RDEV_API_KEY" | jq '.data' +``` + +--- + +## Component Types + +| Type | Directory | Templates | Default Port | +|------|-----------|-----------|--------------| +| service | `services/` | service | 8001+ | +| worker | `workers/` | worker | N/A | +| app | `apps/` | app-astro, app-react | 3001+ | +| cli | `cli/` | cli | N/A | + +Ports auto-increment as you add components of the same type. + +--- + +## Teardown + +```bash +curl -X DELETE "$RDEV_API_URL/projects/taskapp" \ + -H "X-API-Key: $RDEV_API_KEY" +``` + +Removes: DNS records, K8s deployments, project metadata. Gitea repo preserved. + +--- + +## E2E Test Script + +Run the full flow: +```bash +./cookbooks/scripts/composable-test.sh run my-test-app +``` + +Check status: +```bash +./cookbooks/scripts/composable-test.sh status my-test-app +``` + +Cleanup: +```bash +./cookbooks/scripts/composable-test.sh teardown my-test-app +``` + +--- + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ Composable Full-Stack Deployment │ +│ │ +│ POST /projects │ +│ │ │ +│ └──► Creates monorepo skeleton with: │ +│ - Shared pkg/ (8 packages) │ +│ - .woodpecker.yml (base CI) │ +│ - Discovery scripts │ +│ │ +│ POST /projects/{id}/components (× N) │ +│ │ │ +│ └──► For each component: │ +│ - Renders template to services/|apps/|workers/|cli/ │ +│ - Inserts CI step into .woodpecker.yml │ +│ - Updates Procfile, go.work │ +│ │ +│ POST /projects/{id}/builds (optional) │ +│ │ │ +│ └──► Claude customizes existing components │ +│ │ +│ Git push → Woodpecker CI: │ +│ ├──► build-api (Kaniko → registry) │ +│ ├──► build-dashboard (Kaniko → registry) │ +│ └──► deploy (kubectl set image) │ +│ │ +│ Components live at https://{slug}.threesix.ai │ +│ ├──► /api/* → services/api │ +│ └──► /* → apps/dashboard │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Troubleshooting + +### Component addition fails +```bash +# Check project exists +curl -s "$RDEV_API_URL/projects/taskapp" \ + -H "X-API-Key: $RDEV_API_KEY" | jq '.data' + +# Check available templates +curl -s "$RDEV_API_URL/templates/components" \ + -H "X-API-Key: $RDEV_API_KEY" | jq '.data' +``` + +### Build stuck in pending +```bash +# Check worker status +curl -s "$RDEV_API_URL/workers" -H "X-API-Key: $RDEV_API_KEY" | jq '.data.summary' +``` + +### Pipeline fails +```bash +# Get pipeline details +curl -s "$RDEV_API_URL/projects/taskapp/pipelines/1" \ + -H "X-API-Key: $RDEV_API_KEY" | jq '.data' + +# Check Woodpecker UI +open https://ci.threesix.ai/jordan/taskapp +``` + +### Deployment not updating +```bash +# Check K8s pods +kubectl get pods -n projects -l app=taskapp + +# Check deployment events +kubectl describe deployment taskapp-api -n projects +``` + +--- + +## Related + +- [Landing Page Cookbook](./landing-page.md) - Simple single-component site +- [Composable Monorepo Guide](../.claude/guides/services/composable-monorepo.md) +- [Component Templates](../.claude/guides/services/templates.md) diff --git a/cookbooks/fullstack-app.md b/cookbooks/fullstack-app.md deleted file mode 100644 index b510dc3..0000000 --- a/cookbooks/fullstack-app.md +++ /dev/null @@ -1,383 +0,0 @@ -# Full-Stack App Cookbook - -> Deploy a full-stack application (Next.js + Go backend) built entirely by Claude through the threesix.ai infrastructure. - -## Overview - -This cookbook creates and deploys a complete full-stack application using **agent-driven development**: - -``` -POST /project/create-and-build - ↓ -Creates: Gitea repo + DNS + Woodpecker CI + K8s deployment - ↓ -Enqueues build task with comprehensive prompt - ↓ -Worker picks up task → Claude builds the entire stack - ↓ -Agent commits + pushes - ↓ -CI builds and deploys - ↓ -Live full-stack app -``` - -**Claude builds everything from scratch: Next.js frontend with shadcn/ui, Go backend API, Docker configs, and CI pipeline.** - ---- - -## Prerequisites - -### API Access -```bash -export RDEV_API_URL="https://rdev.masq-ops.orchard9.ai" -export RDEV_API_KEY="" -``` - -### Infrastructure Required -- rdev-api running with embedded worker -- Gitea at https://git.threesix.ai -- Woodpecker CI at https://ci.threesix.ai -- claudebox-0 pod running in rdev namespace - ---- - -## Step 1: Create Project and Build Full-Stack App - -Single API call that creates infrastructure AND enqueues the full-stack build: - -```bash -curl -X POST "$RDEV_API_URL/project/create-and-build" \ - -H "X-API-Key: $RDEV_API_KEY" \ - -H "Content-Type: application/json" \ - -d '{ - "name": "my-fullstack-app", - "description": "Full-stack app with Next.js frontend and Go backend", - "build": { - "prompt": "Build a full-stack task management application with the following structure:\n\nFRONTEND (Next.js 14 + shadcn/ui):\n- Create a Next.js 14 app with App Router in /frontend\n- Use shadcn/ui for all components (install with npx shadcn-ui@latest init)\n- Dark theme with modern aesthetic\n- Pages: Dashboard showing tasks, Add Task form, Task detail view\n- Use Tailwind CSS for styling\n- Connect to backend API at /api proxy\n\nBACKEND (Go):\n- Create a Go HTTP server in /backend using chi router\n- Endpoints: GET /api/tasks, POST /api/tasks, GET /api/tasks/{id}, DELETE /api/tasks/{id}\n- In-memory task storage (no database needed)\n- Structured JSON responses\n- CORS middleware for frontend\n\nDOCKER:\n- /frontend/Dockerfile: Multi-stage build for Next.js (node:20-alpine)\n- /backend/Dockerfile: Multi-stage build for Go (golang:1.22-alpine)\n- /docker-compose.yml: Run both services, frontend proxies to backend\n\nCI/CD:\n- /.woodpecker.yml: Build both images, push to registry, deploy to k8s\n\nCreate all necessary files including package.json, go.mod, and configuration files.", - "auto_commit": true, - "auto_push": true - } - }' -``` - -**Response:** -```json -{ - "project": { - "project_id": "my-fullstack-app", - "domain": "xyz789ab.threesix.ai", - "git": { - "html_url": "https://git.threesix.ai/jordan/my-fullstack-app" - } - }, - "build": { - "task_id": "task-uuid", - "status": "pending", - "status_url": "/builds/task-uuid" - } -} -``` - ---- - -## Step 2: Monitor Build Progress - -Poll the build status: - -```bash -curl -s "$RDEV_API_URL/builds/{task_id}" \ - -H "X-API-Key: $RDEV_API_KEY" | jq . -``` - -**Status progression:** `pending` → `running` → `completed` (or `failed`) - -Full-stack builds take longer than simple landing pages. Expect 2-5 minutes. - -When completed: -```json -{ - "task_id": "task-uuid", - "status": "completed", - "result": { - "success": true, - "commit_sha": "def456", - "files_changed": [ - "frontend/package.json", - "frontend/app/page.tsx", - "frontend/app/layout.tsx", - "frontend/components/task-list.tsx", - "frontend/components/add-task-form.tsx", - "frontend/Dockerfile", - "backend/main.go", - "backend/go.mod", - "backend/Dockerfile", - "docker-compose.yml", - ".woodpecker.yml" - ], - "duration_ms": 180000 - } -} -``` - ---- - -## Step 3: Monitor CI Pipeline - -The agent's push triggers Woodpecker CI to build both services: - -```bash -curl -s "$RDEV_API_URL/projects/my-fullstack-app/pipelines" \ - -H "X-API-Key: $RDEV_API_KEY" | jq '.data[0]' -``` - -Pipeline stages: -1. Build frontend Docker image -2. Build backend Docker image -3. Push both to registry -4. Deploy to Kubernetes - -Wait for `status: "success"`. - ---- - -## Step 4: Verify Deployment - -```bash -# Check site is live -curl -I https://xyz789ab.threesix.ai - -# Test frontend loads -curl -s https://xyz789ab.threesix.ai | head -20 - -# Test backend API -curl -s https://xyz789ab.threesix.ai/api/tasks | jq . - -# Open in browser -open https://xyz789ab.threesix.ai -``` - ---- - -## Iterating on the App - -### Add a Feature - -```bash -curl -X POST "$RDEV_API_URL/projects/my-fullstack-app/builds" \ - -H "X-API-Key: $RDEV_API_KEY" \ - -H "Content-Type: application/json" \ - -d '{ - "prompt": "Add task priority levels (low, medium, high) with color-coded badges in the UI. Update the backend Task struct and frontend components to support priorities. Add a priority filter dropdown on the dashboard.", - "auto_commit": true, - "auto_push": true - }' -``` - -### Fix a Bug - -```bash -curl -X POST "$RDEV_API_URL/projects/my-fullstack-app/builds" \ - -H "X-API-Key: $RDEV_API_KEY" \ - -H "Content-Type: application/json" \ - -d '{ - "prompt": "Fix the task deletion - ensure the DELETE endpoint returns 204 No Content and the frontend removes the task from the list immediately without requiring a page refresh.", - "auto_commit": true, - "auto_push": true - }' -``` - -### Add Authentication - -```bash -curl -X POST "$RDEV_API_URL/projects/my-fullstack-app/builds" \ - -H "X-API-Key: $RDEV_API_KEY" \ - -H "Content-Type: application/json" \ - -d '{ - "prompt": "Add simple JWT authentication:\n- Backend: Add /api/auth/login endpoint that accepts username/password and returns JWT\n- Backend: Add auth middleware to protect /api/tasks endpoints\n- Frontend: Add login page with shadcn form components\n- Frontend: Store JWT in localStorage, include in API requests\n- Create a demo user (admin/admin123) for testing", - "auto_commit": true, - "auto_push": true - }' -``` - -Each build: -1. Claude clones the existing repo -2. Makes the requested changes -3. Commits and pushes -4. CI deploys automatically - ---- - -## Alternative Prompts - -### E-commerce Storefront - -```bash -curl -X POST "$RDEV_API_URL/project/create-and-build" \ - -H "X-API-Key: $RDEV_API_KEY" \ - -H "Content-Type: application/json" \ - -d '{ - "name": "my-store", - "description": "E-commerce storefront", - "build": { - "prompt": "Build an e-commerce storefront:\n\nFRONTEND: Next.js 14 with shadcn/ui, dark theme\n- Product grid with images, prices, descriptions\n- Product detail page\n- Shopping cart (localStorage)\n- Checkout form (no payment processing)\n\nBACKEND: Go with chi router\n- GET /api/products - list products\n- GET /api/products/{id} - product detail\n- POST /api/orders - create order (log to console)\n- Seed with 6 sample products\n\nInclude Dockerfiles and .woodpecker.yml for CI/CD.", - "auto_commit": true, - "auto_push": true - } - }' -``` - -### Dashboard App - -```bash -curl -X POST "$RDEV_API_URL/project/create-and-build" \ - -H "X-API-Key: $RDEV_API_KEY" \ - -H "Content-Type: application/json" \ - -d '{ - "name": "my-dashboard", - "description": "Analytics dashboard", - "build": { - "prompt": "Build an analytics dashboard:\n\nFRONTEND: Next.js 14 with shadcn/ui + recharts\n- Dashboard with 4 stat cards (users, revenue, orders, growth)\n- Line chart showing weekly trends\n- Bar chart showing top products\n- Recent activity table\n- Dark theme, responsive grid layout\n\nBACKEND: Go with chi router\n- GET /api/stats - return dashboard statistics\n- GET /api/trends - return weekly trend data\n- GET /api/activity - return recent activity\n- Generate realistic sample data\n\nInclude Dockerfiles and .woodpecker.yml for CI/CD.", - "auto_commit": true, - "auto_push": true - } - }' -``` - ---- - -## Adding Custom Domains - -```bash -# Add custom domain -curl -X POST "$RDEV_API_URL/projects/my-fullstack-app/domains" \ - -H "X-API-Key: $RDEV_API_KEY" \ - -H "Content-Type: application/json" \ - -d '{"domain": "app.mycompany.com"}' - -# List all domains -curl -s "$RDEV_API_URL/projects/my-fullstack-app/domains" \ - -H "X-API-Key: $RDEV_API_KEY" | jq '.data.domains' -``` - ---- - -## Teardown - -```bash -curl -X DELETE "$RDEV_API_URL/project/my-fullstack-app" \ - -H "X-API-Key: $RDEV_API_KEY" -``` - -Removes: DNS records, K8s deployment, project metadata. Gitea repo preserved for safety. - ---- - -## E2E Test Script - -Run the full flow: -```bash -./cookbooks/scripts/fullstack-test.sh run my-test-fullstack -``` - -Check status: -```bash -./cookbooks/scripts/fullstack-test.sh status my-test-fullstack -``` - -Cleanup: -```bash -./cookbooks/scripts/fullstack-test.sh teardown my-test-fullstack -``` - ---- - -## Architecture - -``` -┌─────────────────────────────────────────────────────────────────────┐ -│ Agent-Driven Full-Stack App │ -│ │ -│ POST /project/create-and-build │ -│ │ │ -│ ├──► Gitea: creates repo │ -│ ├──► Cloudflare: creates DNS │ -│ ├──► Woodpecker: activates CI │ -│ ├──► K8s: creates Deployment/Service/Ingress │ -│ └──► Work Queue: enqueues build task │ -│ │ │ -│ ▼ │ -│ Worker polls queue, claims task │ -│ │ │ -│ ▼ │ -│ Claude Code executes in claudebox-0: │ -│ - Clones repo │ -│ - Creates Next.js frontend with shadcn/ui │ -│ - Creates Go backend with chi router │ -│ - Writes Dockerfiles and CI config │ -│ - Commits and pushes │ -│ │ │ -│ ▼ │ -│ Woodpecker CI triggered by push: │ -│ - Builds frontend Docker image │ -│ - Builds backend Docker image │ -│ - Pushes to registry │ -│ - Deploys to K8s │ -│ │ │ -│ ▼ │ -│ Full-stack app live at https://{slug}.threesix.ai │ -│ - Frontend: Next.js + shadcn/ui │ -│ - Backend: Go API │ -│ │ -└─────────────────────────────────────────────────────────────────────┘ -``` - ---- - -## Troubleshooting - -### Build stuck in pending -```bash -# Check worker status -curl -s "$RDEV_API_URL/workers" -H "X-API-Key: $RDEV_API_KEY" | jq '.data.summary' - -# Should show at least 1 idle worker -``` - -### Build failed -```bash -# Get build details with full output -curl -s "$RDEV_API_URL/builds/{task_id}" -H "X-API-Key: $RDEV_API_KEY" | jq '.result' - -# Check rdev-api logs for worker errors -./scripts/logs.sh -e -``` - -### Pipeline not triggering -```bash -# Check if commit was pushed -curl -s "https://git.threesix.ai/api/v1/repos/jordan/my-fullstack-app/commits" | jq '.[0]' - -# Check Woodpecker -open https://ci.threesix.ai/jordan/my-fullstack-app -``` - -### Frontend/Backend connection issues -```bash -# Check both containers are running -kubectl get pods -n projects -l app=my-fullstack-app - -# Check frontend logs -kubectl logs -n projects -l app=my-fullstack-app -c frontend - -# Check backend logs -kubectl logs -n projects -l app=my-fullstack-app -c backend -``` - ---- - -## Related - -- [Landing Page Cookbook](./landing-page.md) - Simpler single-page deployment -- [Worker Pool Guide](../.claude/guides/services/worker-pool.md) -- [Build Orchestration](../.claude/guides/services/build-orchestration.md) diff --git a/cookbooks/landing-page.md b/cookbooks/landing-page.md index d196ca1..c504cc9 100644 --- a/cookbooks/landing-page.md +++ b/cookbooks/landing-page.md @@ -1,28 +1,26 @@ # Landing Page Cookbook -> Deploy a landing page built by a Claude agent through the threesix.ai infrastructure. +> Deploy a landing page using composable templates through the threesix.ai infrastructure. ## Overview -This cookbook creates and deploys a landing page using **agent-driven development**: +This cookbook creates and deploys a landing page using **composable monorepo templates**: ``` -POST /project/create-and-build +POST /projects ↓ -Creates: Gitea repo + DNS + Woodpecker CI + K8s deployment +Creates: Monorepo skeleton + Gitea repo + DNS + CI ↓ -Enqueues build task with prompt +POST /projects/{id}/components (type: app, template: app-astro) ↓ -Worker picks up task → Claude builds the site +Adds: Astro landing page component with valid CI step ↓ -Agent commits + pushes +Git push triggers Woodpecker CI ↓ -CI builds and deploys - ↓ -Live site +Live site at https://{slug}.threesix.ai ``` -**No templates. Claude builds it from scratch based on your prompt.** +**Template-driven. CI is pre-configured. No AI-generated YAML.** --- @@ -38,24 +36,18 @@ export RDEV_API_KEY="" - rdev-api running with embedded worker - Gitea at https://git.threesix.ai - Woodpecker CI at https://ci.threesix.ai -- claudebox-0 pod running in rdev namespace --- -## Step 1: Create Project and Build in One Call - -Single API call that creates infrastructure AND enqueues agent work: +## Step 1: Create Project (Monorepo Skeleton) ```bash -curl -X POST "$RDEV_API_URL/project/create-and-build" \ +curl -X POST "$RDEV_API_URL/projects" \ -H "X-API-Key: $RDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "my-landing", - "description": "Company landing page", - "prompt": "Build a modern landing page with: dark gradient background, centered hero section with company name and tagline, email signup form, responsive design. Use vanilla HTML/CSS/JS. Create index.html, styles.css, and a simple Dockerfile that serves with nginx.", - "auto_commit": true, - "auto_push": true + "description": "Company landing page" }' ``` @@ -71,53 +63,79 @@ curl -X POST "$RDEV_API_URL/project/create-and-build" \ "owner": "jordan", "name": "my-landing", "html_url": "https://git.threesix.ai/jordan/my-landing" - }, - "task_id": "task-uuid", - "status": "pending", - "status_url": "/builds/task-uuid" - }, - "meta": { "timestamp": "..." } + } + } } ``` +This creates a monorepo skeleton with: +- `README.md`, `CLAUDE.md`, `Procfile` +- `.woodpecker.yml` (template-provided, valid CI) +- `scripts/` (discover, install, quality, dev) +- `pkg/` (shared Go packages) +- Empty component directories (`services/`, `apps/`, `workers/`, `cli/`) + --- -## Step 2: Monitor Build Progress +## Step 2: Add Landing Page Component -Poll the build status: +```bash +curl -X POST "$RDEV_API_URL/projects/my-landing/components" \ + -H "X-API-Key: $RDEV_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "type": "app", + "name": "landing", + "template": "app-astro" + }' +``` +**Response:** +```json +{ + "data": { + "type": "app", + "name": "landing", + "path": "apps/landing", + "port": 3001, + "template": "app-astro" + } +} +``` + +This adds: +- `apps/landing/` - Astro project with Tailwind CSS +- Updates `.woodpecker.yml` with build step for this component +- Updates `Procfile` with dev server entry + +--- + +## Step 3: Customize with Claude (Optional) + +Submit a build task to customize the landing page: + +```bash +curl -X POST "$RDEV_API_URL/projects/my-landing/builds" \ + -H "X-API-Key: $RDEV_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "prompt": "Update apps/landing to have: dark gradient background, centered hero section with company name \"Acme Corp\" and tagline \"Building the future\", email signup form with shadcn styling. Keep the existing Astro structure.", + "auto_commit": true, + "auto_push": true + }' +``` + +Monitor the build: ```bash curl -s "$RDEV_API_URL/builds/{task_id}" \ -H "X-API-Key: $RDEV_API_KEY" | jq .data ``` -**Status progression:** `pending` → `running` → `completed` (or `failed`) - -When completed: -```json -{ - "task_id": "task-uuid", - "project_id": "my-landing", - "status": "completed", - "prompt": "Build a modern landing page...", - "auto_commit": true, - "auto_push": true, - "started_at": "2025-01-29T10:00:00Z", - "completed_at": "2025-01-29T10:00:45Z", - "result": { - "success": true, - "commit_sha": "abc123", - "files_changed": ["index.html", "styles.css", "Dockerfile", "nginx.conf"], - "duration_ms": 45000 - } -} -``` - --- -## Step 3: Monitor CI Pipeline +## Step 4: Monitor CI Pipeline -The agent's push triggers Woodpecker CI: +The push triggers Woodpecker CI automatically: ```bash curl -s "$RDEV_API_URL/projects/my-landing/pipelines" \ @@ -128,7 +146,7 @@ Wait for `status: "success"`. --- -## Step 4: Verify Deployment +## Step 5: Verify Deployment ```bash # Check site is live @@ -140,35 +158,6 @@ open https://abc123xy.threesix.ai --- -## Alternative: Two-Step Flow - -If you prefer to create the project first, then submit builds separately: - -### Create Project (empty repo) -```bash -curl -X POST "$RDEV_API_URL/project" \ - -H "X-API-Key: $RDEV_API_KEY" \ - -H "Content-Type: application/json" \ - -d '{ - "name": "my-landing", - "description": "Company landing page" - }' -``` - -### Submit Build Task -```bash -curl -X POST "$RDEV_API_URL/projects/my-landing/builds" \ - -H "X-API-Key: $RDEV_API_KEY" \ - -H "Content-Type: application/json" \ - -d '{ - "prompt": "Build a modern landing page with dark theme, hero section, and email signup form. Use HTML/CSS/JS with nginx Dockerfile.", - "auto_commit": true, - "auto_push": true - }' -``` - ---- - ## Iterating on the Site Submit additional builds to modify the site: @@ -178,7 +167,7 @@ curl -X POST "$RDEV_API_URL/projects/my-landing/builds" \ -H "X-API-Key: $RDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ - "prompt": "Add a pricing section with three tiers: Free, Pro ($29/mo), Enterprise (contact us). Match the existing dark theme.", + "prompt": "Add a pricing section to apps/landing with three tiers: Free, Pro ($29/mo), Enterprise (contact us). Match the existing dark theme.", "auto_commit": true, "auto_push": true }' @@ -211,7 +200,7 @@ curl -s "$RDEV_API_URL/projects/my-landing/domains" \ ## Teardown ```bash -curl -X DELETE "$RDEV_API_URL/project/my-landing" \ +curl -X DELETE "$RDEV_API_URL/projects/my-landing" \ -H "X-API-Key: $RDEV_API_KEY" ``` @@ -242,34 +231,31 @@ Cleanup: ``` ┌─────────────────────────────────────────────────────────────────────┐ -│ Agent-Driven Landing Page │ +│ Composable Landing Page Deployment │ │ │ -│ POST /project/create-and-build │ +│ POST /projects │ │ │ │ -│ ├──► Gitea: creates repo │ +│ ├──► Gitea: creates repo with skeleton │ │ ├──► Cloudflare: creates DNS │ │ ├──► Woodpecker: activates CI │ -│ ├──► K8s: creates Deployment/Service/Ingress │ -│ └──► Work Queue: enqueues build task │ -│ │ │ -│ ▼ │ -│ Worker polls queue, claims task │ -│ │ │ -│ ▼ │ -│ Claude Code executes in claudebox-0: │ -│ - Clones repo │ -│ - Builds site from prompt │ -│ - Commits and pushes │ -│ │ │ -│ ▼ │ -│ Woodpecker CI triggered by push: │ -│ - Builds Docker image │ -│ - Pushes to registry │ -│ - Updates K8s deployment │ -│ │ │ -│ ▼ │ -│ Site live at https://{slug}.threesix.ai │ +│ └──► Skeleton includes .woodpecker.yml (template-provided) │ │ │ +│ POST /projects/{id}/components │ +│ │ │ +│ ├──► Adds apps/landing/ from app-astro template │ +│ ├──► Updates .woodpecker.yml with build step │ +│ └──► Updates Procfile │ +│ │ +│ POST /projects/{id}/builds (optional customization) │ +│ │ │ +│ └──► Claude modifies existing files, commits, pushes │ +│ │ +│ Git push triggers Woodpecker CI: │ +│ ├──► Builds Docker image via Kaniko │ +│ ├──► Pushes to registry.threesix.ai │ +│ └──► Deploys to K8s │ +│ │ +│ Site live at https://{slug}.threesix.ai │ └─────────────────────────────────────────────────────────────────────┘ ``` @@ -303,10 +289,17 @@ curl -s "https://git.threesix.ai/api/v1/repos/jordan/my-landing/commits" | jq '. open https://ci.threesix.ai/jordan/my-landing ``` +### Component not appearing +```bash +# List components +curl -s "$RDEV_API_URL/projects/my-landing/components" \ + -H "X-API-Key: $RDEV_API_KEY" | jq '.data' +``` + --- ## Related -- [Full-Stack App Cookbook](./fullstack-app.md) - Next.js + Go backend +- [Composable App Cookbook](./composable-app.md) - Full-stack apps with multiple components - [Worker Pool Guide](../.claude/guides/services/worker-pool.md) -- [Build Orchestration](../.claude/guides/services/build-orchestration.md) +- [Composable Monorepo Guide](../.claude/guides/services/composable-monorepo.md) diff --git a/cookbooks/scripts/composable-test.sh b/cookbooks/scripts/composable-test.sh new file mode 100755 index 0000000..714745a --- /dev/null +++ b/cookbooks/scripts/composable-test.sh @@ -0,0 +1,194 @@ +#!/bin/bash +set -euo pipefail + +# Composable App E2E Test Script +# Tests the composable monorepo template flow: +# 1. Create project (skeleton) +# 2. Add service component +# 3. Add app component +# 4. Optionally customize with Claude +# 5. Deploy and verify +# +# Usage: ./cookbooks/scripts/composable-test.sh +# Commands: run, status, teardown + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/common.sh" + +COMMAND="${1:-}" +PROJECT_NAME="${2:-}" + +if [[ -z "$COMMAND" || -z "$PROJECT_NAME" ]]; then + echo "Usage: $0 " + echo "Commands:" + echo " run - Create project with components and deploy" + echo " status - Check project and component status" + echo " teardown - Delete the project" + exit 1 +fi + +# Add a component and verify +add_component() { + local comp_type="$1" + local comp_name="$2" + local template="${3:-$comp_type}" + + echo "Adding $comp_type component: $comp_name (template: $template)" + + local payload + payload=$(jq -n \ + --arg type "$comp_type" \ + --arg name "$comp_name" \ + --arg template "$template" \ + '{type: $type, name: $name, template: $template}') + + local result + result=$(api_call POST "/projects/$PROJECT_NAME/components" "$payload") + + local path + path=$(echo "$result" | jq -r '.data.path // .path // ""') + + if [[ -z "$path" ]]; then + print_error "Failed to add component" + echo "$result" | jq '.' + return 1 + fi + + local port + port=$(echo "$result" | jq -r '.data.port // .port // "N/A"') + print_success "Added $comp_type/$comp_name at $path (port: $port)" + return 0 +} + +run_flow() { + print_header "Composable App E2E Test" + echo "Project: $PROJECT_NAME" + + # Step 1: Create project (skeleton) + print_header "Step 1: Creating project skeleton" + local create_payload + create_payload=$(jq -n \ + --arg name "$PROJECT_NAME" \ + --arg desc "Composable app E2E test" \ + '{name: $name, description: $desc}') + + local create_result + create_result=$(api_call POST "/projects" "$create_payload") + echo "$create_result" | jq '.' + + local domain + domain=$(echo "$create_result" | jq -r '.data.domain // .domain // ""') + + if [[ -z "$domain" ]]; then + print_error "Failed to create project" + exit 1 + fi + + print_success "Project created with domain: $domain" + + # Step 2: Add backend service + print_header "Step 2: Adding backend service" + if ! add_component "service" "api" "service"; then + exit 1 + fi + + # Step 3: Add frontend app + print_header "Step 3: Adding frontend app" + if ! add_component "app" "web" "app-react"; then + exit 1 + fi + + # Step 4: List components + print_header "Step 4: Verifying components" + local components + components=$(api_call GET "/projects/$PROJECT_NAME/components") + echo "$components" | jq '.data // .' + + local comp_count + comp_count=$(echo "$components" | jq '.data | length // 0') + if [[ "$comp_count" -lt 2 ]]; then + print_warning "Expected 2 components, got $comp_count" + else + print_success "All components added successfully" + fi + + # Step 5: Wait for CI pipeline + print_header "Step 5: Waiting for CI pipeline" + if ! wait_for_pipeline "$PROJECT_NAME"; then + print_warning "Pipeline may have issues, continuing to check site..." + fi + + # Step 6: Wait for site + print_header "Step 6: Verifying site is accessible" + if ! wait_for_site "$domain"; then + print_error "Site not accessible" + exit 1 + fi + + # Step 7: Test API endpoint + print_header "Step 7: Testing API endpoint" + local api_response + api_response=$(curl -s "https://$domain/api/health" 2>/dev/null || echo '{"error":"failed"}') + + if echo "$api_response" | jq -e '.' > /dev/null 2>&1; then + print_success "API responded with valid JSON" + echo "$api_response" | jq '.' + else + print_warning "API health check returned non-JSON: $api_response" + fi + + # Summary + print_header "E2E Test Results" + print_success "Project created: $PROJECT_NAME" + print_success "Components added: $comp_count" + echo "" + echo "Site URL: https://$domain" + echo "Git repo: https://git.threesix.ai/jordan/$PROJECT_NAME" + echo "CI: https://ci.threesix.ai/jordan/$PROJECT_NAME" +} + +check_status() { + print_header "Project Status: $PROJECT_NAME" + + # Get project info + echo "Project:" + api_call GET "/projects/$PROJECT_NAME" | jq '.data // .' + echo "" + + # Get components + echo "Components:" + api_call GET "/projects/$PROJECT_NAME/components" | jq '.data // .' + echo "" + + # Get latest pipelines + echo "Latest Pipelines:" + api_call GET "/projects/$PROJECT_NAME/pipelines" | jq '.data[:3] // .' +} + +teardown() { + print_header "Tearing down: $PROJECT_NAME" + + local result + result=$(api_call DELETE "/projects/$PROJECT_NAME") + echo "$result" | jq '.' + + echo "" + print_success "Project deleted. Gitea repo preserved." +} + +case "$COMMAND" in + run) + run_flow + ;; + status) + check_status + ;; + teardown) + teardown + ;; + *) + echo "Unknown command: $COMMAND" + echo "Valid commands: run, status, teardown" + exit 1 + ;; +esac diff --git a/cookbooks/scripts/fullstack-test.sh b/cookbooks/scripts/fullstack-test.sh deleted file mode 100755 index f269724..0000000 --- a/cookbooks/scripts/fullstack-test.sh +++ /dev/null @@ -1,202 +0,0 @@ -#!/bin/bash -set -euo pipefail - -# Full-Stack App E2E Test Script -# Usage: ./cookbooks/scripts/fullstack-test.sh -# Commands: run, status, teardown - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "$SCRIPT_DIR/common.sh" - -COMMAND="${1:-}" -PROJECT_NAME="${2:-}" - -if [[ -z "$COMMAND" || -z "$PROJECT_NAME" ]]; then - echo "Usage: $0 " - echo "Commands:" - echo " run - Create project and run full-stack build" - echo " status - Check build and deployment status" - echo " teardown - Delete the project" - exit 1 -fi - -# Full-stack app build prompt -FULLSTACK_PROMPT='Build a full-stack task management application with the following structure: - -FRONTEND (Next.js 14 + shadcn/ui): -- Create a Next.js 14 app with App Router in /frontend -- Use shadcn/ui for all components (install with npx shadcn-ui@latest init) -- Dark theme with modern aesthetic -- Pages: Dashboard showing tasks, Add Task form, Task detail view -- Use Tailwind CSS for styling -- Connect to backend API at /api proxy - -BACKEND (Go): -- Create a Go HTTP server in /backend using chi router -- Endpoints: GET /api/tasks, POST /api/tasks, GET /api/tasks/{id}, DELETE /api/tasks/{id} -- In-memory task storage (no database needed) -- Structured JSON responses -- CORS middleware for frontend - -DOCKER: -- /frontend/Dockerfile: Multi-stage build for Next.js (node:20-alpine) -- /backend/Dockerfile: Multi-stage build for Go (golang:1.22-alpine) -- /docker-compose.yml: Run both services, frontend proxies to backend - -CI/CD: -- /.woodpecker.yml: Build both images, push to registry, deploy to k8s - -Create all necessary files including package.json, go.mod, and configuration files.' - -# Test backend API -test_backend_api() { - local domain="$1" - - echo "Testing backend API..." - - # Test GET /api/tasks - local response - response=$(curl -s "https://$domain/api/tasks" 2>/dev/null || echo '{"error":"failed"}') - - if echo "$response" | jq -e '.' > /dev/null 2>&1; then - echo " GET /api/tasks: OK" - echo " Response: $response" - return 0 - else - echo " GET /api/tasks: FAILED" - echo " Response: $response" - return 1 - fi -} - -run_flow() { - echo "=== Full-Stack App E2E Test ===" - echo "Project: $PROJECT_NAME" - echo "" - - # Step 1: Create project with build - echo "Step 1: Creating project and submitting full-stack build..." - local create_result - # Build the JSON payload (prompt, auto_commit, auto_push are top-level fields) - local payload - payload=$(jq -n \ - --arg name "$PROJECT_NAME" \ - --arg desc "Full-stack app E2E test" \ - --arg prompt "$FULLSTACK_PROMPT" \ - '{ - name: $name, - description: $desc, - prompt: $prompt, - auto_commit: true, - auto_push: true - }') - create_result=$(api_call POST "/project/create-and-build" "$payload") - - echo "$create_result" | jq '.' - - local domain - domain=$(echo "$create_result" | jq -r '.data.domain // .domain // ""') - local task_id - task_id=$(echo "$create_result" | jq -r '.data.task_id // .task_id // ""') - - if [[ -z "$domain" || -z "$task_id" ]]; then - echo "ERROR: Failed to create project" - exit 1 - fi - - echo "" - echo "Domain: $domain" - echo "Build Task: $task_id" - echo "" - - # Step 2: Wait for build - echo "Step 2: Waiting for Claude to build the full-stack app..." - if ! wait_for_build "$task_id"; then - echo "ERROR: Build failed" - exit 1 - fi - echo "" - - # Step 3: Wait for CI pipeline - echo "Step 3: Waiting for CI pipeline to build and deploy..." - if ! wait_for_pipeline "$PROJECT_NAME"; then - echo "WARNING: Pipeline may have failed, continuing to check site..." - fi - echo "" - - # Step 4: Wait for site - echo "Step 4: Verifying site is accessible..." - if ! wait_for_site "$domain"; then - echo "ERROR: Site not accessible" - exit 1 - fi - echo "" - - # Step 5: Test backend API - echo "Step 5: Testing backend API..." - if ! test_backend_api "$domain"; then - echo "WARNING: Backend API test failed" - fi - echo "" - - # Summary - echo "=== E2E Test Results ===" - echo "Project created: PASS" - echo "Build completed: PASS" - echo "CI Pipeline: $(wait_for_pipeline "$PROJECT_NAME" > /dev/null 2>&1 && echo "PASS" || echo "CHECK")" - echo "Site accessible: PASS" - echo "Backend API: $(test_backend_api "$domain" > /dev/null 2>&1 && echo "PASS" || echo "CHECK")" - echo "" - echo "Site URL: https://$domain" - echo "Git repo: https://git.threesix.ai/jordan/$PROJECT_NAME" - echo "CI: https://ci.threesix.ai/jordan/$PROJECT_NAME" -} - -check_status() { - echo "=== Project Status: $PROJECT_NAME ===" - echo "" - - # Get project info - local project_result - project_result=$(api_call GET "/projects/$PROJECT_NAME") - echo "Project:" - echo "$project_result" | jq '.' - echo "" - - # Get latest build - echo "Latest Builds:" - api_call GET "/projects/$PROJECT_NAME/builds" | jq '.data[:3]' - echo "" - - # Get latest pipeline - echo "Latest Pipelines:" - api_call GET "/projects/$PROJECT_NAME/pipelines" | jq '.data[:3]' -} - -teardown() { - echo "=== Tearing down: $PROJECT_NAME ===" - - local result - result=$(api_call DELETE "/project/$PROJECT_NAME") - echo "$result" | jq '.' - - echo "" - echo "Project deleted. Gitea repo preserved." -} - -case "$COMMAND" in - run) - run_flow - ;; - status) - check_status - ;; - teardown) - teardown - ;; - *) - echo "Unknown command: $COMMAND" - echo "Valid commands: run, status, teardown" - exit 1 - ;; -esac diff --git a/cookbooks/scripts/landing-test.sh b/cookbooks/scripts/landing-test.sh index 1370ae0..d39d9ed 100755 --- a/cookbooks/scripts/landing-test.sh +++ b/cookbooks/scripts/landing-test.sh @@ -1,6 +1,12 @@ #!/bin/bash # Landing Page Cookbook Test Script -# Tests the full agent-driven landing page flow from cookbooks/landing-page.md +# Tests the composable landing page flow from cookbooks/landing-page.md +# +# Flow: +# 1. Create project (monorepo skeleton) +# 2. Add app-astro component +# 3. Wait for CI pipeline +# 4. Verify site is live # # Usage: # ./cookbooks/scripts/landing-test.sh run [name] # Run the full flow @@ -26,14 +32,9 @@ log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } log_error() { echo -e "${RED}[ERROR]${NC} $1"; } # Timeouts -BUILD_TIMEOUT=600 # 10 minutes for Claude to build the site -BUILD_POLL_INTERVAL=5 # Check every 5 seconds PIPELINE_TIMEOUT=300 # 5 minutes max wait for CI pipeline PIPELINE_POLL_INTERVAL=10 -SITE_TIMEOUT=60 # 1 minute max wait for site to be live - -# Streaming mode (set to true to stream live build output via SSE) -STREAM_MODE="${STREAM_MODE:-false}" +SITE_TIMEOUT=120 # 2 minutes max wait for site to be live api_call() { local method="$1" @@ -65,136 +66,7 @@ check_health() { fi } -# Stream build events via SSE (real-time output) -# Arguments: project_id, task_id -stream_build_events() { - local project_id="$1" - local task_id="$2" - local stream_url="${API_URL}/projects/${project_id}/events?stream_id=${task_id}" - - log_info "Streaming build events from: $stream_url" - echo "" - - # Use curl to stream SSE events - curl -s -N \ - -H "X-API-Key: ${API_KEY}" \ - -H "Accept: text/event-stream" \ - "$stream_url" 2>/dev/null | while IFS= read -r line; do - # Skip empty lines and event headers - if [[ -z "$line" || "$line" == "event:"* || "$line" == "id:"* ]]; then - continue - fi - - # Parse data lines - if [[ "$line" == "data:"* ]]; then - local data="${line#data: }" - - # Parse event type and content - local event_type content - event_type=$(echo "$data" | jq -r '.type // "unknown"' 2>/dev/null) - - case "$event_type" in - build.started) - echo -e "${GREEN}[BUILD STARTED]${NC}" - ;; - build.output) - content=$(echo "$data" | jq -r '.content // ""' 2>/dev/null) - [[ -n "$content" ]] && echo "$content" - ;; - build.tool_use) - local tool_name - tool_name=$(echo "$data" | jq -r '.tool_name // "unknown"' 2>/dev/null) - echo -e "${YELLOW}[TOOL: $tool_name]${NC}" - ;; - build.completed) - echo -e "${GREEN}[BUILD COMPLETED]${NC}" - return 0 - ;; - build.failed) - local error - error=$(echo "$data" | jq -r '.error // "unknown error"' 2>/dev/null) - echo -e "${RED}[BUILD FAILED]${NC} $error" - return 1 - ;; - esac - fi - done -} - -# Wait for build to complete (Claude building the site) -# Returns: 0 on success, 1 on failure/timeout -wait_for_build() { - local task_id="$1" - local project_id="${2:-}" - local start_time=$(date +%s) - - log_info "Waiting for Claude to build the site (timeout: ${BUILD_TIMEOUT}s)..." - - # If streaming mode is enabled and we have a project_id, use SSE - if [[ "$STREAM_MODE" == "true" && -n "$project_id" ]]; then - log_info "Streaming mode enabled - showing live build output" - stream_build_events "$project_id" "$task_id" & - local stream_pid=$! - fi - - while true; do - local elapsed=$(($(date +%s) - start_time)) - if [[ $elapsed -ge $BUILD_TIMEOUT ]]; then - [[ -n "${stream_pid:-}" ]] && kill "$stream_pid" 2>/dev/null || true - log_error "Build timeout after ${BUILD_TIMEOUT}s" - return 1 - fi - - local response - response=$(api_call GET "/builds/$task_id" 2>/dev/null || echo "{}") - - local status - status=$(echo "$response" | jq -r '.data.status // "unknown"' 2>/dev/null) - - case "$status" in - completed) - [[ -n "${stream_pid:-}" ]] && kill "$stream_pid" 2>/dev/null || true - local success - success=$(echo "$response" | jq -r '.data.result.success // false') - if [[ "$success" == "true" ]]; then - log_success "Build completed successfully (${elapsed}s)" - echo "$response" | jq '.data.result | {success, commit_sha, files_changed, duration_ms}' - return 0 - else - log_error "Build completed but failed" - echo "$response" | jq '.data.result' - return 1 - fi - ;; - failed) - [[ -n "${stream_pid:-}" ]] && kill "$stream_pid" 2>/dev/null || true - log_error "Build failed" - echo "$response" | jq '.data.result // .data' - return 1 - ;; - running) - if [[ "$STREAM_MODE" != "true" ]]; then - echo -ne "\r${BLUE}[INFO]${NC} Build status: running (${elapsed}s)... " - fi - ;; - pending) - if [[ "$STREAM_MODE" != "true" ]]; then - echo -ne "\r${BLUE}[INFO]${NC} Build status: pending (${elapsed}s)... " - fi - ;; - *) - if [[ "$STREAM_MODE" != "true" ]]; then - echo -ne "\r${BLUE}[INFO]${NC} Build status: $status (${elapsed}s)... " - fi - ;; - esac - - sleep $BUILD_POLL_INTERVAL - done -} - # Wait for pipeline to appear and complete -# Returns: 0 on success, 1 on failure/timeout wait_for_pipeline() { local project_name="$1" local start_time=$(date +%s) @@ -214,7 +86,7 @@ wait_for_pipeline() { local response response=$(api_call GET "/projects/$project_name/pipelines" 2>/dev/null || echo "{}") - # Check if we have pipelines (API returns array at .data) + # Check if we have pipelines local pipeline_count pipeline_count=$(echo "$response" | jq -r '.data | length' 2>/dev/null || echo "0") @@ -230,10 +102,12 @@ wait_for_pipeline() { case "$pipeline_status" in success) + echo "" log_success "Pipeline #$pipeline_number completed successfully (${elapsed}s)" return 0 ;; failure|error|killed|declined) + echo "" log_error "Pipeline #$pipeline_number failed with status: $pipeline_status" echo "$response" | jq '.data[0]' return 1 @@ -254,7 +128,6 @@ wait_for_pipeline() { } # Wait for site to be accessible -# Returns: 0 on success, 1 on failure/timeout wait_for_site() { local domain="$1" local start_time=$(date +%s) @@ -285,393 +158,167 @@ wait_for_site() { done } -# Test adding a DNS alias -test_dns_alias() { - local project_name="$1" - local alias_domain="$2" - - log_info "Testing DNS alias: $alias_domain" - - local response - response=$(api_call POST "/projects/$project_name/domains" "{\"domain\": \"$alias_domain\"}") - - if echo "$response" | jq -e '.error' > /dev/null 2>&1; then - local error_code - error_code=$(echo "$response" | jq -r '.error.code // "UNKNOWN"') - if [[ "$error_code" == "DOMAIN_EXISTS" ]]; then - log_warn "Domain alias already exists: $alias_domain" - return 0 - fi - log_error "Failed to add DNS alias" - echo "$response" | jq . - return 1 - fi - - log_success "DNS alias added: $alias_domain" - echo "$response" | jq '.data | {domain, type, record_type}' - return 0 -} - -# Remove a DNS alias -remove_dns_alias() { - local project_name="$1" - local alias_domain="$2" - - log_info "Removing DNS alias: $alias_domain" - - local response - response=$(api_call DELETE "/projects/$project_name/domains/$alias_domain") - - if echo "$response" | jq -e '.error' > /dev/null 2>&1; then - local error_code - error_code=$(echo "$response" | jq -r '.error.code // "UNKNOWN"') - if [[ "$error_code" == "NOT_FOUND" ]]; then - log_warn "Domain alias not found (already deleted?): $alias_domain" - return 0 - fi - log_warn "Failed to remove DNS alias: $alias_domain" - return 1 - fi - - log_success "DNS alias removed: $alias_domain" - return 0 -} - +# Main run flow run_flow() { - local project_name="${1:-landing-test}" - - # Default prompt for building a landing page - local build_prompt="Build a modern landing page with: dark gradient background (#1a1a2e to #16213e), centered hero section with company name 'Acme Corp' and tagline 'Building the future', email signup form with a submit button, responsive design for mobile. Use vanilla HTML/CSS/JS. Create index.html, styles.css, and a Dockerfile that serves with nginx on port 80." + local project_name="$1" echo "" echo "==========================================" - echo " Landing Page Cookbook Test" - echo " Project: $project_name" - echo " Flow: Agent-driven (create-and-build)" + echo " Landing Page E2E Test (Composable)" echo "==========================================" echo "" + echo "Project: $project_name" + echo "" # Step 0: Health check check_health || exit 1 echo "" - # Step 1: Create project AND enqueue build in one call - log_info "Step 1: Creating project and enqueuing build task..." - log_info "Prompt: ${build_prompt:0:80}..." - - local create_payload - create_payload=$(jq -n \ - --arg name "$project_name" \ - --arg desc "Cookbook test: agent-driven landing page" \ - --arg prompt "$build_prompt" \ - '{ - name: $name, - description: $desc, - prompt: $prompt, - auto_commit: true, - auto_push: true - }') + # Step 1: Create project (monorepo skeleton) + log_info "Step 1: Creating project skeleton..." local create_response - create_response=$(api_call POST "/project/create-and-build" "$create_payload") + create_response=$(api_call POST "/projects" "{\"name\": \"$project_name\", \"description\": \"Landing page E2E test\"}") - if echo "$create_response" | jq -e '.error' > /dev/null 2>&1; then + local domain + domain=$(echo "$create_response" | jq -r '.data.domain // ""') + + if [[ -z "$domain" || "$domain" == "null" ]]; then log_error "Failed to create project" echo "$create_response" | jq . exit 1 fi - log_success "Project created and build enqueued" - echo "$create_response" | jq '.data | { - project_id, - domain, - url, - git: .git.html_url, - task_id, - status, - status_url - }' + log_success "Project created: $project_name" + echo " Domain: $domain" + echo " Git: https://git.threesix.ai/jordan/$project_name" + echo "" - # Extract key info - local primary_domain - local task_id - primary_domain=$(echo "$create_response" | jq -r '.data.domain') - task_id=$(echo "$create_response" | jq -r '.data.task_id') + # Step 2: Add app-astro component + log_info "Step 2: Adding landing page component (app-astro)..." - if [[ -z "$task_id" || "$task_id" == "null" ]]; then - log_error "No task_id returned - build was not enqueued" + local component_response + component_response=$(api_call POST "/projects/$project_name/components" '{"type": "app", "name": "landing", "template": "app-astro"}') + + local component_path + component_path=$(echo "$component_response" | jq -r '.data.path // ""') + + if [[ -z "$component_path" || "$component_path" == "null" ]]; then + log_error "Failed to add component" + echo "$component_response" | jq . exit 1 fi - log_success "Build task ID: $task_id" + local component_port + component_port=$(echo "$component_response" | jq -r '.data.port // "N/A"') + + log_success "Component added: $component_path (port: $component_port)" echo "" - # Step 2: Monitor build progress (Claude building the site) - log_info "Step 2: Monitoring build progress..." + # Step 3: Wait for pipeline + log_info "Step 3: Waiting for CI pipeline..." echo "" - local build_success=false - if wait_for_build "$task_id" "$project_name"; then - build_success=true - else - log_error "Build did not complete successfully" - log_info "Check build details: curl -s \"\$RDEV_API_URL/builds/$task_id\" -H \"X-API-Key: \$RDEV_API_KEY\" | jq ." + + if ! wait_for_pipeline "$project_name"; then + log_warn "Pipeline failed, but continuing to check if site is accessible..." fi echo "" - # Step 3: Monitor CI pipeline (only if build succeeded) - local pipeline_success=false - if [[ "$build_success" == "true" ]]; then - log_info "Step 3: Monitoring CI pipeline..." - if wait_for_pipeline "$project_name"; then - pipeline_success=true - else - log_warn "Pipeline did not complete successfully" - log_info "Check Woodpecker: https://ci.threesix.ai/threesix/$project_name" - fi - else - log_info "Step 3: Skipping pipeline monitoring (build failed)" - fi - echo "" + # Step 4: Wait for site + log_info "Step 4: Verifying site is accessible..." - # Step 4: Verify site is live - local site_live=false - if [[ "$pipeline_success" == "true" ]]; then - log_info "Step 4: Verifying site is accessible..." - if wait_for_site "$primary_domain"; then - site_live=true - # Show a snippet of the response - log_info "Fetching site content preview..." - curl -s "https://$primary_domain" | head -20 | grep -E '|<h1' || true - fi + if wait_for_site "$domain"; then + log_success "Site verified!" else - log_info "Step 4: Skipping site verification (pipeline not successful)" - fi - echo "" - - # Step 5: Test adding custom domains - local test_alias="${project_name}-alias.threesix.ai" - log_info "Step 5: Testing custom domain functionality..." - if test_dns_alias "$project_name" "$test_alias"; then - log_success "Domain alias test passed" - # Clean up test alias - sleep 2 - remove_dns_alias "$project_name" "$test_alias" - else - log_warn "Domain alias test failed - check Cloudflare permissions" - fi - echo "" - - # List all domains - log_info "Listing all project domains..." - local domains_response - domains_response=$(api_call GET "/projects/$project_name/domains") - if echo "$domains_response" | jq -e '.data.domains' > /dev/null 2>&1; then - echo "$domains_response" | jq '.data.domains[] | {domain, type, verified}' + log_warn "Site not accessible yet, may need more time" fi echo "" # Summary echo "==========================================" - echo " Test Results Summary" + echo " Test Complete" echo "==========================================" echo "" - echo " Project: $project_name" - echo " Task ID: $task_id" - echo " Git repo: $(echo "$create_response" | jq -r '.data.git.html_url // "N/A"')" - echo " Primary: https://$primary_domain" + echo " Site URL: https://$domain" + echo " Git: https://git.threesix.ai/jordan/$project_name" + echo " CI: https://ci.threesix.ai/jordan/$project_name" echo "" - echo " Test Results:" - echo -e " Project created: ${GREEN}PASS${NC}" - if [[ "$build_success" == "true" ]]; then - echo -e " Agent build: ${GREEN}PASS${NC}" - else - echo -e " Agent build: ${RED}FAIL${NC}" - fi - if [[ "$pipeline_success" == "true" ]]; then - echo -e " CI Pipeline: ${GREEN}PASS${NC}" - elif [[ "$build_success" == "true" ]]; then - echo -e " CI Pipeline: ${RED}FAIL${NC}" - else - echo -e " CI Pipeline: ${YELLOW}SKIPPED${NC}" - fi - if [[ "$site_live" == "true" ]]; then - echo -e " Site accessible: ${GREEN}PASS${NC}" - elif [[ "$pipeline_success" == "true" ]]; then - echo -e " Site accessible: ${YELLOW}PENDING${NC}" - else - echo -e " Site accessible: ${YELLOW}SKIPPED${NC}" - fi - echo -e " Custom domains: ${GREEN}TESTED${NC}" + echo " To customize: POST /projects/$project_name/builds with a prompt" + echo " To teardown: $0 teardown $project_name" echo "" - echo " Useful commands:" - echo " Build status: curl -s \"\$RDEV_API_URL/builds/$task_id\" -H \"X-API-Key: \$RDEV_API_KEY\" | jq .data" - echo " Check status: ./cookbooks/scripts/landing-test.sh status $project_name" - echo " View logs: ./scripts/logs.sh -e" - echo " Woodpecker: https://ci.threesix.ai/threesix/$project_name" - echo " Teardown: ./cookbooks/scripts/landing-test.sh teardown $project_name" - echo "" - - # Return appropriate exit code - if [[ "$build_success" == "true" && "$pipeline_success" == "true" && "$site_live" == "true" ]]; then - log_success "Full E2E test PASSED" - return 0 - elif [[ "$build_success" == "true" && "$pipeline_success" == "true" ]]; then - log_warn "Partial success - build and pipeline passed but site not yet live" - return 0 - elif [[ "$build_success" == "true" ]]; then - log_warn "Partial success - build passed but pipeline failed" - return 1 - else - log_error "E2E test FAILED - build did not complete" - return 1 - fi } +# Check status +check_status() { + local project_name="$1" + + echo "" + echo "=== Project Status: $project_name ===" + echo "" + + log_info "Project info:" + api_call GET "/projects/$project_name" | jq '.data // .' + echo "" + + log_info "Components:" + api_call GET "/projects/$project_name/components" | jq '.data // .' + echo "" + + log_info "Latest pipelines:" + api_call GET "/projects/$project_name/pipelines" | jq '.data[:3] // .' + echo "" +} + +# Teardown teardown() { - local project_name="${1:-landing-test}" + local project_name="$1" echo "" - echo "==========================================" - echo " Teardown: $project_name" - echo "==========================================" - echo "" + log_info "Tearing down project: $project_name" - # First, list domains that will be deleted - log_info "Checking domains to be deleted..." - local domains_response - domains_response=$(api_call GET "/projects/$project_name/domains") - - if echo "$domains_response" | jq -e '.data.total' > /dev/null 2>&1; then - local domain_count - domain_count=$(echo "$domains_response" | jq -r '.data.total') - log_info "Will delete $domain_count domain(s):" - echo "$domains_response" | jq -r '.data.domains[]? | " - \(.domain)"' - echo "" - fi - - log_info "Deleting project $project_name..." local response - response=$(api_call DELETE "/project/$project_name") - - if echo "$response" | jq -e '.data.status == "deleted"' > /dev/null 2>&1; then - log_success "Project deleted" - echo "$response" | jq .data - elif echo "$response" | jq -e '.error.code == "NOT_FOUND"' > /dev/null 2>&1; then - log_warn "Project not found (already deleted?)" - else - log_error "Failed to delete project" - echo "$response" | jq . - exit 1 - fi - echo "" - - log_info "Note: Gitea repo is preserved for safety. Delete manually if needed:" - echo " https://git.threesix.ai/threesix/$project_name/settings" - echo "" -} - -status() { - local project_name="${1:-landing-test}" - - echo "" - log_info "Fetching status for: $project_name" - echo "" - - # Get project info - local response - response=$(api_call GET "/project/$project_name") + response=$(api_call DELETE "/projects/$project_name") if echo "$response" | jq -e '.error' > /dev/null 2>&1; then - log_error "Project not found or error" + log_error "Teardown failed" echo "$response" | jq . exit 1 fi - echo "$response" | jq '.data | { - name, - description, - domain, - url, - git: .git.html_url, - deployment - }' - + log_success "Project deleted (Gitea repo preserved)" + echo "$response" | jq '.data // .' echo "" - log_info "Listing all domains..." - local domains_response - domains_response=$(api_call GET "/projects/$project_name/domains") - - if echo "$domains_response" | jq -e '.data.domains' > /dev/null 2>&1; then - echo "$domains_response" | jq '.data.domains' - fi - - echo "" - log_info "Checking recent builds..." - local builds_response - builds_response=$(api_call GET "/projects/$project_name/builds?limit=3") - - if echo "$builds_response" | jq -e '.data.builds' > /dev/null 2>&1; then - echo "$builds_response" | jq '.data.builds[] | {task_id, status, started_at, result: .result.success}' - else - log_info "No builds found" - fi - - echo "" - log_info "Checking recent pipelines..." - local pipelines_response - pipelines_response=$(api_call GET "/projects/$project_name/pipelines") - - if echo "$pipelines_response" | jq -e '.data | length > 0' > /dev/null 2>&1; then - echo "$pipelines_response" | jq '.data[0:3][] | {number, status, branch, commit: .commit[0:8]}' - else - log_info "No pipelines found" - fi } -# Main -case "${1:-}" in +# Parse command +COMMAND="${1:-}" +PROJECT_NAME="${2:-landing-test-$(date +%s)}" + +case "$COMMAND" in run) - shift - run_flow "${1:-landing-test}" - ;; - teardown) - shift - teardown "${1:-landing-test}" + run_flow "$PROJECT_NAME" ;; status) - shift - status "${1:-landing-test}" + check_status "$PROJECT_NAME" + ;; + teardown) + teardown "$PROJECT_NAME" ;; *) - echo "Usage: $0 {run|teardown|status} [project-name]" + echo "Landing Page E2E Test Script" + echo "" + echo "Usage: $0 <command> [project-name]" echo "" echo "Commands:" - echo " run [name] Create project with agent-driven build and run full E2E flow" - echo " teardown [name] Delete project and clean up" - echo " status [name] Check current project status, builds, and pipelines" - echo "" - echo "E2E Flow (matches cookbooks/landing-page.md):" - echo " 1. POST /project/create-and-build - Create project + enqueue agent build" - echo " 2. GET /builds/{task_id} - Monitor Claude building the site" - echo " 3. GET /projects/{id}/pipelines - Monitor CI pipeline" - echo " 4. Verify site is live (HTTP 200)" - echo " 5. Test custom domains (POST/DELETE /projects/{id}/domains)" - echo " 6. DELETE /project/{name} - Teardown" - echo "" - echo "Timeouts:" - echo " Build: ${BUILD_TIMEOUT}s, Pipeline: ${PIPELINE_TIMEOUT}s, Site: ${SITE_TIMEOUT}s" - echo "" - echo "Environment:" - echo " RDEV_API_URL API endpoint (default: https://rdev.masq-ops.orchard9.ai)" - echo " RDEV_API_KEY API key (required)" - echo " STREAM_MODE Set to 'true' for live SSE streaming of build output" + echo " run Run the full composable landing page flow" + echo " status Check project and component status" + echo " teardown Delete project (preserves git repo)" echo "" echo "Examples:" - echo " $0 run # Run with default project name 'landing-test'" - echo " $0 run my-landing # Run with custom project name" - echo " STREAM_MODE=true $0 run # Run with live build output streaming" - echo " $0 status my-landing # Check status, builds, and pipelines" - echo " $0 teardown my-landing # Clean up project" + echo " $0 run my-landing" + echo " $0 status my-landing" + echo " $0 teardown my-landing" + echo "" exit 1 ;; esac diff --git a/internal/adapter/deployer/deployer_components.go b/internal/adapter/deployer/deployer_components.go new file mode 100644 index 0000000..bedc0a6 --- /dev/null +++ b/internal/adapter/deployer/deployer_components.go @@ -0,0 +1,303 @@ +package deployer + +import ( + "bytes" + "context" + "fmt" + "time" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/orchard9/rdev/internal/domain" +) + +// UndeployComponent removes deployment resources for a specific component. +func (d *Deployer) UndeployComponent(ctx context.Context, projectName, componentPath string) error { + // Build deployment name from project and component + spec := domain.DeploySpec{ + ProjectName: projectName, + ComponentPath: componentPath, + } + deploymentName := spec.DeploymentName() + ns := d.config.Namespace + + // Delete Ingress + err := d.client.NetworkingV1().Ingresses(ns).Delete(ctx, deploymentName, metav1.DeleteOptions{}) + if err != nil && !errors.IsNotFound(err) { + return fmt.Errorf("failed to delete ingress: %w", err) + } + + // Delete Service + err = d.client.CoreV1().Services(ns).Delete(ctx, deploymentName, metav1.DeleteOptions{}) + if err != nil && !errors.IsNotFound(err) { + return fmt.Errorf("failed to delete service: %w", err) + } + + // Delete Deployment + err = d.client.AppsV1().Deployments(ns).Delete(ctx, deploymentName, metav1.DeleteOptions{}) + if err != nil && !errors.IsNotFound(err) { + return fmt.Errorf("failed to delete deployment: %w", err) + } + + // Delete Secret + err = d.client.CoreV1().Secrets(ns).Delete(ctx, deploymentName+"-env", metav1.DeleteOptions{}) + if err != nil && !errors.IsNotFound(err) { + return fmt.Errorf("failed to delete secret: %w", err) + } + + return nil +} + +// GetComponentStatus returns deployment status for a specific component. +func (d *Deployer) GetComponentStatus(ctx context.Context, projectName, componentPath string) (*domain.DeployStatus, error) { + // Build deployment name from project and component + spec := domain.DeploySpec{ + ProjectName: projectName, + ComponentPath: componentPath, + } + deploymentName := spec.DeploymentName() + ns := d.config.Namespace + + deployment, err := d.client.AppsV1().Deployments(ns).Get(ctx, deploymentName, metav1.GetOptions{}) + if err != nil { + if errors.IsNotFound(err) { + return nil, nil + } + return nil, fmt.Errorf("failed to get deployment: %w", err) + } + + // Determine status + var status domain.DeploymentStatus + switch { + case deployment.Status.ReadyReplicas == *deployment.Spec.Replicas: + status = domain.DeploymentStatusRunning + case deployment.Status.UnavailableReplicas > 0: + status = domain.DeploymentStatusFailed + case deployment.Status.ReadyReplicas < *deployment.Spec.Replicas: + status = domain.DeploymentStatusPending + default: + status = domain.DeploymentStatusUnknown + } + + // Get URL from ingress + var url string + ingress, err := d.client.NetworkingV1().Ingresses(ns).Get(ctx, deploymentName, metav1.GetOptions{}) + if err == nil && len(ingress.Spec.Rules) > 0 { + host := ingress.Spec.Rules[0].Host + url = "https://" + host + } + + return &domain.DeployStatus{ + ProjectName: projectName, + ComponentPath: componentPath, + Image: deployment.Spec.Template.Spec.Containers[0].Image, + Replicas: int(*deployment.Spec.Replicas), + ReadyReplicas: int(deployment.Status.ReadyReplicas), + URL: url, + Status: status, + CreatedAt: deployment.CreationTimestamp.Time, + UpdatedAt: time.Now(), + }, nil +} + +// ListComponentStatuses returns deployment status for all components in a project. +func (d *Deployer) ListComponentStatuses(ctx context.Context, projectName string) (*domain.ProjectDeployStatus, error) { + ns := d.config.Namespace + + // List all deployments for this project + deployments, err := d.client.AppsV1().Deployments(ns).List(ctx, metav1.ListOptions{ + LabelSelector: fmt.Sprintf("project=%s", projectName), + }) + if err != nil { + return nil, fmt.Errorf("failed to list deployments: %w", err) + } + + result := &domain.ProjectDeployStatus{ + ProjectName: projectName, + Components: make([]domain.ComponentDeployStatus, 0, len(deployments.Items)), + } + + for _, dep := range deployments.Items { + componentPath := dep.Labels["component"] + componentName := dep.Name + if componentPath == "" { + // This is the main project deployment, not a component + componentName = projectName + } + + // Determine status + var status domain.DeploymentStatus + switch { + case dep.Status.ReadyReplicas == *dep.Spec.Replicas: + status = domain.DeploymentStatusRunning + case dep.Status.UnavailableReplicas > 0: + status = domain.DeploymentStatusFailed + case dep.Status.ReadyReplicas < *dep.Spec.Replicas: + status = domain.DeploymentStatusPending + default: + status = domain.DeploymentStatusUnknown + } + + // Get URL from ingress + var url string + ingress, err := d.client.NetworkingV1().Ingresses(ns).Get(ctx, dep.Name, metav1.GetOptions{}) + if err == nil && len(ingress.Spec.Rules) > 0 { + url = "https://" + ingress.Spec.Rules[0].Host + // Set overall URL to first component URL + if result.OverallURL == "" { + result.OverallURL = url + } + } + + // Determine component type from path + componentType := "unknown" + if componentPath != "" { + parts := splitComponentPath(componentPath) + if len(parts) > 0 { + switch parts[0] { + case "services": + componentType = "service" + case "workers": + componentType = "worker" + case "apps": + componentType = "app" + case "cli": + componentType = "cli" + } + } + } + + result.Components = append(result.Components, domain.ComponentDeployStatus{ + ComponentPath: componentPath, + ComponentName: componentName, + ComponentType: componentType, + Image: dep.Spec.Template.Spec.Containers[0].Image, + Replicas: int(*dep.Spec.Replicas), + ReadyReplicas: int(dep.Status.ReadyReplicas), + URL: url, + Status: status, + }) + } + + return result, nil +} + +// splitComponentPath splits a component path like "services/auth-api" into ["services", "auth-api"]. +func splitComponentPath(path string) []string { + var parts []string + current := "" + for _, c := range path { + if c == '/' { + if current != "" { + parts = append(parts, current) + current = "" + } + } else { + current += string(c) + } + } + if current != "" { + parts = append(parts, current) + } + return parts +} + +// RestartComponent triggers a rolling restart of a specific component. +func (d *Deployer) RestartComponent(ctx context.Context, projectName, componentPath string) error { + // Build deployment name + spec := domain.DeploySpec{ + ProjectName: projectName, + ComponentPath: componentPath, + } + deploymentName := spec.DeploymentName() + ns := d.config.Namespace + + deployment, err := d.client.AppsV1().Deployments(ns).Get(ctx, deploymentName, metav1.GetOptions{}) + if err != nil { + return fmt.Errorf("failed to get deployment: %w", err) + } + + // Add annotation to trigger rollout + if deployment.Spec.Template.Annotations == nil { + deployment.Spec.Template.Annotations = make(map[string]string) + } + deployment.Spec.Template.Annotations["kubectl.kubernetes.io/restartedAt"] = time.Now().Format(time.RFC3339) + + _, err = d.client.AppsV1().Deployments(ns).Update(ctx, deployment, metav1.UpdateOptions{}) + if err != nil { + return fmt.Errorf("failed to update deployment: %w", err) + } + + return nil +} + +// ScaleComponent adjusts the replica count for a component. +func (d *Deployer) ScaleComponent(ctx context.Context, projectName, componentPath string, replicas int) error { + // Build deployment name + spec := domain.DeploySpec{ + ProjectName: projectName, + ComponentPath: componentPath, + } + deploymentName := spec.DeploymentName() + ns := d.config.Namespace + + scale, err := d.client.AppsV1().Deployments(ns).GetScale(ctx, deploymentName, metav1.GetOptions{}) + if err != nil { + return fmt.Errorf("failed to get scale: %w", err) + } + + scale.Spec.Replicas = int32(replicas) + + _, err = d.client.AppsV1().Deployments(ns).UpdateScale(ctx, deploymentName, scale, metav1.UpdateOptions{}) + if err != nil { + return fmt.Errorf("failed to update scale: %w", err) + } + + return nil +} + +// GetComponentLogs returns recent logs from a specific component's pods. +func (d *Deployer) GetComponentLogs(ctx context.Context, projectName, componentPath string, tailLines int) (string, error) { + // Build deployment name + spec := domain.DeploySpec{ + ProjectName: projectName, + ComponentPath: componentPath, + } + deploymentName := spec.DeploymentName() + ns := d.config.Namespace + + // List pods for the component deployment + pods, err := d.client.CoreV1().Pods(ns).List(ctx, metav1.ListOptions{ + LabelSelector: fmt.Sprintf("app=%s", deploymentName), + }) + if err != nil { + return "", fmt.Errorf("failed to list pods: %w", err) + } + + if len(pods.Items) == 0 { + return "", fmt.Errorf("no pods found for component %s in project %s", componentPath, projectName) + } + + // Get logs from the first pod + tail := int64(tailLines) + opts := &corev1.PodLogOptions{ + TailLines: &tail, + } + + req := d.client.CoreV1().Pods(ns).GetLogs(pods.Items[0].Name, opts) + logs, err := req.Stream(ctx) + if err != nil { + return "", fmt.Errorf("failed to get logs: %w", err) + } + defer func() { _ = logs.Close() }() + + buf := new(bytes.Buffer) + _, err = buf.ReadFrom(logs) + if err != nil { + return "", fmt.Errorf("failed to read logs: %w", err) + } + + return buf.String(), nil +} diff --git a/internal/adapter/deployer/resources.go b/internal/adapter/deployer/resources.go index fa06244..9269c9a 100644 --- a/internal/adapter/deployer/resources.go +++ b/internal/adapter/deployer/resources.go @@ -41,7 +41,8 @@ func (d *Deployer) ensureNamespace(ctx context.Context) error { // createOrUpdateSecret manages the secret for environment variables. func (d *Deployer) createOrUpdateSecret(ctx context.Context, spec domain.DeploySpec) error { - secretName := spec.ProjectName + "-env" + deploymentName := spec.DeploymentName() + secretName := deploymentName + "-env" ns := d.config.Namespace secret := &corev1.Secret{ @@ -49,7 +50,7 @@ func (d *Deployer) createOrUpdateSecret(ctx context.Context, spec domain.DeployS Name: secretName, Namespace: ns, Labels: map[string]string{ - "app": spec.ProjectName, + "app": deploymentName, "project": spec.ProjectName, }, }, @@ -69,6 +70,7 @@ func (d *Deployer) createOrUpdateSecret(ctx context.Context, spec domain.DeployS func (d *Deployer) createOrUpdateDeployment(ctx context.Context, spec domain.DeploySpec) error { ns := d.config.Namespace replicas := int32(spec.Replicas) + deploymentName := spec.DeploymentName() // Build env vars var envVars []corev1.EnvVar @@ -82,7 +84,7 @@ func (d *Deployer) createOrUpdateDeployment(ctx context.Context, spec domain.Dep envFrom = append(envFrom, corev1.EnvFromSource{ SecretRef: &corev1.SecretEnvSource{ LocalObjectReference: corev1.LocalObjectReference{ - Name: spec.ProjectName + "-env", + Name: deploymentName + "-env", }, }, }) @@ -90,7 +92,7 @@ func (d *Deployer) createOrUpdateDeployment(ctx context.Context, spec domain.Dep deployment := d.buildDeployment(spec, ns, replicas, envVars, envFrom) - _, err := d.client.AppsV1().Deployments(ns).Get(ctx, spec.ProjectName, metav1.GetOptions{}) + _, err := d.client.AppsV1().Deployments(ns).Get(ctx, deploymentName, metav1.GetOptions{}) if errors.IsNotFound(err) { _, err = d.client.AppsV1().Deployments(ns).Create(ctx, deployment, metav1.CreateOptions{}) } else if err == nil { @@ -100,33 +102,38 @@ func (d *Deployer) createOrUpdateDeployment(ctx context.Context, spec domain.Dep } func (d *Deployer) buildDeployment(spec domain.DeploySpec, ns string, replicas int32, envVars []corev1.EnvVar, envFrom []corev1.EnvFromSource) *appsv1.Deployment { + deploymentName := spec.DeploymentName() + + // Build labels - always include project, component if present + labels := map[string]string{ + "app": deploymentName, + "project": spec.ProjectName, + } + if spec.ComponentPath != "" { + labels["component"] = spec.ComponentPath + } + return &appsv1.Deployment{ ObjectMeta: metav1.ObjectMeta{ - Name: spec.ProjectName, + Name: deploymentName, Namespace: ns, - Labels: map[string]string{ - "app": spec.ProjectName, - "project": spec.ProjectName, - }, + Labels: labels, }, Spec: appsv1.DeploymentSpec{ Replicas: &replicas, Selector: &metav1.LabelSelector{ MatchLabels: map[string]string{ - "app": spec.ProjectName, + "app": deploymentName, }, }, Template: corev1.PodTemplateSpec{ ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{ - "app": spec.ProjectName, - "project": spec.ProjectName, - }, + Labels: labels, }, Spec: corev1.PodSpec{ Containers: []corev1.Container{ { - Name: spec.ProjectName, + Name: deploymentName, Image: spec.Image, Env: envVars, EnvFrom: envFrom, @@ -157,19 +164,26 @@ func (d *Deployer) buildDeployment(spec domain.DeploySpec, ns string, replicas i // createOrUpdateService manages the Kubernetes Service resource. func (d *Deployer) createOrUpdateService(ctx context.Context, spec domain.DeploySpec) error { ns := d.config.Namespace + deploymentName := spec.DeploymentName() + + // Build labels + labels := map[string]string{ + "app": deploymentName, + "project": spec.ProjectName, + } + if spec.ComponentPath != "" { + labels["component"] = spec.ComponentPath + } service := &corev1.Service{ ObjectMeta: metav1.ObjectMeta{ - Name: spec.ProjectName, + Name: deploymentName, Namespace: ns, - Labels: map[string]string{ - "app": spec.ProjectName, - "project": spec.ProjectName, - }, + Labels: labels, }, Spec: corev1.ServiceSpec{ Selector: map[string]string{ - "app": spec.ProjectName, + "app": deploymentName, }, Ports: []corev1.ServicePort{ { @@ -181,7 +195,7 @@ func (d *Deployer) createOrUpdateService(ctx context.Context, spec domain.Deploy }, } - _, err := d.client.CoreV1().Services(ns).Get(ctx, spec.ProjectName, metav1.GetOptions{}) + _, err := d.client.CoreV1().Services(ns).Get(ctx, deploymentName, metav1.GetOptions{}) if errors.IsNotFound(err) { _, err = d.client.CoreV1().Services(ns).Create(ctx, service, metav1.CreateOptions{}) } else if err == nil { @@ -195,6 +209,7 @@ func (d *Deployer) createOrUpdateIngress(ctx context.Context, spec domain.Deploy ns := d.config.Namespace pathType := networkingv1.PathTypePrefix ingressClass := d.config.IngressClass + deploymentName := spec.DeploymentName() // Build TLS secret name from domain tlsSecretName := strings.ReplaceAll(spec.Domain, ".", "-") + "-tls" @@ -206,7 +221,7 @@ func (d *Deployer) createOrUpdateIngress(ctx context.Context, spec domain.Deploy ingress := d.buildIngress(spec, ns, pathType, ingressClass, tlsSecretName, annotations) - _, err := d.client.NetworkingV1().Ingresses(ns).Get(ctx, spec.ProjectName, metav1.GetOptions{}) + _, err := d.client.NetworkingV1().Ingresses(ns).Get(ctx, deploymentName, metav1.GetOptions{}) if errors.IsNotFound(err) { _, err = d.client.NetworkingV1().Ingresses(ns).Create(ctx, ingress, metav1.CreateOptions{}) } else if err == nil { @@ -216,14 +231,22 @@ func (d *Deployer) createOrUpdateIngress(ctx context.Context, spec domain.Deploy } func (d *Deployer) buildIngress(spec domain.DeploySpec, ns string, pathType networkingv1.PathType, ingressClass, tlsSecretName string, annotations map[string]string) *networkingv1.Ingress { + deploymentName := spec.DeploymentName() + + // Build labels + labels := map[string]string{ + "app": deploymentName, + "project": spec.ProjectName, + } + if spec.ComponentPath != "" { + labels["component"] = spec.ComponentPath + } + return &networkingv1.Ingress{ ObjectMeta: metav1.ObjectMeta{ - Name: spec.ProjectName, - Namespace: ns, - Labels: map[string]string{ - "app": spec.ProjectName, - "project": spec.ProjectName, - }, + Name: deploymentName, + Namespace: ns, + Labels: labels, Annotations: annotations, }, Spec: networkingv1.IngressSpec{ @@ -245,7 +268,7 @@ func (d *Deployer) buildIngress(spec domain.DeploySpec, ns string, pathType netw PathType: &pathType, Backend: networkingv1.IngressBackend{ Service: &networkingv1.IngressServiceBackend{ - Name: spec.ProjectName, + Name: deploymentName, Port: networkingv1.ServiceBackendPort{ Number: int32(spec.Port), }, diff --git a/internal/adapter/gitea/bulk_files.go b/internal/adapter/gitea/bulk_files.go index f7c5230..991adce 100644 --- a/internal/adapter/gitea/bulk_files.go +++ b/internal/adapter/gitea/bulk_files.go @@ -4,6 +4,7 @@ package gitea import ( "bytes" "context" + "encoding/base64" "encoding/json" "fmt" "io" @@ -88,7 +89,9 @@ func NewBulkFileClient(baseURL, token string) *BulkFileClient { return &BulkFileClient{ baseURL: strings.TrimSuffix(baseURL, "/"), token: token, - client: &http.Client{}, + client: &http.Client{ + Timeout: 30 * time.Second, + }, } } @@ -183,3 +186,53 @@ func isRetryableError(err error) bool { strings.Contains(err.Error(), "timeout") || strings.Contains(err.Error(), "EOF") } + +// GetFileContent retrieves the content of a file from a repository. +// Returns the decoded content and the file's SHA (needed for updates). +// Returns nil, nil if the file doesn't exist (404). +func (c *BulkFileClient) GetFileContent(ctx context.Context, owner, repo, filepath string) ([]byte, string, error) { + url := fmt.Sprintf("%s/api/v1/repos/%s/%s/contents/%s", c.baseURL, owner, repo, filepath) + + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, "", fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Authorization", "token "+c.token) + + resp, err := c.client.Do(req) + if err != nil { + return nil, "", fmt.Errorf("request failed: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, "", fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode == 404 { + return nil, "", nil // File doesn't exist + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, "", &apiError{StatusCode: resp.StatusCode, Body: string(respBody)} + } + + var result struct { + Content string `json:"content"` + Encoding string `json:"encoding"` + SHA string `json:"sha"` + } + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, "", fmt.Errorf("failed to parse response: %w", err) + } + + // Decode base64 content + content, err := base64.StdEncoding.DecodeString(result.Content) + if err != nil { + return nil, "", fmt.Errorf("failed to decode content: %w", err) + } + + return content, result.SHA, nil +} diff --git a/internal/adapter/templates/provider.go b/internal/adapter/templates/provider.go index a91b2a8..cc9f249 100644 --- a/internal/adapter/templates/provider.go +++ b/internal/adapter/templates/provider.go @@ -36,6 +36,52 @@ var availableTemplates = []port.TemplateInfo{ {Name: "go-api", Description: "Go REST API with chi router", Stack: "go"}, } +// skeletonTemplate is the monorepo skeleton template used for composable projects. +var skeletonTemplate = port.TemplateInfo{ + Name: "skeleton", + Description: "Composable monorepo skeleton with services, workers, apps, and CLI directories", + Stack: "monorepo", +} + +// availableComponentTemplates lists all supported component templates. +var availableComponentTemplates = []port.ComponentTemplateInfo{ + { + Type: "service", + Description: "Go API service using pkg/ shared packages", + Stack: "go", + DefaultPort: 8080, + DestDir: "services", + }, + { + Type: "worker", + Description: "Go background worker for async job processing", + Stack: "go", + DefaultPort: 0, // Workers don't expose ports + DestDir: "workers", + }, + { + Type: "app-astro", + Description: "Astro landing page with Tailwind CSS", + Stack: "astro", + DefaultPort: 4321, + DestDir: "apps", + }, + { + Type: "app-react", + Description: "React SPA with Vite, TypeScript, and Tailwind", + Stack: "react", + DefaultPort: 5173, + DestDir: "apps", + }, + { + Type: "cli", + Description: "Go CLI tool using Cobra", + Stack: "go", + DefaultPort: 0, // CLIs don't expose ports + DestDir: "cli", + }, +} + // templateNameRegex validates template names (alphanumeric, dash only). var templateNameRegex = regexp.MustCompile(`^[a-z][a-z0-9-]*$`) @@ -247,3 +293,195 @@ func listTemplateFiles(templateName string) ([]string, error) { return files, err } + +// SeedSkeleton populates a repository with the monorepo skeleton template. +// This creates the base monorepo structure without any components. +func (p *Provider) SeedSkeleton(ctx context.Context, owner, repo string, vars map[string]string) error { + return p.SeedRepo(ctx, owner, repo, "skeleton", vars) +} + +// GetSkeleton returns info about the monorepo skeleton template. +func (p *Provider) GetSkeleton(ctx context.Context) (*port.TemplateInfo, error) { + // Check for context cancellation + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + + result := skeletonTemplate + files, err := listTemplateFiles("skeleton") + if err == nil { + result.Files = files + } + return &result, nil +} + +// GetComponentTemplate returns info about a specific component template. +func (p *Provider) GetComponentTemplate(ctx context.Context, componentType string) (*port.ComponentTemplateInfo, error) { + // Check for context cancellation + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + + for _, t := range availableComponentTemplates { + if t.Type == componentType { + result := t + files, err := listComponentTemplateFiles(componentType) + if err == nil { + result.Files = files + } + return &result, nil + } + } + return nil, fmt.Errorf("%w: component type %s", domain.ErrTemplateNotFound, componentType) +} + +// ListComponentTemplates returns available component templates. +// If componentType is empty, returns all templates; otherwise filters by type. +func (p *Provider) ListComponentTemplates(ctx context.Context, componentType string) ([]port.ComponentTemplateInfo, error) { + // Check for context cancellation + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + + var result []port.ComponentTemplateInfo + for _, t := range availableComponentTemplates { + if componentType != "" && t.Type != componentType { + continue + } + info := t + files, err := listComponentTemplateFiles(t.Type) + if err == nil { + info.Files = files + } + result = append(result, info) + } + return result, nil +} + +// GetComponentFiles returns the files for a component template with variables interpolated. +func (p *Provider) GetComponentFiles(ctx context.Context, componentType string, destPath string, vars map[string]string) ([]port.ComponentFile, error) { + // Check for context cancellation + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + + // Validate component type exists + found := false + for _, t := range availableComponentTemplates { + if t.Type == componentType { + found = true + break + } + } + if !found { + return nil, fmt.Errorf("%w: component type %s", domain.ErrTemplateNotFound, componentType) + } + + templateDir := "templates/components/" + componentType + var files []port.ComponentFile + + err := fs.WalkDir(templatesFS, templateDir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + + // Read file content + content, err := templatesFS.ReadFile(path) + if err != nil { + return fmt.Errorf("failed to read component template file %s: %w", path, err) + } + + // Interpolate variables + interpolated := interpolateVars(string(content), vars) + + // Calculate relative path from component template root + relPath, err := filepath.Rel(templateDir, path) + if err != nil { + return fmt.Errorf("failed to get relative path: %w", err) + } + + // Strip .tmpl extension + relPath = strings.TrimSuffix(relPath, ".tmpl") + + // Prepend destination path + fullPath := filepath.Join(destPath, relPath) + + files = append(files, port.ComponentFile{ + Path: fullPath, + Content: interpolated, + }) + + return nil + }) + + if err != nil { + return nil, fmt.Errorf("failed to collect component template files: %w", err) + } + + if len(files) == 0 { + return nil, fmt.Errorf("component template %s contains no files", componentType) + } + + p.logger.Debug("prepared component files", + "component_type", componentType, + "dest_path", destPath, + "file_count", len(files), + ) + + return files, nil +} + +// listComponentTemplateFiles returns the list of files in a component template. +func listComponentTemplateFiles(componentType string) ([]string, error) { + templateDir := "templates/components/" + componentType + var files []string + + err := fs.WalkDir(templatesFS, templateDir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + relPath, err := filepath.Rel(templateDir, path) + if err != nil { + return err + } + // Strip .tmpl extension for display + relPath = strings.TrimSuffix(relPath, ".tmpl") + files = append(files, relPath) + return nil + }) + + return files, err +} + +// GetComponentWoodpeckerStep returns the .woodpecker.step.yml content for a component. +// This is the CI step that should be inserted into the main .woodpecker.yml file. +func (p *Provider) GetComponentWoodpeckerStep(ctx context.Context, componentType string, vars map[string]string) (string, error) { + // Check for context cancellation + select { + case <-ctx.Done(): + return "", ctx.Err() + default: + } + + stepPath := "templates/components/" + componentType + "/.woodpecker.step.yml.tmpl" + content, err := templatesFS.ReadFile(stepPath) + if err != nil { + return "", fmt.Errorf("failed to read woodpecker step template: %w", err) + } + + return interpolateVars(string(content), vars), nil +} diff --git a/internal/adapter/templates/provider_test.go b/internal/adapter/templates/provider_test.go index 0feb432..66ab4b1 100644 --- a/internal/adapter/templates/provider_test.go +++ b/internal/adapter/templates/provider_test.go @@ -141,3 +141,48 @@ func TestListTemplateFiles_TmplExtensionStripped(t *testing.T) { assert.NotContains(t, f, ".tmpl", "file %s should not have .tmpl extension", f) } } + +func TestSkeletonTemplate(t *testing.T) { + // Test that skeleton template exists and has expected files + files, err := listTemplateFiles("skeleton") + require.NoError(t, err) + + // Should have the core monorepo files + assert.Contains(t, files, "CLAUDE.md", "skeleton template should have CLAUDE.md") + assert.Contains(t, files, "README.md", "skeleton template should have README.md") + assert.Contains(t, files, ".woodpecker.yml", "skeleton template should have .woodpecker.yml") + assert.Contains(t, files, "docker-compose.yml", "skeleton template should have docker-compose.yml") + assert.Contains(t, files, "go.work", "skeleton template should have go.work") + assert.Contains(t, files, "Procfile", "skeleton template should have Procfile") + assert.Contains(t, files, ".gitignore", "skeleton template should have .gitignore") + assert.Contains(t, files, ".golangci.yml", "skeleton template should have .golangci.yml") + + // Should have scripts + assert.Contains(t, files, "scripts/dev.sh", "skeleton template should have scripts/dev.sh") + assert.Contains(t, files, "scripts/install.sh", "skeleton template should have scripts/install.sh") + assert.Contains(t, files, "scripts/quality.sh", "skeleton template should have scripts/quality.sh") + assert.Contains(t, files, "scripts/discover.sh", "skeleton template should have scripts/discover.sh") + + // Should have .claude structure + assert.Contains(t, files, ".claude/settings.local.json", "skeleton template should have .claude/settings.local.json") + assert.Contains(t, files, ".claude/guides/local/setup.md", "skeleton template should have .claude/guides/local/setup.md") + assert.Contains(t, files, ".claude/guides/ops/deploying.md", "skeleton template should have .claude/guides/ops/deploying.md") + assert.Contains(t, files, ".claude/skills/code-review/SKILL.md", "skeleton template should have .claude/skills/code-review/SKILL.md") + + // Should have component directory placeholders + assert.Contains(t, files, "services/.gitkeep", "skeleton template should have services/.gitkeep") + assert.Contains(t, files, "workers/.gitkeep", "skeleton template should have workers/.gitkeep") + assert.Contains(t, files, "apps/.gitkeep", "skeleton template should have apps/.gitkeep") + assert.Contains(t, files, "cli/.gitkeep", "skeleton template should have cli/.gitkeep") + + // Should have pkg directory files + assert.Contains(t, files, "pkg/go.mod", "skeleton template should have pkg/go.mod") + assert.Contains(t, files, "pkg/README.md", "skeleton template should have pkg/README.md") +} + +func TestSkeletonTemplateInfo(t *testing.T) { + // Verify skeleton template metadata + assert.Equal(t, "skeleton", skeletonTemplate.Name) + assert.Equal(t, "monorepo", skeletonTemplate.Stack) + assert.NotEmpty(t, skeletonTemplate.Description) +} diff --git a/internal/adapter/templates/templates/components/app-astro/.woodpecker.step.yml.tmpl b/internal/adapter/templates/templates/components/app-astro/.woodpecker.step.yml.tmpl new file mode 100644 index 0000000..f8bd6d7 --- /dev/null +++ b/internal/adapter/templates/templates/components/app-astro/.woodpecker.step.yml.tmpl @@ -0,0 +1,18 @@ +# Woodpecker CI step for {{COMPONENT_NAME}} Astro app +# Add this step to your .woodpecker.yml + +build-{{COMPONENT_NAME}}: + image: woodpeckerci/plugin-kaniko + settings: + registry: registry.threesix.ai + repo: {{PROJECT_NAME}}/{{COMPONENT_NAME}} + tags: + - latest + - ${CI_COMMIT_SHA:0:8} + context: . + dockerfile: apps/{{COMPONENT_NAME}}/Dockerfile + cache: true + skip-tls-verify: true + when: + branch: main + event: push diff --git a/internal/adapter/templates/templates/components/app-astro/Dockerfile.tmpl b/internal/adapter/templates/templates/components/app-astro/Dockerfile.tmpl new file mode 100644 index 0000000..4ea4f46 --- /dev/null +++ b/internal/adapter/templates/templates/components/app-astro/Dockerfile.tmpl @@ -0,0 +1,27 @@ +# Build stage +FROM node:20-alpine AS build + +WORKDIR /app + +# Copy package files +COPY apps/{{COMPONENT_NAME}}/package*.json ./ + +# Install dependencies +RUN npm install + +# Copy source +COPY apps/{{COMPONENT_NAME}}/ ./ + +# Build +RUN npm run build + +# Production stage +FROM nginx:alpine + +# Copy built assets +COPY --from=build /app/dist /usr/share/nginx/html +COPY apps/{{COMPONENT_NAME}}/nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/internal/adapter/templates/templates/components/app-astro/astro.config.mjs.tmpl b/internal/adapter/templates/templates/components/app-astro/astro.config.mjs.tmpl new file mode 100644 index 0000000..4070478 --- /dev/null +++ b/internal/adapter/templates/templates/components/app-astro/astro.config.mjs.tmpl @@ -0,0 +1,10 @@ +import { defineConfig } from 'astro/config'; +import tailwind from '@astrojs/tailwind'; + +export default defineConfig({ + integrations: [tailwind()], + output: 'static', + server: { + port: {{PORT}}, + }, +}); diff --git a/internal/adapter/templates/templates/components/app-astro/component.yaml.tmpl b/internal/adapter/templates/templates/components/app-astro/component.yaml.tmpl new file mode 100644 index 0000000..724839b --- /dev/null +++ b/internal/adapter/templates/templates/components/app-astro/component.yaml.tmpl @@ -0,0 +1,6 @@ +name: {{COMPONENT_NAME}} +type: app +port: {{PORT}} +path: apps/{{COMPONENT_NAME}} +stack: astro +dependencies: [] diff --git a/internal/adapter/templates/templates/components/app-astro/nginx.conf.tmpl b/internal/adapter/templates/templates/components/app-astro/nginx.conf.tmpl new file mode 100644 index 0000000..2ab46b7 --- /dev/null +++ b/internal/adapter/templates/templates/components/app-astro/nginx.conf.tmpl @@ -0,0 +1,26 @@ +server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + # Gzip compression + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml; + + # Cache static assets + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # SPA fallback + location / { + try_files $uri $uri/ /index.html; + } + + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; +} diff --git a/internal/adapter/templates/templates/components/app-astro/package.json.tmpl b/internal/adapter/templates/templates/components/app-astro/package.json.tmpl new file mode 100644 index 0000000..b4564d2 --- /dev/null +++ b/internal/adapter/templates/templates/components/app-astro/package.json.tmpl @@ -0,0 +1,23 @@ +{ + "name": "{{COMPONENT_NAME}}", + "type": "module", + "version": "0.0.1", + "scripts": { + "dev": "astro dev --port {{PORT}}", + "start": "astro dev --port {{PORT}}", + "build": "astro build", + "preview": "astro preview --port {{PORT}}", + "lint": "eslint . --ext .js,.mjs,.ts,.astro", + "format": "prettier --write ." + }, + "dependencies": { + "@{{PROJECT_NAME}}/logger": "workspace:*", + "astro": "^4.0.0" + }, + "devDependencies": { + "@astrojs/tailwind": "^5.0.0", + "tailwindcss": "^3.4.0", + "prettier": "^3.2.0", + "prettier-plugin-astro": "^0.13.0" + } +} diff --git a/internal/adapter/templates/templates/components/app-astro/public/favicon.svg b/internal/adapter/templates/templates/components/app-astro/public/favicon.svg new file mode 100644 index 0000000..5b8757b --- /dev/null +++ b/internal/adapter/templates/templates/components/app-astro/public/favicon.svg @@ -0,0 +1,4 @@ +<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128"> + <path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.8L50.4 78.5z" fill="#fff"/> + <path d="M90.9 110.8c-7.6 3-21.3 6-37 6S30.5 113.8 23 110.8L32.8 97a150.3 150.3 0 0 1 31.2-3c11.2 0 22.2.7 31.2 3l9.7 13.8z" fill="#ff5d01"/> +</svg> diff --git a/internal/adapter/templates/templates/components/app-astro/src/layouts/Layout.astro.tmpl b/internal/adapter/templates/templates/components/app-astro/src/layouts/Layout.astro.tmpl new file mode 100644 index 0000000..199f640 --- /dev/null +++ b/internal/adapter/templates/templates/components/app-astro/src/layouts/Layout.astro.tmpl @@ -0,0 +1,25 @@ +--- +interface Props { + title: string; + description?: string; +} + +const { title, description = '{{PROJECT_NAME}} - {{COMPONENT_NAME}}' } = Astro.props; +--- + +<!doctype html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <meta name="description" content={description} /> + <link rel="icon" type="image/svg+xml" href="/favicon.svg" /> + <title>{title} + + + + + + diff --git a/internal/adapter/templates/templates/components/app-astro/src/lib/logger.ts.tmpl b/internal/adapter/templates/templates/components/app-astro/src/lib/logger.ts.tmpl new file mode 100644 index 0000000..e23cec4 --- /dev/null +++ b/internal/adapter/templates/templates/components/app-astro/src/lib/logger.ts.tmpl @@ -0,0 +1,13 @@ +import { createLogger, installGlobalHandlers } from '@{{PROJECT_NAME}}/logger'; + +export const logger = createLogger({ + level: import.meta.env.DEV ? 'debug' : 'info', + service: '{{COMPONENT_NAME}}', + // Set endpoint to send logs to your backend: + // endpoint: '/api/logs', +}); + +// Install global error handlers (client-side only) +if (typeof window !== 'undefined') { + installGlobalHandlers(logger); +} diff --git a/internal/adapter/templates/templates/components/app-astro/src/pages/index.astro.tmpl b/internal/adapter/templates/templates/components/app-astro/src/pages/index.astro.tmpl new file mode 100644 index 0000000..1a12016 --- /dev/null +++ b/internal/adapter/templates/templates/components/app-astro/src/pages/index.astro.tmpl @@ -0,0 +1,37 @@ +--- +import Layout from '../layouts/Layout.astro'; +--- + + +
+
+
+

+ {{COMPONENT_NAME}} +

+

+ Welcome to your Astro app. This is part of the + {{PROJECT_NAME}} monorepo. +

+

+ Edit this file at + apps/{{COMPONENT_NAME}}/src/pages/index.astro +

+ +
+
+
+
diff --git a/internal/adapter/templates/templates/components/app-astro/tailwind.config.mjs.tmpl b/internal/adapter/templates/templates/components/app-astro/tailwind.config.mjs.tmpl new file mode 100644 index 0000000..83cac5e --- /dev/null +++ b/internal/adapter/templates/templates/components/app-astro/tailwind.config.mjs.tmpl @@ -0,0 +1,8 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'], + theme: { + extend: {}, + }, + plugins: [], +}; diff --git a/internal/adapter/templates/templates/components/app-react/.woodpecker.step.yml.tmpl b/internal/adapter/templates/templates/components/app-react/.woodpecker.step.yml.tmpl new file mode 100644 index 0000000..dc98d77 --- /dev/null +++ b/internal/adapter/templates/templates/components/app-react/.woodpecker.step.yml.tmpl @@ -0,0 +1,18 @@ +# Woodpecker CI step for {{COMPONENT_NAME}} React app +# Add this step to your .woodpecker.yml + +build-{{COMPONENT_NAME}}: + image: woodpeckerci/plugin-kaniko + settings: + registry: registry.threesix.ai + repo: {{PROJECT_NAME}}/{{COMPONENT_NAME}} + tags: + - latest + - ${CI_COMMIT_SHA:0:8} + context: . + dockerfile: apps/{{COMPONENT_NAME}}/Dockerfile + cache: true + skip-tls-verify: true + when: + branch: main + event: push diff --git a/internal/adapter/templates/templates/components/app-react/Dockerfile.tmpl b/internal/adapter/templates/templates/components/app-react/Dockerfile.tmpl new file mode 100644 index 0000000..4ea4f46 --- /dev/null +++ b/internal/adapter/templates/templates/components/app-react/Dockerfile.tmpl @@ -0,0 +1,27 @@ +# Build stage +FROM node:20-alpine AS build + +WORKDIR /app + +# Copy package files +COPY apps/{{COMPONENT_NAME}}/package*.json ./ + +# Install dependencies +RUN npm install + +# Copy source +COPY apps/{{COMPONENT_NAME}}/ ./ + +# Build +RUN npm run build + +# Production stage +FROM nginx:alpine + +# Copy built assets +COPY --from=build /app/dist /usr/share/nginx/html +COPY apps/{{COMPONENT_NAME}}/nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/internal/adapter/templates/templates/components/app-react/component.yaml.tmpl b/internal/adapter/templates/templates/components/app-react/component.yaml.tmpl new file mode 100644 index 0000000..fc25924 --- /dev/null +++ b/internal/adapter/templates/templates/components/app-react/component.yaml.tmpl @@ -0,0 +1,6 @@ +name: {{COMPONENT_NAME}} +type: app +port: {{PORT}} +path: apps/{{COMPONENT_NAME}} +stack: react +dependencies: [] diff --git a/internal/adapter/templates/templates/components/app-react/index.html.tmpl b/internal/adapter/templates/templates/components/app-react/index.html.tmpl new file mode 100644 index 0000000..b1d2230 --- /dev/null +++ b/internal/adapter/templates/templates/components/app-react/index.html.tmpl @@ -0,0 +1,13 @@ + + + + + + + {{COMPONENT_NAME}} | {{PROJECT_NAME}} + + +
+ + + diff --git a/internal/adapter/templates/templates/components/app-react/nginx.conf.tmpl b/internal/adapter/templates/templates/components/app-react/nginx.conf.tmpl new file mode 100644 index 0000000..2ab46b7 --- /dev/null +++ b/internal/adapter/templates/templates/components/app-react/nginx.conf.tmpl @@ -0,0 +1,26 @@ +server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + # Gzip compression + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml; + + # Cache static assets + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # SPA fallback + location / { + try_files $uri $uri/ /index.html; + } + + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; +} diff --git a/internal/adapter/templates/templates/components/app-react/package.json.tmpl b/internal/adapter/templates/templates/components/app-react/package.json.tmpl new file mode 100644 index 0000000..dd636c9 --- /dev/null +++ b/internal/adapter/templates/templates/components/app-react/package.json.tmpl @@ -0,0 +1,34 @@ +{ + "name": "{{COMPONENT_NAME}}", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite --port {{PORT}}", + "build": "tsc && vite build", + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "preview": "vite preview --port {{PORT}}", + "format": "prettier --write src/" + }, + "dependencies": { + "@{{PROJECT_NAME}}/logger": "workspace:*", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@typescript-eslint/eslint-plugin": "^7.13.1", + "@typescript-eslint/parser": "^7.13.1", + "@vitejs/plugin-react": "^4.3.1", + "autoprefixer": "^10.4.19", + "eslint": "^8.57.0", + "eslint-plugin-react-hooks": "^4.6.2", + "eslint-plugin-react-refresh": "^0.4.7", + "postcss": "^8.4.38", + "prettier": "^3.3.2", + "tailwindcss": "^3.4.4", + "typescript": "^5.5.3", + "vite": "^5.4.1" + } +} diff --git a/internal/adapter/templates/templates/components/app-react/postcss.config.js b/internal/adapter/templates/templates/components/app-react/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/internal/adapter/templates/templates/components/app-react/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/internal/adapter/templates/templates/components/app-react/public/vite.svg b/internal/adapter/templates/templates/components/app-react/public/vite.svg new file mode 100644 index 0000000..6a41099 --- /dev/null +++ b/internal/adapter/templates/templates/components/app-react/public/vite.svg @@ -0,0 +1 @@ + diff --git a/internal/adapter/templates/templates/components/app-react/src/App.tsx.tmpl b/internal/adapter/templates/templates/components/app-react/src/App.tsx.tmpl new file mode 100644 index 0000000..00543bb --- /dev/null +++ b/internal/adapter/templates/templates/components/app-react/src/App.tsx.tmpl @@ -0,0 +1,46 @@ +function App() { + return ( +
+
+
+

+ {{COMPONENT_NAME}} +

+

+ Welcome to your React app. This is part of the{' '} + {{PROJECT_NAME}}{' '} + monorepo. +

+

+ Edit this file at{' '} + + apps/{{COMPONENT_NAME}}/src/App.tsx + +

+ +
+
+
+ ); +} + +export default App; diff --git a/internal/adapter/templates/templates/components/app-react/src/index.css b/internal/adapter/templates/templates/components/app-react/src/index.css new file mode 100644 index 0000000..17df0e7 --- /dev/null +++ b/internal/adapter/templates/templates/components/app-react/src/index.css @@ -0,0 +1,17 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', + 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', + sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +code { + font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', + monospace; +} diff --git a/internal/adapter/templates/templates/components/app-react/src/lib/logger.ts.tmpl b/internal/adapter/templates/templates/components/app-react/src/lib/logger.ts.tmpl new file mode 100644 index 0000000..64235da --- /dev/null +++ b/internal/adapter/templates/templates/components/app-react/src/lib/logger.ts.tmpl @@ -0,0 +1,11 @@ +import { createLogger, installGlobalHandlers } from '@{{PROJECT_NAME}}/logger'; + +export const logger = createLogger({ + level: import.meta.env.DEV ? 'debug' : 'info', + service: '{{COMPONENT_NAME}}', + // Set endpoint to send logs to your backend: + // endpoint: '/api/logs', +}); + +// Install global error handlers +installGlobalHandlers(logger); diff --git a/internal/adapter/templates/templates/components/app-react/src/main.tsx.tmpl b/internal/adapter/templates/templates/components/app-react/src/main.tsx.tmpl new file mode 100644 index 0000000..174dfe8 --- /dev/null +++ b/internal/adapter/templates/templates/components/app-react/src/main.tsx.tmpl @@ -0,0 +1,11 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App.tsx'; +import './index.css'; +import './lib/logger'; + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + +); diff --git a/internal/adapter/templates/templates/components/app-react/src/vite-env.d.ts b/internal/adapter/templates/templates/components/app-react/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/internal/adapter/templates/templates/components/app-react/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/internal/adapter/templates/templates/components/app-react/tailwind.config.js b/internal/adapter/templates/templates/components/app-react/tailwind.config.js new file mode 100644 index 0000000..d21f1cd --- /dev/null +++ b/internal/adapter/templates/templates/components/app-react/tailwind.config.js @@ -0,0 +1,8 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], + theme: { + extend: {}, + }, + plugins: [], +}; diff --git a/internal/adapter/templates/templates/components/app-react/tsconfig.json b/internal/adapter/templates/templates/components/app-react/tsconfig.json new file mode 100644 index 0000000..a7fc6fb --- /dev/null +++ b/internal/adapter/templates/templates/components/app-react/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/internal/adapter/templates/templates/components/app-react/tsconfig.node.json b/internal/adapter/templates/templates/components/app-react/tsconfig.node.json new file mode 100644 index 0000000..97ede7e --- /dev/null +++ b/internal/adapter/templates/templates/components/app-react/tsconfig.node.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true + }, + "include": ["vite.config.ts"] +} diff --git a/internal/adapter/templates/templates/components/app-react/vite.config.ts.tmpl b/internal/adapter/templates/templates/components/app-react/vite.config.ts.tmpl new file mode 100644 index 0000000..a214c56 --- /dev/null +++ b/internal/adapter/templates/templates/components/app-react/vite.config.ts.tmpl @@ -0,0 +1,13 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react()], + server: { + port: {{PORT}}, + }, + preview: { + port: {{PORT}}, + }, +}); diff --git a/internal/adapter/templates/templates/components/cli/.woodpecker.step.yml.tmpl b/internal/adapter/templates/templates/components/cli/.woodpecker.step.yml.tmpl new file mode 100644 index 0000000..6141a40 --- /dev/null +++ b/internal/adapter/templates/templates/components/cli/.woodpecker.step.yml.tmpl @@ -0,0 +1,16 @@ +# Woodpecker CI step for {{COMPONENT_NAME}} CLI +# Add this step to your .woodpecker.yml + +# CLI binaries typically don't need Docker images for deployment. +# This step builds and tests the CLI. + +build-{{COMPONENT_NAME}}: + image: golang:1.23-alpine + commands: + - cd cli/{{COMPONENT_NAME}} + - go mod download + - go build -o bin/{{COMPONENT_NAME}} ./cmd + - go test -v ./... + when: + branch: main + event: push diff --git a/internal/adapter/templates/templates/components/cli/Makefile.tmpl b/internal/adapter/templates/templates/components/cli/Makefile.tmpl new file mode 100644 index 0000000..26ede39 --- /dev/null +++ b/internal/adapter/templates/templates/components/cli/Makefile.tmpl @@ -0,0 +1,46 @@ +.PHONY: build install test lint fmt clean + +CLI := {{COMPONENT_NAME}} +BINARY := bin/$(CLI) +GO_MODULE := {{GO_MODULE}} + +# Build variables (for version injection) +VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") +GIT_COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown") +BUILD_DATE ?= $(shell date -u +"%Y-%m-%dT%H:%M:%SZ") +LDFLAGS := -ldflags "-X '{{GO_MODULE}}/cli/{{COMPONENT_NAME}}/internal/cmd.Version=$(VERSION)' \ + -X '{{GO_MODULE}}/cli/{{COMPONENT_NAME}}/internal/cmd.GitCommit=$(GIT_COMMIT)' \ + -X '{{GO_MODULE}}/cli/{{COMPONENT_NAME}}/internal/cmd.BuildDate=$(BUILD_DATE)'" + +# Build the CLI binary +build: + go build $(LDFLAGS) -o $(BINARY) ./cmd + +# Install to $GOPATH/bin +install: + go install $(LDFLAGS) ./cmd + +# Run tests +test: + go test -v ./... + +# Run linter +lint: + golangci-lint run ./... + +# Format code +fmt: + gofmt -w . + goimports -w -local $(GO_MODULE) . + +# Clean build artifacts +clean: + rm -rf bin/ + +# Build for multiple platforms +build-all: clean + GOOS=linux GOARCH=amd64 go build $(LDFLAGS) -o bin/$(CLI)-linux-amd64 ./cmd + GOOS=linux GOARCH=arm64 go build $(LDFLAGS) -o bin/$(CLI)-linux-arm64 ./cmd + GOOS=darwin GOARCH=amd64 go build $(LDFLAGS) -o bin/$(CLI)-darwin-amd64 ./cmd + GOOS=darwin GOARCH=arm64 go build $(LDFLAGS) -o bin/$(CLI)-darwin-arm64 ./cmd + GOOS=windows GOARCH=amd64 go build $(LDFLAGS) -o bin/$(CLI)-windows-amd64.exe ./cmd diff --git a/internal/adapter/templates/templates/components/cli/cmd/main.go.tmpl b/internal/adapter/templates/templates/components/cli/cmd/main.go.tmpl new file mode 100644 index 0000000..0de4de1 --- /dev/null +++ b/internal/adapter/templates/templates/components/cli/cmd/main.go.tmpl @@ -0,0 +1,14 @@ +// Package main is the entry point for the {{COMPONENT_NAME}} CLI. +package main + +import ( + "os" + + "{{GO_MODULE}}/cli/{{COMPONENT_NAME}}/internal/cmd" +) + +func main() { + if err := cmd.Execute(); err != nil { + os.Exit(1) + } +} diff --git a/internal/adapter/templates/templates/components/cli/component.yaml.tmpl b/internal/adapter/templates/templates/components/cli/component.yaml.tmpl new file mode 100644 index 0000000..1a00a8d --- /dev/null +++ b/internal/adapter/templates/templates/components/cli/component.yaml.tmpl @@ -0,0 +1,4 @@ +name: {{COMPONENT_NAME}} +type: cli +path: cli/{{COMPONENT_NAME}} +dependencies: [] diff --git a/internal/adapter/templates/templates/components/cli/go.mod.tmpl b/internal/adapter/templates/templates/components/cli/go.mod.tmpl new file mode 100644 index 0000000..a61eb0c --- /dev/null +++ b/internal/adapter/templates/templates/components/cli/go.mod.tmpl @@ -0,0 +1,31 @@ +module {{GO_MODULE}}/cli/{{COMPONENT_NAME}} + +go 1.23 + +require ( + github.com/spf13/cobra v1.8.1 + github.com/spf13/viper v1.19.0 +) + +require ( + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/sagikazarmark/locafero v0.4.0 // indirect + github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.11.0 // indirect + github.com/spf13/cast v1.6.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + go.uber.org/atomic v1.9.0 // indirect + go.uber.org/multierr v1.9.0 // indirect + golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect + golang.org/x/sys v0.18.0 // indirect + golang.org/x/text v0.14.0 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/internal/adapter/templates/templates/components/cli/internal/cmd/root.go.tmpl b/internal/adapter/templates/templates/components/cli/internal/cmd/root.go.tmpl new file mode 100644 index 0000000..c480c57 --- /dev/null +++ b/internal/adapter/templates/templates/components/cli/internal/cmd/root.go.tmpl @@ -0,0 +1,64 @@ +// Package cmd provides CLI commands for {{COMPONENT_NAME}}. +package cmd + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var cfgFile string + +// rootCmd represents the base command when called without any subcommands. +var rootCmd = &cobra.Command{ + Use: "{{COMPONENT_NAME}}", + Short: "{{COMPONENT_NAME}} CLI tool", + Long: `{{COMPONENT_NAME}} is a CLI tool for the {{PROJECT_NAME}} project. + +This CLI provides commands for managing and interacting with +the {{PROJECT_NAME}} system.`, +} + +// Execute adds all child commands to the root command and sets flags appropriately. +func Execute() error { + return rootCmd.Execute() +} + +func init() { + cobra.OnInitialize(initConfig) + + // Global flags + rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.{{COMPONENT_NAME}}.yaml)") + rootCmd.PersistentFlags().Bool("verbose", false, "enable verbose output") + + // Bind flags to viper + _ = viper.BindPFlag("verbose", rootCmd.PersistentFlags().Lookup("verbose")) +} + +// initConfig reads in config file and ENV variables if set. +func initConfig() { + if cfgFile != "" { + // Use config file from the flag + viper.SetConfigFile(cfgFile) + } else { + // Find home directory + home, err := os.UserHomeDir() + cobra.CheckErr(err) + + // Search config in home directory with name ".{{COMPONENT_NAME}}" (without extension) + viper.AddConfigPath(home) + viper.SetConfigType("yaml") + viper.SetConfigName(".{{COMPONENT_NAME}}") + } + + viper.AutomaticEnv() + + // If a config file is found, read it in + if err := viper.ReadInConfig(); err == nil { + if viper.GetBool("verbose") { + fmt.Fprintln(os.Stderr, "Using config file:", viper.ConfigFileUsed()) + } + } +} diff --git a/internal/adapter/templates/templates/components/cli/internal/cmd/version.go.tmpl b/internal/adapter/templates/templates/components/cli/internal/cmd/version.go.tmpl new file mode 100644 index 0000000..41b49f9 --- /dev/null +++ b/internal/adapter/templates/templates/components/cli/internal/cmd/version.go.tmpl @@ -0,0 +1,28 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +// Version information (set at build time via ldflags). +var ( + Version = "dev" + GitCommit = "unknown" + BuildDate = "unknown" +) + +var versionCmd = &cobra.Command{ + Use: "version", + Short: "Print version information", + Run: func(cmd *cobra.Command, args []string) { + fmt.Printf("{{COMPONENT_NAME}} %s\n", Version) + fmt.Printf(" Git commit: %s\n", GitCommit) + fmt.Printf(" Build date: %s\n", BuildDate) + }, +} + +func init() { + rootCmd.AddCommand(versionCmd) +} diff --git a/internal/adapter/templates/templates/components/service/.env.example.tmpl b/internal/adapter/templates/templates/components/service/.env.example.tmpl new file mode 100644 index 0000000..5a631b0 --- /dev/null +++ b/internal/adapter/templates/templates/components/service/.env.example.tmpl @@ -0,0 +1,17 @@ +# {{COMPONENT_NAME}} Service Configuration + +# Server +SERVER_PORT={{PORT}} +SERVER_HOST=0.0.0.0 + +# App +APP_NAME={{COMPONENT_NAME}} +APP_ENVIRONMENT=development +APP_DEBUG=true + +# Logging +LOG_LEVEL=debug +LOG_FORMAT=text + +# Database (if needed) +DATABASE_URL=postgres://dev:dev@localhost:5432/{{PROJECT_NAME}}?sslmode=disable diff --git a/internal/adapter/templates/templates/components/service/.woodpecker.step.yml.tmpl b/internal/adapter/templates/templates/components/service/.woodpecker.step.yml.tmpl new file mode 100644 index 0000000..a825e3f --- /dev/null +++ b/internal/adapter/templates/templates/components/service/.woodpecker.step.yml.tmpl @@ -0,0 +1,18 @@ +# Woodpecker CI step for {{COMPONENT_NAME}} service +# Add this step to your .woodpecker.yml + +build-{{COMPONENT_NAME}}: + image: woodpeckerci/plugin-kaniko + settings: + registry: registry.threesix.ai + repo: {{PROJECT_NAME}}/{{COMPONENT_NAME}} + tags: + - latest + - ${CI_COMMIT_SHA:0:8} + context: . + dockerfile: services/{{COMPONENT_NAME}}/Dockerfile + cache: true + skip-tls-verify: true + when: + branch: main + event: push diff --git a/internal/adapter/templates/templates/components/service/Dockerfile.tmpl b/internal/adapter/templates/templates/components/service/Dockerfile.tmpl new file mode 100644 index 0000000..657b5d8 --- /dev/null +++ b/internal/adapter/templates/templates/components/service/Dockerfile.tmpl @@ -0,0 +1,32 @@ +# Build stage +FROM golang:1.23-alpine AS builder + +WORKDIR /app + +# Copy go workspace files +COPY go.work go.work.sum* ./ +COPY pkg/go.mod pkg/go.sum* ./pkg/ +COPY services/{{COMPONENT_NAME}}/go.mod services/{{COMPONENT_NAME}}/go.sum* ./services/{{COMPONENT_NAME}}/ + +# Download dependencies +RUN cd services/{{COMPONENT_NAME}} && go mod download + +# Copy source +COPY pkg/ ./pkg/ +COPY services/{{COMPONENT_NAME}}/ ./services/{{COMPONENT_NAME}}/ + +# Build +RUN cd services/{{COMPONENT_NAME}} && CGO_ENABLED=0 go build -o /{{COMPONENT_NAME}} ./cmd/server + +# Production stage +FROM alpine:3.19 + +RUN apk add --no-cache ca-certificates tzdata + +WORKDIR / + +COPY --from=builder /{{COMPONENT_NAME}} /{{COMPONENT_NAME}} + +EXPOSE {{PORT}} + +ENTRYPOINT ["/{{COMPONENT_NAME}}"] diff --git a/internal/adapter/templates/templates/components/service/Makefile.tmpl b/internal/adapter/templates/templates/components/service/Makefile.tmpl new file mode 100644 index 0000000..a23d1e3 --- /dev/null +++ b/internal/adapter/templates/templates/components/service/Makefile.tmpl @@ -0,0 +1,34 @@ +.PHONY: build run test lint fmt docker-build clean + +SERVICE := {{COMPONENT_NAME}} +BINARY := bin/$(SERVICE) +GO_MODULE := {{GO_MODULE}} + +# Build the service binary +build: + go build -o $(BINARY) ./cmd/server + +# Run the service locally +run: + go run ./cmd/server + +# Run tests +test: + go test -v ./... + +# Run linter +lint: + golangci-lint run ./... + +# Format code +fmt: + gofmt -w . + goimports -w -local $(GO_MODULE) . + +# Build Docker image (run from monorepo root) +docker-build: + docker build -t $(SERVICE):latest -f Dockerfile ../.. + +# Clean build artifacts +clean: + rm -rf bin/ diff --git a/internal/adapter/templates/templates/components/service/cmd/server/main.go.tmpl b/internal/adapter/templates/templates/components/service/cmd/server/main.go.tmpl new file mode 100644 index 0000000..5d5bf27 --- /dev/null +++ b/internal/adapter/templates/templates/components/service/cmd/server/main.go.tmpl @@ -0,0 +1,18 @@ +// Package main is the entry point for the {{COMPONENT_NAME}} service. +package main + +import ( + "{{GO_MODULE}}/pkg/app" + "{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/api" +) + +func main() { + // Create application + application := app.New("{{COMPONENT_NAME}}", app.WithDefaultPort({{PORT}})) + + // Register routes + api.RegisterRoutes(application) + + // Start server + application.Run() +} diff --git a/internal/adapter/templates/templates/components/service/component.yaml.tmpl b/internal/adapter/templates/templates/components/service/component.yaml.tmpl new file mode 100644 index 0000000..4dd9e12 --- /dev/null +++ b/internal/adapter/templates/templates/components/service/component.yaml.tmpl @@ -0,0 +1,9 @@ +name: {{COMPONENT_NAME}} +type: service +port: {{PORT}} +path: services/{{COMPONENT_NAME}} +dependencies: [] +# Add dependencies as needed: +# - postgres +# - redis +# - other-service diff --git a/internal/adapter/templates/templates/components/service/go.mod.tmpl b/internal/adapter/templates/templates/components/service/go.mod.tmpl new file mode 100644 index 0000000..0976184 --- /dev/null +++ b/internal/adapter/templates/templates/components/service/go.mod.tmpl @@ -0,0 +1,5 @@ +module {{GO_MODULE}}/services/{{COMPONENT_NAME}} + +go 1.23 + +require {{GO_MODULE}}/pkg v0.0.0 diff --git a/internal/adapter/templates/templates/components/service/internal/api/handlers/health.go.tmpl b/internal/adapter/templates/templates/components/service/internal/api/handlers/health.go.tmpl new file mode 100644 index 0000000..9a58d3c --- /dev/null +++ b/internal/adapter/templates/templates/components/service/internal/api/handlers/health.go.tmpl @@ -0,0 +1,26 @@ +package handlers + +import ( + "net/http" + + "{{GO_MODULE}}/pkg/httpresponse" + "{{GO_MODULE}}/pkg/logging" +) + +// Health handles health check endpoints. +type Health struct { + logger *logging.Logger +} + +// NewHealth creates a new Health handler. +func NewHealth(logger *logging.Logger) *Health { + return &Health{logger: logger} +} + +// Check returns the service health status. +func (h *Health) Check(w http.ResponseWriter, r *http.Request) { + httpresponse.OK(w, r, map[string]string{ + "service": "{{COMPONENT_NAME}}", + "status": "healthy", + }) +} diff --git a/internal/adapter/templates/templates/components/service/internal/api/routes.go.tmpl b/internal/adapter/templates/templates/components/service/internal/api/routes.go.tmpl new file mode 100644 index 0000000..f87ba6f --- /dev/null +++ b/internal/adapter/templates/templates/components/service/internal/api/routes.go.tmpl @@ -0,0 +1,21 @@ +// Package api provides HTTP routing and handlers for the {{COMPONENT_NAME}} service. +package api + +import ( + "{{GO_MODULE}}/pkg/app" + "{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/api/handlers" +) + +// RegisterRoutes registers all HTTP routes for the service. +func RegisterRoutes(application *app.App) { + logger := application.Logger() + + // Initialize handlers + healthHandler := handlers.NewHealth(logger) + + // Register API routes + application.Route("/api/v1", func(r app.Router) { + r.Get("/health", healthHandler.Check) + // Add more routes here + }) +} diff --git a/internal/adapter/templates/templates/components/service/internal/config/config.go.tmpl b/internal/adapter/templates/templates/components/service/internal/config/config.go.tmpl new file mode 100644 index 0000000..884b9d1 --- /dev/null +++ b/internal/adapter/templates/templates/components/service/internal/config/config.go.tmpl @@ -0,0 +1,32 @@ +// Package config provides service-specific configuration. +package config + +import ( + "{{GO_MODULE}}/pkg/config" +) + +// Config extends the base config with {{COMPONENT_NAME}}-specific settings. +type Config struct { + config.AppConfig + Server config.ServerConfig + Database config.DatabaseConfig + Logging config.LoggingConfig + // Add service-specific config fields here +} + +// Load reads configuration from environment variables. +func Load() (*Config, error) { + if err := config.Init(config.Options{ + AppName: "{{COMPONENT_NAME}}", + DefaultPort: {{PORT}}, + }); err != nil { + return nil, err + } + + return &Config{ + AppConfig: config.ReadAppConfig(), + Server: config.ReadServerConfig(), + Database: config.ReadDatabaseConfig(), + Logging: config.ReadLoggingConfig(), + }, nil +} diff --git a/internal/adapter/templates/templates/components/service/migrations/.gitkeep b/internal/adapter/templates/templates/components/service/migrations/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/internal/adapter/templates/templates/components/worker/.env.example.tmpl b/internal/adapter/templates/templates/components/worker/.env.example.tmpl new file mode 100644 index 0000000..fd1ed07 --- /dev/null +++ b/internal/adapter/templates/templates/components/worker/.env.example.tmpl @@ -0,0 +1,21 @@ +# {{COMPONENT_NAME}} Worker Configuration + +# App +APP_NAME={{COMPONENT_NAME}} +APP_ENVIRONMENT=development +APP_DEBUG=true + +# Logging +LOG_LEVEL=debug +LOG_FORMAT=text + +# Worker +WORKER_POLL_INTERVAL=10s +WORKER_BATCH_SIZE=10 +WORKER_MAX_RETRIES=3 + +# Database (if needed) +DATABASE_URL=postgres://dev:dev@localhost:5432/{{PROJECT_NAME}}?sslmode=disable + +# Redis (if needed) +# REDIS_URL=redis://localhost:6379/0 diff --git a/internal/adapter/templates/templates/components/worker/.woodpecker.step.yml.tmpl b/internal/adapter/templates/templates/components/worker/.woodpecker.step.yml.tmpl new file mode 100644 index 0000000..e5fd57c --- /dev/null +++ b/internal/adapter/templates/templates/components/worker/.woodpecker.step.yml.tmpl @@ -0,0 +1,18 @@ +# Woodpecker CI step for {{COMPONENT_NAME}} worker +# Add this step to your .woodpecker.yml + +build-{{COMPONENT_NAME}}: + image: woodpeckerci/plugin-kaniko + settings: + registry: registry.threesix.ai + repo: {{PROJECT_NAME}}/{{COMPONENT_NAME}} + tags: + - latest + - ${CI_COMMIT_SHA:0:8} + context: . + dockerfile: workers/{{COMPONENT_NAME}}/Dockerfile + cache: true + skip-tls-verify: true + when: + branch: main + event: push diff --git a/internal/adapter/templates/templates/components/worker/Dockerfile.tmpl b/internal/adapter/templates/templates/components/worker/Dockerfile.tmpl new file mode 100644 index 0000000..377e137 --- /dev/null +++ b/internal/adapter/templates/templates/components/worker/Dockerfile.tmpl @@ -0,0 +1,30 @@ +# Build stage +FROM golang:1.23-alpine AS builder + +WORKDIR /app + +# Copy go workspace files +COPY go.work go.work.sum* ./ +COPY pkg/go.mod pkg/go.sum* ./pkg/ +COPY workers/{{COMPONENT_NAME}}/go.mod workers/{{COMPONENT_NAME}}/go.sum* ./workers/{{COMPONENT_NAME}}/ + +# Download dependencies +RUN cd workers/{{COMPONENT_NAME}} && go mod download + +# Copy source +COPY pkg/ ./pkg/ +COPY workers/{{COMPONENT_NAME}}/ ./workers/{{COMPONENT_NAME}}/ + +# Build +RUN cd workers/{{COMPONENT_NAME}} && CGO_ENABLED=0 go build -o /{{COMPONENT_NAME}} ./cmd/worker + +# Production stage +FROM alpine:3.19 + +RUN apk add --no-cache ca-certificates tzdata + +WORKDIR / + +COPY --from=builder /{{COMPONENT_NAME}} /{{COMPONENT_NAME}} + +ENTRYPOINT ["/{{COMPONENT_NAME}}"] diff --git a/internal/adapter/templates/templates/components/worker/Makefile.tmpl b/internal/adapter/templates/templates/components/worker/Makefile.tmpl new file mode 100644 index 0000000..fb8befd --- /dev/null +++ b/internal/adapter/templates/templates/components/worker/Makefile.tmpl @@ -0,0 +1,34 @@ +.PHONY: build run test lint fmt docker-build clean + +WORKER := {{COMPONENT_NAME}} +BINARY := bin/$(WORKER) +GO_MODULE := {{GO_MODULE}} + +# Build the worker binary +build: + go build -o $(BINARY) ./cmd/worker + +# Run the worker locally +run: + go run ./cmd/worker + +# Run tests +test: + go test -v ./... + +# Run linter +lint: + golangci-lint run ./... + +# Format code +fmt: + gofmt -w . + goimports -w -local $(GO_MODULE) . + +# Build Docker image (run from monorepo root) +docker-build: + docker build -t $(WORKER):latest -f Dockerfile ../.. + +# Clean build artifacts +clean: + rm -rf bin/ diff --git a/internal/adapter/templates/templates/components/worker/cmd/worker/main.go.tmpl b/internal/adapter/templates/templates/components/worker/cmd/worker/main.go.tmpl new file mode 100644 index 0000000..4c32742 --- /dev/null +++ b/internal/adapter/templates/templates/components/worker/cmd/worker/main.go.tmpl @@ -0,0 +1,54 @@ +// Package main is the entry point for the {{COMPONENT_NAME}} worker. +package main + +import ( + "context" + "os" + "os/signal" + "syscall" + + "{{GO_MODULE}}/pkg/config" + "{{GO_MODULE}}/pkg/logging" + "{{GO_MODULE}}/workers/{{COMPONENT_NAME}}/internal/handlers" +) + +func main() { + // Initialize configuration + config.MustInit(config.Options{ + AppName: "{{COMPONENT_NAME}}", + }) + + // Initialize logger + logCfg := config.ReadLoggingConfig() + appCfg := config.ReadAppConfig() + logger := logging.New(logging.Config{ + Level: logging.ParseLevel(logCfg.Level), + Format: logging.ParseFormat(logCfg.Format), + Environment: appCfg.Environment, + AddSource: appCfg.IsDevelopment(), + }).WithService("{{COMPONENT_NAME}}") + + logger.Info("starting {{COMPONENT_NAME}} worker") + + // Setup graceful shutdown + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + + // Initialize and start handler + handler := handlers.New(logger) + + // Start worker in goroutine + go handler.Run(ctx) + + // Wait for shutdown signal + sig := <-sigCh + logger.Info("received shutdown signal", "signal", sig.String()) + + // Trigger graceful shutdown + cancel() + + logger.Info("{{COMPONENT_NAME}} worker stopped") +} diff --git a/internal/adapter/templates/templates/components/worker/component.yaml.tmpl b/internal/adapter/templates/templates/components/worker/component.yaml.tmpl new file mode 100644 index 0000000..e421267 --- /dev/null +++ b/internal/adapter/templates/templates/components/worker/component.yaml.tmpl @@ -0,0 +1,8 @@ +name: {{COMPONENT_NAME}} +type: worker +path: workers/{{COMPONENT_NAME}} +dependencies: [] +# Add dependencies as needed: +# - postgres +# - redis +# - rabbitmq diff --git a/internal/adapter/templates/templates/components/worker/go.mod.tmpl b/internal/adapter/templates/templates/components/worker/go.mod.tmpl new file mode 100644 index 0000000..3a67ee9 --- /dev/null +++ b/internal/adapter/templates/templates/components/worker/go.mod.tmpl @@ -0,0 +1,5 @@ +module {{GO_MODULE}}/workers/{{COMPONENT_NAME}} + +go 1.23 + +require {{GO_MODULE}}/pkg v0.0.0 diff --git a/internal/adapter/templates/templates/components/worker/internal/config/config.go.tmpl b/internal/adapter/templates/templates/components/worker/internal/config/config.go.tmpl new file mode 100644 index 0000000..ac4cfe3 --- /dev/null +++ b/internal/adapter/templates/templates/components/worker/internal/config/config.go.tmpl @@ -0,0 +1,55 @@ +// Package config provides worker-specific configuration. +package config + +import ( + "time" + + "github.com/spf13/viper" + + "{{GO_MODULE}}/pkg/config" +) + +// Config holds {{COMPONENT_NAME}} worker configuration. +type Config struct { + config.AppConfig + Database config.DatabaseConfig + Logging config.LoggingConfig + Worker WorkerConfig +} + +// WorkerConfig holds worker-specific settings. +type WorkerConfig struct { + // PollInterval is how often to check for new jobs. + PollInterval time.Duration + + // BatchSize is the max number of jobs to process per poll. + BatchSize int + + // MaxRetries is the maximum number of retry attempts for failed jobs. + MaxRetries int +} + +// Load reads configuration from environment variables. +func Load() (*Config, error) { + if err := config.Init(config.Options{ + AppName: "{{COMPONENT_NAME}}", + SetDefaults: func() { + viper.SetDefault("WORKER_POLL_INTERVAL", "10s") + viper.SetDefault("WORKER_BATCH_SIZE", 10) + viper.SetDefault("WORKER_MAX_RETRIES", 3) + }, + }); err != nil { + return nil, err + } + + return &Config{ + AppConfig: config.ReadAppConfig(), + Database: config.ReadDatabaseConfig(), + Logging: config.ReadLoggingConfig(), + Worker: WorkerConfig{ + PollInterval: viper.GetDuration("WORKER_POLL_INTERVAL"), + BatchSize: viper.GetInt("WORKER_BATCH_SIZE"), + MaxRetries: viper.GetInt("WORKER_MAX_RETRIES"), + }, + }, nil +} diff --git a/internal/adapter/templates/templates/components/worker/internal/handlers/handler.go.tmpl b/internal/adapter/templates/templates/components/worker/internal/handlers/handler.go.tmpl new file mode 100644 index 0000000..abd40a9 --- /dev/null +++ b/internal/adapter/templates/templates/components/worker/internal/handlers/handler.go.tmpl @@ -0,0 +1,53 @@ +// Package handlers provides the worker's job processing logic. +package handlers + +import ( + "context" + "time" + + "{{GO_MODULE}}/pkg/logging" +) + +// Handler processes background jobs. +type Handler struct { + logger *logging.Logger +} + +// New creates a new Handler. +func New(logger *logging.Logger) *Handler { + return &Handler{ + logger: logger.WithComponent("handler"), + } +} + +// Run starts the worker loop and processes jobs until context is cancelled. +func (h *Handler) Run(ctx context.Context) { + h.logger.Info("worker loop started") + + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + h.logger.Info("worker loop stopping") + return + case <-ticker.C: + h.processJobs(ctx) + } + } +} + +// processJobs processes pending jobs. +func (h *Handler) processJobs(ctx context.Context) { + h.logger.Debug("checking for jobs") + + // TODO: Implement job processing logic + // Example: + // jobs, err := h.queue.Dequeue(ctx, 10) + // for _, job := range jobs { + // if err := h.process(ctx, job); err != nil { + // h.logger.Error("job failed", "job_id", job.ID, "error", err) + // } + // } +} diff --git a/internal/adapter/templates/templates/skeleton/.githooks/commit-msg b/internal/adapter/templates/templates/skeleton/.githooks/commit-msg new file mode 100644 index 0000000..5e6373f --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/.githooks/commit-msg @@ -0,0 +1,56 @@ +#!/bin/bash +# Commit message validation hook +# Validates conventional commit format +# Install: ./scripts/setup-hooks.sh + +set -e + +COMMIT_MSG_FILE="$1" +COMMIT_MSG=$(cat "$COMMIT_MSG_FILE") + +RED='\033[0;31m' +GREEN='\033[0;32m' +NC='\033[0m' + +# Conventional commit pattern: +# type(scope): description +# Types: feat, fix, docs, style, refactor, test, chore, perf, ci, build, revert +PATTERN='^(feat|fix|docs|style|refactor|test|chore|perf|ci|build|revert)(\([a-z0-9-]+\))?: .{1,100}$' + +# Also allow merge commits and WIP commits +if echo "$COMMIT_MSG" | grep -qE "^(Merge|WIP|fixup!|squash!)"; then + exit 0 +fi + +# Check first line of commit message +FIRST_LINE=$(echo "$COMMIT_MSG" | head -n1) + +if ! echo "$FIRST_LINE" | grep -qE "$PATTERN"; then + echo -e "${RED}ERROR: Invalid commit message format${NC}" + echo "" + echo "Expected format: type(scope): description" + echo "" + echo "Valid types:" + echo " feat - A new feature" + echo " fix - A bug fix" + echo " docs - Documentation changes" + echo " style - Code style changes (formatting, etc.)" + echo " refactor - Code refactoring" + echo " test - Adding or updating tests" + echo " chore - Maintenance tasks" + echo " perf - Performance improvements" + echo " ci - CI/CD changes" + echo " build - Build system changes" + echo " revert - Reverting changes" + echo "" + echo "Examples:" + echo " feat(auth): add JWT authentication" + echo " fix(api): handle null response" + echo " docs: update README" + echo "" + echo "Your message: $FIRST_LINE" + exit 1 +fi + +echo -e "${GREEN}Commit message format is valid${NC}" +exit 0 diff --git a/internal/adapter/templates/templates/skeleton/.githooks/pre-commit b/internal/adapter/templates/templates/skeleton/.githooks/pre-commit new file mode 100644 index 0000000..547984e --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/.githooks/pre-commit @@ -0,0 +1,135 @@ +#!/bin/bash +# Pre-commit hook for monorepo quality checks +# Install: ./scripts/setup-hooks.sh + +set -e + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +echo "Running pre-commit checks..." + +# Get staged files +STAGED_GO_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.go$' || true) +STAGED_TS_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(ts|tsx|js|jsx)$' || true) + +ERRORS=0 + +# ============================================ +# 1. File Length Check (500 lines max) +# ============================================ +echo "Checking file lengths..." +for file in $STAGED_GO_FILES $STAGED_TS_FILES; do + if [ -f "$file" ]; then + LINE_COUNT=$(wc -l < "$file" | tr -d ' ') + if [ "$LINE_COUNT" -gt 500 ]; then + echo -e "${RED}ERROR: $file has $LINE_COUNT lines (max 500)${NC}" + ERRORS=$((ERRORS + 1)) + fi + fi +done + +# ============================================ +# 2. Go Checks (if Go files are staged) +# ============================================ +if [ -n "$STAGED_GO_FILES" ]; then + echo "Running Go checks..." + + # gofmt + echo " - gofmt..." + GOFMT_OUTPUT=$(gofmt -l $STAGED_GO_FILES 2>&1 || true) + if [ -n "$GOFMT_OUTPUT" ]; then + echo -e "${YELLOW}Auto-fixing gofmt issues...${NC}" + gofmt -w $STAGED_GO_FILES + git add $STAGED_GO_FILES + fi + + # goimports (if available) + if command -v goimports &> /dev/null; then + echo " - goimports..." + GOIMPORTS_OUTPUT=$(goimports -l $STAGED_GO_FILES 2>&1 || true) + if [ -n "$GOIMPORTS_OUTPUT" ]; then + echo -e "${YELLOW}Auto-fixing goimports issues...${NC}" + goimports -w $STAGED_GO_FILES + git add $STAGED_GO_FILES + fi + fi + + # golangci-lint (if available) + if command -v golangci-lint &> /dev/null; then + echo " - golangci-lint..." + # Get unique directories with Go files + DIRS=$(echo "$STAGED_GO_FILES" | xargs -n1 dirname | sort -u) + for dir in $DIRS; do + if ! golangci-lint run "$dir/..." --fast 2>/dev/null; then + echo -e "${RED}golangci-lint found issues in $dir${NC}" + ERRORS=$((ERRORS + 1)) + fi + done + fi + + # go vet + echo " - go vet..." + if ! go vet ./... 2>/dev/null; then + echo -e "${RED}go vet found issues${NC}" + ERRORS=$((ERRORS + 1)) + fi +fi + +# ============================================ +# 3. TypeScript/JavaScript Checks +# ============================================ +if [ -n "$STAGED_TS_FILES" ]; then + echo "Running TypeScript checks..." + + # Get component directories with TS files + TS_DIRS=$(echo "$STAGED_TS_FILES" | xargs -n1 dirname | sort -u | grep -E '^apps/' | cut -d'/' -f1-2 | sort -u || true) + + for dir in $TS_DIRS; do + if [ -f "$dir/package.json" ]; then + # Use subshell to automatically restore directory on exit + ( + cd "$dir" + + # Get files relative to this component directory + COMPONENT_FILES=$(echo "$STAGED_TS_FILES" | grep "^$dir/" | xargs) + + # prettier (if available) + if [ -f "node_modules/.bin/prettier" ] || command -v prettier &> /dev/null; then + echo " - prettier in $dir..." + npx prettier --write $COMPONENT_FILES 2>/dev/null || true + fi + + # eslint (if available) + if [ -f "node_modules/.bin/eslint" ] || command -v eslint &> /dev/null; then + echo " - eslint in $dir..." + if ! npx eslint --fix $COMPONENT_FILES 2>/dev/null; then + echo -e "${RED}eslint found issues in $dir${NC}" + # Note: ERRORS can't propagate from subshell, so we exit with error + exit 1 + fi + fi + ) || ERRORS=$((ERRORS + 1)) + fi + done + + # Re-add auto-fixed files + for file in $STAGED_TS_FILES; do + if [ -f "$file" ]; then + git add "$file" + fi + done +fi + +# ============================================ +# 4. Final Result +# ============================================ +if [ $ERRORS -gt 0 ]; then + echo -e "${RED}Pre-commit checks failed with $ERRORS error(s)${NC}" + exit 1 +fi + +echo -e "${GREEN}Pre-commit checks passed!${NC}" +exit 0 diff --git a/internal/adapter/templates/templates/skeleton/.gitignore b/internal/adapter/templates/templates/skeleton/.gitignore new file mode 100644 index 0000000..02efa94 --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/.gitignore @@ -0,0 +1,48 @@ +# Binaries +*.exe +*.exe~ +*.dll +*.so +*.dylib +bin/ +dist/ + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool +*.out +coverage.html + +# Dependency directories +vendor/ + +# Go workspace file (local only) +go.work.sum + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Environment files +.env +.env.local +*.env + +# Node +node_modules/ +.npm/ + +# Shared packages +packages/*/node_modules/ +packages/*/dist/ + +# Build artifacts +build/ +.next/ + +# OS +.DS_Store +Thumbs.db diff --git a/internal/adapter/templates/templates/skeleton/.golangci.yml.tmpl b/internal/adapter/templates/templates/skeleton/.golangci.yml.tmpl new file mode 100644 index 0000000..1754a82 --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/.golangci.yml.tmpl @@ -0,0 +1,25 @@ +run: + timeout: 5m + modules-download-mode: readonly + +linters: + enable: + - errcheck + - gosimple + - govet + - ineffassign + - staticcheck + - unused + - gofmt + - goimports + +linters-settings: + gofmt: + simplify: true + goimports: + local-prefixes: {{GO_MODULE}} + +issues: + exclude-use-default: false + max-issues-per-linter: 0 + max-same-issues: 0 diff --git a/internal/adapter/templates/templates/skeleton/.woodpecker.yml.tmpl b/internal/adapter/templates/templates/skeleton/.woodpecker.yml.tmpl new file mode 100644 index 0000000..23f8e8b --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/.woodpecker.yml.tmpl @@ -0,0 +1,19 @@ +# CI/CD Pipeline for {{PROJECT_NAME}} +# Components will add their build steps below the marker + +clone: + git: + image: woodpeckerci/plugin-git + settings: + depth: 1 + +steps: + # COMPONENT_STEPS_BELOW - Do not remove this marker + + deploy: + image: bitnami/kubectl:latest + commands: + - echo "Deploying {{PROJECT_NAME}}" + when: + branch: main + event: push diff --git a/internal/adapter/templates/templates/skeleton/CLAUDE.md.tmpl b/internal/adapter/templates/templates/skeleton/CLAUDE.md.tmpl new file mode 100644 index 0000000..66ae883 --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/CLAUDE.md.tmpl @@ -0,0 +1,49 @@ +# {{PROJECT_NAME}} + +{{DESCRIPTION}} + +## Find Your Guide + +| If you need to... | Read this | +|-------------------|-----------| +| **Set up local dev** | [local/setup.md](.claude/guides/local/setup.md) | +| **Deploy** | [ops/deploying.md](.claude/guides/ops/deploying.md) | + +## Quick Reference + +```bash +# Start local dev +./scripts/dev.sh + +# Run quality checks +./scripts/quality.sh + +# List all components +./scripts/discover.sh +``` + +## Architecture + +``` +{{PROJECT_NAME}}/ +├── services/ # Go API services (port 8001+) +├── workers/ # Background workers (no port) +├── apps/ # Frontend applications (port 3001+) +├── cli/ # CLI tools (no port) +├── packages/ # Shared TypeScript packages (@{{PROJECT_NAME}}/*) +├── pkg/ # Shared Go packages ({{GO_MODULE}}/pkg/*) +└── scripts/ # Development & CI scripts +``` + +| Slot | Language | Port Range | Purpose | +|------|----------|------------|---------| +| services/ | Go | 8001+ | REST APIs, backend services | +| workers/ | Go | none | Background jobs, queue consumers | +| apps/ | TypeScript | 3001+ | React, Astro frontends | +| cli/ | Go | none | CLI tools, scripts | +| packages/ | TypeScript | none | Shared frontend packages | +| pkg/ | Go | none | Shared backend packages | + +## Components + + diff --git a/internal/adapter/templates/templates/skeleton/Procfile.tmpl b/internal/adapter/templates/templates/skeleton/Procfile.tmpl new file mode 100644 index 0000000..8e897c6 --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/Procfile.tmpl @@ -0,0 +1,2 @@ +# Local development processes +# Components will be added below as they're created diff --git a/internal/adapter/templates/templates/skeleton/README.md.tmpl b/internal/adapter/templates/templates/skeleton/README.md.tmpl new file mode 100644 index 0000000..d76ba5f --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/README.md.tmpl @@ -0,0 +1,55 @@ +# {{PROJECT_NAME}} + +{{DESCRIPTION}} + +## Quickstart + +```bash +# Clone the repo +git clone {{GIT_URL}} +cd {{PROJECT_NAME}} + +# Install dependencies +./scripts/install.sh + +# Start local development +./scripts/dev.sh +``` + +## Project Structure + +``` +{{PROJECT_NAME}}/ +├── services/ # Go API services +├── workers/ # Background workers +├── apps/ # Frontend applications +├── cli/ # CLI tools +├── packages/ # Shared TypeScript packages +├── pkg/ # Shared Go packages +└── scripts/ # Development scripts +``` + +## Scripts + +| Script | Description | +|--------|-------------| +| `./scripts/dev.sh` | Start local development environment | +| `./scripts/install.sh` | Install all dependencies | +| `./scripts/quality.sh` | Run quality checks on all components | +| `./scripts/discover.sh` | List all components in the monorepo | + +## Adding Components + +Components are added via the rdev API: + +```bash +# Add a Go service +curl -X POST $RDEV_API_URL/projects/{{PROJECT_NAME}}/components \ + -H "X-API-Key: $RDEV_API_KEY" \ + -d '{"type": "service", "name": "auth-api"}' + +# Add a React app +curl -X POST $RDEV_API_URL/projects/{{PROJECT_NAME}}/components \ + -H "X-API-Key: $RDEV_API_KEY" \ + -d '{"type": "app", "name": "dashboard", "template": "app-react"}' +``` diff --git a/internal/adapter/templates/templates/skeleton/apps/.gitkeep b/internal/adapter/templates/templates/skeleton/apps/.gitkeep new file mode 100644 index 0000000..29b8cd8 --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/apps/.gitkeep @@ -0,0 +1 @@ +# Frontend applications go here diff --git a/internal/adapter/templates/templates/skeleton/cli/.gitkeep b/internal/adapter/templates/templates/skeleton/cli/.gitkeep new file mode 100644 index 0000000..49cee16 --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/cli/.gitkeep @@ -0,0 +1 @@ +# CLI tools go here diff --git a/internal/adapter/templates/templates/skeleton/docker-compose.yml.tmpl b/internal/adapter/templates/templates/skeleton/docker-compose.yml.tmpl new file mode 100644 index 0000000..864fa98 --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/docker-compose.yml.tmpl @@ -0,0 +1,24 @@ +version: '3.8' + +services: + postgres: + image: postgres:16 + environment: + POSTGRES_USER: dev + POSTGRES_PASSWORD: dev + POSTGRES_DB: {{PROJECT_NAME}} + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + + redis: + image: redis:7-alpine + ports: + - "6379:6379" + volumes: + - redis_data:/data + +volumes: + postgres_data: + redis_data: diff --git a/internal/adapter/templates/templates/skeleton/go.work.tmpl b/internal/adapter/templates/templates/skeleton/go.work.tmpl new file mode 100644 index 0000000..9ffbefe --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/go.work.tmpl @@ -0,0 +1,4 @@ +go 1.23 + +use ./pkg +// Component modules will be added below diff --git a/internal/adapter/templates/templates/skeleton/packages/.gitkeep b/internal/adapter/templates/templates/skeleton/packages/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/internal/adapter/templates/templates/skeleton/packages/logger/package.json b/internal/adapter/templates/templates/skeleton/packages/logger/package.json new file mode 100644 index 0000000..7088514 --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/packages/logger/package.json @@ -0,0 +1,15 @@ +{ + "name": "@{{PROJECT_NAME}}/logger", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "src/index.ts", + "types": "src/index.ts", + "scripts": { + "typecheck": "tsc --noEmit", + "build": "tsc" + }, + "devDependencies": { + "typescript": "^5.5.3" + } +} diff --git a/internal/adapter/templates/templates/skeleton/packages/logger/src/handlers.ts b/internal/adapter/templates/templates/skeleton/packages/logger/src/handlers.ts new file mode 100644 index 0000000..76969d7 --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/packages/logger/src/handlers.ts @@ -0,0 +1,35 @@ +import type { Logger } from './logger'; + +/** + * Install global error handlers that route uncaught errors to the logger. + * + * Captures: + * - window.onerror (uncaught exceptions) + * - window.onunhandledrejection (unhandled promise rejections) + * + * Call once at app init. Returns a cleanup function. + */ +export function installGlobalHandlers(logger: Logger): () => void { + const onError = (event: ErrorEvent) => { + logger.error('Uncaught exception', event.error ?? new Error(event.message), { + source: event.filename, + line: event.lineno, + col: event.colno, + }); + }; + + const onRejection = (event: PromiseRejectionEvent) => { + const err = event.reason instanceof Error + ? event.reason + : new Error(String(event.reason)); + logger.error('Unhandled promise rejection', err); + }; + + window.addEventListener('error', onError); + window.addEventListener('unhandledrejection', onRejection); + + return () => { + window.removeEventListener('error', onError); + window.removeEventListener('unhandledrejection', onRejection); + }; +} diff --git a/internal/adapter/templates/templates/skeleton/packages/logger/src/index.ts b/internal/adapter/templates/templates/skeleton/packages/logger/src/index.ts new file mode 100644 index 0000000..f8fd28f --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/packages/logger/src/index.ts @@ -0,0 +1,3 @@ +export { createLogger, Logger } from './logger'; +export { installGlobalHandlers } from './handlers'; +export type { LogLevel, LogContext, LogEntry, LoggerConfig, LogTransport } from './types'; diff --git a/internal/adapter/templates/templates/skeleton/packages/logger/src/logger.ts b/internal/adapter/templates/templates/skeleton/packages/logger/src/logger.ts new file mode 100644 index 0000000..acee745 --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/packages/logger/src/logger.ts @@ -0,0 +1,170 @@ +import type { LogLevel, LogContext, LogEntry, LoggerConfig, LogTransport } from './types'; + +const LEVEL_PRIORITY: Record = { + debug: 0, + info: 1, + warn: 2, + error: 3, +}; + +/** Default transport: sends batched logs via sendBeacon or fetch. */ +class HttpTransport implements LogTransport { + constructor(private endpoint: string) {} + + send(entries: LogEntry[]): void { + const payload = JSON.stringify(entries); + + // sendBeacon is fire-and-forget, works during page unload + if (typeof navigator !== 'undefined' && navigator.sendBeacon) { + const blob = new Blob([payload], { type: 'application/json' }); + const sent = navigator.sendBeacon(this.endpoint, blob); + if (sent) return; + } + + // Fallback to fetch (non-blocking) + fetch(this.endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: payload, + keepalive: true, + }).catch(() => { + // Silently drop - we don't want logging failures to break the app + }); + } +} + +/** Console transport for development. */ +class ConsoleTransport implements LogTransport { + send(entries: LogEntry[]): void { + for (const entry of entries) { + const method = entry.level === 'debug' ? 'log' : entry.level; + const ctx = Object.keys(entry.context).length > 0 ? entry.context : undefined; + if (entry.error) { + console[method](`[${entry.level.toUpperCase()}] ${entry.message}`, entry.error, ctx); + } else if (ctx) { + console[method](`[${entry.level.toUpperCase()}] ${entry.message}`, ctx); + } else { + console[method](`[${entry.level.toUpperCase()}] ${entry.message}`); + } + } + } +} + +export class Logger { + private buffer: LogEntry[] = []; + private timer: ReturnType | null = null; + private transport: LogTransport; + private minLevel: number; + private baseContext: LogContext; + private batchSize: number; + private flushInterval: number; + + constructor(private config: LoggerConfig) { + this.minLevel = LEVEL_PRIORITY[config.level]; + this.batchSize = config.batchSize ?? 20; + this.flushInterval = config.flushInterval ?? 5000; + this.baseContext = { service: config.service }; + + if (config.endpoint) { + this.transport = new HttpTransport(config.endpoint); + } else { + this.transport = new ConsoleTransport(); + } + + this.startFlushTimer(); + + // Flush on page unload + if (typeof window !== 'undefined') { + window.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden') { + this.flush(); + } + }); + window.addEventListener('pagehide', () => this.flush()); + } + } + + /** Create a child logger with additional context fields. */ + withContext(ctx: LogContext): Logger { + const child = Object.create(this) as Logger; + child.baseContext = { ...this.baseContext, ...ctx }; + return child; + } + + debug(message: string, ctx?: LogContext): void { + this.log('debug', message, ctx); + } + + info(message: string, ctx?: LogContext): void { + this.log('info', message, ctx); + } + + warn(message: string, ctx?: LogContext): void { + this.log('warn', message, ctx); + } + + error(message: string, error?: Error | unknown, ctx?: LogContext): void { + const entry = this.createEntry('error', message, ctx); + if (error instanceof Error) { + entry.error = { + name: error.name, + message: error.message, + stack: error.stack, + }; + } else if (error !== undefined) { + entry.error = { + name: 'UnknownError', + message: String(error), + }; + } + this.enqueue(entry); + } + + /** Force-flush the buffer immediately. */ + flush(): void { + if (this.buffer.length === 0) return; + const entries = this.buffer.splice(0); + this.transport.send(entries); + } + + /** Stop the flush timer (call on teardown). */ + destroy(): void { + this.flush(); + if (this.timer) { + clearInterval(this.timer); + this.timer = null; + } + } + + private log(level: LogLevel, message: string, ctx?: LogContext): void { + if (LEVEL_PRIORITY[level] < this.minLevel) return; + this.enqueue(this.createEntry(level, message, ctx)); + } + + private createEntry(level: LogLevel, message: string, ctx?: LogContext): LogEntry { + return { + level, + message, + timestamp: new Date().toISOString(), + context: { ...this.baseContext, ...ctx }, + }; + } + + private enqueue(entry: LogEntry): void { + this.buffer.push(entry); + if (this.buffer.length >= this.batchSize) { + this.flush(); + } + } + + private startFlushTimer(): void { + if (this.flushInterval > 0 && typeof setInterval !== 'undefined') { + this.timer = setInterval(() => this.flush(), this.flushInterval); + } + } +} + +/** Create a logger instance. */ +export function createLogger(config: LoggerConfig): Logger { + return new Logger(config); +} diff --git a/internal/adapter/templates/templates/skeleton/packages/logger/src/types.ts b/internal/adapter/templates/templates/skeleton/packages/logger/src/types.ts new file mode 100644 index 0000000..9eb83cd --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/packages/logger/src/types.ts @@ -0,0 +1,40 @@ +export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; + +export interface LogContext { + trace_id?: string; + request_id?: string; + user_id?: string; + component?: string; + [key: string]: unknown; +} + +export interface LogEntry { + level: LogLevel; + message: string; + timestamp: string; + context: LogContext; + error?: { + name: string; + message: string; + stack?: string; + }; +} + +export interface LoggerConfig { + /** Minimum log level to emit */ + level: LogLevel; + /** Service/app name for log context */ + service: string; + /** Endpoint to send logs to (POST). Omit for console-only. */ + endpoint?: string; + /** Max entries to buffer before flushing (default: 20) */ + batchSize?: number; + /** Max ms to wait before flushing (default: 5000) */ + flushInterval?: number; + /** Install global error/rejection handlers (default: true) */ + captureGlobalErrors?: boolean; +} + +export interface LogTransport { + send(entries: LogEntry[]): void; +} diff --git a/internal/adapter/templates/templates/skeleton/packages/logger/tsconfig.json b/internal/adapter/templates/templates/skeleton/packages/logger/tsconfig.json new file mode 100644 index 0000000..f238cb6 --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/packages/logger/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/internal/adapter/templates/templates/skeleton/pkg/README.md b/internal/adapter/templates/templates/skeleton/pkg/README.md new file mode 100644 index 0000000..d15ee7f --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/pkg/README.md @@ -0,0 +1,286 @@ +# Shared Packages + +This directory contains shared Go packages used across all components in the monorepo. + +## Package Overview + +| Package | Description | +|---------|-------------| +| `app` | Service bootstrapper with chi router, middleware, and graceful shutdown | +| `config` | Viper-based configuration loading from environment variables | +| `httpcontext` | Type-safe context key helpers for request-scoped data | +| `httpclient` | Resilient HTTP client with automatic retries and exponential backoff | +| `httpresponse` | Standard response envelope pattern for API responses | +| `httpvalidation` | Struct validation wrapper around go-playground/validator | +| `logging` | slog-based structured logging with context integration | +| `middleware` | HTTP middleware: CORS, recovery, request ID, request logging | + +## Quick Start + +### Creating a New Service + +```go +package main + +import ( + "net/http" + + "{{GO_MODULE}}/pkg/app" + "{{GO_MODULE}}/pkg/httpresponse" +) + +func main() { + // Create application with default middleware and health endpoints + svc := app.New("my-service", app.WithDefaultPort(8080)) + + // Register routes + svc.GET("/hello", func(w http.ResponseWriter, r *http.Request) { + httpresponse.OK(w, r, map[string]string{"message": "Hello, World!"}) + }) + + // Start server (blocks until shutdown signal) + svc.Run() +} +``` + +## Package Documentation + +### pkg/app + +Service bootstrapper that provides: +- Chi router with standard middleware +- Graceful shutdown handling +- Health check endpoints (`/health`, `/ready`) + +```go +app := app.New("my-service", + app.WithDefaultPort(8080), + app.WithLogger(customLogger), +) + +// Register routes +app.GET("/users/{id}", getUser) +app.POST("/users", createUser) + +// Group routes +app.Route("/api/v1", func(r chi.Router) { + r.Get("/users", listUsers) +}) + +// Register shutdown hooks +app.OnShutdown(func(ctx context.Context) error { + return db.Close() +}) + +app.Run() +``` + +### pkg/config + +Configuration loading from environment variables with Viper. + +```go +// Initialize configuration (once at startup) +config.MustInit(config.Options{ + AppName: "my-service", + DefaultPort: 8080, +}) + +// Read typed configuration +appCfg := config.ReadAppConfig() // APP_NAME, APP_ENVIRONMENT, APP_DEBUG +serverCfg := config.ReadServerConfig() // SERVER_HOST, SERVER_PORT, timeouts +dbCfg := config.ReadDatabaseConfig() // DATABASE_URL, pool settings + +// Direct access +dbURL := config.GetString("DATABASE_URL") +debug := config.GetBool("APP_DEBUG") +``` + +**Environment Variables:** +- `APP_NAME` - Application name (default: service name) +- `APP_ENVIRONMENT` - development, staging, production +- `APP_DEBUG` - Enable debug mode +- `SERVER_HOST` - Server bind host (default: 0.0.0.0) +- `SERVER_PORT` - Server port (default: 8080) +- `DATABASE_URL` - Database connection string +- `LOG_LEVEL` - debug, info, warn, error +- `LOG_FORMAT` - json, text, auto + +### pkg/httpcontext + +Type-safe context key helpers. + +```go +// Set values in middleware +ctx := httpcontext.SetRequestID(r.Context(), requestID) +ctx = httpcontext.SetUser(ctx, user) +ctx = httpcontext.SetOrgID(ctx, orgID) + +// Get values in handlers +requestID, ok := httpcontext.GetRequestID(ctx) +user, ok := httpcontext.GetUser(ctx) +orgID, ok := httpcontext.GetOrgID(ctx) + +// Panic if not found (use when middleware guarantees presence) +user := httpcontext.MustGetUser(ctx) +``` + +### pkg/httpclient + +HTTP client with automatic retries. + +```go +// Create client +client := httpclient.New(httpclient.Config{ + Timeout: 10 * time.Second, + MaxRetries: 3, +}) + +// Make requests +resp, err := client.Do(req) + +// Convenience methods +resp, err := httpclient.Get(ctx, "https://api.example.com/users") +resp, err := httpclient.JSONPost(ctx, url, bytes.NewReader(jsonData)) +``` + +Retries on: +- HTTP 5xx server errors +- HTTP 429 Too Many Requests +- Connection errors (timeout, refused) + +Does NOT retry on: +- HTTP 4xx client errors (except 429) +- Context cancellation + +### pkg/httpresponse + +Standard response envelope for API responses. + +```go +// Success responses +httpresponse.OK(w, r, data) // 200 OK +httpresponse.Created(w, r, data) // 201 Created +httpresponse.NoContent(w) // 204 No Content + +// Error responses +httpresponse.BadRequest(w, r, "invalid input") +httpresponse.Unauthorized(w, r, "authentication required") +httpresponse.Forbidden(w, r, "insufficient permissions") +httpresponse.NotFound(w, r, "user not found") +httpresponse.InternalError(w, r, "something went wrong") + +// Validation errors with details +httpresponse.ValidationError(w, r, "validation failed", details) + +// Decode request body +var req CreateUserRequest +if err := httpresponse.DecodeJSON(r, &req); err != nil { + httpresponse.BadRequest(w, r, "invalid JSON") + return +} +``` + +**Response Format:** +```json +{ + "data": { ... }, + "error": { + "code": "VALIDATION_ERROR", + "message": "validation failed", + "details": [ ... ] + }, + "meta": { + "request_id": "abc-123", + "timestamp": "2024-01-15T10:30:00Z" + } +} +``` + +### pkg/httpvalidation + +Struct validation using go-playground/validator. + +```go +type CreateUserRequest struct { + Email string `json:"email" validate:"required,email"` + Name string `json:"name" validate:"required,min=2,max=100"` + Phone string `json:"phone" validate:"omitempty,phone"` +} + +// Validate struct +if details := httpvalidation.ValidateStruct(req); len(details) > 0 { + httpresponse.ValidationError(w, r, "validation failed", details) + return +} + +// Custom validators available: +// - uuid: Valid UUID +// - uuid_or_empty: Valid UUID or empty string +// - phone: E.164 phone number format +// - slug: URL-safe slug (lowercase, numbers, hyphens) +// - hex_color: Hex color code (#RGB, #RRGGBB, #RRGGBBAA) +``` + +### pkg/logging + +Structured logging with slog. + +```go +// Create logger +logger := logging.New(logging.Config{ + Level: logging.LevelInfo, + Format: logging.FormatJSON, + Environment: "production", +}) + +// Or use convenience constructors +logger := logging.NewDevelopment() // text format, debug level +logger := logging.NewProduction() // JSON format, info level + +// Log messages +logger.Info("user created", "user_id", userID) +logger.Error("failed to connect", "error", err) + +// Create derived loggers +reqLogger := logger.With("request_id", requestID) +svcLogger := logger.WithService("user-service") + +// Get logger from context (set by middleware) +logger := logging.FromContext(r.Context()) +``` + +### pkg/middleware + +HTTP middleware for chi router. + +```go +r := chi.NewRouter() + +// Request ID generation/propagation +r.Use(middleware.RequestID()) + +// Request logging +r.Use(middleware.RequestLogger(logger)) + +// Panic recovery +r.Use(middleware.Recoverer(logger)) + +// CORS +r.Use(middleware.CORS(middleware.DefaultCORSConfig())) + +// Production CORS +r.Use(middleware.CORS(middleware.CORSConfig{ + AllowedOrigins: []string{"https://app.example.com"}, + AllowedMethods: []string{"GET", "POST", "PUT", "DELETE"}, + AllowCredentials: true, +})) +``` + +## Guidelines + +- **Import Path**: Use `{{GO_MODULE}}/pkg/` for imports +- **Keep packages focused**: Each package should do one thing well +- **No circular dependencies**: pkg packages should not import from services/workers +- **Document public APIs**: All exported functions should have doc comments +- **Write tests**: Cover exported functions with unit tests diff --git a/internal/adapter/templates/templates/skeleton/pkg/app/app.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/app/app.go.tmpl new file mode 100644 index 0000000..5803ecc --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/pkg/app/app.go.tmpl @@ -0,0 +1,297 @@ +// Package app provides a service bootstrapper for HTTP services. +// +// App is the main application struct that provides infrastructure for HTTP services. +// It manages configuration, logging, routing, and graceful shutdown. +// +// Example usage: +// +// func main() { +// app := app.New("my-service", app.WithDefaultPort(8080)) +// app.GET("/users/{id}", handlers.GetUser) +// app.POST("/users", handlers.CreateUser) +// app.Run() +// } +package app + +import ( + "context" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/go-chi/chi/v5" + + "{{GO_MODULE}}/pkg/config" + "{{GO_MODULE}}/pkg/httpresponse" + "{{GO_MODULE}}/pkg/logging" + "{{GO_MODULE}}/pkg/middleware" +) + +// Router is an alias for chi.Router, exposing it for handler mounting. +type Router = chi.Router + +// App is the main application struct that provides infrastructure for HTTP services. +type App struct { + name string + defaultPort int + logger *logging.Logger + router chi.Router + server *http.Server + + // Configuration + appConfig config.AppConfig + serverConfig config.ServerConfig + + // Lifecycle hooks + onShutdown []func(context.Context) error +} + +// Option configures the App. +type Option func(*App) + +// WithLogger sets a custom logger for the application. +func WithLogger(logger *logging.Logger) Option { + return func(a *App) { + a.logger = logger + } +} + +// WithDefaultPort sets the default port if not configured via environment. +func WithDefaultPort(port int) Option { + return func(a *App) { + a.defaultPort = port + } +} + +// New creates a new App instance with the given service name. +// It initializes configuration, logging, and routing infrastructure. +// +// The service name is used for: +// - Configuration defaults (APP_NAME) +// - Logging context (service attribute) +// - Health check identification +// +// Configuration is loaded from environment variables with support for: +// - .env file (in development) +// - Environment variables (highest priority) +func New(serviceName string, opts ...Option) *App { + app := &App{ + name: serviceName, + defaultPort: 8080, + onShutdown: make([]func(context.Context) error, 0), + } + + // Apply options before initialization (to capture defaultPort) + for _, opt := range opts { + opt(app) + } + + // Initialize configuration + config.MustInit(config.Options{ + AppName: serviceName, + DefaultPort: app.defaultPort, + }) + + // Load configuration + app.appConfig = config.ReadAppConfig() + app.serverConfig = config.ReadServerConfig() + + // Initialize logger if not provided + if app.logger == nil { + logCfg := config.ReadLoggingConfig() + app.logger = logging.New(logging.Config{ + Level: logging.ParseLevel(logCfg.Level), + Format: logging.ParseFormat(logCfg.Format), + Environment: app.appConfig.Environment, + AddSource: app.appConfig.IsDevelopment(), + }).WithService(serviceName) + } + + // Initialize router with standard middleware + app.router = chi.NewRouter() + app.setupMiddleware() + app.setupHealthEndpoints() + + return app +} + +// setupMiddleware configures the standard middleware stack. +func (a *App) setupMiddleware() { + // Core middleware (order matters) + a.router.Use(middleware.RequestID()) + a.router.Use(middleware.Tracing()) + a.router.Use(middleware.RequestLogger(a.logger)) + a.router.Use(middleware.Recoverer(a.logger)) + + // CORS (configurable via environment) + a.router.Use(middleware.CORS(middleware.DefaultCORSConfig())) +} + +// setupHealthEndpoints registers /health and /ready endpoints. +func (a *App) setupHealthEndpoints() { + // Liveness probe - returns 200 if the process is running + a.router.Get("/health", func(w http.ResponseWriter, r *http.Request) { + httpresponse.OK(w, r, map[string]string{ + "status": "ok", + "service": a.name, + }) + }) + + // Readiness probe - returns 200 if the service is ready to accept traffic + a.router.Get("/ready", func(w http.ResponseWriter, r *http.Request) { + httpresponse.OK(w, r, map[string]string{ + "status": "ready", + "service": a.name, + }) + }) +} + +// Logger returns the application logger. +func (a *App) Logger() *logging.Logger { + return a.logger +} + +// Config returns the application configuration. +func (a *App) Config() config.AppConfig { + return a.appConfig +} + +// ServerConfig returns the server configuration. +func (a *App) ServerConfig() config.ServerConfig { + return a.serverConfig +} + +// Router returns the underlying chi router for advanced configuration. +func (a *App) Router() chi.Router { + return a.router +} + +// Use appends middleware to the router middleware stack. +func (a *App) Use(middlewares ...func(http.Handler) http.Handler) { + a.router.Use(middlewares...) +} + +// GET registers a handler for GET requests to the given pattern. +func (a *App) GET(pattern string, handler http.HandlerFunc) { + a.router.Get(pattern, handler) +} + +// POST registers a handler for POST requests to the given pattern. +func (a *App) POST(pattern string, handler http.HandlerFunc) { + a.router.Post(pattern, handler) +} + +// PUT registers a handler for PUT requests to the given pattern. +func (a *App) PUT(pattern string, handler http.HandlerFunc) { + a.router.Put(pattern, handler) +} + +// PATCH registers a handler for PATCH requests to the given pattern. +func (a *App) PATCH(pattern string, handler http.HandlerFunc) { + a.router.Patch(pattern, handler) +} + +// DELETE registers a handler for DELETE requests to the given pattern. +func (a *App) DELETE(pattern string, handler http.HandlerFunc) { + a.router.Delete(pattern, handler) +} + +// Route creates a new sub-router with the given pattern prefix. +// +// Example: +// +// app.Route("/api/v1", func(r chi.Router) { +// r.Get("/users", listUsers) +// r.Post("/users", createUser) +// }) +func (a *App) Route(pattern string, fn func(r chi.Router)) { + a.router.Route(pattern, fn) +} + +// Mount attaches a sub-router or http.Handler at the given pattern. +func (a *App) Mount(pattern string, handler http.Handler) { + a.router.Mount(pattern, handler) +} + +// OnShutdown registers a function to be called during graceful shutdown. +// Functions are called in the order they were registered. +func (a *App) OnShutdown(fn func(context.Context) error) { + a.onShutdown = append(a.onShutdown, fn) +} + +// Run starts the HTTP server and blocks until shutdown. +// It handles graceful shutdown on SIGINT and SIGTERM signals. +func (a *App) Run() { + addr := a.serverConfig.Addr() + + a.server = &http.Server{ + Addr: addr, + Handler: a.router, + ReadTimeout: a.serverConfig.ReadTimeout, + WriteTimeout: a.serverConfig.WriteTimeout, + IdleTimeout: a.serverConfig.IdleTimeout, + } + + // Start server in a goroutine + errChan := make(chan error, 1) + go func() { + a.logger.Info("starting server", + "service", a.name, + "address", addr, + "environment", a.appConfig.Environment, + ) + if err := a.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + errChan <- err + } + }() + + // Wait for shutdown signal or server error + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + + select { + case err := <-errChan: + a.logger.Error("server error", "error", err) + os.Exit(1) + case sig := <-quit: + a.logger.Info("received shutdown signal", "signal", sig.String()) + } + + // Graceful shutdown + a.shutdown() +} + +// shutdown performs graceful shutdown of the application. +func (a *App) shutdown() { + // Create shutdown context with timeout + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + a.logger.Info("shutting down server") + + // Shutdown HTTP server + if err := a.server.Shutdown(ctx); err != nil { + a.logger.Error("server shutdown error", "error", err) + } + + // Run shutdown hooks + for _, fn := range a.onShutdown { + if err := fn(ctx); err != nil { + a.logger.Error("shutdown hook error", "error", err) + } + } + + a.logger.Info("server stopped", "service", a.name) +} + +// ListenAddr returns the address the server is configured to listen on. +func (a *App) ListenAddr() string { + return a.serverConfig.Addr() +} + +// ServeHTTP implements http.Handler, allowing App to be used in tests. +func (a *App) ServeHTTP(w http.ResponseWriter, r *http.Request) { + a.router.ServeHTTP(w, r) +} diff --git a/internal/adapter/templates/templates/skeleton/pkg/config/config.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/config/config.go.tmpl new file mode 100644 index 0000000..7f5e2ef --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/pkg/config/config.go.tmpl @@ -0,0 +1,235 @@ +// Package config provides configuration loading using Viper. +// +// This package standardizes configuration loading across all services with: +// - Environment variable support +// - .env file support for development +// - Sensible defaults +// - Type-safe configuration structs +// +// Usage: +// +// // Initialize configuration (call once at startup) +// config.MustInit(config.Options{ +// AppName: "my-service", +// DefaultPort: 8080, +// }) +// +// // Read configuration +// appCfg := config.ReadAppConfig() +// serverCfg := config.ReadServerConfig() +// +// // Or read specific values +// dbURL := viper.GetString("DATABASE_URL") +package config + +import ( + "fmt" + "time" + + "github.com/spf13/viper" +) + +// ServerConfig holds HTTP server configuration. +type ServerConfig struct { + Host string `json:"host"` + Port int `json:"port"` + ReadTimeout time.Duration `json:"read_timeout"` + WriteTimeout time.Duration `json:"write_timeout"` + IdleTimeout time.Duration `json:"idle_timeout"` +} + +// Addr returns the server address in host:port format. +func (c ServerConfig) Addr() string { + return fmt.Sprintf("%s:%d", c.Host, c.Port) +} + +// DatabaseConfig holds database connection configuration. +type DatabaseConfig struct { + URL string `json:"url"` + MaxOpenConns int `json:"max_open_conns"` + MaxIdleConns int `json:"max_idle_conns"` + ConnMaxLifetime time.Duration `json:"conn_max_lifetime"` +} + +// AppConfig holds application-level configuration common to all services. +type AppConfig struct { + Name string `json:"name"` + Environment string `json:"environment"` + Debug bool `json:"debug"` +} + +// IsDevelopment returns true if the environment is development. +func (c AppConfig) IsDevelopment() bool { + return c.Environment == "development" +} + +// IsProduction returns true if the environment is production. +func (c AppConfig) IsProduction() bool { + return c.Environment == "production" +} + +// LoggingConfig holds logging configuration. +type LoggingConfig struct { + Level string `json:"level"` + Format string `json:"format"` +} + +// Options configures the behavior of Init. +type Options struct { + // AppName is the default name for the application. + AppName string + + // DefaultPort is the default server port if not specified. + DefaultPort int + + // EnvFile is the path to the .env file for development. + // Defaults to ".env" if not specified. + EnvFile string + + // SetDefaults is an optional function to set additional viper defaults + // before loading configuration. + SetDefaults func() + + // SkipEnvFile skips loading from .env file. + // Useful for production where all config comes from environment. + SkipEnvFile bool +} + +// Init initializes viper with common defaults and loads configuration. +// This should be called once at service startup before using viper.Get* functions. +// +// Load order (later sources override earlier): +// 1. Default values +// 2. .env file (development only) +// 3. Environment variables +func Init(opts Options) error { + viper.SetConfigType("env") + viper.SetEnvPrefix("") + viper.AutomaticEnv() + + // Set common defaults + setCommonDefaults(opts) + + // Set service-specific defaults + if opts.SetDefaults != nil { + opts.SetDefaults() + } + + // In development, optionally load from .env file + if !opts.SkipEnvFile { + env := viper.GetString("APP_ENVIRONMENT") + if env == "development" || env == "" { + envFile := opts.EnvFile + if envFile == "" { + envFile = ".env" + } + viper.SetConfigFile(envFile) + _ = viper.ReadInConfig() // Ignore error - fallback to env vars + } + } + + return nil +} + +// MustInit is like Init but panics if initialization fails. +// This is useful in main() where you want to fail fast on configuration errors. +func MustInit(opts Options) { + if err := Init(opts); err != nil { + panic(fmt.Sprintf("failed to initialize config: %v", err)) + } +} + +// setCommonDefaults sets default values for common configuration fields. +func setCommonDefaults(opts Options) { + // App defaults + appName := opts.AppName + if appName == "" { + appName = "service" + } + viper.SetDefault("APP_NAME", appName) + viper.SetDefault("APP_ENVIRONMENT", "development") + viper.SetDefault("APP_DEBUG", false) + + // Server defaults + viper.SetDefault("SERVER_HOST", "0.0.0.0") + port := opts.DefaultPort + if port == 0 { + port = 8080 + } + viper.SetDefault("SERVER_PORT", port) + viper.SetDefault("SERVER_READ_TIMEOUT", "30s") + viper.SetDefault("SERVER_WRITE_TIMEOUT", "0s") // Disabled for SSE support + viper.SetDefault("SERVER_IDLE_TIMEOUT", "120s") + + // Database defaults + viper.SetDefault("DATABASE_MAX_OPEN_CONNS", 25) + viper.SetDefault("DATABASE_MAX_IDLE_CONNS", 5) + viper.SetDefault("DATABASE_CONN_MAX_LIFETIME", "5m") + + // Logging defaults + viper.SetDefault("LOG_LEVEL", "info") + viper.SetDefault("LOG_FORMAT", "auto") // auto = JSON in prod, text in dev +} + +// ReadAppConfig reads AppConfig from viper. +func ReadAppConfig() AppConfig { + return AppConfig{ + Name: viper.GetString("APP_NAME"), + Environment: viper.GetString("APP_ENVIRONMENT"), + Debug: viper.GetBool("APP_DEBUG"), + } +} + +// ReadServerConfig reads ServerConfig from viper. +func ReadServerConfig() ServerConfig { + return ServerConfig{ + Host: viper.GetString("SERVER_HOST"), + Port: viper.GetInt("SERVER_PORT"), + ReadTimeout: viper.GetDuration("SERVER_READ_TIMEOUT"), + WriteTimeout: viper.GetDuration("SERVER_WRITE_TIMEOUT"), + IdleTimeout: viper.GetDuration("SERVER_IDLE_TIMEOUT"), + } +} + +// ReadDatabaseConfig reads DatabaseConfig from viper. +func ReadDatabaseConfig() DatabaseConfig { + return DatabaseConfig{ + URL: viper.GetString("DATABASE_URL"), + MaxOpenConns: viper.GetInt("DATABASE_MAX_OPEN_CONNS"), + MaxIdleConns: viper.GetInt("DATABASE_MAX_IDLE_CONNS"), + ConnMaxLifetime: viper.GetDuration("DATABASE_CONN_MAX_LIFETIME"), + } +} + +// ReadLoggingConfig reads LoggingConfig from viper. +func ReadLoggingConfig() LoggingConfig { + return LoggingConfig{ + Level: viper.GetString("LOG_LEVEL"), + Format: viper.GetString("LOG_FORMAT"), + } +} + +// GetString returns a string configuration value. +func GetString(key string) string { + return viper.GetString(key) +} + +// GetInt returns an integer configuration value. +func GetInt(key string) int { + return viper.GetInt(key) +} + +// GetBool returns a boolean configuration value. +func GetBool(key string) bool { + return viper.GetBool(key) +} + +// GetDuration returns a duration configuration value. +func GetDuration(key string) time.Duration { + return viper.GetDuration(key) +} + +// IsSet returns true if the key is set in configuration. +func IsSet(key string) bool { + return viper.IsSet(key) +} diff --git a/internal/adapter/templates/templates/skeleton/pkg/go.mod.tmpl b/internal/adapter/templates/templates/skeleton/pkg/go.mod.tmpl new file mode 100644 index 0000000..b675191 --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/pkg/go.mod.tmpl @@ -0,0 +1,39 @@ +module {{GO_MODULE}}/pkg + +go 1.23 + +require ( + github.com/go-chi/chi/v5 v5.2.0 + github.com/go-chi/cors v1.2.1 + github.com/go-playground/validator/v10 v10.23.0 + github.com/google/uuid v1.6.0 + github.com/spf13/viper v1.19.0 +) + +require ( + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/sagikazarmark/locafero v0.4.0 // indirect + github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.11.0 // indirect + github.com/spf13/cast v1.6.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + go.uber.org/atomic v1.9.0 // indirect + go.uber.org/multierr v1.9.0 // indirect + golang.org/x/crypto v0.21.0 // indirect + golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect + golang.org/x/net v0.23.0 // indirect + golang.org/x/sys v0.18.0 // indirect + golang.org/x/text v0.14.0 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/internal/adapter/templates/templates/skeleton/pkg/httpclient/client.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/httpclient/client.go.tmpl new file mode 100644 index 0000000..6d67cbf --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/pkg/httpclient/client.go.tmpl @@ -0,0 +1,253 @@ +// Package httpclient provides a robust HTTP client with automatic retries and exponential backoff. +// +// This package wraps the standard http.Client to provide: +// - Automatic retries with exponential backoff +// - Request ID and trace ID propagation +// - Configurable timeouts +// +// Usage: +// +// // Create a client with default settings +// client := httpclient.New(httpclient.Config{ +// Timeout: 10 * time.Second, +// MaxRetries: 3, +// }) +// +// // Make requests +// resp, err := client.Do(req) +// +// // Or use convenience methods +// resp, err := httpclient.Get(ctx, "https://api.example.com/users") +package httpclient + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "time" + + "{{GO_MODULE}}/pkg/httpcontext" +) + +// Config holds configuration for the HTTP client. +type Config struct { + // Timeout for individual HTTP requests (default: 10s) + Timeout time.Duration + + // MaxRetries for failed requests (default: 3) + MaxRetries int + + // Logger for structured logging (optional, defaults to slog.Default()) + Logger *slog.Logger +} + +// Client wraps http.Client to provide retry logic and request ID propagation. +type Client struct { + httpClient *http.Client + logger *slog.Logger + config Config +} + +// New creates a new robust HTTP client. +func New(config Config) *Client { + if config.Timeout == 0 { + config.Timeout = 10 * time.Second + } + if config.MaxRetries == 0 { + config.MaxRetries = 3 + } + if config.Logger == nil { + config.Logger = slog.Default() + } + + return &Client{ + httpClient: &http.Client{ + Timeout: config.Timeout, + }, + logger: config.Logger, + config: config, + } +} + +// Do executes an HTTP request with exponential backoff retry logic. +// +// Retries on transient errors: +// - HTTP 5xx server errors +// - HTTP 429 Too Many Requests +// - Connection errors (timeout, connection refused) +// +// Does NOT retry on: +// - HTTP 4xx client errors (except 429) +// - Context cancellation or deadline exceeded +func (c *Client) Do(req *http.Request) (*http.Response, error) { + const ( + initialDelay = 100 * time.Millisecond + maxDelay = 2 * time.Second + ) + + // Propagate request ID if present in context + if requestID, ok := httpcontext.GetRequestID(req.Context()); ok && requestID != "" { + if req.Header.Get("X-Request-ID") == "" { + req.Header.Set("X-Request-ID", requestID) + } + } + + // Propagate trace ID if present in context + if traceID, ok := httpcontext.GetTraceID(req.Context()); ok && traceID != "" { + if req.Header.Get("X-Trace-ID") == "" { + req.Header.Set("X-Trace-ID", traceID) + } + } + + // Clone request body for retries (critical: POST/PUT bodies get exhausted) + var bodyBytes []byte + if req.Body != nil { + var err error + bodyBytes, err = io.ReadAll(req.Body) + if err != nil { + return nil, fmt.Errorf("read request body: %w", err) + } + _ = req.Body.Close() + } + + var lastErr error + maxRetries := c.config.MaxRetries + ctx := req.Context() + + for attempt := 0; attempt <= maxRetries; attempt++ { + // Check if context is already cancelled + if err := ctx.Err(); err != nil { + return nil, err + } + + // Reset body for each attempt + if bodyBytes != nil { + req.Body = io.NopCloser(bytes.NewReader(bodyBytes)) + } + + // Execute the request + resp, err := c.httpClient.Do(req) + + // Network error + if err != nil { + lastErr = err + if !isRetryableError(err, nil) { + return nil, lastErr + } + // Continue to retry + } else { + // HTTP 429 - retry + if resp.StatusCode == http.StatusTooManyRequests { + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + lastErr = fmt.Errorf("HTTP %d", resp.StatusCode) + // Continue to retry + } else if resp.StatusCode >= 400 && resp.StatusCode < 500 { + // Other HTTP 4xx - return immediately (not transient) + return resp, nil + } else if resp.StatusCode >= 500 { + // HTTP 5xx - retry + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + lastErr = fmt.Errorf("HTTP %d", resp.StatusCode) + // Continue to retry + } else { + // HTTP 2xx/3xx - success + return resp, nil + } + } + + // Don't retry if we've exhausted attempts + if attempt >= maxRetries { + break + } + + // Calculate exponential backoff delay using bit-shift + delay := initialDelay << attempt + if delay > maxDelay { + delay = maxDelay + } + + c.logger.Debug("retrying http request", + "attempt", attempt+1, + "max_retries", maxRetries, + "delay_ms", delay.Milliseconds(), + "url", req.URL.String(), + "error", lastErr) + + // Wait with context awareness + select { + case <-time.After(delay): + // Continue to next retry + case <-ctx.Done(): + return nil, ctx.Err() + } + } + + return nil, fmt.Errorf("request failed after %d retries: %w", maxRetries, lastErr) +} + +// isRetryableError determines if an error or response should trigger a retry. +func isRetryableError(err error, resp *http.Response) bool { + // Network/connection errors are retryable + if err != nil { + // Don't retry on context cancellation + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return false + } + // Retry on all other errors (connection refused, timeout, etc.) + return true + } + + // HTTP 5xx errors and 429 are retryable + if resp != nil { + if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 { + return true + } + } + + return false +} + +// ----------------------------------------------------------------------------- +// Convenience methods using a default client +// ----------------------------------------------------------------------------- + +// Default is a pre-configured client with 30s timeout and 3 retries. +var Default = New(Config{ + Timeout: 30 * time.Second, + MaxRetries: 3, +}) + +// Do performs an HTTP request with retry logic using the default client. +func Do(req *http.Request) (*http.Response, error) { + return Default.Do(req) +} + +// Get performs a GET request with the default client. +func Get(ctx context.Context, url string) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + return Default.Do(req) +} + +// Post performs a POST request with the default client. +func Post(ctx context.Context, url, contentType string, body io.Reader) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, body) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", contentType) + return Default.Do(req) +} + +// JSONPost performs a POST request with JSON content type. +func JSONPost(ctx context.Context, url string, body io.Reader) (*http.Response, error) { + return Post(ctx, url, "application/json", body) +} diff --git a/internal/adapter/templates/templates/skeleton/pkg/httpcontext/keys.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/httpcontext/keys.go.tmpl new file mode 100644 index 0000000..4495ec2 --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/pkg/httpcontext/keys.go.tmpl @@ -0,0 +1,186 @@ +// Package httpcontext provides type-safe context keys and helpers for HTTP request contexts. +// +// This package standardizes how context values are stored and retrieved across all services. +// Using unexported types for context keys prevents collisions with other packages. +// +// Usage in middleware: +// +// func AuthMiddleware() func(http.Handler) http.Handler { +// return func(next http.Handler) http.Handler { +// return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { +// user := extractUserFromAuth(r) +// ctx := httpcontext.SetUser(r.Context(), user) +// ctx = httpcontext.SetOrgID(ctx, user.OrganizationID) +// next.ServeHTTP(w, r.WithContext(ctx)) +// }) +// } +// } +// +// Usage in handlers: +// +// func (h *Handler) GetProfile(w http.ResponseWriter, r *http.Request) { +// user, ok := httpcontext.GetUser(r.Context()) +// if !ok { +// http.Error(w, "unauthorized", http.StatusUnauthorized) +// return +// } +// // ... use user +// } +package httpcontext + +import "context" + +// contextKey is an unexported type used for context keys to prevent collisions. +// Other packages cannot create values of this type, ensuring our keys are unique. +type contextKey string + +// Standard context keys used across services. +const ( + keyUser contextKey = "user" + keyOrgID contextKey = "organization_id" + keyRequestID contextKey = "request_id" + keyTraceID contextKey = "trace_id" + keyJWTClaims contextKey = "jwt_claims" +) + +// SetUser adds a user to the context. +// The user can be any type - typically a domain user struct. +// Returns a new context with the user attached. +func SetUser(ctx context.Context, user any) context.Context { + if user == nil { + return ctx + } + return context.WithValue(ctx, keyUser, user) +} + +// GetUser retrieves the user from context. +// Returns (user, true) if present, (nil, false) if not found. +// Caller should type-assert the returned value to their user type. +// +// Example: +// +// if val, ok := httpcontext.GetUser(ctx); ok { +// user := val.(*domain.User) +// // ... use user +// } +func GetUser(ctx context.Context) (any, bool) { + val := ctx.Value(keyUser) + if val == nil { + return nil, false + } + return val, true +} + +// SetOrgID adds an organization ID to the context. +// Returns a new context with the organization ID attached. +func SetOrgID(ctx context.Context, orgID string) context.Context { + if orgID == "" { + return ctx + } + return context.WithValue(ctx, keyOrgID, orgID) +} + +// GetOrgID retrieves the organization ID from context. +// Returns (orgID, true) if present, ("", false) if not found. +func GetOrgID(ctx context.Context) (string, bool) { + val := ctx.Value(keyOrgID) + if val == nil { + return "", false + } + if orgID, ok := val.(string); ok { + return orgID, true + } + return "", false +} + +// SetRequestID adds a request ID to the context. +// Returns a new context with the request ID attached. +func SetRequestID(ctx context.Context, requestID string) context.Context { + if requestID == "" { + return ctx + } + return context.WithValue(ctx, keyRequestID, requestID) +} + +// GetRequestID retrieves the request ID from context. +// Returns (requestID, true) if present, ("", false) if not found. +func GetRequestID(ctx context.Context) (string, bool) { + val := ctx.Value(keyRequestID) + if val == nil { + return "", false + } + if requestID, ok := val.(string); ok { + return requestID, true + } + return "", false +} + +// SetTraceID adds a trace ID to the context. +// Returns a new context with the trace ID attached. +func SetTraceID(ctx context.Context, traceID string) context.Context { + if traceID == "" { + return ctx + } + return context.WithValue(ctx, keyTraceID, traceID) +} + +// GetTraceID retrieves the trace ID from context. +// Returns (traceID, true) if present, ("", false) if not found. +func GetTraceID(ctx context.Context) (string, bool) { + val := ctx.Value(keyTraceID) + if val == nil { + return "", false + } + if traceID, ok := val.(string); ok { + return traceID, true + } + return "", false +} + +// SetJWTClaims adds JWT claims to the context. +// The claims can be any type - typically a custom claims struct. +// Returns a new context with the claims attached. +func SetJWTClaims(ctx context.Context, claims any) context.Context { + if claims == nil { + return ctx + } + return context.WithValue(ctx, keyJWTClaims, claims) +} + +// GetJWTClaims retrieves JWT claims from context. +// Returns (claims, true) if present, (nil, false) if not found. +// Caller should type-assert the returned value to their claims type. +// +// Example: +// +// if val, ok := httpcontext.GetJWTClaims(ctx); ok { +// claims := val.(*auth.CustomClaims) +// // ... use claims +// } +func GetJWTClaims(ctx context.Context) (any, bool) { + val := ctx.Value(keyJWTClaims) + if val == nil { + return nil, false + } + return val, true +} + +// MustGetUser retrieves the user from context and panics if not found. +// Use only when authentication middleware guarantees user presence. +func MustGetUser(ctx context.Context) any { + user, ok := GetUser(ctx) + if !ok { + panic("httpcontext: user not found in context") + } + return user +} + +// MustGetRequestID retrieves the request ID from context and panics if not found. +// Use only when middleware guarantees request ID presence. +func MustGetRequestID(ctx context.Context) string { + requestID, ok := GetRequestID(ctx) + if !ok { + panic("httpcontext: request_id not found in context") + } + return requestID +} diff --git a/internal/adapter/templates/templates/skeleton/pkg/httpresponse/envelope.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/httpresponse/envelope.go.tmpl new file mode 100644 index 0000000..bb8395f --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/pkg/httpresponse/envelope.go.tmpl @@ -0,0 +1,75 @@ +// Package httpresponse provides standard HTTP response types and helpers. +// +// This package implements an envelope pattern for consistent API responses: +// +// { +// "data": {...}, // Present on success +// "error": {...}, // Present on error +// "meta": { +// "request_id": "...", +// "trace_id": "...", +// "timestamp": "..." +// } +// } +// +// Usage: +// +// func GetUser(w http.ResponseWriter, r *http.Request) { +// user, err := svc.Get(ctx, id) +// if err != nil { +// httpresponse.NotFound(w, r, "user not found") +// return +// } +// httpresponse.OK(w, r, user) +// } +package httpresponse + +import ( + "net/http" + "time" + + "{{GO_MODULE}}/pkg/httpcontext" +) + +// Response is the standard envelope for all API responses. +type Response struct { + Data any `json:"data,omitempty"` + Error *Error `json:"error,omitempty"` + Meta Meta `json:"meta"` +} + +// Error represents an API error in the response envelope. +type Error struct { + Code string `json:"code"` + Message string `json:"message"` + Details any `json:"details,omitempty"` +} + +// Meta contains response metadata. +type Meta struct { + RequestID string `json:"request_id,omitempty"` + TraceID string `json:"trace_id,omitempty"` + Timestamp string `json:"timestamp"` +} + +// newMeta creates a Meta with current timestamp, request ID, and trace ID from context. +func newMeta(r *http.Request) Meta { + requestID, _ := httpcontext.GetRequestID(r.Context()) + traceID, _ := httpcontext.GetTraceID(r.Context()) + return Meta{ + RequestID: requestID, + TraceID: traceID, + Timestamp: time.Now().UTC().Format(time.RFC3339), + } +} + +// Error codes for machine-readable error classification. +const ( + CodeBadRequest = "BAD_REQUEST" + CodeUnauthorized = "UNAUTHORIZED" + CodeForbidden = "FORBIDDEN" + CodeNotFound = "NOT_FOUND" + CodeConflict = "CONFLICT" + CodeInternal = "INTERNAL_ERROR" + CodeValidation = "VALIDATION_ERROR" +) diff --git a/internal/adapter/templates/templates/skeleton/pkg/httpresponse/response.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/httpresponse/response.go.tmpl new file mode 100644 index 0000000..12ee9ca --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/pkg/httpresponse/response.go.tmpl @@ -0,0 +1,192 @@ +package httpresponse + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" +) + +var ( + // ErrEmptyBody is returned when the request body is empty. + ErrEmptyBody = errors.New("request body is empty") + // ErrInvalidJSON is returned when the request body contains invalid JSON. + ErrInvalidJSON = errors.New("invalid JSON") + // ErrUnknownFields is returned when strict decoding encounters unknown fields. + ErrUnknownFields = errors.New("unknown fields in JSON") +) + +// ----------------------------------------------------------------------------- +// Success Responses +// ----------------------------------------------------------------------------- + +// JSON writes a JSON response with the given status code. +// The data is wrapped in the standard response envelope. +func JSON(w http.ResponseWriter, r *http.Request, status int, data any) { + resp := Response{ + Data: data, + Meta: newMeta(r), + } + writeJSON(w, status, resp) +} + +// OK writes a successful JSON response with status 200 OK. +func OK(w http.ResponseWriter, r *http.Request, data any) { + JSON(w, r, http.StatusOK, data) +} + +// Created writes a successful JSON response with status 201 Created. +func Created(w http.ResponseWriter, r *http.Request, data any) { + JSON(w, r, http.StatusCreated, data) +} + +// Accepted writes a successful JSON response with status 202 Accepted. +func Accepted(w http.ResponseWriter, r *http.Request, data any) { + JSON(w, r, http.StatusAccepted, data) +} + +// NoContent writes a successful response with status 204 No Content. +func NoContent(w http.ResponseWriter) { + w.WriteHeader(http.StatusNoContent) +} + +// ----------------------------------------------------------------------------- +// Error Responses +// ----------------------------------------------------------------------------- + +// WriteError writes an error response with the given status code. +func WriteError(w http.ResponseWriter, r *http.Request, status int, code, message string, details ...any) { + var detailsVal any + if len(details) > 0 { + detailsVal = details[0] + } + + resp := Response{ + Error: &Error{ + Code: code, + Message: message, + Details: detailsVal, + }, + Meta: newMeta(r), + } + writeJSON(w, status, resp) +} + +// BadRequest writes a 400 Bad Request error response. +func BadRequest(w http.ResponseWriter, r *http.Request, message string) { + WriteError(w, r, http.StatusBadRequest, CodeBadRequest, message) +} + +// ValidationError writes a 400 Bad Request error response for validation failures. +func ValidationError(w http.ResponseWriter, r *http.Request, message string, details any) { + WriteError(w, r, http.StatusBadRequest, CodeValidation, message, details) +} + +// Unauthorized writes a 401 Unauthorized error response. +func Unauthorized(w http.ResponseWriter, r *http.Request, message string) { + WriteError(w, r, http.StatusUnauthorized, CodeUnauthorized, message) +} + +// Forbidden writes a 403 Forbidden error response. +func Forbidden(w http.ResponseWriter, r *http.Request, message string) { + WriteError(w, r, http.StatusForbidden, CodeForbidden, message) +} + +// NotFound writes a 404 Not Found error response. +func NotFound(w http.ResponseWriter, r *http.Request, message string) { + WriteError(w, r, http.StatusNotFound, CodeNotFound, message) +} + +// Conflict writes a 409 Conflict error response. +func Conflict(w http.ResponseWriter, r *http.Request, message string) { + WriteError(w, r, http.StatusConflict, CodeConflict, message) +} + +// InternalError writes a 500 Internal Server Error response. +// The message should be safe to expose to clients; internal details should be logged. +func InternalError(w http.ResponseWriter, r *http.Request, message string) { + WriteError(w, r, http.StatusInternalServerError, CodeInternal, message) +} + +// ServiceUnavailable writes a 503 Service Unavailable error response. +func ServiceUnavailable(w http.ResponseWriter, r *http.Request, message string) { + WriteError(w, r, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", message) +} + +// ----------------------------------------------------------------------------- +// Request Body Decoding +// ----------------------------------------------------------------------------- + +// DecodeJSON decodes JSON from request body into v. +// Returns descriptive errors for common failure cases. +// Does not enforce strict field matching. +func DecodeJSON(r *http.Request, v any) error { + if r.Body == nil { + return ErrEmptyBody + } + + decoder := json.NewDecoder(r.Body) + if err := decoder.Decode(v); err != nil { + if errors.Is(err, io.EOF) { + return ErrEmptyBody + } + return fmt.Errorf("%w: %w", ErrInvalidJSON, err) + } + + return nil +} + +// DecodeJSONStrict decodes JSON from request body into v. +// Rejects JSON that contains fields not present in the target struct. +// Useful for strict API validation to catch client errors early. +func DecodeJSONStrict(r *http.Request, v any) error { + if r.Body == nil { + return ErrEmptyBody + } + + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + + if err := decoder.Decode(v); err != nil { + if errors.Is(err, io.EOF) { + return ErrEmptyBody + } + // Check if it's an unknown field error + var syntaxErr *json.SyntaxError + var unmarshalErr *json.UnmarshalTypeError + if errors.As(err, &syntaxErr) || errors.As(err, &unmarshalErr) { + return fmt.Errorf("%w: %w", ErrInvalidJSON, err) + } + // Unknown field errors contain "unknown field" in the message + return fmt.Errorf("%w: %w", ErrUnknownFields, err) + } + + return nil +} + +// IsEmptyBodyError checks if an error is ErrEmptyBody. +func IsEmptyBodyError(err error) bool { + return errors.Is(err, ErrEmptyBody) +} + +// IsInvalidJSONError checks if an error is ErrInvalidJSON. +func IsInvalidJSONError(err error) bool { + return errors.Is(err, ErrInvalidJSON) +} + +// IsUnknownFieldsError checks if an error is ErrUnknownFields. +func IsUnknownFieldsError(err error) bool { + return errors.Is(err, ErrUnknownFields) +} + +// ----------------------------------------------------------------------------- +// Internal helpers +// ----------------------------------------------------------------------------- + +// writeJSON marshals and writes the response. +func writeJSON(w http.ResponseWriter, status int, data any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(data) +} diff --git a/internal/adapter/templates/templates/skeleton/pkg/httpvalidation/validator.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/httpvalidation/validator.go.tmpl new file mode 100644 index 0000000..c4c74f8 --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/pkg/httpvalidation/validator.go.tmpl @@ -0,0 +1,308 @@ +// Package httpvalidation provides consistent request validation across services. +// +// This package wraps go-playground/validator/v10 with a simpler API +// and human-readable error messages suitable for API responses. +// +// Usage: +// +// type CreateUserRequest struct { +// Email string `json:"email" validate:"required,email"` +// Phone string `json:"phone" validate:"omitempty,e164"` +// } +// +// func CreateUser(w http.ResponseWriter, r *http.Request) { +// var req CreateUserRequest +// if err := httpresponse.DecodeJSON(r, &req); err != nil { +// httpresponse.BadRequest(w, r, "invalid JSON") +// return +// } +// if details := httpvalidation.ValidateStruct(req); len(details) > 0 { +// httpresponse.ValidationError(w, r, "validation failed", details) +// return +// } +// // ... proceed with valid request +// } +package httpvalidation + +import ( + "errors" + "fmt" + "reflect" + "regexp" + "strings" + "sync" + + "github.com/go-playground/validator/v10" + "github.com/google/uuid" +) + +var ( + // Singleton validator instance + once sync.Once + validate *validator.Validate + + // Regex patterns for custom validations + // E.164 allows 1-15 digits total, with country code starting with 1-9 + phoneRegex = regexp.MustCompile(`^\+?[1-9]\d{4,14}$`) +) + +// ValidationDetail represents a single field validation error. +// This structure is designed for API responses, providing clear +// field-level error information to clients. +type ValidationDetail struct { + // Field is the JSON field name that failed validation. + Field string `json:"field"` + // Message is a human-readable description of the validation failure. + Message string `json:"message"` +} + +// Validator returns the singleton validator instance with all custom validators registered. +// Thread-safe and initialized only once. +func Validator() *validator.Validate { + once.Do(func() { + validate = validator.New() + + // Use JSON tag names in error messages + validate.RegisterTagNameFunc(func(fld reflect.StructField) string { + name := strings.SplitN(fld.Tag.Get("json"), ",", 2)[0] + if name == "-" || name == "" { + return fld.Name + } + return name + }) + + // Register custom validators + _ = validate.RegisterValidation("uuid", validateUUID) + _ = validate.RegisterValidation("uuid_or_empty", validateUUIDOrEmpty) + _ = validate.RegisterValidation("phone", validatePhone) + _ = validate.RegisterValidation("slug", validateSlug) + _ = validate.RegisterValidation("hex_color", validateHexColor) + }) + return validate +} + +// ValidateStruct validates a struct and returns a slice of ValidationDetail for any validation errors. +// Returns nil if validation passes. +// +// Example: +// +// type CreateFanRequest struct { +// Email string `json:"email" validate:"required,email"` +// Phone string `json:"phone" validate:"omitempty,phone"` +// } +// +// if details := httpvalidation.ValidateStruct(req); len(details) > 0 { +// httpresponse.ValidationError(w, r, "validation failed", details) +// return +// } +func ValidateStruct(s any) []ValidationDetail { + v := Validator() + err := v.Struct(s) + if err == nil { + return nil + } + + var details []ValidationDetail + + // Use errors.As to handle wrapped errors + var validationErrs validator.ValidationErrors + if !errors.As(err, &validationErrs) { + // If not validation errors, return generic error + details = append(details, ValidationDetail{ + Field: "unknown", + Message: err.Error(), + }) + return details + } + + // Convert validator errors to ValidationDetails + for _, e := range validationErrs { + details = append(details, ValidationDetail{ + Field: fieldName(e), + Message: fieldError(e), + }) + } + + return details +} + +// ValidateVar validates a single variable against validation tags. +// Returns nil if validation passes, or a ValidationDetail slice with the error. +// +// Example: +// +// if err := httpvalidation.ValidateVar(email, "required,email"); err != nil { +// // handle error +// } +func ValidateVar(field any, tag string) []ValidationDetail { + v := Validator() + err := v.Var(field, tag) + if err == nil { + return nil + } + + var validationErrs validator.ValidationErrors + if !errors.As(err, &validationErrs) { + return []ValidationDetail{{Field: "value", Message: err.Error()}} + } + + if len(validationErrs) > 0 { + return []ValidationDetail{{Field: "value", Message: fieldError(validationErrs[0])}} + } + + return nil +} + +// fieldName extracts the JSON field name from a validation error. +// Falls back to the struct field name if JSON tag is not present. +func fieldName(e validator.FieldError) string { + field := e.Field() + + // Remove any struct prefix + parts := strings.Split(field, ".") + if len(parts) > 0 { + field = parts[len(parts)-1] + } + + // Convert to camelCase for API consistency + if len(field) > 0 { + return strings.ToLower(field[:1]) + field[1:] + } + return field +} + +// fieldError generates a human-readable error message for a validation error. +func fieldError(e validator.FieldError) string { + field := e.Field() + tag := e.Tag() + param := e.Param() + + switch tag { + case "required": + return fmt.Sprintf("%s is required", field) + case "email": + return fmt.Sprintf("%s must be a valid email address", field) + case "min": + if e.Kind() == reflect.String { + return fmt.Sprintf("%s must be at least %s characters", field, param) + } + return fmt.Sprintf("%s must be at least %s", field, param) + case "max": + if e.Kind() == reflect.String { + return fmt.Sprintf("%s must be at most %s characters", field, param) + } + return fmt.Sprintf("%s must be at most %s", field, param) + case "len": + if e.Kind() == reflect.String { + return fmt.Sprintf("%s must be exactly %s characters", field, param) + } + return fmt.Sprintf("%s must have exactly %s items", field, param) + case "uuid": + return fmt.Sprintf("%s must be a valid UUID", field) + case "uuid_or_empty": + return fmt.Sprintf("%s must be a valid UUID or empty", field) + case "phone", "e164": + return fmt.Sprintf("%s must be a valid phone number in E.164 format", field) + case "url": + return fmt.Sprintf("%s must be a valid URL", field) + case "oneof": + return fmt.Sprintf("%s must be one of: %s", field, param) + case "gt": + return fmt.Sprintf("%s must be greater than %s", field, param) + case "gte": + return fmt.Sprintf("%s must be greater than or equal to %s", field, param) + case "lt": + return fmt.Sprintf("%s must be less than %s", field, param) + case "lte": + return fmt.Sprintf("%s must be less than or equal to %s", field, param) + case "slug": + return fmt.Sprintf("%s must be a valid slug (lowercase letters, numbers, hyphens)", field) + case "hex_color": + return fmt.Sprintf("%s must be a valid hex color code", field) + case "alphanum": + return fmt.Sprintf("%s must contain only alphanumeric characters", field) + case "alpha": + return fmt.Sprintf("%s must contain only alphabetic characters", field) + case "numeric": + return fmt.Sprintf("%s must be numeric", field) + case "datetime": + return fmt.Sprintf("%s must be a valid datetime in format %s", field, param) + case "eqfield": + return fmt.Sprintf("%s must equal %s", field, param) + case "nefield": + return fmt.Sprintf("%s must not equal %s", field, param) + default: + return fmt.Sprintf("%s failed validation (%s)", field, tag) + } +} + +// ----------------------------------------------------------------------------- +// Custom Validators +// ----------------------------------------------------------------------------- + +// validateUUID checks if a field is a valid UUID. +func validateUUID(fl validator.FieldLevel) bool { + field := fl.Field().String() + if field == "" { + return false + } + _, err := uuid.Parse(field) + return err == nil +} + +// validateUUIDOrEmpty checks if a field is either empty or a valid UUID. +func validateUUIDOrEmpty(fl validator.FieldLevel) bool { + field := fl.Field().String() + if field == "" { + return true + } + _, err := uuid.Parse(field) + return err == nil +} + +// validatePhone checks if a field is a valid phone number in E.164 format. +// E.164 format: +[country code][number] (e.g., +14155552671) +func validatePhone(fl validator.FieldLevel) bool { + phone := fl.Field().String() + if phone == "" { + return false + } + return phoneRegex.MatchString(phone) +} + +// validateSlug checks if a field is a valid URL slug. +// Valid slugs contain only lowercase letters, numbers, and hyphens. +func validateSlug(fl validator.FieldLevel) bool { + slug := fl.Field().String() + if slug == "" { + return false + } + // Must start with letter or number, can contain hyphens, must end with letter or number + match, _ := regexp.MatchString(`^[a-z0-9]+(-[a-z0-9]+)*$`, slug) + return match +} + +// validateHexColor checks if a field is a valid hex color code. +// Accepts #RGB, #RRGGBB, #RRGGBBAA formats. +func validateHexColor(fl validator.FieldLevel) bool { + color := fl.Field().String() + if color == "" { + return false + } + match, _ := regexp.MatchString(`^#([A-Fa-f0-9]{3}|[A-Fa-f0-9]{6}|[A-Fa-f0-9]{8})$`, color) + return match +} + +// RegisterValidation registers a custom validation function. +// Returns an error if registration fails. +func RegisterValidation(tag string, fn validator.Func) error { + return Validator().RegisterValidation(tag, fn) +} + +// MustRegisterValidation registers a custom validation function and panics on error. +// Use this during initialization when registration failure should be fatal. +func MustRegisterValidation(tag string, fn validator.Func) { + if err := RegisterValidation(tag, fn); err != nil { + panic(fmt.Sprintf("failed to register validation %q: %v", tag, err)) + } +} diff --git a/internal/adapter/templates/templates/skeleton/pkg/logging/context.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/logging/context.go.tmpl new file mode 100644 index 0000000..31e13c4 --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/pkg/logging/context.go.tmpl @@ -0,0 +1,99 @@ +package logging + +import ( + "context" + "log/slog" +) + +type contextKey int + +const ( + loggerKey contextKey = iota + requestIDKey + userIDKey + traceIDKey +) + +// NewContext returns a new context with the logger attached. +func NewContext(ctx context.Context, logger *Logger) context.Context { + return context.WithValue(ctx, loggerKey, logger) +} + +// FromContext extracts the logger from the context. +// Returns a no-op logger if none is found. +func FromContext(ctx context.Context) *Logger { + if logger, ok := ctx.Value(loggerKey).(*Logger); ok { + return logger + } + return Nop() +} + +// WithRequestID adds a request ID to the context. +func WithRequestID(ctx context.Context, requestID string) context.Context { + return context.WithValue(ctx, requestIDKey, requestID) +} + +// RequestIDFromContext extracts the request ID from the context. +func RequestIDFromContext(ctx context.Context) string { + if id, ok := ctx.Value(requestIDKey).(string); ok { + return id + } + return "" +} + +// WithUserID adds a user ID to the context. +func WithUserID(ctx context.Context, userID string) context.Context { + return context.WithValue(ctx, userIDKey, userID) +} + +// UserIDFromContext extracts the user ID from the context. +func UserIDFromContext(ctx context.Context) string { + if id, ok := ctx.Value(userIDKey).(string); ok { + return id + } + return "" +} + +// WithTraceID adds a trace ID to the context. +func WithTraceID(ctx context.Context, traceID string) context.Context { + return context.WithValue(ctx, traceIDKey, traceID) +} + +// TraceIDFromContext extracts the trace ID from the context. +func TraceIDFromContext(ctx context.Context) string { + if id, ok := ctx.Value(traceIDKey).(string); ok { + return id + } + return "" +} + +// ContextAttrs returns slog attributes from context values. +func ContextAttrs(ctx context.Context) []slog.Attr { + var attrs []slog.Attr + + if id := RequestIDFromContext(ctx); id != "" { + attrs = append(attrs, slog.String("request_id", id)) + } + if id := UserIDFromContext(ctx); id != "" { + attrs = append(attrs, slog.String("user_id", id)) + } + if id := TraceIDFromContext(ctx); id != "" { + attrs = append(attrs, slog.String("trace_id", id)) + } + + return attrs +} + +// LoggerWithContext returns a logger enriched with context attributes. +func LoggerWithContext(ctx context.Context, logger *Logger) *Logger { + attrs := ContextAttrs(ctx) + if len(attrs) == 0 { + return logger + } + + args := make([]any, 0, len(attrs)*2) + for _, attr := range attrs { + args = append(args, attr.Key, attr.Value.Any()) + } + return logger.With(args...) +} diff --git a/internal/adapter/templates/templates/skeleton/pkg/logging/logger.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/logging/logger.go.tmpl new file mode 100644 index 0000000..a6a815a --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/pkg/logging/logger.go.tmpl @@ -0,0 +1,245 @@ +// Package logging provides slog-based structured logging with context integration. +// +// This package standardizes logging across all services with: +// - Environment-aware formatting (JSON for production, text for development) +// - Request-scoped loggers with context propagation +// - Convenience methods for common logging patterns +// +// Usage: +// +// // Create a logger based on environment +// logger := logging.New(logging.Config{ +// Level: logging.LevelInfo, +// Format: logging.FormatJSON, +// Environment: "production", +// }) +// +// // Or use convenience constructors +// logger := logging.NewDevelopment() // text format, debug level +// logger := logging.NewProduction() // JSON format, info level +package logging + +import ( + "io" + "log/slog" + "os" + "strings" +) + +// Level represents the logging level. +type Level int + +const ( + LevelDebug Level = iota + LevelInfo + LevelWarn + LevelError +) + +// String returns the string representation of the level. +func (l Level) String() string { + switch l { + case LevelDebug: + return "debug" + case LevelInfo: + return "info" + case LevelWarn: + return "warn" + case LevelError: + return "error" + default: + return "info" + } +} + +// ParseLevel parses a string into a Level. +// Returns LevelInfo if the string is not recognized. +func ParseLevel(s string) Level { + switch strings.ToLower(strings.TrimSpace(s)) { + case "debug": + return LevelDebug + case "info": + return LevelInfo + case "warn", "warning": + return LevelWarn + case "error": + return LevelError + default: + return LevelInfo + } +} + +func (l Level) toSlog() slog.Level { + switch l { + case LevelDebug: + return slog.LevelDebug + case LevelInfo: + return slog.LevelInfo + case LevelWarn: + return slog.LevelWarn + case LevelError: + return slog.LevelError + default: + return slog.LevelInfo + } +} + +// Format represents the output format. +type Format int + +const ( + FormatJSON Format = iota + FormatText +) + +// String returns the string representation of the format. +func (f Format) String() string { + switch f { + case FormatJSON: + return "json" + case FormatText: + return "text" + default: + return "json" + } +} + +// ParseFormat parses a string into a Format. +// Returns FormatJSON if the string is not recognized. +func ParseFormat(s string) Format { + switch strings.ToLower(strings.TrimSpace(s)) { + case "text", "console": + return FormatText + case "json": + return FormatJSON + default: + return FormatJSON + } +} + +// Config holds the logger configuration. +type Config struct { + // Level sets the minimum log level. + // Default: LevelInfo + Level Level + + // Format sets the output format. + // Default: FormatJSON + Format Format + + // Output sets the output writer. + // Default: os.Stdout + Output io.Writer + + // AddSource adds source file and line number to log entries. + // Default: false + AddSource bool + + // Environment determines default format if not specified. + // "development" uses text format, others use JSON. + Environment string +} + +// Logger wraps slog.Logger with additional convenience methods. +type Logger struct { + *slog.Logger +} + +// New creates a new Logger with the given configuration. +func New(cfg Config) *Logger { + if cfg.Output == nil { + cfg.Output = os.Stdout + } + + // Auto-detect format based on environment if not explicitly set + format := cfg.Format + if cfg.Environment == "development" && format == FormatJSON { + format = FormatText + } + + opts := &slog.HandlerOptions{ + Level: cfg.Level.toSlog(), + AddSource: cfg.AddSource, + } + + var handler slog.Handler + switch format { + case FormatText: + handler = slog.NewTextHandler(cfg.Output, opts) + default: + handler = slog.NewJSONHandler(cfg.Output, opts) + } + + return &Logger{ + Logger: slog.New(handler), + } +} + +// NewDevelopment creates a logger configured for development. +// Uses text format, debug level, and includes source location. +func NewDevelopment() *Logger { + return New(Config{ + Level: LevelDebug, + Format: FormatText, + AddSource: true, + }) +} + +// NewProduction creates a logger configured for production. +// Uses JSON format and info level. +func NewProduction() *Logger { + return New(Config{ + Level: LevelInfo, + Format: FormatJSON, + }) +} + +// With returns a new Logger with the given attributes. +func (l *Logger) With(args ...any) *Logger { + return &Logger{ + Logger: l.Logger.With(args...), + } +} + +// WithGroup returns a new Logger with the given group name. +func (l *Logger) WithGroup(name string) *Logger { + return &Logger{ + Logger: l.Logger.WithGroup(name), + } +} + +// WithError returns a new Logger with the error attribute. +func (l *Logger) WithError(err error) *Logger { + if err == nil { + return l + } + return l.With("error", err.Error()) +} + +// WithComponent returns a new Logger with the component attribute. +func (l *Logger) WithComponent(name string) *Logger { + return l.With("component", name) +} + +// WithService returns a new Logger with the service attribute. +func (l *Logger) WithService(name string) *Logger { + return l.With("service", name) +} + +// Nop returns a logger that discards all output. +func Nop() *Logger { + return New(Config{ + Output: io.Discard, + Level: LevelError, + }) +} + +// Default returns the default logger configured for the current environment. +// Uses APP_ENVIRONMENT env var to determine format. +func Default() *Logger { + env := os.Getenv("APP_ENVIRONMENT") + if env == "development" || env == "" { + return NewDevelopment() + } + return NewProduction() +} diff --git a/internal/adapter/templates/templates/skeleton/pkg/logging/worker.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/logging/worker.go.tmpl new file mode 100644 index 0000000..e4caafe --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/pkg/logging/worker.go.tmpl @@ -0,0 +1,37 @@ +package logging + +import ( + "context" + + "github.com/google/uuid" +) + +// WorkerContext creates a context with trace and request IDs for background work. +// Workers and cron jobs don't receive HTTP requests, so they need to generate +// their own correlation IDs for log tracing. +// +// Usage: +// +// func (w *Worker) ProcessJob(ctx context.Context, job Job) error { +// ctx = logging.WorkerContext(ctx, "order-processor") +// logger := logging.FromContext(ctx) +// logger.Info("processing job", "job_id", job.ID) +// // ... all downstream logs include trace_id and request_id +// } +func WorkerContext(ctx context.Context, component string) context.Context { + traceID := uuid.New().String() + requestID := uuid.New().String() + + ctx = WithTraceID(ctx, traceID) + ctx = WithRequestID(ctx, requestID) + + // If there's a logger in context, enrich it + logger := FromContext(ctx) + enriched := logger.With( + "trace_id", traceID, + "request_id", requestID, + "component", component, + ) + + return NewContext(ctx, enriched) +} diff --git a/internal/adapter/templates/templates/skeleton/pkg/middleware/cors.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/middleware/cors.go.tmpl new file mode 100644 index 0000000..d688e58 --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/pkg/middleware/cors.go.tmpl @@ -0,0 +1,98 @@ +// Package middleware provides HTTP middleware for services. +package middleware + +import ( + "net/http" + + "github.com/go-chi/cors" +) + +// CORSConfig configures CORS behavior for HTTP services. +// Used to control cross-origin requests from browsers. +type CORSConfig struct { + // AllowedOrigins lists domains allowed to make cross-origin requests. + // Use []string{"*"} for open access (dev/staging), specific domains for production. + // Example: []string{"https://app.example.com", "https://admin.example.com"} + AllowedOrigins []string + + // AllowedMethods lists HTTP methods that can be used in cross-origin requests. + // Example: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"} + AllowedMethods []string + + // AllowedHeaders lists request headers that can be included in cross-origin requests. + // Must include headers used by services (Authorization, X-Request-ID, etc.) + AllowedHeaders []string + + // ExposedHeaders lists response headers that the browser can expose to JavaScript. + // Useful for pagination headers, custom metadata, etc. + ExposedHeaders []string + + // AllowCredentials controls whether browsers can send credentials (cookies, auth headers) + // in cross-origin requests. MUST be false when AllowedOrigins is "*". + AllowCredentials bool + + // MaxAge specifies how long (in seconds) browsers can cache preflight responses. + // Reduces OPTIONS requests for the same resource. + MaxAge int +} + +// DefaultCORSConfig returns sensible defaults for services. +// Designed for local development and staging environments. +// +// Defaults: +// - AllowedOrigins: ["*"] (override in production to specific domains) +// - AllowedMethods: GET, POST, PUT, PATCH, DELETE, OPTIONS +// - AllowedHeaders: ["*"] (allows any header - simplest for development) +// - ExposedHeaders: Link (for pagination) +// - AllowCredentials: false (required when AllowedOrigins is "*") +// - MaxAge: 300 seconds (5 minutes) +// +// Production services should override AllowedOrigins with specific domains, +// set AllowCredentials: true if needed, and optionally restrict AllowedHeaders. +func DefaultCORSConfig() CORSConfig { + return CORSConfig{ + AllowedOrigins: []string{"*"}, + AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}, + AllowedHeaders: []string{"*"}, + ExposedHeaders: []string{"Link"}, + AllowCredentials: false, + MaxAge: 300, + } +} + +// CORS returns middleware that handles CORS headers for cross-origin requests. +// Uses github.com/go-chi/cors under the hood for RFC compliance. +// +// The middleware: +// - Handles preflight OPTIONS requests automatically +// - Sets Access-Control-* headers on actual requests +// - Validates origins against AllowedOrigins +// - Caches preflight responses according to MaxAge +// +// Usage: +// +// r := chi.NewRouter() +// r.Use(middleware.CORS(middleware.DefaultCORSConfig())) +// +// Production example: +// +// cfg := middleware.CORSConfig{ +// AllowedOrigins: []string{"https://app.example.com"}, +// AllowedMethods: []string{"GET", "POST", "PUT", "DELETE"}, +// AllowedHeaders: []string{"Content-Type", "Authorization"}, +// AllowCredentials: true, +// MaxAge: 600, +// } +// r.Use(middleware.CORS(cfg)) +func CORS(cfg CORSConfig) func(http.Handler) http.Handler { + c := cors.New(cors.Options{ + AllowedOrigins: cfg.AllowedOrigins, + AllowedMethods: cfg.AllowedMethods, + AllowedHeaders: cfg.AllowedHeaders, + ExposedHeaders: cfg.ExposedHeaders, + AllowCredentials: cfg.AllowCredentials, + MaxAge: cfg.MaxAge, + }) + + return c.Handler +} diff --git a/internal/adapter/templates/templates/skeleton/pkg/middleware/logger.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/middleware/logger.go.tmpl new file mode 100644 index 0000000..67a4c03 --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/pkg/middleware/logger.go.tmpl @@ -0,0 +1,105 @@ +package middleware + +import ( + "net/http" + "time" + + "{{GO_MODULE}}/pkg/httpcontext" + "{{GO_MODULE}}/pkg/logging" +) + +// responseWriter wraps http.ResponseWriter to capture status code. +type responseWriter struct { + http.ResponseWriter + status int + wroteHeader bool + bytesWritten int +} + +func (rw *responseWriter) WriteHeader(code int) { + if rw.wroteHeader { + return + } + rw.status = code + rw.wroteHeader = true + rw.ResponseWriter.WriteHeader(code) +} + +func (rw *responseWriter) Write(b []byte) (int, error) { + if !rw.wroteHeader { + rw.WriteHeader(http.StatusOK) + } + n, err := rw.ResponseWriter.Write(b) + rw.bytesWritten += n + return n, err +} + +// RequestLogger returns a middleware that logs HTTP requests using slog. +// It logs request completion with status code, duration, and bytes written. +// Log level is determined by response status (error for 5xx, warn for 4xx, info otherwise). +// +// IMPORTANT: This middleware expects the RequestID and Tracing middleware to have +// run first to set request_id and trace_id in context. +// +// Usage: +// +// r.Use(middleware.RequestID()) +// r.Use(middleware.Tracing()) +// r.Use(middleware.RequestLogger(logger)) +// r.Use(middleware.Recoverer(logger)) +func RequestLogger(logger *logging.Logger) func(next http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + + // Wrap response writer to capture status code and bytes + wrapped := &responseWriter{ + ResponseWriter: w, + status: http.StatusOK, + } + + // Get request ID and trace ID from context (set by middleware) + requestID, _ := httpcontext.GetRequestID(r.Context()) + traceID, _ := httpcontext.GetTraceID(r.Context()) + + // Create request-scoped logger + reqLogger := logger.With( + "request_id", requestID, + "trace_id", traceID, + "method", r.Method, + "path", r.URL.Path, + "remote_addr", r.RemoteAddr, + ) + + // Store logger in context for handlers to use + ctx := logging.NewContext(r.Context(), reqLogger) + + // Log request start at debug level + reqLogger.Debug("request started", + "user_agent", r.UserAgent(), + ) + + // Call next handler with enriched context + next.ServeHTTP(wrapped, r.WithContext(ctx)) + + // Calculate duration + duration := time.Since(start) + + // Determine log level based on status and log completion + attrs := []any{ + "status", wrapped.status, + "duration_ms", duration.Milliseconds(), + "bytes", wrapped.bytesWritten, + } + + switch { + case wrapped.status >= 500: + reqLogger.Error("request completed", attrs...) + case wrapped.status >= 400: + reqLogger.Warn("request completed", attrs...) + default: + reqLogger.Info("request completed", attrs...) + } + }) + } +} diff --git a/internal/adapter/templates/templates/skeleton/pkg/middleware/recovery.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/middleware/recovery.go.tmpl new file mode 100644 index 0000000..a09aacd --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/pkg/middleware/recovery.go.tmpl @@ -0,0 +1,54 @@ +package middleware + +import ( + "net/http" + "runtime/debug" + + "{{GO_MODULE}}/pkg/httpcontext" + "{{GO_MODULE}}/pkg/logging" +) + +// Recoverer is middleware that recovers from panics, logs the error with stack trace +// using slog, and returns a 500 Internal Server Error response. +// +// The middleware captures: +// - request_id: For request correlation +// - method, path, remote_addr: Request context +// - panic: The recovered panic value +// - stack_trace: Full stack trace for debugging +// +// Usage: +// +// r.Use(middleware.RequestID()) +// r.Use(middleware.RequestLogger(logger)) +// r.Use(middleware.Recoverer(logger)) +func Recoverer(logger *logging.Logger) func(next http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if rvr := recover(); rvr != nil { + // Capture stack trace for debugging + stack := debug.Stack() + + // Get request ID from context + requestID, _ := httpcontext.GetRequestID(r.Context()) + + // Log panic with full context and stack trace + logger.Error("panic recovered", + "request_id", requestID, + "method", r.Method, + "path", r.URL.Path, + "remote_addr", r.RemoteAddr, + "panic", rvr, + "stack_trace", string(stack), + ) + + // Return 500 Internal Server Error to client + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } + }() + + next.ServeHTTP(w, r) + }) + } +} diff --git a/internal/adapter/templates/templates/skeleton/pkg/middleware/request_id.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/middleware/request_id.go.tmpl new file mode 100644 index 0000000..7610a2a --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/pkg/middleware/request_id.go.tmpl @@ -0,0 +1,75 @@ +package middleware + +import ( + "context" + "net/http" + + "github.com/google/uuid" + + "{{GO_MODULE}}/pkg/httpcontext" + "{{GO_MODULE}}/pkg/logging" +) + +// RequestIDHeader is the header name for request IDs. +const RequestIDHeader = "X-Request-ID" + +// RequestID returns middleware that generates/extracts request IDs. +// +// If X-Request-ID header is present in the incoming request, uses that value. +// This allows clients to set their own request IDs for tracking purposes. +// Otherwise generates a new UUID. +// +// The request ID is stored in context using httpcontext.SetRequestID and +// also set in the X-Request-ID response header for client correlation. +// +// This middleware is idempotent - if a request ID is already present in the +// context (from a previous middleware), it will not be overwritten. +// +// Usage: +// +// r.Use(middleware.RequestID()) +// r.Use(middleware.RequestLogger(logger)) +// +// In handlers, retrieve the request ID: +// +// requestID, ok := httpcontext.GetRequestID(r.Context()) +func RequestID() func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Check if request ID already in context (idempotent) + if existingID, ok := httpcontext.GetRequestID(r.Context()); ok && existingID != "" { + // Already set, set response header and continue + w.Header().Set(RequestIDHeader, existingID) + next.ServeHTTP(w, r) + return + } + + // Try to get request ID from incoming header + requestID := r.Header.Get(RequestIDHeader) + + // If not present, generate a new UUID + if requestID == "" { + requestID = uuid.New().String() + } + + // Store in context (both httpcontext and logging) + ctx := httpcontext.SetRequestID(r.Context(), requestID) + ctx = logging.WithRequestID(ctx, requestID) + + // Set response header for client correlation + w.Header().Set(RequestIDHeader, requestID) + + // Continue with request ID in context + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +// GetRequestID returns the request ID from context. +// This is a convenience wrapper around httpcontext.GetRequestID. +// +// Returns the request ID string if present, empty string if not found. +func GetRequestID(ctx context.Context) string { + requestID, _ := httpcontext.GetRequestID(ctx) + return requestID +} diff --git a/internal/adapter/templates/templates/skeleton/pkg/middleware/tracing.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/middleware/tracing.go.tmpl new file mode 100644 index 0000000..d6cd69f --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/pkg/middleware/tracing.go.tmpl @@ -0,0 +1,74 @@ +package middleware + +import ( + "net/http" + "strings" + + "github.com/google/uuid" + + "{{GO_MODULE}}/pkg/httpcontext" + "{{GO_MODULE}}/pkg/logging" +) + +// Trace ID headers in priority order. +const ( + TraceIDHeader = "X-Trace-ID" + CloudTraceHeader = "X-Cloud-Trace-Context" +) + +// Tracing returns middleware that extracts or generates trace IDs. +// +// Checks headers in order: +// 1. X-Trace-ID - direct trace ID +// 2. X-Cloud-Trace-Context - GCP format "TRACE_ID/SPAN_ID;o=OPTIONS" +// 3. Generates a new UUID if none found +// +// The trace ID is stored in context via httpcontext.SetTraceID and +// logging.WithTraceID, and set in the X-Trace-ID response header. +// +// Usage: +// +// r.Use(middleware.RequestID()) +// r.Use(middleware.Tracing()) +// r.Use(middleware.RequestLogger(logger)) +func Tracing() func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + traceID := extractTraceID(r) + + if traceID == "" { + traceID = uuid.New().String() + } + + // Store in context + ctx := httpcontext.SetTraceID(r.Context(), traceID) + ctx = logging.WithTraceID(ctx, traceID) + + // Set response header + w.Header().Set(TraceIDHeader, traceID) + + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +// extractTraceID tries to extract a trace ID from known headers. +func extractTraceID(r *http.Request) string { + // X-Trace-ID takes priority + if traceID := r.Header.Get(TraceIDHeader); traceID != "" { + return traceID + } + + // X-Cloud-Trace-Context format: "TRACE_ID/SPAN_ID;o=OPTIONS" + if cloudTrace := r.Header.Get(CloudTraceHeader); cloudTrace != "" { + if idx := strings.IndexByte(cloudTrace, '/'); idx > 0 { + return cloudTrace[:idx] + } + if idx := strings.IndexByte(cloudTrace, ';'); idx > 0 { + return cloudTrace[:idx] + } + return cloudTrace + } + + return "" +} diff --git a/internal/adapter/templates/templates/skeleton/scripts/dev.sh.tmpl b/internal/adapter/templates/templates/skeleton/scripts/dev.sh.tmpl new file mode 100644 index 0000000..5e6031c --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/scripts/dev.sh.tmpl @@ -0,0 +1,19 @@ +#!/bin/bash +set -e + +echo "Starting development environment for {{PROJECT_NAME}}..." + +# Start docker services +if [ -f "docker-compose.yml" ]; then + echo "Starting Docker services..." + docker-compose up -d +fi + +# Start services with overmind if available +if command -v overmind &> /dev/null && [ -f "Procfile" ]; then + echo "Starting services with overmind..." + overmind start +else + echo "Install overmind for process management: brew install overmind" + echo "Or run services manually from Procfile" +fi diff --git a/internal/adapter/templates/templates/skeleton/scripts/discover.sh.tmpl b/internal/adapter/templates/templates/skeleton/scripts/discover.sh.tmpl new file mode 100644 index 0000000..94ed1db --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/scripts/discover.sh.tmpl @@ -0,0 +1,138 @@ +#!/bin/bash +# Discover all components in the monorepo +# Usage: ./scripts/discover.sh [--json] + +set -e + +JSON_OUTPUT=false +if [ "$1" = "--json" ]; then + JSON_OUTPUT=true +fi + +# Colors (only for non-JSON output) +GREEN='\033[0;32m' +BLUE='\033[0;34m' +YELLOW='\033[1;33m' +NC='\033[0m' + +# Component discovery +declare -a COMPONENTS + +discover_components() { + for type in services workers apps cli packages; do + if [ -d "$type" ]; then + for dir in "$type"/*/; do + if [ -d "$dir" ] && [ "$dir" != "$type/*/" ]; then + name=$(basename "$dir") + path="$type/$name" + port="" + stack="" + + # Detect stack type + if [ -f "$dir/go.mod" ]; then + stack="go" + elif [ -f "$dir/package.json" ]; then + stack="node" + # Try to detect framework + if grep -q "astro" "$dir/package.json" 2>/dev/null; then + stack="astro" + elif grep -q "react" "$dir/package.json" 2>/dev/null; then + stack="react" + fi + fi + + # Try to get port from component.yaml + if [ -f "$dir/component.yaml" ]; then + port=$(grep -E '^port:' "$dir/component.yaml" 2>/dev/null | awk '{print $2}' || echo "") + fi + + # Try to get port from .env.example + if [ -z "$port" ] && [ -f "$dir/.env.example" ]; then + port=$(grep -E '^PORT=' "$dir/.env.example" 2>/dev/null | cut -d'=' -f2 || echo "") + fi + + COMPONENTS+=("$type|$name|$path|$port|$stack") + fi + done + fi + done +} + +output_json() { + echo "{" + echo " \"project\": \"{{PROJECT_NAME}}\"," + echo " \"components\": [" + + local first=true + for comp in "${COMPONENTS[@]}"; do + IFS='|' read -r type name path port stack <<< "$comp" + + if [ "$first" = false ]; then + echo "," + fi + first=false + + echo -n " {\"type\": \"$type\", \"name\": \"$name\", \"path\": \"$path\"" + if [ -n "$port" ]; then + echo -n ", \"port\": $port" + fi + if [ -n "$stack" ]; then + echo -n ", \"stack\": \"$stack\"" + fi + echo -n "}" + done + + echo "" + echo " ]" + echo "}" +} + +output_pretty() { + echo -e "${BLUE}Components in {{PROJECT_NAME}}${NC}" + echo "" + + if [ ${#COMPONENTS[@]} -eq 0 ]; then + echo -e "${YELLOW}No components found. Add one with:${NC}" + echo " curl -X POST .../projects/{{PROJECT_NAME}}/components -d '{\"type\":\"service\",\"name\":\"my-api\"}'" + return + fi + + # Group by type + for type in services workers apps cli packages; do + local has_type=false + for comp in "${COMPONENTS[@]}"; do + IFS='|' read -r ctype name path port stack <<< "$comp" + if [ "$ctype" = "$type" ]; then + if [ "$has_type" = false ]; then + echo -e "${GREEN}$type/${NC}" + has_type=true + fi + local info=" $name" + if [ -n "$stack" ]; then + info="$info ${YELLOW}[$stack]${NC}" + fi + if [ -n "$port" ]; then + info="$info :$port" + fi + echo -e "$info" + fi + done + if [ "$has_type" = true ]; then + echo "" + fi + done + + echo "Total: ${#COMPONENTS[@]} component(s)" + echo "" + echo "Run './scripts/install.sh' to install all dependencies" + echo "Run './scripts/dev.sh' to start local development" +} + +# Main +discover_components + +if [ "$JSON_OUTPUT" = true ]; then + output_json +else + output_pretty +fi diff --git a/internal/adapter/templates/templates/skeleton/scripts/install.sh.tmpl b/internal/adapter/templates/templates/skeleton/scripts/install.sh.tmpl new file mode 100644 index 0000000..67a3abb --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/scripts/install.sh.tmpl @@ -0,0 +1,32 @@ +#!/bin/bash +set -e + +echo "Installing dependencies for {{PROJECT_NAME}}..." +echo "" + +# Install shared package dependencies first +for dir in packages/*/; do + if [ -f "${dir}package.json" ]; then + echo "Installing deps for package $(basename $dir)..." + (cd "$dir" && npm install) + fi +done + +# Install Go dependencies +for dir in services/*/ workers/*/ cli/*/; do + if [ -f "${dir}go.mod" ]; then + echo "Installing Go deps for $(basename $dir)..." + (cd "$dir" && go mod download) + fi +done + +# Install Node dependencies +for dir in apps/*/; do + if [ -f "${dir}package.json" ]; then + echo "Installing Node deps for $(basename $dir)..." + (cd "$dir" && npm install) + fi +done + +echo "" +echo "All dependencies installed!" diff --git a/internal/adapter/templates/templates/skeleton/scripts/quality.sh.tmpl b/internal/adapter/templates/templates/skeleton/scripts/quality.sh.tmpl new file mode 100644 index 0000000..57adfb6 --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/scripts/quality.sh.tmpl @@ -0,0 +1,26 @@ +#!/bin/bash +set -e + +echo "Running quality checks for {{PROJECT_NAME}}..." +echo "" + +# Run golangci-lint for Go code +if command -v golangci-lint &> /dev/null; then + echo "Running golangci-lint..." + golangci-lint run ./services/... ./workers/... ./cli/... ./pkg/... 2>/dev/null || true +fi + +# Run tests +echo "Running Go tests..." +go test ./... 2>/dev/null || true + +# Run ESLint for apps +for dir in apps/*/; do + if [ -f "${dir}package.json" ]; then + echo "Running lint for $(basename $dir)..." + (cd "$dir" && npm run lint 2>/dev/null) || true + fi +done + +echo "" +echo "Quality checks complete!" diff --git a/internal/adapter/templates/templates/skeleton/scripts/setup-hooks.sh.tmpl b/internal/adapter/templates/templates/skeleton/scripts/setup-hooks.sh.tmpl new file mode 100644 index 0000000..9f67f35 --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/scripts/setup-hooks.sh.tmpl @@ -0,0 +1,44 @@ +#!/bin/bash +# Set up git hooks for {{PROJECT_NAME}} +# Run this after cloning the repository + +set -e + +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" + +echo "Setting up git hooks for {{PROJECT_NAME}}..." + +# Ensure we're in a git repo +if [ ! -d "$PROJECT_ROOT/.git" ]; then + echo "Error: Not a git repository" + exit 1 +fi + +# Ensure .githooks directory exists +if [ ! -d "$PROJECT_ROOT/.githooks" ]; then + echo "Error: .githooks directory not found" + exit 1 +fi + +# Configure git to use .githooks +git config core.hooksPath .githooks + +# Make hooks executable +chmod +x "$PROJECT_ROOT/.githooks/"* + +echo -e "${GREEN}Git hooks installed successfully!${NC}" +echo "" +echo "Installed hooks:" +echo " - pre-commit: Code quality checks (formatting, linting)" +echo " - commit-msg: Conventional commit validation" +echo "" +echo -e "${YELLOW}Note:${NC} You may need to install these tools for full functionality:" +echo " - goimports: go install golang.org/x/tools/cmd/goimports@latest" +echo " - golangci-lint: brew install golangci-lint" +echo " - prettier: npm install -g prettier (or use per-project)" +echo " - eslint: npm install -g eslint (or use per-project)" diff --git a/internal/adapter/templates/templates/skeleton/services/.gitkeep b/internal/adapter/templates/templates/skeleton/services/.gitkeep new file mode 100644 index 0000000..a636190 --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/services/.gitkeep @@ -0,0 +1 @@ +# Go API services go here diff --git a/internal/adapter/templates/templates/skeleton/workers/.gitkeep b/internal/adapter/templates/templates/skeleton/workers/.gitkeep new file mode 100644 index 0000000..77fc93b --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/workers/.gitkeep @@ -0,0 +1 @@ +# Background workers go here diff --git a/internal/auth/middleware.go b/internal/auth/middleware.go index aefdbfa..b73bc0f 100644 --- a/internal/auth/middleware.go +++ b/internal/auth/middleware.go @@ -99,7 +99,7 @@ func Middleware(svc *Service) func(http.Handler) http.Handler { } if key == "" { - api.WriteError(w, r, http.StatusUnauthorized, "UNAUTHORIZED", "Missing API key") + api.WriteUnauthorized(w, r, "Missing API key") return } @@ -107,7 +107,7 @@ func Middleware(svc *Service) func(http.Handler) http.Handler { apiKey, err := svc.Validate(r.Context(), key) if err != nil { if errors.Is(err, ErrKeyNotFound) { - api.WriteError(w, r, http.StatusUnauthorized, "UNAUTHORIZED", "Invalid API key") + api.WriteUnauthorized(w, r, "Invalid API key") return } if errors.Is(err, ErrKeyRevoked) { @@ -142,13 +142,12 @@ func RequireScope(required ...Scope) func(http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { apiKey := GetAPIKey(r.Context()) if apiKey == nil { - api.WriteError(w, r, http.StatusUnauthorized, "UNAUTHORIZED", "Not authenticated") + api.WriteUnauthorized(w, r, "Not authenticated") return } if !HasAnyScope(apiKey.Scopes, required...) { - api.WriteError(w, r, http.StatusForbidden, "FORBIDDEN", - "Insufficient permissions. Required: "+scopesToString(required)) + api.WriteForbidden(w, r, "Insufficient permissions. Required: "+scopesToString(required)) return } @@ -164,7 +163,7 @@ func RequireProjectAccess(projectIDParam string) func(http.Handler) http.Handler return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { apiKey := GetAPIKey(r.Context()) if apiKey == nil { - api.WriteError(w, r, http.StatusUnauthorized, "UNAUTHORIZED", "Not authenticated") + api.WriteUnauthorized(w, r, "Not authenticated") return } diff --git a/internal/domain/component.go b/internal/domain/component.go new file mode 100644 index 0000000..6e9602e --- /dev/null +++ b/internal/domain/component.go @@ -0,0 +1,103 @@ +// Package domain contains pure domain models with no external dependencies. +package domain + +import "regexp" + +// ComponentType represents the type of component in a monorepo. +type ComponentType string + +const ( + ComponentTypeService ComponentType = "service" + ComponentTypeWorker ComponentType = "worker" + ComponentTypeAppAstro ComponentType = "app-astro" + ComponentTypeAppReact ComponentType = "app-react" + ComponentTypeCLI ComponentType = "cli" +) + +// ValidComponentTypes lists all valid component types. +var ValidComponentTypes = []ComponentType{ + ComponentTypeService, + ComponentTypeWorker, + ComponentTypeAppAstro, + ComponentTypeAppReact, + ComponentTypeCLI, +} + +// IsValidComponentType checks if a string is a valid component type. +func IsValidComponentType(t string) bool { + for _, valid := range ValidComponentTypes { + if string(valid) == t { + return true + } + } + return false +} + +// Component represents a component in a monorepo project. +type Component struct { + Type ComponentType `json:"type"` + Name string `json:"name"` + Path string `json:"path"` // e.g., "services/auth-api" + Port int `json:"port"` // 0 if not applicable + Template string `json:"template"` // template used + Dependencies []string `json:"dependencies"` // e.g., ["postgres", "redis"] +} + +// DestDir returns the destination directory for this component type. +func (c ComponentType) DestDir() string { + switch c { + case ComponentTypeService: + return "services" + case ComponentTypeWorker: + return "workers" + case ComponentTypeAppAstro, ComponentTypeAppReact: + return "apps" + case ComponentTypeCLI: + return "cli" + default: + return "" + } +} + +// StartingPort returns the starting port number for this component type. +// Workers and CLIs don't expose ports (return 0). +func (c ComponentType) StartingPort() int { + switch c { + case ComponentTypeService: + return 8001 + case ComponentTypeAppAstro, ComponentTypeAppReact: + return 3001 + case ComponentTypeWorker, ComponentTypeCLI: + return 0 + default: + return 0 + } +} + +// NeedsPort returns true if this component type requires a port assignment. +func (c ComponentType) NeedsPort() bool { + return c == ComponentTypeService || c == ComponentTypeAppAstro || c == ComponentTypeAppReact +} + +// IsGoComponent returns true if this component type uses Go (and needs go.work entry). +func (c ComponentType) IsGoComponent() bool { + return c == ComponentTypeService || c == ComponentTypeWorker || c == ComponentTypeCLI +} + +// componentNameRegex validates component names (slug format: lowercase, alphanumeric, dashes). +var componentNameRegex = regexp.MustCompile(`^[a-z][a-z0-9-]*$`) + +// ValidateComponentName validates that a component name is in slug format. +// Must be lowercase, start with a letter, and contain only letters, numbers, and dashes. +func ValidateComponentName(name string) error { + if name == "" { + return ErrInvalidComponentName + } + if len(name) > MaxProjectNameLen { // Reuse the 63-char limit from K8s + return ErrInvalidComponentName + } + if !componentNameRegex.MatchString(name) { + return ErrInvalidComponentName + } + return nil +} diff --git a/internal/domain/component_test.go b/internal/domain/component_test.go new file mode 100644 index 0000000..731fcbb --- /dev/null +++ b/internal/domain/component_test.go @@ -0,0 +1,176 @@ +package domain + +import "testing" + +func TestIsValidComponentType(t *testing.T) { + tests := []struct { + name string + input string + expected bool + }{ + {"service", "service", true}, + {"worker", "worker", true}, + {"app-astro", "app-astro", true}, + {"app-react", "app-react", true}, + {"cli", "cli", true}, + {"invalid", "invalid", false}, + {"empty", "", false}, + {"uppercase", "SERVICE", false}, + {"partial", "serv", false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := IsValidComponentType(tc.input) + if result != tc.expected { + t.Errorf("IsValidComponentType(%q) = %v, want %v", tc.input, result, tc.expected) + } + }) + } +} + +func TestComponentType_DestDir(t *testing.T) { + tests := []struct { + name string + componentType ComponentType + expected string + }{ + {"service", ComponentTypeService, "services"}, + {"worker", ComponentTypeWorker, "workers"}, + {"app-astro", ComponentTypeAppAstro, "apps"}, + {"app-react", ComponentTypeAppReact, "apps"}, + {"cli", ComponentTypeCLI, "cli"}, + {"unknown", ComponentType("unknown"), ""}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := tc.componentType.DestDir() + if result != tc.expected { + t.Errorf("%s.DestDir() = %q, want %q", tc.componentType, result, tc.expected) + } + }) + } +} + +func TestComponentType_StartingPort(t *testing.T) { + tests := []struct { + name string + componentType ComponentType + expected int + }{ + {"service", ComponentTypeService, 8001}, + {"worker", ComponentTypeWorker, 0}, + {"app-astro", ComponentTypeAppAstro, 3001}, + {"app-react", ComponentTypeAppReact, 3001}, + {"cli", ComponentTypeCLI, 0}, + {"unknown", ComponentType("unknown"), 0}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := tc.componentType.StartingPort() + if result != tc.expected { + t.Errorf("%s.StartingPort() = %d, want %d", tc.componentType, result, tc.expected) + } + }) + } +} + +func TestComponentType_NeedsPort(t *testing.T) { + tests := []struct { + name string + componentType ComponentType + expected bool + }{ + {"service", ComponentTypeService, true}, + {"worker", ComponentTypeWorker, false}, + {"app-astro", ComponentTypeAppAstro, true}, + {"app-react", ComponentTypeAppReact, true}, + {"cli", ComponentTypeCLI, false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := tc.componentType.NeedsPort() + if result != tc.expected { + t.Errorf("%s.NeedsPort() = %v, want %v", tc.componentType, result, tc.expected) + } + }) + } +} + +func TestComponentType_IsGoComponent(t *testing.T) { + tests := []struct { + name string + componentType ComponentType + expected bool + }{ + {"service", ComponentTypeService, true}, + {"worker", ComponentTypeWorker, true}, + {"app-astro", ComponentTypeAppAstro, false}, + {"app-react", ComponentTypeAppReact, false}, + {"cli", ComponentTypeCLI, true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := tc.componentType.IsGoComponent() + if result != tc.expected { + t.Errorf("%s.IsGoComponent() = %v, want %v", tc.componentType, result, tc.expected) + } + }) + } +} + +func TestValidateComponentName(t *testing.T) { + tests := []struct { + name string + input string + wantErr bool + }{ + {"valid simple", "auth", false}, + {"valid with dash", "auth-api", false}, + {"valid with numbers", "api2", false}, + {"valid complex", "user-service-v2", false}, + {"empty", "", true}, + {"starts with number", "2api", true}, + {"starts with dash", "-api", true}, + {"uppercase", "Auth", true}, + {"mixed case", "authApi", true}, + {"underscore", "auth_api", true}, + {"space", "auth api", true}, + {"special char", "auth@api", true}, + {"too long", "a" + string(make([]byte, 63)), true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := ValidateComponentName(tc.input) + if (err != nil) != tc.wantErr { + t.Errorf("ValidateComponentName(%q) error = %v, wantErr %v", tc.input, err, tc.wantErr) + } + }) + } +} + +func TestValidComponentTypes(t *testing.T) { + // Ensure all valid types are in the slice + expected := []ComponentType{ + ComponentTypeService, + ComponentTypeWorker, + ComponentTypeAppAstro, + ComponentTypeAppReact, + ComponentTypeCLI, + } + + if len(ValidComponentTypes) != len(expected) { + t.Errorf("ValidComponentTypes has %d types, want %d", len(ValidComponentTypes), len(expected)) + } + + for i, ct := range ValidComponentTypes { + if ct != expected[i] { + t.Errorf("ValidComponentTypes[%d] = %s, want %s", i, ct, expected[i]) + } + } +} diff --git a/internal/domain/deployment.go b/internal/domain/deployment.go index d55defd..ec396f7 100644 --- a/internal/domain/deployment.go +++ b/internal/domain/deployment.go @@ -5,18 +5,54 @@ import "time" // DeploySpec defines a deployment request. type DeploySpec struct { - ProjectName string // Project identifier - Image string // Container image (e.g., zot.threesix.svc:5000/myapp:latest) - Domain string // Domain for ingress (e.g., myapp.threesix.ai) - Port int // Container port to expose - Replicas int // Number of replicas - EnvVars map[string]string // Plain environment variables - Secrets map[string]string // Secret environment variables (stored in K8s Secret) + ProjectName string // Project identifier + ComponentPath string // Component path within monorepo (e.g., "services/auth-api"), empty for single-app projects + Image string // Container image (e.g., zot.threesix.svc:5000/myapp:latest) + Domain string // Domain for ingress (e.g., myapp.threesix.ai) + Port int // Container port to expose + Replicas int // Number of replicas + EnvVars map[string]string // Plain environment variables + Secrets map[string]string // Secret environment variables (stored in K8s Secret) +} + +// DeploymentName returns the K8s resource name for this deployment. +// For monorepo components, it's "{project}-{component}", for single apps it's just "{project}". +func (s DeploySpec) DeploymentName() string { + if s.ComponentPath == "" { + return s.ProjectName + } + // Extract component name from path (e.g., "services/auth-api" -> "auth-api") + parts := splitPath(s.ComponentPath) + if len(parts) > 0 { + return s.ProjectName + "-" + parts[len(parts)-1] + } + return s.ProjectName +} + +// splitPath splits a path like "services/auth-api" into ["services", "auth-api"]. +func splitPath(path string) []string { + var parts []string + current := "" + for _, c := range path { + if c == '/' { + if current != "" { + parts = append(parts, current) + current = "" + } + } else { + current += string(c) + } + } + if current != "" { + parts = append(parts, current) + } + return parts } // DeployStatus represents the current state of a deployment. type DeployStatus struct { ProjectName string + ComponentPath string // Component path if this is a component deployment Image string Replicas int ReadyReplicas int @@ -26,6 +62,25 @@ type DeployStatus struct { UpdatedAt time.Time } +// ComponentDeployStatus holds deployment status for a component. +type ComponentDeployStatus struct { + ComponentPath string // e.g., "services/auth-api" + ComponentName string // e.g., "auth-api" + ComponentType string // e.g., "service", "worker", "app" + Image string // Container image + Replicas int // Desired replicas + ReadyReplicas int // Ready replicas + URL string // URL if component has ingress + Status DeploymentStatus // Current status +} + +// ProjectDeployStatus holds overall deployment status for a project. +type ProjectDeployStatus struct { + ProjectName string // Project identifier + Components []ComponentDeployStatus // Status of each deployed component + OverallURL string // Primary URL (usually app or main service) +} + // DeploymentStatus represents the state of a deployment. type DeploymentStatus string diff --git a/internal/domain/errors.go b/internal/domain/errors.go index 800ac23..5069077 100644 --- a/internal/domain/errors.go +++ b/internal/domain/errors.go @@ -66,6 +66,12 @@ var ( ErrDuplicateDomain = errors.New("domain already exists") ErrDomainNotFound = errors.New("domain not found") + // Component errors + ErrInvalidComponentName = errors.New("invalid component name") + ErrInvalidComponentType = errors.New("invalid component type") + ErrDuplicateComponent = errors.New("component already exists") + ErrComponentNotFound = errors.New("component not found") + // Audit errors ErrAuditNotFound = errors.New("audit log entry not found") diff --git a/internal/envutil/envutil.go b/internal/envutil/envutil.go new file mode 100644 index 0000000..8184f00 --- /dev/null +++ b/internal/envutil/envutil.go @@ -0,0 +1,50 @@ +// Package envutil provides environment variable helpers with defaults. +// +// rdev uses os.Getenv() directly rather than Viper because: +// - rdev always runs in Kubernetes with env vars injected via ConfigMaps/Secrets +// - rdev has a credential store overlay (loadInfraConfig) that Viper can't model +// - Adding Viper would add complexity without functional benefit for this use case +// +// Generated projects (skeleton templates) use Viper because: +// - App services benefit from .env file loading during development +// - Viper provides built-in duration parsing, type coercion, and defaults +// - App config is simpler (no credential store overlay) +// +// This divergence is intentional. See: .claude/guides/services/templates.md +package envutil + +import ( + "os" + "strconv" + "strings" +) + +// GetEnv returns the environment variable value or the default. +func GetEnv(key, defaultVal string) string { + if v := os.Getenv(key); v != "" { + return v + } + return defaultVal +} + +// GetEnvInt returns the environment variable as an int or the default. +func GetEnvInt(key string, defaultVal int) int { + v := os.Getenv(key) + if v == "" { + return defaultVal + } + if i, err := strconv.Atoi(v); err == nil { + return i + } + return defaultVal +} + +// GetEnvBool returns the environment variable as a bool or the default. +func GetEnvBool(key string, defaultVal bool) bool { + v := os.Getenv(key) + if v == "" { + return defaultVal + } + v = strings.ToLower(v) + return v == "true" || v == "1" || v == "yes" +} diff --git a/internal/handlers/agents.go b/internal/handlers/agents.go index 882e473..512e878 100644 --- a/internal/handlers/agents.go +++ b/internal/handlers/agents.go @@ -2,7 +2,6 @@ package handlers import ( - "encoding/json" "net/http" "time" @@ -246,7 +245,7 @@ func (h *AgentsHandler) SetDefault(w http.ResponseWriter, r *http.Request) { } var req SetDefaultRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + if err := api.DecodeJSON(r, &req); err != nil { api.WriteBadRequest(w, r, "invalid request body") return } diff --git a/internal/handlers/builds.go b/internal/handlers/builds.go index 39a964f..d5e7068 100644 --- a/internal/handlers/builds.go +++ b/internal/handlers/builds.go @@ -2,7 +2,6 @@ package handlers import ( - "encoding/json" "errors" "net/http" "strconv" @@ -133,7 +132,7 @@ func (h *BuildsHandler) StartBuild(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize) var req StartBuildRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + if err := api.DecodeJSON(r, &req); err != nil { api.WriteBadRequest(w, r, "invalid request body") return } diff --git a/internal/handlers/claude_config.go b/internal/handlers/claude_config.go index 0d2ddd8..7211dc4 100644 --- a/internal/handlers/claude_config.go +++ b/internal/handlers/claude_config.go @@ -4,12 +4,10 @@ package handlers import ( "context" "encoding/base64" - "encoding/json" "errors" "fmt" "net/http" "strings" - "time" "github.com/go-chi/chi/v5" "github.com/orchard9/rdev/internal/adapter/kubernetes" @@ -281,8 +279,8 @@ func (h *ClaudeConfigHandler) createItem(w http.ResponseWriter, r *http.Request, r.Body = http.MaxBytesReader(w, r.Body, maxContentSize) var req ConfigItemRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - api.WriteBadRequest(w, r, "invalid request body or content too large") + if err := api.DecodeJSON(r, &req); err != nil { + api.WriteBadRequest(w, r, "invalid request body") return } @@ -387,8 +385,8 @@ func (h *ClaudeConfigHandler) updateItem(w http.ResponseWriter, r *http.Request, r.Body = http.MaxBytesReader(w, r.Body, maxContentSize) var req ConfigItemRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - api.WriteBadRequest(w, r, "invalid request body or content too large") + if err := api.DecodeJSON(r, &req); err != nil { + api.WriteBadRequest(w, r, "invalid request body") return } @@ -467,7 +465,7 @@ func (h *ClaudeConfigHandler) deleteItem(w http.ResponseWriter, r *http.Request, // It prefers the project service if available, otherwise falls back to the project repository. func (h *ClaudeConfigHandler) getProject(ctx context.Context, id domain.ProjectID) (*domain.Project, error) { // Add timeout for project lookup - ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + ctx, cancel := context.WithTimeout(ctx, TimeoutFastLookup) defer cancel() // Use service if available diff --git a/internal/handlers/components.go b/internal/handlers/components.go new file mode 100644 index 0000000..2ddb780 --- /dev/null +++ b/internal/handlers/components.go @@ -0,0 +1,216 @@ +// Package handlers provides HTTP handlers for the rdev API. +package handlers + +import ( + "context" + "errors" + "log/slog" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/orchard9/rdev/internal/domain" + "github.com/orchard9/rdev/internal/port" + "github.com/orchard9/rdev/internal/validate" + "github.com/orchard9/rdev/pkg/api" +) + +// ComponentsHandler handles component management endpoints. +type ComponentsHandler struct { + service port.ComponentService + logger *slog.Logger +} + +// NewComponentsHandler creates a new components handler. +func NewComponentsHandler(service port.ComponentService, logger *slog.Logger) *ComponentsHandler { + if logger == nil { + logger = slog.Default() + } + return &ComponentsHandler{service: service, logger: logger} +} + +// Mount registers the component routes. +func (h *ComponentsHandler) Mount(r api.Router) { + r.Route("/projects/{id}/components", func(r chi.Router) { + r.Post("/", h.Add) + r.Get("/", h.List) + r.Delete("/*", h.Remove) // Wildcard to capture path like "services/auth-api" + }) +} + +// AddComponentRequest is the request body for POST /projects/{id}/components. +type AddComponentRequest struct { + Type string `json:"type"` // service, worker, app-astro, app-react, cli + Name string `json:"name"` // component name (slug format) + Template string `json:"template"` // optional: specific template variant + Port int `json:"port"` // optional: specific port (auto-assigned if 0) +} + +// ComponentResponse is the response for component operations. +type ComponentResponse struct { + Type string `json:"type"` + Name string `json:"name"` + Path string `json:"path"` + Port int `json:"port"` + Template string `json:"template"` + Dependencies []string `json:"dependencies"` +} + +// Add adds a new component to a project's monorepo. +// POST /projects/{id}/components +func (h *ComponentsHandler) Add(w http.ResponseWriter, r *http.Request) { + projectID := chi.URLParam(r, "id") + ctx, cancel := context.WithTimeout(r.Context(), TimeoutHeavyWrite) + defer cancel() + + // Validate project ID + if err := domain.ValidateProjectID(projectID); err != nil { + api.WriteBadRequest(w, r, err.Error()) + return + } + + if h.service == nil { + api.WriteInternalError(w, r, "component service not configured") + return + } + + var req AddComponentRequest + if err := api.DecodeJSON(r, &req); err != nil { + api.WriteBadRequest(w, r, "invalid request body") + return + } + + // Validate required fields + v := validate.New() + v.Required(req.Type, "type") + v.Required(req.Name, "name") + if err := v.Error(); err != nil { + api.WriteBadRequest(w, r, err.Error()) + return + } + + component, err := h.service.AddComponent(ctx, projectID, port.AddComponentRequest{ + Type: req.Type, + Name: req.Name, + Template: req.Template, + Port: req.Port, + }) + if err != nil { + // Map domain errors to HTTP responses + switch { + case errors.Is(err, domain.ErrInvalidComponentType): + api.WriteBadRequest(w, r, err.Error()) + case errors.Is(err, domain.ErrInvalidComponentName): + api.WriteBadRequest(w, r, err.Error()) + case errors.Is(err, domain.ErrDuplicateComponent): + api.WriteError(w, r, http.StatusConflict, "CONFLICT", err.Error()) + case errors.Is(err, domain.ErrProjectNotFound): + api.WriteNotFound(w, r, err.Error()) + default: + h.logger.Error("failed to add component", "error", err, "project", projectID, "name", req.Name) + api.WriteInternalError(w, r, "failed to add component") + } + return + } + + api.WriteCreated(w, r, ComponentResponse{ + Type: string(component.Type), + Name: component.Name, + Path: component.Path, + Port: component.Port, + Template: component.Template, + Dependencies: component.Dependencies, + }) +} + +// List lists all components in a project's monorepo. +// GET /projects/{id}/components +func (h *ComponentsHandler) List(w http.ResponseWriter, r *http.Request) { + projectID := chi.URLParam(r, "id") + ctx, cancel := context.WithTimeout(r.Context(), TimeoutStandard) + defer cancel() + + // Validate project ID + if err := domain.ValidateProjectID(projectID); err != nil { + api.WriteBadRequest(w, r, err.Error()) + return + } + + if h.service == nil { + api.WriteInternalError(w, r, "component service not configured") + return + } + + components, err := h.service.ListComponents(ctx, projectID) + if err != nil { + if errors.Is(err, domain.ErrProjectNotFound) { + api.WriteNotFound(w, r, err.Error()) + return + } + h.logger.Error("failed to list components", "error", err, "project", projectID) + api.WriteInternalError(w, r, "failed to list components") + return + } + + // Convert to response format + response := make([]ComponentResponse, len(components)) + for i, c := range components { + response[i] = ComponentResponse{ + Type: string(c.Type), + Name: c.Name, + Path: c.Path, + Port: c.Port, + Template: c.Template, + Dependencies: c.Dependencies, + } + // Ensure dependencies is not nil for JSON + if response[i].Dependencies == nil { + response[i].Dependencies = []string{} + } + } + + api.WriteSuccess(w, r, map[string]any{"components": response}) +} + +// Remove removes a component from a project's monorepo. +// DELETE /projects/{id}/components/{path} +func (h *ComponentsHandler) Remove(w http.ResponseWriter, r *http.Request) { + projectID := chi.URLParam(r, "id") + + // Get the component path from the wildcard - chi captures everything after /components/ + componentPath := chi.URLParam(r, "*") + if componentPath == "" { + api.WriteBadRequest(w, r, "component path is required") + return + } + + ctx, cancel := context.WithTimeout(r.Context(), TimeoutStandard) + defer cancel() + + // Validate project ID + if err := domain.ValidateProjectID(projectID); err != nil { + api.WriteBadRequest(w, r, err.Error()) + return + } + + if h.service == nil { + api.WriteInternalError(w, r, "component service not configured") + return + } + + err := h.service.RemoveComponent(ctx, projectID, componentPath) + if err != nil { + if errors.Is(err, domain.ErrProjectNotFound) { + api.WriteNotFound(w, r, err.Error()) + return + } + if errors.Is(err, domain.ErrComponentNotFound) { + api.WriteNotFound(w, r, err.Error()) + return + } + h.logger.Error("failed to remove component", "error", err, "project", projectID, "path", componentPath) + api.WriteInternalError(w, r, "failed to remove component") + return + } + + api.WriteNoContent(w) +} diff --git a/internal/handlers/components_test.go b/internal/handlers/components_test.go new file mode 100644 index 0000000..c51f206 --- /dev/null +++ b/internal/handlers/components_test.go @@ -0,0 +1,436 @@ +package handlers + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + "github.com/orchard9/rdev/internal/domain" + "github.com/orchard9/rdev/internal/port" +) + +// mockComponentService is a mock implementation of port.ComponentService for testing. +type mockComponentService struct { + addComponent func(ctx context.Context, projectID string, req port.AddComponentRequest) (*domain.Component, error) + listComponents func(ctx context.Context, projectID string) ([]domain.Component, error) + removeComponent func(ctx context.Context, projectID string, componentPath string) error +} + +func (m *mockComponentService) AddComponent(ctx context.Context, projectID string, req port.AddComponentRequest) (*domain.Component, error) { + if m.addComponent != nil { + return m.addComponent(ctx, projectID, req) + } + return nil, nil +} + +func (m *mockComponentService) ListComponents(ctx context.Context, projectID string) ([]domain.Component, error) { + if m.listComponents != nil { + return m.listComponents(ctx, projectID) + } + return nil, nil +} + +func (m *mockComponentService) RemoveComponent(ctx context.Context, projectID string, componentPath string) error { + if m.removeComponent != nil { + return m.removeComponent(ctx, projectID, componentPath) + } + return nil +} + +func TestComponentsHandler_Add(t *testing.T) { + tests := []struct { + name string + projectID string + body any + setupMock func() *mockComponentService + expectedStatus int + checkResponse func(t *testing.T, body []byte) + }{ + { + name: "successful add service component", + projectID: "my-project", + body: AddComponentRequest{ + Type: "service", + Name: "auth-api", + }, + setupMock: func() *mockComponentService { + return &mockComponentService{ + addComponent: func(ctx context.Context, projectID string, req port.AddComponentRequest) (*domain.Component, error) { + return &domain.Component{ + Type: domain.ComponentTypeService, + Name: "auth-api", + Path: "services/auth-api", + Port: 8001, + Template: "service", + Dependencies: []string{}, + }, nil + }, + } + }, + expectedStatus: http.StatusCreated, + checkResponse: func(t *testing.T, body []byte) { + var resp struct { + Data ComponentResponse `json:"data"` + } + if err := json.Unmarshal(body, &resp); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + if resp.Data.Type != "service" { + t.Errorf("expected type service, got %s", resp.Data.Type) + } + if resp.Data.Name != "auth-api" { + t.Errorf("expected name auth-api, got %s", resp.Data.Name) + } + if resp.Data.Path != "services/auth-api" { + t.Errorf("expected path services/auth-api, got %s", resp.Data.Path) + } + if resp.Data.Port != 8001 { + t.Errorf("expected port 8001, got %d", resp.Data.Port) + } + }, + }, + { + name: "missing type", + projectID: "my-project", + body: AddComponentRequest{ + Name: "auth-api", + }, + setupMock: func() *mockComponentService { return &mockComponentService{} }, + expectedStatus: http.StatusBadRequest, + }, + { + name: "missing name", + projectID: "my-project", + body: AddComponentRequest{ + Type: "service", + }, + setupMock: func() *mockComponentService { return &mockComponentService{} }, + expectedStatus: http.StatusBadRequest, + }, + { + name: "invalid component type", + projectID: "my-project", + body: AddComponentRequest{ + Type: "invalid", + Name: "auth-api", + }, + setupMock: func() *mockComponentService { + return &mockComponentService{ + addComponent: func(ctx context.Context, projectID string, req port.AddComponentRequest) (*domain.Component, error) { + return nil, domain.ErrInvalidComponentType + }, + } + }, + expectedStatus: http.StatusBadRequest, + }, + { + name: "duplicate component", + projectID: "my-project", + body: AddComponentRequest{ + Type: "service", + Name: "auth-api", + }, + setupMock: func() *mockComponentService { + return &mockComponentService{ + addComponent: func(ctx context.Context, projectID string, req port.AddComponentRequest) (*domain.Component, error) { + return nil, domain.ErrDuplicateComponent + }, + } + }, + expectedStatus: http.StatusConflict, + }, + { + name: "project not found", + projectID: "nonexistent", + body: AddComponentRequest{ + Type: "service", + Name: "auth-api", + }, + setupMock: func() *mockComponentService { + return &mockComponentService{ + addComponent: func(ctx context.Context, projectID string, req port.AddComponentRequest) (*domain.Component, error) { + return nil, domain.ErrProjectNotFound + }, + } + }, + expectedStatus: http.StatusNotFound, + }, + { + name: "invalid project ID", + projectID: "123-invalid", // starts with number + body: AddComponentRequest{Type: "service", Name: "auth-api"}, + setupMock: func() *mockComponentService { return &mockComponentService{} }, + expectedStatus: http.StatusBadRequest, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + mock := tc.setupMock() + handler := NewComponentsHandler(mock, nil) + + body, _ := json.Marshal(tc.body) + req := httptest.NewRequest(http.MethodPost, "/projects/"+tc.projectID+"/components", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + // Set up chi routing context + rctx := chi.NewRouteContext() + rctx.URLParams.Add("id", tc.projectID) + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + rec := httptest.NewRecorder() + handler.Add(rec, req) + + if rec.Code != tc.expectedStatus { + t.Errorf("expected status %d, got %d. Body: %s", tc.expectedStatus, rec.Code, rec.Body.String()) + } + + if tc.checkResponse != nil { + tc.checkResponse(t, rec.Body.Bytes()) + } + }) + } +} + +func TestComponentsHandler_List(t *testing.T) { + tests := []struct { + name string + projectID string + setupMock func() *mockComponentService + expectedStatus int + checkResponse func(t *testing.T, body []byte) + }{ + { + name: "successful list", + projectID: "my-project", + setupMock: func() *mockComponentService { + return &mockComponentService{ + listComponents: func(ctx context.Context, projectID string) ([]domain.Component, error) { + return []domain.Component{ + { + Type: domain.ComponentTypeService, + Name: "auth-api", + Path: "services/auth-api", + Port: 8001, + Template: "service", + Dependencies: []string{}, + }, + { + Type: domain.ComponentTypeAppAstro, + Name: "landing", + Path: "apps/landing", + Port: 3001, + Template: "app-astro", + Dependencies: []string{}, + }, + }, nil + }, + } + }, + expectedStatus: http.StatusOK, + checkResponse: func(t *testing.T, body []byte) { + var resp struct { + Data struct { + Components []ComponentResponse `json:"components"` + } `json:"data"` + } + if err := json.Unmarshal(body, &resp); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + if len(resp.Data.Components) != 2 { + t.Errorf("expected 2 components, got %d", len(resp.Data.Components)) + } + }, + }, + { + name: "empty list", + projectID: "my-project", + setupMock: func() *mockComponentService { + return &mockComponentService{ + listComponents: func(ctx context.Context, projectID string) ([]domain.Component, error) { + return []domain.Component{}, nil + }, + } + }, + expectedStatus: http.StatusOK, + checkResponse: func(t *testing.T, body []byte) { + var resp struct { + Data struct { + Components []ComponentResponse `json:"components"` + } `json:"data"` + } + if err := json.Unmarshal(body, &resp); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + if len(resp.Data.Components) != 0 { + t.Errorf("expected 0 components, got %d", len(resp.Data.Components)) + } + }, + }, + { + name: "project not found", + projectID: "nonexistent", + setupMock: func() *mockComponentService { + return &mockComponentService{ + listComponents: func(ctx context.Context, projectID string) ([]domain.Component, error) { + return nil, domain.ErrProjectNotFound + }, + } + }, + expectedStatus: http.StatusNotFound, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + mock := tc.setupMock() + handler := NewComponentsHandler(mock, nil) + + req := httptest.NewRequest(http.MethodGet, "/projects/"+tc.projectID+"/components", nil) + + // Set up chi routing context + rctx := chi.NewRouteContext() + rctx.URLParams.Add("id", tc.projectID) + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + rec := httptest.NewRecorder() + handler.List(rec, req) + + if rec.Code != tc.expectedStatus { + t.Errorf("expected status %d, got %d. Body: %s", tc.expectedStatus, rec.Code, rec.Body.String()) + } + + if tc.checkResponse != nil { + tc.checkResponse(t, rec.Body.Bytes()) + } + }) + } +} + +func TestComponentsHandler_Remove(t *testing.T) { + tests := []struct { + name string + projectID string + componentPath string + setupMock func() *mockComponentService + expectedStatus int + }{ + { + name: "successful remove", + projectID: "my-project", + componentPath: "services/auth-api", + setupMock: func() *mockComponentService { + return &mockComponentService{ + removeComponent: func(ctx context.Context, projectID string, componentPath string) error { + return nil + }, + } + }, + expectedStatus: http.StatusNoContent, + }, + { + name: "component not found", + projectID: "my-project", + componentPath: "services/nonexistent", + setupMock: func() *mockComponentService { + return &mockComponentService{ + removeComponent: func(ctx context.Context, projectID string, componentPath string) error { + return domain.ErrComponentNotFound + }, + } + }, + expectedStatus: http.StatusNotFound, + }, + { + name: "project not found", + projectID: "nonexistent", + componentPath: "services/auth-api", + setupMock: func() *mockComponentService { + return &mockComponentService{ + removeComponent: func(ctx context.Context, projectID string, componentPath string) error { + return domain.ErrProjectNotFound + }, + } + }, + expectedStatus: http.StatusNotFound, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + mock := tc.setupMock() + handler := NewComponentsHandler(mock, nil) + + req := httptest.NewRequest(http.MethodDelete, "/projects/"+tc.projectID+"/components/"+tc.componentPath, nil) + + // Set up chi routing context + rctx := chi.NewRouteContext() + rctx.URLParams.Add("id", tc.projectID) + rctx.URLParams.Add("*", tc.componentPath) + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + rec := httptest.NewRecorder() + handler.Remove(rec, req) + + if rec.Code != tc.expectedStatus { + t.Errorf("expected status %d, got %d. Body: %s", tc.expectedStatus, rec.Code, rec.Body.String()) + } + }) + } +} + +func TestComponentsHandler_NilService(t *testing.T) { + handler := NewComponentsHandler(nil, nil) + + t.Run("add with nil service", func(t *testing.T) { + body, _ := json.Marshal(AddComponentRequest{Type: "service", Name: "auth-api"}) + req := httptest.NewRequest(http.MethodPost, "/projects/my-project/components", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + rctx := chi.NewRouteContext() + rctx.URLParams.Add("id", "my-project") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + rec := httptest.NewRecorder() + handler.Add(rec, req) + + if rec.Code != http.StatusInternalServerError { + t.Errorf("expected status 500, got %d", rec.Code) + } + }) + + t.Run("list with nil service", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/projects/my-project/components", nil) + + rctx := chi.NewRouteContext() + rctx.URLParams.Add("id", "my-project") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + rec := httptest.NewRecorder() + handler.List(rec, req) + + if rec.Code != http.StatusInternalServerError { + t.Errorf("expected status 500, got %d", rec.Code) + } + }) + + t.Run("remove with nil service", func(t *testing.T) { + req := httptest.NewRequest(http.MethodDelete, "/projects/my-project/components/services/auth-api", nil) + + rctx := chi.NewRouteContext() + rctx.URLParams.Add("id", "my-project") + rctx.URLParams.Add("*", "services/auth-api") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + rec := httptest.NewRecorder() + handler.Remove(rec, req) + + if rec.Code != http.StatusInternalServerError { + t.Errorf("expected status 500, got %d", rec.Code) + } + }) +} diff --git a/internal/handlers/create_and_build.go b/internal/handlers/create_and_build.go index d49976a..6e3d74c 100644 --- a/internal/handlers/create_and_build.go +++ b/internal/handlers/create_and_build.go @@ -3,14 +3,13 @@ package handlers import ( "context" - "encoding/json" "errors" "log/slog" "net/http" - "time" "github.com/orchard9/rdev/internal/domain" "github.com/orchard9/rdev/internal/service" + "github.com/orchard9/rdev/internal/validate" "github.com/orchard9/rdev/pkg/api" ) @@ -78,7 +77,7 @@ type CreateAndBuildResponse struct { // CreateAndBuild creates a project and immediately enqueues a build task. // POST /project/create-and-build func (h *CreateAndBuildHandler) CreateAndBuild(w http.ResponseWriter, r *http.Request) { - ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second) + ctx, cancel := context.WithTimeout(r.Context(), TimeoutHeavyWrite) defer cancel() if h.infraService == nil { @@ -92,17 +91,16 @@ func (h *CreateAndBuildHandler) CreateAndBuild(w http.ResponseWriter, r *http.Re r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize) var req CreateAndBuildRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + if err := api.DecodeJSON(r, &req); err != nil { api.WriteBadRequest(w, r, "invalid request body") return } - if req.Name == "" { - api.WriteBadRequest(w, r, "name is required") - return - } - if req.Prompt == "" { - api.WriteBadRequest(w, r, "prompt is required") + v := validate.New() + v.Required(req.Name, "name") + v.Required(req.Prompt, "prompt") + if err := v.Error(); err != nil { + api.WriteBadRequest(w, r, err.Error()) return } diff --git a/internal/handlers/credentials.go b/internal/handlers/credentials.go index d6f46aa..03514db 100644 --- a/internal/handlers/credentials.go +++ b/internal/handlers/credentials.go @@ -3,7 +3,6 @@ package handlers import ( "context" - "encoding/json" "errors" "net/http" @@ -11,6 +10,7 @@ import ( "github.com/orchard9/rdev/internal/auth" "github.com/orchard9/rdev/internal/domain" "github.com/orchard9/rdev/internal/port" + "github.com/orchard9/rdev/internal/validate" "github.com/orchard9/rdev/pkg/api" ) @@ -140,17 +140,16 @@ func (h *CredentialsHandler) Set(w http.ResponseWriter, r *http.Request) { ctx := r.Context() var req SetCredentialRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + if err := api.DecodeJSON(r, &req); err != nil { api.WriteBadRequest(w, r, "invalid request body") return } - if req.Key == "" { - api.WriteBadRequest(w, r, "key is required") - return - } - if req.Value == "" { - api.WriteBadRequest(w, r, "value is required") + v := validate.New() + v.Required(req.Key, "key") + v.Required(req.Value, "value") + if err := v.Error(); err != nil { + api.WriteBadRequest(w, r, err.Error()) return } @@ -179,7 +178,7 @@ func (h *CredentialsHandler) SetBatch(w http.ResponseWriter, r *http.Request) { ctx := r.Context() var req SetBatchRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + if err := api.DecodeJSON(r, &req); err != nil { api.WriteBadRequest(w, r, "invalid request body") return } diff --git a/internal/handlers/health.go b/internal/handlers/health.go index 4160462..fbbb224 100644 --- a/internal/handlers/health.go +++ b/internal/handlers/health.go @@ -63,7 +63,7 @@ func (h *HealthHandler) Health(w http.ResponseWriter, r *http.Request) { // 503 if any are unhealthy. // GET /ready func (h *HealthHandler) Ready(w http.ResponseWriter, r *http.Request) { - ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + ctx, cancel := context.WithTimeout(r.Context(), TimeoutFastLookup) defer cancel() checks := make(map[string]CheckResult) diff --git a/internal/handlers/infrastructure.go b/internal/handlers/infrastructure.go index ef276fd..3fd9365 100644 --- a/internal/handlers/infrastructure.go +++ b/internal/handlers/infrastructure.go @@ -3,10 +3,9 @@ package handlers import ( "context" - "encoding/json" + "errors" "fmt" "net/http" - "time" "github.com/go-chi/chi/v5" "github.com/orchard9/rdev/internal/domain" @@ -136,7 +135,7 @@ type CreateRepoResponse struct { // POST /projects/{id}/repo func (h *InfrastructureHandler) CreateRepo(w http.ResponseWriter, r *http.Request) { projectID := chi.URLParam(r, "id") - ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + ctx, cancel := context.WithTimeout(r.Context(), TimeoutStandard) defer cancel() // Validate project ID @@ -151,7 +150,7 @@ func (h *InfrastructureHandler) CreateRepo(w http.ResponseWriter, r *http.Reques } var req CreateRepoRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil && err.Error() != "EOF" { + if err := api.DecodeJSON(r, &req); err != nil && !errors.Is(err, api.ErrEmptyBody) { api.WriteBadRequest(w, r, "invalid request body") return } @@ -184,7 +183,7 @@ func (h *InfrastructureHandler) CreateRepo(w http.ResponseWriter, r *http.Reques // GET /projects/{id}/repo func (h *InfrastructureHandler) GetRepo(w http.ResponseWriter, r *http.Request) { projectID := chi.URLParam(r, "id") - ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + ctx, cancel := context.WithTimeout(r.Context(), TimeoutLookup) defer cancel() // Validate project ID @@ -221,7 +220,7 @@ func (h *InfrastructureHandler) GetRepo(w http.ResponseWriter, r *http.Request) // DELETE /projects/{id}/repo func (h *InfrastructureHandler) DeleteRepo(w http.ResponseWriter, r *http.Request) { projectID := chi.URLParam(r, "id") - ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + ctx, cancel := context.WithTimeout(r.Context(), TimeoutStandard) defer cancel() // Validate project ID @@ -256,7 +255,7 @@ type AddDomainRequest struct { // POST /projects/{id}/domain func (h *InfrastructureHandler) AddDomain(w http.ResponseWriter, r *http.Request) { projectID := chi.URLParam(r, "id") - ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + ctx, cancel := context.WithTimeout(r.Context(), TimeoutStandard) defer cancel() // Validate project ID @@ -266,7 +265,7 @@ func (h *InfrastructureHandler) AddDomain(w http.ResponseWriter, r *http.Request } var req AddDomainRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + if err := api.DecodeJSON(r, &req); err != nil { api.WriteBadRequest(w, r, "invalid request body") return } @@ -312,7 +311,7 @@ func (h *InfrastructureHandler) AddDomain(w http.ResponseWriter, r *http.Request // DELETE /projects/{id}/domain func (h *InfrastructureHandler) RemoveDomain(w http.ResponseWriter, r *http.Request) { projectID := chi.URLParam(r, "id") - ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + ctx, cancel := context.WithTimeout(r.Context(), TimeoutStandard) defer cancel() // Validate project ID diff --git a/internal/handlers/infrastructure_deploy.go b/internal/handlers/infrastructure_deploy.go index 66c2c4d..6ee5657 100644 --- a/internal/handlers/infrastructure_deploy.go +++ b/internal/handlers/infrastructure_deploy.go @@ -2,10 +2,8 @@ package handlers import ( "context" - "encoding/json" "fmt" "net/http" - "time" "github.com/go-chi/chi/v5" "github.com/orchard9/rdev/internal/domain" @@ -17,28 +15,30 @@ const maxReplicas = 10 // DeployRequest is the request body for POST /projects/{id}/deploy. type DeployRequest struct { - Image string `json:"image"` // Container image - Domain string `json:"domain,omitempty"` // Custom domain (optional) - Port int `json:"port,omitempty"` // Container port (default 8080) - Replicas int `json:"replicas,omitempty"` // Number of replicas (default 1) - EnvVars map[string]string `json:"env_vars,omitempty"` // Plain environment variables - Secrets map[string]string `json:"secrets,omitempty"` // Secret environment variables + Component string `json:"component,omitempty"` // Component path (e.g., "services/auth-api"), empty for single-app or all components + Image string `json:"image"` // Container image + Domain string `json:"domain,omitempty"` // Custom domain (optional) + Port int `json:"port,omitempty"` // Container port (default 8080) + Replicas int `json:"replicas,omitempty"` // Number of replicas (default 1) + EnvVars map[string]string `json:"env_vars,omitempty"` // Plain environment variables + Secrets map[string]string `json:"secrets,omitempty"` // Secret environment variables } // DeployResponse is the response for POST /projects/{id}/deploy. type DeployResponse struct { - ProjectName string `json:"project_name"` - Image string `json:"image"` - Domain string `json:"domain"` - URL string `json:"url"` - Status string `json:"status"` + ProjectName string `json:"project_name"` + ComponentPath string `json:"component_path,omitempty"` // Component path if deploying a component + Image string `json:"image"` + Domain string `json:"domain"` + URL string `json:"url"` + Status string `json:"status"` } // Deploy deploys a project. // POST /projects/{id}/deploy func (h *InfrastructureHandler) Deploy(w http.ResponseWriter, r *http.Request) { projectID := chi.URLParam(r, "id") - ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second) + ctx, cancel := context.WithTimeout(r.Context(), TimeoutHeavyWrite) defer cancel() // Validate project ID @@ -53,7 +53,7 @@ func (h *InfrastructureHandler) Deploy(w http.ResponseWriter, r *http.Request) { } var req DeployRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + if err := api.DecodeJSON(r, &req); err != nil { api.WriteBadRequest(w, r, "invalid request body") return } @@ -63,17 +63,28 @@ func (h *InfrastructureHandler) Deploy(w http.ResponseWriter, r *http.Request) { return } - // Build domain + // Build domain - for components, include component name in subdomain deployDomain := req.Domain if deployDomain == "" { - deployDomain = projectID + "." + h.defaultDomain + if req.Component != "" { + // For components, build domain like "component-project.domain.com" + // Extract component name from path (e.g., "services/auth-api" -> "auth-api") + componentName := extractComponentName(req.Component) + deployDomain = componentName + "-" + projectID + "." + h.defaultDomain + } else { + deployDomain = projectID + "." + h.defaultDomain + } } // Create DNS record if DNS provider is configured + dnsName := projectID + if req.Component != "" { + dnsName = extractComponentName(req.Component) + "-" + projectID + } if h.dns != nil && h.clusterIP != "" { _, err := h.dns.CreateRecord(ctx, domain.DNSRecord{ Type: "A", - Name: projectID, + Name: dnsName, Content: h.clusterIP, TTL: 1, Proxied: false, @@ -89,13 +100,14 @@ func (h *InfrastructureHandler) Deploy(w http.ResponseWriter, r *http.Request) { // Deploy spec := domain.DeploySpec{ - ProjectName: projectID, - Image: req.Image, - Domain: deployDomain, - Port: req.Port, - Replicas: req.Replicas, - EnvVars: req.EnvVars, - Secrets: req.Secrets, + ProjectName: projectID, + ComponentPath: req.Component, + Image: req.Image, + Domain: deployDomain, + Port: req.Port, + Replicas: req.Replicas, + EnvVars: req.EnvVars, + Secrets: req.Secrets, } if err := h.deployer.Deploy(ctx, spec); err != nil { @@ -104,11 +116,12 @@ func (h *InfrastructureHandler) Deploy(w http.ResponseWriter, r *http.Request) { } api.WriteCreated(w, r, DeployResponse{ - ProjectName: projectID, - Image: req.Image, - Domain: deployDomain, - URL: "https://" + deployDomain, - Status: "deploying", + ProjectName: projectID, + ComponentPath: req.Component, + Image: req.Image, + Domain: deployDomain, + URL: "https://" + deployDomain, + Status: "deploying", }) } @@ -116,7 +129,7 @@ func (h *InfrastructureHandler) Deploy(w http.ResponseWriter, r *http.Request) { // GET /projects/{id}/deploy/status func (h *InfrastructureHandler) GetDeployStatus(w http.ResponseWriter, r *http.Request) { projectID := chi.URLParam(r, "id") - ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + ctx, cancel := context.WithTimeout(r.Context(), TimeoutLookup) defer cancel() if h.deployer == nil { @@ -151,7 +164,7 @@ func (h *InfrastructureHandler) GetDeployStatus(w http.ResponseWriter, r *http.R // DELETE /projects/{id}/deploy func (h *InfrastructureHandler) Undeploy(w http.ResponseWriter, r *http.Request) { projectID := chi.URLParam(r, "id") - ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + ctx, cancel := context.WithTimeout(r.Context(), TimeoutStandard) defer cancel() if h.deployer == nil { @@ -179,7 +192,7 @@ func (h *InfrastructureHandler) Undeploy(w http.ResponseWriter, r *http.Request) // POST /projects/{id}/deploy/restart func (h *InfrastructureHandler) RestartDeploy(w http.ResponseWriter, r *http.Request) { projectID := chi.URLParam(r, "id") - ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + ctx, cancel := context.WithTimeout(r.Context(), TimeoutStandard) defer cancel() if h.deployer == nil { @@ -207,7 +220,7 @@ type ScaleRequest struct { // POST /projects/{id}/deploy/scale func (h *InfrastructureHandler) ScaleDeploy(w http.ResponseWriter, r *http.Request) { projectID := chi.URLParam(r, "id") - ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + ctx, cancel := context.WithTimeout(r.Context(), TimeoutStandard) defer cancel() if h.deployer == nil { @@ -216,7 +229,7 @@ func (h *InfrastructureHandler) ScaleDeploy(w http.ResponseWriter, r *http.Reque } var req ScaleRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + if err := api.DecodeJSON(r, &req); err != nil { api.WriteBadRequest(w, r, "invalid request body") return } @@ -242,7 +255,7 @@ func (h *InfrastructureHandler) ScaleDeploy(w http.ResponseWriter, r *http.Reque // GET /projects/{id}/deploy/logs func (h *InfrastructureHandler) GetDeployLogs(w http.ResponseWriter, r *http.Request) { projectID := chi.URLParam(r, "id") - ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + ctx, cancel := context.WithTimeout(r.Context(), TimeoutStandard) defer cancel() if h.deployer == nil { @@ -264,3 +277,31 @@ func (h *InfrastructureHandler) GetDeployLogs(w http.ResponseWriter, r *http.Req "logs": logs, }) } + +// extractComponentName extracts the component name from a path like "services/auth-api". +// Returns the last segment (e.g., "auth-api"). +func extractComponentName(componentPath string) string { + if componentPath == "" { + return "" + } + // Split by "/" and return last segment + parts := make([]string, 0) + current := "" + for _, c := range componentPath { + if c == '/' { + if current != "" { + parts = append(parts, current) + current = "" + } + } else { + current += string(c) + } + } + if current != "" { + parts = append(parts, current) + } + if len(parts) == 0 { + return componentPath + } + return parts[len(parts)-1] +} diff --git a/internal/handlers/infrastructure_domains.go b/internal/handlers/infrastructure_domains.go index 95c39ea..9679b39 100644 --- a/internal/handlers/infrastructure_domains.go +++ b/internal/handlers/infrastructure_domains.go @@ -2,7 +2,6 @@ package handlers import ( "context" - "encoding/json" "errors" "fmt" "net/http" @@ -36,7 +35,7 @@ type DomainResponse struct { // GET /projects/{id}/domains func (h *InfrastructureHandler) ListDomains(w http.ResponseWriter, r *http.Request) { projectID := chi.URLParam(r, "id") - ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + ctx, cancel := context.WithTimeout(r.Context(), TimeoutLookup) defer cancel() if err := validateProjectID(projectID); err != nil { @@ -79,7 +78,7 @@ func (h *InfrastructureHandler) ListDomains(w http.ResponseWriter, r *http.Reque // POST /projects/{id}/domains func (h *InfrastructureHandler) AddDomainAlias(w http.ResponseWriter, r *http.Request) { projectID := chi.URLParam(r, "id") - ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + ctx, cancel := context.WithTimeout(r.Context(), TimeoutStandard) defer cancel() if err := validateProjectID(projectID); err != nil { @@ -93,7 +92,7 @@ func (h *InfrastructureHandler) AddDomainAlias(w http.ResponseWriter, r *http.Re } var req DomainAliasRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + if err := api.DecodeJSON(r, &req); err != nil { api.WriteBadRequest(w, r, "invalid request body") return } @@ -157,7 +156,7 @@ func (h *InfrastructureHandler) AddDomainAlias(w http.ResponseWriter, r *http.Re func (h *InfrastructureHandler) RemoveDomainAlias(w http.ResponseWriter, r *http.Request) { projectID := chi.URLParam(r, "id") aliasDomain := chi.URLParam(r, "domain") - ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + ctx, cancel := context.WithTimeout(r.Context(), TimeoutStandard) defer cancel() if err := validateProjectID(projectID); err != nil { diff --git a/internal/handlers/infrastructure_mocks_test.go b/internal/handlers/infrastructure_mocks_test.go new file mode 100644 index 0000000..be767de --- /dev/null +++ b/internal/handlers/infrastructure_mocks_test.go @@ -0,0 +1,289 @@ +package handlers + +import ( + "context" + "fmt" + "time" + + "github.com/orchard9/rdev/internal/domain" +) + +// mockGitRepository implements port.GitRepository for testing. +type mockGitRepository struct { + repos map[string]*domain.Repo + err error +} + +func newMockGitRepository() *mockGitRepository { + return &mockGitRepository{repos: make(map[string]*domain.Repo)} +} + +func (m *mockGitRepository) CreateRepo(_ context.Context, name, description string, private bool) (*domain.Repo, error) { + if m.err != nil { + return nil, m.err + } + repo := &domain.Repo{ + ID: 1, + Owner: "threesix", + Name: name, + FullName: "threesix/" + name, + Description: description, + Private: private, + CloneSSH: fmt.Sprintf("git@git.threesix.ai:threesix/%s.git", name), + CloneHTTP: fmt.Sprintf("https://git.threesix.ai/threesix/%s.git", name), + HTMLURL: fmt.Sprintf("https://git.threesix.ai/threesix/%s", name), + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + m.repos[name] = repo + return repo, nil +} + +func (m *mockGitRepository) DeleteRepo(_ context.Context, _, name string) error { + if m.err != nil { + return m.err + } + delete(m.repos, name) + return nil +} + +func (m *mockGitRepository) ListRepos(_ context.Context, _ string) ([]*domain.Repo, error) { + if m.err != nil { + return nil, m.err + } + var repos []*domain.Repo + for _, r := range m.repos { + repos = append(repos, r) + } + return repos, nil +} + +func (m *mockGitRepository) GetRepo(_ context.Context, _, name string) (*domain.Repo, error) { + if m.err != nil { + return nil, m.err + } + r, ok := m.repos[name] + if !ok { + return nil, fmt.Errorf("repo not found: %s", name) + } + return r, nil +} + +func (m *mockGitRepository) AddCollaborator(context.Context, string, string, string, string) error { + return m.err +} +func (m *mockGitRepository) RemoveCollaborator(context.Context, string, string, string) error { + return m.err +} +func (m *mockGitRepository) AddDeployKey(context.Context, string, string, string, string, bool) (*domain.DeployKey, error) { + return nil, m.err +} +func (m *mockGitRepository) DeleteDeployKey(context.Context, string, string, int64) error { + return m.err +} +func (m *mockGitRepository) CreateWebhook(context.Context, string, string, string, string, []string) (*domain.RepoWebhook, error) { + return nil, m.err +} +func (m *mockGitRepository) DeleteWebhook(context.Context, string, string, int64) error { + return m.err +} + +// mockDNSProvider implements port.DNSProvider for testing. +type mockDNSProvider struct { + records map[string]*domain.DNSRecord + err error +} + +func newMockDNSProvider() *mockDNSProvider { + return &mockDNSProvider{records: make(map[string]*domain.DNSRecord)} +} + +func (m *mockDNSProvider) CreateRecord(_ context.Context, record domain.DNSRecord) (*domain.DNSRecord, error) { + if m.err != nil { + return nil, m.err + } + record.ID = "rec-" + record.Name + m.records[record.Name] = &record + return &record, nil +} + +func (m *mockDNSProvider) UpdateRecord(_ context.Context, recordID string, record domain.DNSRecord) (*domain.DNSRecord, error) { + if m.err != nil { + return nil, m.err + } + record.ID = recordID + m.records[recordID] = &record + return &record, nil +} + +func (m *mockDNSProvider) DeleteRecord(_ context.Context, recordID string) error { + if m.err != nil { + return m.err + } + delete(m.records, recordID) + return nil +} + +func (m *mockDNSProvider) DeleteRecordByName(_ context.Context, _, name string) error { + if m.err != nil { + return m.err + } + delete(m.records, name) + return nil +} + +func (m *mockDNSProvider) GetRecord(_ context.Context, recordID string) (*domain.DNSRecord, error) { + if m.err != nil { + return nil, m.err + } + r, ok := m.records[recordID] + if !ok { + return nil, fmt.Errorf("record not found") + } + return r, nil +} + +func (m *mockDNSProvider) ListRecords(_ context.Context, recordType string) ([]*domain.DNSRecord, error) { + if m.err != nil { + return nil, m.err + } + var result []*domain.DNSRecord + for _, r := range m.records { + if recordType == "" || r.Type == recordType { + result = append(result, r) + } + } + return result, nil +} + +func (m *mockDNSProvider) FindRecord(_ context.Context, _, name string) (*domain.DNSRecord, error) { + if m.err != nil { + return nil, m.err + } + r, ok := m.records[name] + if !ok { + return nil, nil + } + return r, nil +} + +func (m *mockDNSProvider) UpsertRecord(ctx context.Context, record domain.DNSRecord) (*domain.DNSRecord, error) { + if m.err != nil { + return nil, m.err + } + // Check if record exists, then update or create + existing, _ := m.FindRecord(ctx, record.Type, record.Name) + if existing != nil { + return m.UpdateRecord(ctx, existing.ID, record) + } + return m.CreateRecord(ctx, record) +} + +// mockDeployer implements port.Deployer for testing. +type mockDeployer struct { + deployments map[string]*domain.DeployStatus + logs string + err error +} + +func newMockDeployer() *mockDeployer { + return &mockDeployer{deployments: make(map[string]*domain.DeployStatus)} +} + +func (m *mockDeployer) Deploy(_ context.Context, spec domain.DeploySpec) error { + if m.err != nil { + return m.err + } + m.deployments[spec.ProjectName] = &domain.DeployStatus{ + ProjectName: spec.ProjectName, + Image: spec.Image, + Replicas: spec.Replicas, + ReadyReplicas: 0, + URL: "https://" + spec.Domain, + Status: domain.DeploymentStatusPending, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + return nil +} + +func (m *mockDeployer) Undeploy(_ context.Context, projectName string) error { + if m.err != nil { + return m.err + } + delete(m.deployments, projectName) + return nil +} + +func (m *mockDeployer) GetStatus(_ context.Context, projectName string) (*domain.DeployStatus, error) { + if m.err != nil { + return nil, m.err + } + s, ok := m.deployments[projectName] + if !ok { + return nil, nil + } + return s, nil +} + +func (m *mockDeployer) Restart(_ context.Context, _ string) error { + return m.err +} + +func (m *mockDeployer) Scale(_ context.Context, projectName string, replicas int) error { + if m.err != nil { + return m.err + } + if s, ok := m.deployments[projectName]; ok { + s.Replicas = replicas + } + return nil +} + +func (m *mockDeployer) GetLogs(_ context.Context, _ string, _ int) (string, error) { + if m.err != nil { + return "", m.err + } + return m.logs, nil +} + +func (m *mockDeployer) AddIngressHost(_ context.Context, _, _ string) error { + return m.err +} + +func (m *mockDeployer) RemoveIngressHost(_ context.Context, _, _ string) error { + return m.err +} + +func (m *mockDeployer) UndeployComponent(_ context.Context, _, _ string) error { + return m.err +} + +func (m *mockDeployer) GetComponentStatus(_ context.Context, _, _ string) (*domain.DeployStatus, error) { + if m.err != nil { + return nil, m.err + } + return nil, nil +} + +func (m *mockDeployer) ListComponentStatuses(_ context.Context, _ string) (*domain.ProjectDeployStatus, error) { + if m.err != nil { + return nil, m.err + } + return &domain.ProjectDeployStatus{}, nil +} + +func (m *mockDeployer) RestartComponent(_ context.Context, _, _ string) error { + return m.err +} + +func (m *mockDeployer) ScaleComponent(_ context.Context, _, _ string, _ int) error { + return m.err +} + +func (m *mockDeployer) GetComponentLogs(_ context.Context, _, _ string, _ int) (string, error) { + if m.err != nil { + return "", m.err + } + return m.logs, nil +} diff --git a/internal/handlers/infrastructure_pipelines.go b/internal/handlers/infrastructure_pipelines.go index 6628a72..5301b9c 100644 --- a/internal/handlers/infrastructure_pipelines.go +++ b/internal/handlers/infrastructure_pipelines.go @@ -38,7 +38,7 @@ type PipelineResponse struct { // GET /projects/{id}/pipelines func (h *InfrastructureHandler) ListPipelines(w http.ResponseWriter, r *http.Request) { projectID := chi.URLParam(r, "id") - ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + ctx, cancel := context.WithTimeout(r.Context(), TimeoutLookup) defer cancel() if err := validateProjectID(projectID); err != nil { @@ -82,7 +82,7 @@ func (h *InfrastructureHandler) ListPipelines(w http.ResponseWriter, r *http.Req func (h *InfrastructureHandler) GetPipeline(w http.ResponseWriter, r *http.Request) { projectID := chi.URLParam(r, "id") numberStr := chi.URLParam(r, "number") - ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + ctx, cancel := context.WithTimeout(r.Context(), TimeoutLookup) defer cancel() if err := validateProjectID(projectID); err != nil { diff --git a/internal/handlers/infrastructure_test.go b/internal/handlers/infrastructure_test.go index 9d85522..ad99212 100644 --- a/internal/handlers/infrastructure_test.go +++ b/internal/handlers/infrastructure_test.go @@ -2,265 +2,15 @@ package handlers import ( "bytes" - "context" "encoding/json" - "fmt" "net/http" "net/http/httptest" "testing" - "time" "github.com/go-chi/chi/v5" "github.com/orchard9/rdev/internal/domain" ) -// mockGitRepository implements port.GitRepository for testing. -type mockGitRepository struct { - repos map[string]*domain.Repo - err error -} - -func newMockGitRepository() *mockGitRepository { - return &mockGitRepository{repos: make(map[string]*domain.Repo)} -} - -func (m *mockGitRepository) CreateRepo(_ context.Context, name, description string, private bool) (*domain.Repo, error) { - if m.err != nil { - return nil, m.err - } - repo := &domain.Repo{ - ID: 1, - Owner: "threesix", - Name: name, - FullName: "threesix/" + name, - Description: description, - Private: private, - CloneSSH: fmt.Sprintf("git@git.threesix.ai:threesix/%s.git", name), - CloneHTTP: fmt.Sprintf("https://git.threesix.ai/threesix/%s.git", name), - HTMLURL: fmt.Sprintf("https://git.threesix.ai/threesix/%s", name), - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - } - m.repos[name] = repo - return repo, nil -} - -func (m *mockGitRepository) DeleteRepo(_ context.Context, _, name string) error { - if m.err != nil { - return m.err - } - delete(m.repos, name) - return nil -} - -func (m *mockGitRepository) ListRepos(_ context.Context, _ string) ([]*domain.Repo, error) { - if m.err != nil { - return nil, m.err - } - var repos []*domain.Repo - for _, r := range m.repos { - repos = append(repos, r) - } - return repos, nil -} - -func (m *mockGitRepository) GetRepo(_ context.Context, _, name string) (*domain.Repo, error) { - if m.err != nil { - return nil, m.err - } - r, ok := m.repos[name] - if !ok { - return nil, fmt.Errorf("repo not found: %s", name) - } - return r, nil -} - -func (m *mockGitRepository) AddCollaborator(context.Context, string, string, string, string) error { - return m.err -} -func (m *mockGitRepository) RemoveCollaborator(context.Context, string, string, string) error { - return m.err -} -func (m *mockGitRepository) AddDeployKey(context.Context, string, string, string, string, bool) (*domain.DeployKey, error) { - return nil, m.err -} -func (m *mockGitRepository) DeleteDeployKey(context.Context, string, string, int64) error { - return m.err -} -func (m *mockGitRepository) CreateWebhook(context.Context, string, string, string, string, []string) (*domain.RepoWebhook, error) { - return nil, m.err -} -func (m *mockGitRepository) DeleteWebhook(context.Context, string, string, int64) error { - return m.err -} - -// mockDNSProvider implements port.DNSProvider for testing. -type mockDNSProvider struct { - records map[string]*domain.DNSRecord - err error -} - -func newMockDNSProvider() *mockDNSProvider { - return &mockDNSProvider{records: make(map[string]*domain.DNSRecord)} -} - -func (m *mockDNSProvider) CreateRecord(_ context.Context, record domain.DNSRecord) (*domain.DNSRecord, error) { - if m.err != nil { - return nil, m.err - } - record.ID = "rec-" + record.Name - m.records[record.Name] = &record - return &record, nil -} - -func (m *mockDNSProvider) UpdateRecord(_ context.Context, recordID string, record domain.DNSRecord) (*domain.DNSRecord, error) { - if m.err != nil { - return nil, m.err - } - record.ID = recordID - m.records[recordID] = &record - return &record, nil -} - -func (m *mockDNSProvider) DeleteRecord(_ context.Context, recordID string) error { - if m.err != nil { - return m.err - } - delete(m.records, recordID) - return nil -} - -func (m *mockDNSProvider) DeleteRecordByName(_ context.Context, _, name string) error { - if m.err != nil { - return m.err - } - delete(m.records, name) - return nil -} - -func (m *mockDNSProvider) GetRecord(_ context.Context, recordID string) (*domain.DNSRecord, error) { - if m.err != nil { - return nil, m.err - } - r, ok := m.records[recordID] - if !ok { - return nil, fmt.Errorf("record not found") - } - return r, nil -} - -func (m *mockDNSProvider) ListRecords(_ context.Context, recordType string) ([]*domain.DNSRecord, error) { - if m.err != nil { - return nil, m.err - } - var result []*domain.DNSRecord - for _, r := range m.records { - if recordType == "" || r.Type == recordType { - result = append(result, r) - } - } - return result, nil -} - -func (m *mockDNSProvider) FindRecord(_ context.Context, _, name string) (*domain.DNSRecord, error) { - if m.err != nil { - return nil, m.err - } - r, ok := m.records[name] - if !ok { - return nil, nil - } - return r, nil -} - -func (m *mockDNSProvider) UpsertRecord(ctx context.Context, record domain.DNSRecord) (*domain.DNSRecord, error) { - if m.err != nil { - return nil, m.err - } - // Check if record exists, then update or create - existing, _ := m.FindRecord(ctx, record.Type, record.Name) - if existing != nil { - return m.UpdateRecord(ctx, existing.ID, record) - } - return m.CreateRecord(ctx, record) -} - -// mockDeployer implements port.Deployer for testing. -type mockDeployer struct { - deployments map[string]*domain.DeployStatus - logs string - err error -} - -func newMockDeployer() *mockDeployer { - return &mockDeployer{deployments: make(map[string]*domain.DeployStatus)} -} - -func (m *mockDeployer) Deploy(_ context.Context, spec domain.DeploySpec) error { - if m.err != nil { - return m.err - } - m.deployments[spec.ProjectName] = &domain.DeployStatus{ - ProjectName: spec.ProjectName, - Image: spec.Image, - Replicas: spec.Replicas, - ReadyReplicas: 0, - URL: "https://" + spec.Domain, - Status: domain.DeploymentStatusPending, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - } - return nil -} - -func (m *mockDeployer) Undeploy(_ context.Context, projectName string) error { - if m.err != nil { - return m.err - } - delete(m.deployments, projectName) - return nil -} - -func (m *mockDeployer) GetStatus(_ context.Context, projectName string) (*domain.DeployStatus, error) { - if m.err != nil { - return nil, m.err - } - s, ok := m.deployments[projectName] - if !ok { - return nil, nil - } - return s, nil -} - -func (m *mockDeployer) Restart(_ context.Context, _ string) error { - return m.err -} - -func (m *mockDeployer) Scale(_ context.Context, projectName string, replicas int) error { - if m.err != nil { - return m.err - } - if s, ok := m.deployments[projectName]; ok { - s.Replicas = replicas - } - return nil -} - -func (m *mockDeployer) GetLogs(_ context.Context, _ string, _ int) (string, error) { - if m.err != nil { - return "", m.err - } - return m.logs, nil -} - -func (m *mockDeployer) AddIngressHost(_ context.Context, _, _ string) error { - return m.err -} - -func (m *mockDeployer) RemoveIngressHost(_ context.Context, _, _ string) error { - return m.err -} - func setupInfraHandler() (*InfrastructureHandler, *mockGitRepository, *mockDNSProvider, *mockDeployer, chi.Router) { git := newMockGitRepository() dns := newMockDNSProvider() diff --git a/internal/handlers/keys.go b/internal/handlers/keys.go index ee07ac9..7c84a9b 100644 --- a/internal/handlers/keys.go +++ b/internal/handlers/keys.go @@ -1,7 +1,6 @@ package handlers import ( - "encoding/json" "errors" "net" "net/http" @@ -126,8 +125,8 @@ func (h *KeysHandler) List(w http.ResponseWriter, r *http.Request) { // POST /keys func (h *KeysHandler) Create(w http.ResponseWriter, r *http.Request) { var req CreateKeyRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - api.WriteBadRequest(w, r, "Invalid JSON body") + if err := api.DecodeJSON(r, &req); err != nil { + api.WriteBadRequest(w, r, "invalid request body") return } diff --git a/internal/handlers/project_management.go b/internal/handlers/project_management.go index 25a71a5..092a5dd 100644 --- a/internal/handlers/project_management.go +++ b/internal/handlers/project_management.go @@ -3,11 +3,9 @@ package handlers import ( "context" - "encoding/json" "errors" "log/slog" "net/http" - "time" "github.com/go-chi/chi/v5" "github.com/orchard9/rdev/internal/domain" @@ -57,9 +55,7 @@ type CreateRequest struct { // Create creates a new project with git repo and DNS. // POST /project func (h *ProjectManagementHandler) Create(w http.ResponseWriter, r *http.Request) { - // 90 second timeout to allow for Woodpecker sync retry (up to 45s) - // plus Gitea repo creation, DNS, and template seeding - ctx, cancel := context.WithTimeout(r.Context(), 90*time.Second) + ctx, cancel := context.WithTimeout(r.Context(), TimeoutOrchestration) defer cancel() if h.infraService == nil { @@ -68,7 +64,7 @@ func (h *ProjectManagementHandler) Create(w http.ResponseWriter, r *http.Request } var req CreateRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + if err := api.DecodeJSON(r, &req); err != nil { api.WriteBadRequest(w, r, "invalid request body") return } @@ -116,7 +112,7 @@ func (h *ProjectManagementHandler) Create(w http.ResponseWriter, r *http.Request // List returns all projects. // GET /project func (h *ProjectManagementHandler) List(w http.ResponseWriter, r *http.Request) { - ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + ctx, cancel := context.WithTimeout(r.Context(), TimeoutLookup) defer cancel() if h.infraService == nil { @@ -161,7 +157,7 @@ func (h *ProjectManagementHandler) List(w http.ResponseWriter, r *http.Request) // GET /project/{name} func (h *ProjectManagementHandler) Status(w http.ResponseWriter, r *http.Request) { name := chi.URLParam(r, "name") - ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + ctx, cancel := context.WithTimeout(r.Context(), TimeoutLookup) defer cancel() if h.infraService == nil { @@ -208,7 +204,7 @@ func (h *ProjectManagementHandler) Status(w http.ResponseWriter, r *http.Request // DELETE /project/{name} func (h *ProjectManagementHandler) Delete(w http.ResponseWriter, r *http.Request) { name := chi.URLParam(r, "name") - ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second) + ctx, cancel := context.WithTimeout(r.Context(), TimeoutHeavyWrite) defer cancel() if h.infraService == nil { @@ -237,7 +233,7 @@ func (h *ProjectManagementHandler) Delete(w http.ResponseWriter, r *http.Request // ListTemplates returns available project templates. // GET /templates func (h *ProjectManagementHandler) ListTemplates(w http.ResponseWriter, r *http.Request) { - ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + ctx, cancel := context.WithTimeout(r.Context(), TimeoutLookup) defer cancel() if h.infraService == nil { @@ -270,7 +266,7 @@ func (h *ProjectManagementHandler) ListTemplates(w http.ResponseWriter, r *http. // GET /templates/{name} func (h *ProjectManagementHandler) GetTemplate(w http.ResponseWriter, r *http.Request) { name := chi.URLParam(r, "name") - ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + ctx, cancel := context.WithTimeout(r.Context(), TimeoutLookup) defer cancel() if h.infraService == nil { diff --git a/internal/handlers/projects.go b/internal/handlers/projects.go index 4a1def5..285186a 100644 --- a/internal/handlers/projects.go +++ b/internal/handlers/projects.go @@ -108,7 +108,7 @@ func getClientIP(r *http.Request) string { // List returns all available projects. // GET /projects func (h *ProjectsHandler) List(w http.ResponseWriter, r *http.Request) { - ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + ctx, cancel := context.WithTimeout(r.Context(), TimeoutFastLookup) defer cancel() // Use new service if available @@ -141,7 +141,7 @@ func (h *ProjectsHandler) List(w http.ResponseWriter, r *http.Request) { // GET /projects/{id} func (h *ProjectsHandler) Get(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") - ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + ctx, cancel := context.WithTimeout(r.Context(), TimeoutFastLookup) defer cancel() // Use new service if available diff --git a/internal/handlers/projects_commands.go b/internal/handlers/projects_commands.go index 8178d6b..ad384ae 100644 --- a/internal/handlers/projects_commands.go +++ b/internal/handlers/projects_commands.go @@ -3,7 +3,6 @@ package handlers import ( "context" - "encoding/json" "errors" "fmt" "net/http" @@ -32,7 +31,7 @@ func (h *ProjectsHandler) RunClaude(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") var req ClaudeRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + if err := api.DecodeJSON(r, &req); err != nil { api.WriteBadRequest(w, r, "invalid request body") return } @@ -143,7 +142,7 @@ func (h *ProjectsHandler) RunShell(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") var req ShellRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + if err := api.DecodeJSON(r, &req); err != nil { api.WriteBadRequest(w, r, "invalid request body") return } @@ -251,7 +250,7 @@ func (h *ProjectsHandler) RunGit(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") var req GitRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + if err := api.DecodeJSON(r, &req); err != nil { api.WriteBadRequest(w, r, "invalid request body") return } @@ -349,7 +348,7 @@ func (h *ProjectsHandler) RunGit(w http.ResponseWriter, r *http.Request) { // executeCommand runs a command and streams output to subscribers. func (h *ProjectsHandler) executeCommand(cmd *domain.Command, podName string) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + ctx, cancel := context.WithTimeout(context.Background(), TimeoutLongRunning) defer cancel() cmdID := string(cmd.ID) diff --git a/internal/handlers/queue.go b/internal/handlers/queue.go index 440c2ce..f2b3329 100644 --- a/internal/handlers/queue.go +++ b/internal/handlers/queue.go @@ -79,7 +79,7 @@ func (h *QueueHandler) Enqueue(w http.ResponseWriter, r *http.Request) { // Parse request var req EnqueueRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + if err := api.DecodeJSON(r, &req); err != nil { api.WriteBadRequest(w, r, "invalid request body") return } diff --git a/internal/handlers/timeouts.go b/internal/handlers/timeouts.go new file mode 100644 index 0000000..3195822 --- /dev/null +++ b/internal/handlers/timeouts.go @@ -0,0 +1,40 @@ +package handlers + +import "time" + +// Handler operation timeout categories. +// +// Use these constants instead of inline magic numbers in context.WithTimeout calls. +// Choose the category that matches the operation's characteristics: +// +// - TimeoutFastLookup: simple reads, single-resource fetches, health checks +// - TimeoutStandard: single-service writes, K8s ops, DNS record ops +// - TimeoutHeavyWrite: multi-step writes with git operations +// - TimeoutOrchestration: multi-service coordination (create project, deploy) +// - TimeoutLongRunning: agent/command execution, async tasks +const ( + // TimeoutFastLookup is for simple reads and lookups (DB queries, K8s get/list, health checks). + // 5 seconds. If a read takes longer, something is wrong. + TimeoutFastLookup = 5 * time.Second + + // TimeoutLookup is for API lookups that may involve a remote call (Woodpecker list, template fetch). + // 10 seconds. Slightly more headroom for external API calls. + TimeoutLookup = 10 * time.Second + + // TimeoutStandard is for single-service write operations (K8s apply, DNS create, git push). + // 30 seconds. Most write operations complete well within this. + TimeoutStandard = 30 * time.Second + + // TimeoutHeavyWrite is for multi-step write operations (component add with git clone + template + push). + // 60 seconds. Git operations over network can be slow. + TimeoutHeavyWrite = 60 * time.Second + + // TimeoutOrchestration is for operations that coordinate multiple services sequentially + // (project creation: repo + DNS + template + CI activation). + // 90 seconds. Longest synchronous handler operation. + TimeoutOrchestration = 90 * time.Second + + // TimeoutLongRunning is for agent/command execution that streams output. + // 10 minutes. Claude Code commands can run extended operations. + TimeoutLongRunning = 10 * time.Minute +) diff --git a/internal/handlers/webhooks.go b/internal/handlers/webhooks.go index 5a0a4b5..9343883 100644 --- a/internal/handlers/webhooks.go +++ b/internal/handlers/webhooks.go @@ -4,7 +4,6 @@ package handlers import ( "crypto/rand" "encoding/hex" - "encoding/json" "errors" "fmt" "net/http" @@ -105,7 +104,7 @@ func (h *WebhookHandler) Create(w http.ResponseWriter, r *http.Request) { // Parse request var req CreateWebhookRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + if err := api.DecodeJSON(r, &req); err != nil { api.WriteBadRequest(w, r, "invalid request body") return } @@ -280,7 +279,7 @@ func (h *WebhookHandler) Update(w http.ResponseWriter, r *http.Request) { // Parse request var req UpdateWebhookRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + if err := api.DecodeJSON(r, &req); err != nil { api.WriteBadRequest(w, r, "invalid request body") return } diff --git a/internal/handlers/woodpecker_webhook.go b/internal/handlers/woodpecker_webhook.go index ce6dd19..3c49c7d 100644 --- a/internal/handlers/woodpecker_webhook.go +++ b/internal/handlers/woodpecker_webhook.go @@ -12,7 +12,6 @@ import ( "log/slog" "net/http" "strings" - "time" "github.com/orchard9/rdev/internal/domain" "github.com/orchard9/rdev/internal/port" @@ -116,7 +115,7 @@ type WoodpeckerPipeline struct { // HandleWebhook processes incoming Woodpecker webhooks. // POST /webhooks/woodpecker func (h *WoodpeckerWebhookHandler) HandleWebhook(w http.ResponseWriter, r *http.Request) { - ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second) + ctx, cancel := context.WithTimeout(r.Context(), TimeoutHeavyWrite) defer cancel() // Read body @@ -137,7 +136,7 @@ func (h *WoodpeckerWebhookHandler) HandleWebhook(w http.ResponseWriter, r *http. signature := r.Header.Get("X-Woodpecker-Signature") if !h.verifySignature(body, signature) { h.logger.Warn("webhook signature verification failed") - api.WriteError(w, r, http.StatusUnauthorized, "UNAUTHORIZED", "invalid signature") + api.WriteUnauthorized(w, r, "invalid signature") return } } @@ -146,7 +145,7 @@ func (h *WoodpeckerWebhookHandler) HandleWebhook(w http.ResponseWriter, r *http. var payload WoodpeckerPayload if err := json.Unmarshal(body, &payload); err != nil { h.logger.Error("failed to parse webhook payload", "error", err) - api.WriteBadRequest(w, r, "invalid JSON payload") + api.WriteBadRequest(w, r, "invalid request body") return } diff --git a/internal/handlers/work.go b/internal/handlers/work.go index b3b346c..490cf41 100644 --- a/internal/handlers/work.go +++ b/internal/handlers/work.go @@ -2,7 +2,6 @@ package handlers import ( - "encoding/json" "errors" "fmt" "net/http" @@ -71,18 +70,17 @@ type EnqueueWorkResponse struct { // POST /work/enqueue func (h *WorkHandler) Enqueue(w http.ResponseWriter, r *http.Request) { var req EnqueueWorkRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + if err := api.DecodeJSON(r, &req); err != nil { api.WriteBadRequest(w, r, "invalid request body") return } // Validate required fields - if req.ProjectID == "" { - api.WriteBadRequest(w, r, "project_id is required") - return - } - if req.TaskType == "" { - api.WriteBadRequest(w, r, "task_type is required") + v := validate.New() + v.Required(req.ProjectID, "project_id") + v.Required(req.TaskType, "task_type") + if err := v.Error(); err != nil { + api.WriteBadRequest(w, r, err.Error()) return } @@ -191,7 +189,7 @@ func toWorkTaskDTO(t *domain.WorkTask) *WorkTaskDTO { // POST /work/dequeue func (h *WorkHandler) Dequeue(w http.ResponseWriter, r *http.Request) { var req DequeueWorkRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + if err := api.DecodeJSON(r, &req); err != nil { api.WriteBadRequest(w, r, "invalid request body") return } @@ -264,7 +262,7 @@ func (h *WorkHandler) Complete(w http.ResponseWriter, r *http.Request) { taskID := chi.URLParam(r, "taskId") var req CompleteWorkRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + if err := api.DecodeJSON(r, &req); err != nil { api.WriteBadRequest(w, r, "invalid request body") return } @@ -301,7 +299,7 @@ func (h *WorkHandler) Fail(w http.ResponseWriter, r *http.Request) { taskID := chi.URLParam(r, "taskId") var req FailWorkRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + if err := api.DecodeJSON(r, &req); err != nil { api.WriteBadRequest(w, r, "invalid request body") return } diff --git a/internal/port/component.go b/internal/port/component.go new file mode 100644 index 0000000..bacbac7 --- /dev/null +++ b/internal/port/component.go @@ -0,0 +1,28 @@ +// Package port defines interfaces (ports) for external dependencies. +package port + +import ( + "context" + + "github.com/orchard9/rdev/internal/domain" +) + +// ComponentService manages components within monorepo projects. +type ComponentService interface { + // AddComponent adds a new component to a project's monorepo. + AddComponent(ctx context.Context, projectID string, req AddComponentRequest) (*domain.Component, error) + + // ListComponents lists all components in a project's monorepo. + ListComponents(ctx context.Context, projectID string) ([]domain.Component, error) + + // RemoveComponent removes a component from a project's monorepo. + RemoveComponent(ctx context.Context, projectID string, componentPath string) error +} + +// AddComponentRequest contains the parameters for adding a component. +type AddComponentRequest struct { + Type string `json:"type"` // service, worker, app-astro, app-react, cli + Name string `json:"name"` // component name (slug format) + Template string `json:"template"` // optional: specific template variant + Port int `json:"port"` // optional: specific port (auto-assigned if 0) +} diff --git a/internal/port/deployer.go b/internal/port/deployer.go index a87b773..b0796af 100644 --- a/internal/port/deployer.go +++ b/internal/port/deployer.go @@ -11,26 +11,47 @@ import ( type Deployer interface { // Deploy creates or updates a deployment for a project. // This includes creating/updating Deployment, Service, and Ingress resources. + // For monorepo projects with ComponentPath set, creates component-specific resources. Deploy(ctx context.Context, spec domain.DeploySpec) error // Undeploy removes all deployment resources for a project. Undeploy(ctx context.Context, projectName string) error + // UndeployComponent removes deployment resources for a specific component. + // The componentPath is the path within the monorepo (e.g., "services/auth-api"). + UndeployComponent(ctx context.Context, projectName, componentPath string) error + // GetStatus returns the current deployment status for a project. // Returns nil if no deployment exists. GetStatus(ctx context.Context, projectName string) (*domain.DeployStatus, error) + // GetComponentStatus returns deployment status for a specific component. + // Returns nil if no deployment exists for the component. + GetComponentStatus(ctx context.Context, projectName, componentPath string) (*domain.DeployStatus, error) + + // ListComponentStatuses returns deployment status for all components in a project. + ListComponentStatuses(ctx context.Context, projectName string) (*domain.ProjectDeployStatus, error) + // Restart triggers a rolling restart of the deployment. // This is useful for picking up new images with the same tag. Restart(ctx context.Context, projectName string) error + // RestartComponent triggers a rolling restart of a specific component. + RestartComponent(ctx context.Context, projectName, componentPath string) error + // Scale adjusts the replica count for a deployment. Scale(ctx context.Context, projectName string, replicas int) error + // ScaleComponent adjusts the replica count for a component. + ScaleComponent(ctx context.Context, projectName, componentPath string, replicas int) error + // GetLogs returns recent logs from the deployment pods. // tailLines specifies how many recent lines to return. GetLogs(ctx context.Context, projectName string, tailLines int) (string, error) + // GetComponentLogs returns recent logs from a specific component's pods. + GetComponentLogs(ctx context.Context, projectName, componentPath string, tailLines int) (string, error) + // AddIngressHost adds a new host to an existing project's ingress. // This is used when adding domain aliases to a project. // The host is added to both the TLS configuration and the routing rules. diff --git a/internal/port/template_provider.go b/internal/port/template_provider.go index de47b3f..472f76c 100644 --- a/internal/port/template_provider.go +++ b/internal/port/template_provider.go @@ -10,11 +10,37 @@ type TemplateProvider interface { // vars contains template variables for interpolation (e.g., PROJECT_NAME, DOMAIN). SeedRepo(ctx context.Context, owner, repo, templateName string, vars map[string]string) error + // SeedSkeleton populates a repository with the monorepo skeleton template. + // This creates the base monorepo structure without any components. + // Components are added later via POST /projects/{id}/components. + SeedSkeleton(ctx context.Context, owner, repo string, vars map[string]string) error + // ListTemplates returns available templates. ListTemplates(ctx context.Context) ([]TemplateInfo, error) // GetTemplate returns info about a specific template. GetTemplate(ctx context.Context, name string) (*TemplateInfo, error) + + // GetSkeleton returns info about the monorepo skeleton template. + GetSkeleton(ctx context.Context) (*TemplateInfo, error) + + // GetComponentTemplate returns info about a specific component template. + // componentType is one of: service, worker, app-astro, app-react, cli + GetComponentTemplate(ctx context.Context, componentType string) (*ComponentTemplateInfo, error) + + // ListComponentTemplates returns available component templates. + // If componentType is empty, returns all component templates. + // Otherwise, returns only templates matching the specified type. + ListComponentTemplates(ctx context.Context, componentType string) ([]ComponentTemplateInfo, error) + + // GetComponentFiles returns the files for a component template with variables interpolated. + // destPath is the destination path prefix (e.g., "services/my-api" or "apps/landing"). + // vars contains template variables for interpolation. + GetComponentFiles(ctx context.Context, componentType string, destPath string, vars map[string]string) ([]ComponentFile, error) + + // GetComponentWoodpeckerStep returns the .woodpecker.step.yml content for a component. + // This is the CI step that should be inserted into the main .woodpecker.yml file. + GetComponentWoodpeckerStep(ctx context.Context, componentType string, vars map[string]string) (string, error) } // TemplateInfo describes an available project template. @@ -31,3 +57,33 @@ type TemplateInfo struct { // Files lists the files included in the template Files []string } + +// ComponentTemplateInfo describes a component template for monorepo projects. +type ComponentTemplateInfo struct { + // Type is the component type (e.g., "service", "worker", "app-astro", "app-react", "cli") + Type string + + // Description explains what the component provides + Description string + + // Stack indicates the technology stack (e.g., "go", "astro", "react") + Stack string + + // DefaultPort is the default port for this component type (0 if not applicable) + DefaultPort int + + // DestDir is the destination directory for this component type (e.g., "services", "workers", "apps", "cli") + DestDir string + + // Files lists the files included in the template + Files []string +} + +// ComponentFile represents a file to be created for a component. +type ComponentFile struct { + // Path is the file path relative to the repository root + Path string + + // Content is the file content (already interpolated with variables) + Content string +} diff --git a/internal/service/component.go b/internal/service/component.go new file mode 100644 index 0000000..3f844bf --- /dev/null +++ b/internal/service/component.go @@ -0,0 +1,499 @@ +// Package service provides business logic services. +package service + +import ( + "context" + "database/sql" + "encoding/base64" + "fmt" + "log/slog" + "path/filepath" + "regexp" + "strconv" + "strings" + + giteaadapter "github.com/orchard9/rdev/internal/adapter/gitea" + "github.com/orchard9/rdev/internal/domain" + "github.com/orchard9/rdev/internal/port" +) + +// procfilePatterns contains pre-compiled regex patterns for parsing Procfile entries +// by component type. Compiled once at package init for performance. +var procfilePatterns = make(map[domain.ComponentType]*regexp.Regexp) + +func init() { + // Pre-compile regex patterns for each component type's Procfile entries + for _, ct := range domain.ValidComponentTypes { + destDir := ct.DestDir() + if destDir != "" { + // Pattern matches: "component-name: cd services/component-name && ..." + procfilePatterns[ct] = regexp.MustCompile(`^([a-z][a-z0-9-]*): cd (` + destDir + `/[a-z0-9-]+)`) + } + } +} + +// ComponentService manages components within monorepo projects. +type ComponentService struct { + db *sql.DB + templateProvider port.TemplateProvider + bulkClient *giteaadapter.BulkFileClient + defaultGitOwner string + logger *slog.Logger +} + +// ComponentServiceConfig configures the component service. +type ComponentServiceConfig struct { + DefaultGitOwner string // e.g., "threesix" + Logger *slog.Logger +} + +// NewComponentService creates a new component service. +func NewComponentService( + db *sql.DB, + templateProvider port.TemplateProvider, + bulkClient *giteaadapter.BulkFileClient, + cfg ComponentServiceConfig, +) *ComponentService { + logger := cfg.Logger + if logger == nil { + logger = slog.Default() + } + return &ComponentService{ + db: db, + templateProvider: templateProvider, + bulkClient: bulkClient, + defaultGitOwner: cfg.DefaultGitOwner, + logger: logger, + } +} + +// Ensure ComponentService implements the interface. +var _ port.ComponentService = (*ComponentService)(nil) + +// AddComponent adds a new component to a project's monorepo. +func (s *ComponentService) AddComponent(ctx context.Context, projectID string, req port.AddComponentRequest) (*domain.Component, error) { + // 1. Validate component type + if !domain.IsValidComponentType(req.Type) { + return nil, fmt.Errorf("%w: %s", domain.ErrInvalidComponentType, req.Type) + } + componentType := domain.ComponentType(req.Type) + + // 2. Validate component name + if err := domain.ValidateComponentName(req.Name); err != nil { + return nil, fmt.Errorf("%w: %s", err, req.Name) + } + + // 3. Get project info from database + var gitRepoOwner, gitRepoName, goModule string + var projectDomain string + err := s.db.QueryRowContext(ctx, ` + SELECT COALESCE(git_repo_owner, $2), COALESCE(git_repo_name, $1), COALESCE(domain, '') + FROM projects WHERE id = $1 + `, projectID, s.defaultGitOwner).Scan(&gitRepoOwner, &gitRepoName, &projectDomain) + if err == sql.ErrNoRows { + return nil, fmt.Errorf("%w: %s", domain.ErrProjectNotFound, projectID) + } + if err != nil { + return nil, fmt.Errorf("failed to get project: %w", err) + } + + // Build Go module path + goModule = fmt.Sprintf("github.com/%s/%s", gitRepoOwner, gitRepoName) + + // 4. Calculate component path + destDir := componentType.DestDir() + componentPath := filepath.Join(destDir, req.Name) + + // 5. Check for duplicate component by checking for key files + checkFile := componentPath + "/go.mod" + if componentType == domain.ComponentTypeAppAstro || componentType == domain.ComponentTypeAppReact { + checkFile = componentPath + "/package.json" + } + existingContent, _, err := s.bulkClient.GetFileContent(ctx, gitRepoOwner, gitRepoName, checkFile) + if err != nil { + return nil, fmt.Errorf("failed to check for existing component: %w", err) + } + if existingContent != nil { + return nil, fmt.Errorf("%w: %s", domain.ErrDuplicateComponent, componentPath) + } + + // 6. Assign port if needed + port := req.Port + if port == 0 && componentType.NeedsPort() { + port, err = s.assignPort(ctx, projectID, componentType) + if err != nil { + return nil, fmt.Errorf("failed to assign port: %w", err) + } + } + + // 7. Prepare template variables + vars := map[string]string{ + "PROJECT_NAME": projectID, + "GO_MODULE": goModule, + "COMPONENT_NAME": req.Name, + "PORT": strconv.Itoa(port), + "DOMAIN": projectDomain, + } + + // 8. Get component template files + componentFiles, err := s.templateProvider.GetComponentFiles(ctx, req.Type, componentPath, vars) + if err != nil { + return nil, fmt.Errorf("failed to get component template files: %w", err) + } + + // 9. Read and update monorepo files + fileOps, err := s.prepareMonorepoUpdates(ctx, gitRepoOwner, gitRepoName, componentType, req.Name, componentPath, port, vars) + if err != nil { + return nil, fmt.Errorf("failed to prepare monorepo updates: %w", err) + } + + // 10. Add component files to the operations + for _, cf := range componentFiles { + // Skip the .woodpecker.step.yml file - it's merged into main .woodpecker.yml + if strings.HasSuffix(cf.Path, ".woodpecker.step.yml") { + continue + } + encodedContent := base64.StdEncoding.EncodeToString([]byte(cf.Content)) + fileOps = append(fileOps, giteaadapter.ChangeFileOperation{ + Operation: "create", + Path: cf.Path, + Content: encodedContent, + }) + } + + // 11. Commit all files in a single atomic commit + opts := giteaadapter.ChangeFilesOptions{ + Files: fileOps, + Message: fmt.Sprintf("Add %s component: %s", req.Type, req.Name), + } + + _, err = s.bulkClient.ChangeFiles(ctx, gitRepoOwner, gitRepoName, opts) + if err != nil { + return nil, fmt.Errorf("failed to commit component files: %w", err) + } + + s.logger.Info("component added successfully", + "project", projectID, + "component_type", req.Type, + "component_name", req.Name, + "path", componentPath, + "port", port, + ) + + // 12. Build and return the component + component := &domain.Component{ + Type: componentType, + Name: req.Name, + Path: componentPath, + Port: port, + Template: req.Type, + Dependencies: []string{}, // Could be parsed from component.yaml + } + + return component, nil +} + +// assignPort finds the next available port for a component type. +func (s *ComponentService) assignPort(ctx context.Context, projectID string, componentType domain.ComponentType) (int, error) { + // Get existing components to find the highest used port + components, err := s.ListComponents(ctx, projectID) + if err != nil { + return 0, err + } + + startingPort := componentType.StartingPort() + if startingPort == 0 { + return 0, nil // Component type doesn't need a port + } + + maxPort := startingPort - 1 + for _, c := range components { + // Only consider components that share the same port range + if c.Type.StartingPort() == startingPort && c.Port > maxPort { + maxPort = c.Port + } + } + + return maxPort + 1, nil +} + +// prepareMonorepoUpdates reads existing monorepo files and prepares updates. +func (s *ComponentService) prepareMonorepoUpdates( + ctx context.Context, + owner, repo string, + componentType domain.ComponentType, + componentName, componentPath string, + port int, + vars map[string]string, +) ([]giteaadapter.ChangeFileOperation, error) { + var fileOps []giteaadapter.ChangeFileOperation + + // 1. Update Procfile + procfileContent, procfileSHA, err := s.bulkClient.GetFileContent(ctx, owner, repo, "Procfile") + if err != nil { + return nil, fmt.Errorf("failed to get Procfile: %w", err) + } + if procfileContent != nil { + updated := s.updateProcfile(string(procfileContent), componentType, componentName, componentPath, port) + fileOps = append(fileOps, giteaadapter.ChangeFileOperation{ + Operation: "update", + Path: "Procfile", + Content: base64.StdEncoding.EncodeToString([]byte(updated)), + SHA: procfileSHA, + }) + } + + // 2. Update go.work for Go components + if componentType.IsGoComponent() { + goWorkContent, goWorkSHA, err := s.bulkClient.GetFileContent(ctx, owner, repo, "go.work") + if err != nil { + return nil, fmt.Errorf("failed to get go.work: %w", err) + } + if goWorkContent != nil { + updated := s.updateGoWork(string(goWorkContent), componentPath) + fileOps = append(fileOps, giteaadapter.ChangeFileOperation{ + Operation: "update", + Path: "go.work", + Content: base64.StdEncoding.EncodeToString([]byte(updated)), + SHA: goWorkSHA, + }) + } + } + + // 3. Update .woodpecker.yml + woodpeckerContent, woodpeckerSHA, err := s.bulkClient.GetFileContent(ctx, owner, repo, ".woodpecker.yml") + if err != nil { + return nil, fmt.Errorf("failed to get .woodpecker.yml: %w", err) + } + if woodpeckerContent != nil { + // Get the CI step template for this component type + stepYaml, err := s.templateProvider.GetComponentWoodpeckerStep(ctx, string(componentType), vars) + if err != nil { + s.logger.Warn("failed to get woodpecker step template", "error", err) + } else { + updated := s.updateWoodpeckerYml(string(woodpeckerContent), stepYaml) + fileOps = append(fileOps, giteaadapter.ChangeFileOperation{ + Operation: "update", + Path: ".woodpecker.yml", + Content: base64.StdEncoding.EncodeToString([]byte(updated)), + SHA: woodpeckerSHA, + }) + } + } + + // 4. Update CLAUDE.md + claudeMdContent, claudeMdSHA, err := s.bulkClient.GetFileContent(ctx, owner, repo, "CLAUDE.md") + if err != nil { + return nil, fmt.Errorf("failed to get CLAUDE.md: %w", err) + } + if claudeMdContent != nil { + updated := s.updateClaudeMd(string(claudeMdContent), componentType, componentName, componentPath) + fileOps = append(fileOps, giteaadapter.ChangeFileOperation{ + Operation: "update", + Path: "CLAUDE.md", + Content: base64.StdEncoding.EncodeToString([]byte(updated)), + SHA: claudeMdSHA, + }) + } + + return fileOps, nil +} + +// ListComponents lists all components in a project's monorepo. +func (s *ComponentService) ListComponents(ctx context.Context, projectID string) ([]domain.Component, error) { + // Get project info from database + var gitRepoOwner, gitRepoName string + err := s.db.QueryRowContext(ctx, ` + SELECT COALESCE(git_repo_owner, $2), COALESCE(git_repo_name, $1) + FROM projects WHERE id = $1 + `, projectID, s.defaultGitOwner).Scan(&gitRepoOwner, &gitRepoName) + if err == sql.ErrNoRows { + return nil, fmt.Errorf("%w: %s", domain.ErrProjectNotFound, projectID) + } + if err != nil { + return nil, fmt.Errorf("failed to get project: %w", err) + } + + // Read Procfile once (not inside the loop) + procfileContent, _, err := s.bulkClient.GetFileContent(ctx, gitRepoOwner, gitRepoName, "Procfile") + if err != nil { + s.logger.Warn("failed to read Procfile", "error", err) + return []domain.Component{}, nil + } + if procfileContent == nil { + return []domain.Component{}, nil + } + + var components []domain.Component + procfileStr := string(procfileContent) + + // Check each component type's directory + for _, ct := range domain.ValidComponentTypes { + destDir := ct.DestDir() + if destDir == "" { + continue + } + + // Parse Procfile to extract component info for this type + comps := s.parseComponentsFromProcfile(procfileStr, ct) + components = append(components, comps...) + } + + return components, nil +} + +// parseComponentsFromProcfile extracts component information from a Procfile. +// Ports are assigned incrementally based on discovery order within each component type. +func (s *ComponentService) parseComponentsFromProcfile(procfile string, componentType domain.ComponentType) []domain.Component { + var components []domain.Component + + // Use pre-compiled pattern from package-level map + pattern, ok := procfilePatterns[componentType] + if !ok { + return components + } + + startingPort := componentType.StartingPort() + portOffset := 0 + + for _, line := range strings.Split(procfile, "\n") { + matches := pattern.FindStringSubmatch(strings.TrimSpace(line)) + if len(matches) >= 3 { + name := matches[1] + path := matches[2] + + // Assign ports incrementally based on discovery order + port := 0 + if componentType.NeedsPort() { + port = startingPort + portOffset + portOffset++ + } + + components = append(components, domain.Component{ + Type: componentType, + Name: name, + Path: path, + Port: port, + Template: string(componentType), + Dependencies: []string{}, + }) + } + } + + return components +} + +// RemoveComponent removes a component from a project's monorepo. +func (s *ComponentService) RemoveComponent(ctx context.Context, projectID string, componentPath string) error { + // Get project info from database + var gitRepoOwner, gitRepoName string + err := s.db.QueryRowContext(ctx, ` + SELECT COALESCE(git_repo_owner, $2), COALESCE(git_repo_name, $1) + FROM projects WHERE id = $1 + `, projectID, s.defaultGitOwner).Scan(&gitRepoOwner, &gitRepoName) + if err == sql.ErrNoRows { + return fmt.Errorf("%w: %s", domain.ErrProjectNotFound, projectID) + } + if err != nil { + return fmt.Errorf("failed to get project: %w", err) + } + + // Verify component exists by checking for a file in the path + checkFile := componentPath + "/go.mod" + content, _, err := s.bulkClient.GetFileContent(ctx, gitRepoOwner, gitRepoName, checkFile) + if err != nil { + return fmt.Errorf("failed to check component: %w", err) + } + if content == nil { + // Try package.json for frontend apps + checkFile = componentPath + "/package.json" + content, _, err = s.bulkClient.GetFileContent(ctx, gitRepoOwner, gitRepoName, checkFile) + if err != nil { + return fmt.Errorf("failed to check component: %w", err) + } + if content == nil { + return fmt.Errorf("%w: %s", domain.ErrComponentNotFound, componentPath) + } + } + + // Extract component name from path + componentName := filepath.Base(componentPath) + + // Determine component type from path + var componentType domain.ComponentType + switch { + case strings.HasPrefix(componentPath, "services/"): + componentType = domain.ComponentTypeService + case strings.HasPrefix(componentPath, "workers/"): + componentType = domain.ComponentTypeWorker + case strings.HasPrefix(componentPath, "apps/"): + // Could be astro or react - check package.json later + componentType = domain.ComponentTypeAppAstro + case strings.HasPrefix(componentPath, "cli/"): + componentType = domain.ComponentTypeCLI + default: + return fmt.Errorf("unknown component path structure: %s", componentPath) + } + + // For now, we'll update the monorepo files to remove references + // Actual file deletion would require listing all files in the directory + // which the Gitea API doesn't support easily + + var fileOps []giteaadapter.ChangeFileOperation + + // 1. Update Procfile - remove the component entry + procfileContent, procfileSHA, err := s.bulkClient.GetFileContent(ctx, gitRepoOwner, gitRepoName, "Procfile") + if err != nil { + return fmt.Errorf("failed to get Procfile: %w", err) + } + if procfileContent != nil { + updated := s.removeProcfileEntry(string(procfileContent), componentName) + fileOps = append(fileOps, giteaadapter.ChangeFileOperation{ + Operation: "update", + Path: "Procfile", + Content: base64.StdEncoding.EncodeToString([]byte(updated)), + SHA: procfileSHA, + }) + } + + // 2. Update go.work if Go component + if componentType.IsGoComponent() { + goWorkContent, goWorkSHA, err := s.bulkClient.GetFileContent(ctx, gitRepoOwner, gitRepoName, "go.work") + if err != nil { + return fmt.Errorf("failed to get go.work: %w", err) + } + if goWorkContent != nil { + updated := s.removeGoWorkEntry(string(goWorkContent), componentPath) + fileOps = append(fileOps, giteaadapter.ChangeFileOperation{ + Operation: "update", + Path: "go.work", + Content: base64.StdEncoding.EncodeToString([]byte(updated)), + SHA: goWorkSHA, + }) + } + } + + // Note: Removing from .woodpecker.yml and CLAUDE.md is more complex + // because we'd need to parse YAML and markdown tables properly. + // For now, we'll leave those as manual cleanup tasks. + + if len(fileOps) > 0 { + opts := giteaadapter.ChangeFilesOptions{ + Files: fileOps, + Message: fmt.Sprintf("Remove component references: %s", componentName), + } + + _, err = s.bulkClient.ChangeFiles(ctx, gitRepoOwner, gitRepoName, opts) + if err != nil { + return fmt.Errorf("failed to commit changes: %w", err) + } + } + + s.logger.Info("component removed", + "project", projectID, + "path", componentPath, + "note", "Component files remain in repo - delete manually if needed", + ) + + return nil +} diff --git a/internal/service/component_updates.go b/internal/service/component_updates.go new file mode 100644 index 0000000..35bfd04 --- /dev/null +++ b/internal/service/component_updates.go @@ -0,0 +1,171 @@ +package service + +import ( + "fmt" + "strings" + + "github.com/orchard9/rdev/internal/domain" +) + +// updateProcfile adds an entry for the component. +func (s *ComponentService) updateProcfile(existing string, componentType domain.ComponentType, componentName, componentPath string, _ int) string { + var entry string + + switch componentType { + case domain.ComponentTypeService: + entry = fmt.Sprintf("%s: cd %s && make run", componentName, componentPath) + case domain.ComponentTypeWorker: + entry = fmt.Sprintf("%s: cd %s && make run", componentName, componentPath) + case domain.ComponentTypeAppAstro, domain.ComponentTypeAppReact: + entry = fmt.Sprintf("%s: cd %s && npm run dev", componentName, componentPath) + case domain.ComponentTypeCLI: + // CLIs don't run as processes + return existing + } + + // Add the entry before any empty line at the end, or at the end + lines := strings.Split(strings.TrimRight(existing, "\n"), "\n") + lines = append(lines, entry) + return strings.Join(lines, "\n") + "\n" +} + +// updateGoWork adds a use directive for Go components. +func (s *ComponentService) updateGoWork(existing, componentPath string) string { + useLine := fmt.Sprintf("use ./%s", componentPath) + + // Check if already present + if strings.Contains(existing, useLine) { + return existing + } + + // Find where to insert: after the last 'use' line or after 'go X.XX' + lines := strings.Split(existing, "\n") + insertIdx := len(lines) - 1 + + // Find the last use statement + for i := len(lines) - 1; i >= 0; i-- { + trimmed := strings.TrimSpace(lines[i]) + if strings.HasPrefix(trimmed, "use ") { + insertIdx = i + 1 + break + } + if strings.HasPrefix(trimmed, "go ") { + insertIdx = i + 1 + } + } + + // Insert the new use line + newLines := make([]string, 0, len(lines)+1) + newLines = append(newLines, lines[:insertIdx]...) + newLines = append(newLines, useLine) + newLines = append(newLines, lines[insertIdx:]...) + + return strings.Join(newLines, "\n") +} + +// updateWoodpeckerYml inserts the component step at the COMPONENT_STEPS_BELOW marker. +func (s *ComponentService) updateWoodpeckerYml(existing, stepYaml string) string { + marker := "# COMPONENT_STEPS_BELOW" + + if !strings.Contains(existing, marker) { + s.logger.Warn("COMPONENT_STEPS_BELOW marker not found in .woodpecker.yml") + return existing + } + + // Indent the step YAML properly (2 spaces for YAML steps) + var sb strings.Builder + lines := strings.Split(strings.TrimSpace(stepYaml), "\n") + for _, line := range lines { + sb.WriteString(" ") + sb.WriteString(line) + sb.WriteString("\n") + } + + // Insert after the marker + return strings.Replace(existing, marker, marker+"\n\n"+strings.TrimRight(sb.String(), "\n"), 1) +} + +// updateClaudeMd adds the component to the routing table. +func (s *ComponentService) updateClaudeMd(existing string, componentType domain.ComponentType, componentName, componentPath string) string { + // Find the "## Components" section and add entry + marker := "" + + var description string + switch componentType { + case domain.ComponentTypeService: + description = "API service" + case domain.ComponentTypeWorker: + description = "Background worker" + case domain.ComponentTypeAppAstro: + description = "Astro app" + case domain.ComponentTypeAppReact: + description = "React app" + case domain.ComponentTypeCLI: + description = "CLI tool" + } + + entry := fmt.Sprintf("| **%s** | %s | `%s/` |", componentName, description, componentPath) + + if strings.Contains(existing, marker) { + // First component - replace the marker with a table + table := fmt.Sprintf(`| Component | Type | Path | +|-----------|------|------| +%s +`, entry) + return strings.Replace(existing, marker, table, 1) + } + + // Add to existing table - find the last table row in ## Components section + lines := strings.Split(existing, "\n") + inComponents := false + insertIdx := -1 + + for i, line := range lines { + if strings.HasPrefix(line, "## Components") { + inComponents = true + continue + } + if inComponents && strings.HasPrefix(line, "## ") { + // End of Components section + insertIdx = i + break + } + if inComponents && strings.HasPrefix(line, "|") { + insertIdx = i + 1 + } + } + + if insertIdx > 0 { + // Insert the new entry + newLines := make([]string, 0, len(lines)+1) + newLines = append(newLines, lines[:insertIdx]...) + newLines = append(newLines, entry) + newLines = append(newLines, lines[insertIdx:]...) + return strings.Join(newLines, "\n") + } + + return existing +} + +// removeProcfileEntry removes a component entry from the Procfile. +func (s *ComponentService) removeProcfileEntry(procfile, componentName string) string { + var lines []string + for _, line := range strings.Split(procfile, "\n") { + if !strings.HasPrefix(strings.TrimSpace(line), componentName+":") { + lines = append(lines, line) + } + } + return strings.Join(lines, "\n") +} + +// removeGoWorkEntry removes a use directive from go.work. +func (s *ComponentService) removeGoWorkEntry(goWork, componentPath string) string { + useLine := "use ./" + componentPath + var lines []string + for _, line := range strings.Split(goWork, "\n") { + if strings.TrimSpace(line) != useLine { + lines = append(lines, line) + } + } + return strings.Join(lines, "\n") +} diff --git a/internal/service/project_infra.go b/internal/service/project_infra.go index 9f5ff6b..09370ff 100644 --- a/internal/service/project_infra.go +++ b/internal/service/project_infra.go @@ -113,7 +113,7 @@ type CreateProjectRequest struct { Name string Description string Private bool - Template string // Template to seed the repo with (default: "default") + Template string // Template to seed the repo with (default: "skeleton" for composable monorepos) CustomSubdomain string // Optional: custom subdomain (e.g., "my-app" for my-app.threesix.ai) } diff --git a/internal/service/project_infra_crud.go b/internal/service/project_infra_crud.go index d74f9f2..5131f95 100644 --- a/internal/service/project_infra_crud.go +++ b/internal/service/project_infra_crud.go @@ -288,13 +288,18 @@ func (s *ProjectInfraService) seedTemplate(ctx context.Context, req CreateProjec templateName := req.Template if templateName == "" { - templateName = "default" + templateName = "skeleton" // Default to composable monorepo skeleton } + // Build Go module path for the project + goModule := fmt.Sprintf("github.com/%s/%s", result.GitRepoOwner, result.GitRepoName) + vars := map[string]string{ "PROJECT_NAME": req.Name, "DOMAIN": result.Domain, "GIT_URL": result.CloneHTTP, + "DESCRIPTION": req.Description, + "GO_MODULE": goModule, } err := s.templateProvider.SeedRepo(ctx, result.GitRepoOwner, result.GitRepoName, templateName, vars) @@ -458,6 +463,7 @@ var templateDefaultPorts = map[string]int{ "astro-landing": 80, // nginx static server "default": 80, // nginx static server "go-api": 8080, // Go API server + "skeleton": 8080, // monorepo skeleton (Go services default) } func templateDefaultPort(templateName string) int { diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go index 3265879..68508cb 100644 --- a/internal/telemetry/telemetry.go +++ b/internal/telemetry/telemetry.go @@ -16,9 +16,9 @@ import ( "fmt" "log/slog" "os" - "strings" "time" + "github.com/orchard9/rdev/internal/envutil" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" @@ -63,11 +63,11 @@ type Config struct { // DefaultConfig returns configuration with defaults applied. func DefaultConfig() Config { return Config{ - Endpoint: getEnv("OTEL_EXPORTER_OTLP_ENDPOINT", "otel-collector.observability.svc:4317"), - ServiceName: getEnv("OTEL_SERVICE_NAME", "rdev-api"), - ServiceVersion: getEnv("OTEL_SERVICE_VERSION", "unknown"), - ServiceNamespace: getEnv("OTEL_SERVICE_NAMESPACE", "rdev"), - Enabled: getEnvBool("OTEL_ENABLED", true), + Endpoint: envutil.GetEnv("OTEL_EXPORTER_OTLP_ENDPOINT", "otel-collector.observability.svc:4317"), + ServiceName: envutil.GetEnv("OTEL_SERVICE_NAME", "rdev-api"), + ServiceVersion: envutil.GetEnv("OTEL_SERVICE_VERSION", "unknown"), + ServiceNamespace: envutil.GetEnv("OTEL_SERVICE_NAMESPACE", "rdev"), + Enabled: envutil.GetEnvBool("OTEL_ENABLED", true), Insecure: true, } } @@ -122,7 +122,7 @@ func New(ctx context.Context, cfg Config) (*Telemetry, error) { semconv.ServiceName(cfg.ServiceName), semconv.ServiceVersion(cfg.ServiceVersion), semconv.ServiceNamespace(cfg.ServiceNamespace), - attribute.String("deployment.environment", getEnv("ENVIRONMENT", "production")), + attribute.String("deployment.environment", envutil.GetEnv("ENVIRONMENT", "production")), ) // Create tracer provider with batch span processor @@ -209,21 +209,3 @@ func SetSpanAttributes(ctx context.Context, attrs ...attribute.KeyValue) { span := trace.SpanFromContext(ctx) span.SetAttributes(attrs...) } - -// getEnv returns the environment variable value or the default. -func getEnv(key, defaultVal string) string { - if v := os.Getenv(key); v != "" { - return v - } - return defaultVal -} - -// getEnvBool returns the environment variable as bool or the default. -func getEnvBool(key string, defaultVal bool) bool { - v := os.Getenv(key) - if v == "" { - return defaultVal - } - v = strings.ToLower(v) - return v == "true" || v == "1" || v == "yes" -} diff --git a/internal/telemetry/telemetry_test.go b/internal/telemetry/telemetry_test.go index b4317aa..054f9db 100644 --- a/internal/telemetry/telemetry_test.go +++ b/internal/telemetry/telemetry_test.go @@ -7,6 +7,8 @@ import ( "os" "testing" "time" + + "github.com/orchard9/rdev/internal/envutil" ) func TestDefaultConfig(t *testing.T) { @@ -165,9 +167,9 @@ func TestGetEnvBool(t *testing.T) { os.Setenv("TEST_BOOL", tt.value) defer os.Unsetenv("TEST_BOOL") - result := getEnvBool("TEST_BOOL", false) + result := envutil.GetEnvBool("TEST_BOOL", false) if result != tt.expected { - t.Errorf("getEnvBool(%q) = %v, want %v", tt.value, result, tt.expected) + t.Errorf("GetEnvBool(%q) = %v, want %v", tt.value, result, tt.expected) } }) } diff --git a/pkg/api/decode.go b/pkg/api/decode.go new file mode 100644 index 0000000..7a7ed48 --- /dev/null +++ b/pkg/api/decode.go @@ -0,0 +1,63 @@ +package api + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" +) + +var ( + // ErrEmptyBody is returned when the request body is empty or nil. + ErrEmptyBody = errors.New("request body is empty") + // ErrInvalidJSON is returned when the request body contains invalid JSON. + ErrInvalidJSON = errors.New("invalid JSON") +) + +// DecodeJSON decodes JSON from the request body into v. +// Returns descriptive errors for common failure cases: +// - nil or empty body → ErrEmptyBody +// - malformed JSON → ErrInvalidJSON +// +// Usage: +// +// if err := api.DecodeJSON(r, &req); err != nil { +// api.WriteBadRequest(w, r, "invalid request body") +// return +// } +func DecodeJSON(r *http.Request, v any) error { + if r.Body == nil { + return ErrEmptyBody + } + + decoder := json.NewDecoder(r.Body) + if err := decoder.Decode(v); err != nil { + if errors.Is(err, io.EOF) { + return ErrEmptyBody + } + return fmt.Errorf("%w: %w", ErrInvalidJSON, err) + } + + return nil +} + +// DecodeJSONStrict decodes JSON from the request body into v. +// Rejects JSON containing fields not present in the target struct. +func DecodeJSONStrict(r *http.Request, v any) error { + if r.Body == nil { + return ErrEmptyBody + } + + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + + if err := decoder.Decode(v); err != nil { + if errors.Is(err, io.EOF) { + return ErrEmptyBody + } + return fmt.Errorf("%w: %w", ErrInvalidJSON, err) + } + + return nil +} diff --git a/pkg/api/response.go b/pkg/api/response.go index df272cc..e39a27a 100644 --- a/pkg/api/response.go +++ b/pkg/api/response.go @@ -83,6 +83,16 @@ func WriteNotFound(w http.ResponseWriter, r *http.Request, message string) { WriteError(w, r, http.StatusNotFound, "NOT_FOUND", message) } +// WriteUnauthorized writes a 401 Unauthorized error response. +func WriteUnauthorized(w http.ResponseWriter, r *http.Request, message string) { + WriteError(w, r, http.StatusUnauthorized, "UNAUTHORIZED", message) +} + +// WriteForbidden writes a 403 Forbidden error response. +func WriteForbidden(w http.ResponseWriter, r *http.Request, message string) { + WriteError(w, r, http.StatusForbidden, "FORBIDDEN", message) +} + // WriteInternalError writes a 500 Internal Server Error response. func WriteInternalError(w http.ResponseWriter, r *http.Request, message string) { WriteError(w, r, http.StatusInternalServerError, "INTERNAL_ERROR", message)