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 <noreply@anthropic.com>
This commit is contained in:
parent
c59d348040
commit
8282d60c69
@ -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
|
- **500-line limit:** Files exceeding 500 lines must be split
|
||||||
- **Tests:** All handlers and services require tests
|
- **Tests:** All handlers and services require tests
|
||||||
- **Multi-step ops:** NEVER log-and-continue after partial failure. Rollback or document partial state.
|
- **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
|
## Quick Reference
|
||||||
|
|
||||||
|
|||||||
@ -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
|
- Optional `component.yaml` per component for ports, dependencies, build order
|
||||||
- Shared `pkg/` from Aeries chassis + Colix patterns (8 packages)
|
- Shared `pkg/` from Aeries chassis + Colix patterns (8 packages)
|
||||||
- Deployment supports whole-monorepo or individual-component targets
|
- 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:**
|
**File Pointers:**
|
||||||
- Plan: `tmp/template-monorepo-plan.md`
|
- Plan: `tmp/template-monorepo-plan.md`
|
||||||
@ -30,6 +35,7 @@ POST /projects {"name": "acme"}
|
|||||||
Creates monorepo skeleton:
|
Creates monorepo skeleton:
|
||||||
- CLAUDE.md, README.md, Procfile
|
- CLAUDE.md, README.md, Procfile
|
||||||
- docker-compose.yml, go.work, .golangci.yml
|
- docker-compose.yml, go.work, .golangci.yml
|
||||||
|
- .woodpecker.yml (template-provided CI)
|
||||||
- scripts/ (discover, install, quality, dev)
|
- scripts/ (discover, install, quality, dev)
|
||||||
- pkg/ (8 shared packages from Aeries + Colix)
|
- pkg/ (8 shared packages from Aeries + Colix)
|
||||||
- .claude/ (guides, skills, commands)
|
- .claude/ (guides, skills, commands)
|
||||||
@ -49,6 +55,7 @@ Auto-updates:
|
|||||||
- Procfile (add service entry)
|
- Procfile (add service entry)
|
||||||
- go.work (add module)
|
- go.work (add module)
|
||||||
- CLAUDE.md (add routing)
|
- CLAUDE.md (add routing)
|
||||||
|
- .woodpecker.yml (add build step for component)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Monorepo Structure
|
### Monorepo Structure
|
||||||
|
|||||||
@ -4,9 +4,9 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"github.com/orchard9/rdev/internal/domain"
|
"github.com/orchard9/rdev/internal/domain"
|
||||||
|
"github.com/orchard9/rdev/internal/envutil"
|
||||||
"github.com/orchard9/rdev/internal/port"
|
"github.com/orchard9/rdev/internal/port"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -75,28 +75,14 @@ type InfraConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func loadConfig() Config {
|
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{
|
return Config{
|
||||||
Port: port,
|
Port: envutil.GetEnvInt("PORT", 8080),
|
||||||
DBHost: getEnv("DB_HOST", "postgres.databases.svc"),
|
DBHost: envutil.GetEnv("DB_HOST", "postgres.databases.svc"),
|
||||||
DBPort: dbPort,
|
DBPort: envutil.GetEnvInt("DB_PORT", 5432),
|
||||||
DBUser: getEnv("DB_USER", "appuser"),
|
DBUser: envutil.GetEnv("DB_USER", "appuser"),
|
||||||
DBPassword: os.Getenv("DB_PASSWORD"),
|
DBPassword: os.Getenv("DB_PASSWORD"),
|
||||||
DBName: getEnv("DB_NAME", "rdev"),
|
DBName: envutil.GetEnv("DB_NAME", "rdev"),
|
||||||
DBSSLMode: getEnv("DB_SSL_MODE", "disable"),
|
DBSSLMode: envutil.GetEnv("DB_SSL_MODE", "disable"),
|
||||||
AdminKey: os.Getenv("RDEV_ADMIN_KEY"),
|
AdminKey: os.Getenv("RDEV_ADMIN_KEY"),
|
||||||
|
|
||||||
// Encryption key for credential store (generate with: openssl rand -base64 32)
|
// Encryption key for credential store (generate with: openssl rand -base64 32)
|
||||||
@ -105,33 +91,26 @@ func loadConfig() Config {
|
|||||||
|
|
||||||
// OpenCode (optional alternative code agent)
|
// OpenCode (optional alternative code agent)
|
||||||
OpenCodeURL: os.Getenv("OPENCODE_URL"), // e.g., "http://opencode:4096"
|
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"),
|
OpenCodePassword: os.Getenv("OPENCODE_PASSWORD"),
|
||||||
|
|
||||||
// Infrastructure adapters (fallback if not in credential store)
|
// 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"),
|
GiteaToken: os.Getenv("GITEA_TOKEN"),
|
||||||
GiteaDefaultOrg: getEnv("GITEA_DEFAULT_ORG", "jordan"),
|
GiteaDefaultOrg: envutil.GetEnv("GITEA_DEFAULT_ORG", "jordan"),
|
||||||
CloudflareToken: os.Getenv("CLOUDFLARE_API_TOKEN"),
|
CloudflareToken: os.Getenv("CLOUDFLARE_API_TOKEN"),
|
||||||
CloudflareZoneID: os.Getenv("CLOUDFLARE_ZONE_ID"),
|
CloudflareZoneID: os.Getenv("CLOUDFLARE_ZONE_ID"),
|
||||||
DefaultDomain: getEnv("DEFAULT_DOMAIN", "threesix.ai"),
|
DefaultDomain: envutil.GetEnv("DEFAULT_DOMAIN", "threesix.ai"),
|
||||||
DeployNamespace: getEnv("DEPLOY_NAMESPACE", "projects"),
|
DeployNamespace: envutil.GetEnv("DEPLOY_NAMESPACE", "projects"),
|
||||||
DeployTLSIssuer: getEnv("DEPLOY_TLS_ISSUER", "letsencrypt-prod"),
|
DeployTLSIssuer: envutil.GetEnv("DEPLOY_TLS_ISSUER", "letsencrypt-prod"),
|
||||||
ClusterIP: getEnv("CLUSTER_IP", "208.122.204.172"),
|
ClusterIP: envutil.GetEnv("CLUSTER_IP", "208.122.204.172"),
|
||||||
RegistryURL: getEnv("REGISTRY_URL", "zot.threesix.svc.cluster.local:5000"),
|
RegistryURL: envutil.GetEnv("REGISTRY_URL", "zot.threesix.svc.cluster.local:5000"),
|
||||||
WoodpeckerURL: getEnv("WOODPECKER_URL", "https://ci.threesix.ai"),
|
WoodpeckerURL: envutil.GetEnv("WOODPECKER_URL", "https://ci.threesix.ai"),
|
||||||
WoodpeckerAPIToken: os.Getenv("WOODPECKER_API_TOKEN"),
|
WoodpeckerAPIToken: os.Getenv("WOODPECKER_API_TOKEN"),
|
||||||
WoodpeckerWebhookSecret: os.Getenv("WOODPECKER_WEBHOOK_SECRET"),
|
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,
|
// loadInfraConfig loads infrastructure configuration from credential store,
|
||||||
// falling back to environment variables if not found in the 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 {
|
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
|
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{
|
infraCfg := InfraConfig{
|
||||||
GiteaURL: getOrFallback(domain.CredKeyGiteaURL, cfg.GiteaURL),
|
GiteaURL: getOrFallback(domain.CredKeyGiteaURL, cfg.GiteaURL),
|
||||||
GiteaToken: getOrFallback(domain.CredKeyGiteaToken, cfg.GiteaToken),
|
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)
|
// CockroachDB and Redis provisioners (env-only for now)
|
||||||
CRDBHost: os.Getenv("CRDB_HOST"), // e.g., "cockroachdb-public.databases.svc"
|
CRDBHost: os.Getenv("CRDB_HOST"), // e.g., "cockroachdb-public.databases.svc"
|
||||||
CRDBPort: crdbPort,
|
CRDBPort: envutil.GetEnvInt("CRDB_PORT", 26257),
|
||||||
CRDBUser: getEnv("CRDB_USER", "root"),
|
CRDBUser: envutil.GetEnv("CRDB_USER", "root"),
|
||||||
CRDBSSLMode: getEnv("CRDB_SSL_MODE", "disable"),
|
CRDBSSLMode: envutil.GetEnv("CRDB_SSL_MODE", "disable"),
|
||||||
RedisHost: os.Getenv("REDIS_HOST"), // e.g., "redis.threesix.svc"
|
RedisHost: os.Getenv("REDIS_HOST"), // e.g., "redis.threesix.svc"
|
||||||
RedisPort: redisPort,
|
RedisPort: envutil.GetEnvInt("REDIS_PORT", 6379),
|
||||||
RedisPassword: os.Getenv("REDIS_PASSWORD"),
|
RedisPassword: os.Getenv("REDIS_PASSWORD"),
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -211,3 +176,15 @@ func loadInfraConfig(ctx context.Context, store port.CredentialStore, cfg Config
|
|||||||
|
|
||||||
return infraCfg
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,36 +1,4 @@
|
|||||||
// Package main provides the entry point for the rdev API server.
|
// 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
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@ -54,6 +22,7 @@ import (
|
|||||||
"github.com/orchard9/rdev/internal/adapter/woodpecker"
|
"github.com/orchard9/rdev/internal/adapter/woodpecker"
|
||||||
"github.com/orchard9/rdev/internal/auth"
|
"github.com/orchard9/rdev/internal/auth"
|
||||||
"github.com/orchard9/rdev/internal/db"
|
"github.com/orchard9/rdev/internal/db"
|
||||||
|
"github.com/orchard9/rdev/internal/envutil"
|
||||||
"github.com/orchard9/rdev/internal/handlers"
|
"github.com/orchard9/rdev/internal/handlers"
|
||||||
"github.com/orchard9/rdev/internal/metrics"
|
"github.com/orchard9/rdev/internal/metrics"
|
||||||
"github.com/orchard9/rdev/internal/middleware"
|
"github.com/orchard9/rdev/internal/middleware"
|
||||||
@ -120,7 +89,7 @@ func main() {
|
|||||||
infraCfg := loadInfraConfig(context.Background(), credentialStore, cfg, logger)
|
infraCfg := loadInfraConfig(context.Background(), credentialStore, cfg, logger)
|
||||||
|
|
||||||
// Create adapters (dependency injection)
|
// Create adapters (dependency injection)
|
||||||
namespace := getEnv("K8S_NAMESPACE", "rdev")
|
namespace := envutil.GetEnv("K8S_NAMESPACE", "rdev")
|
||||||
|
|
||||||
// Initialize K8s client for dynamic project discovery
|
// Initialize K8s client for dynamic project discovery
|
||||||
// Falls back gracefully if K8s is unavailable (e.g., local development)
|
// Falls back gracefully if K8s is unavailable (e.g., local development)
|
||||||
@ -138,27 +107,18 @@ func main() {
|
|||||||
k8sExecutor := kubernetes.NewExecutor(namespace)
|
k8sExecutor := kubernetes.NewExecutor(namespace)
|
||||||
streamPub := memory.NewStreamPublisher()
|
streamPub := memory.NewStreamPublisher()
|
||||||
|
|
||||||
// Start watching for project pod changes if K8s client is available
|
|
||||||
if k8sClient != nil {
|
if k8sClient != nil {
|
||||||
if err := projectRepo.StartWatching(context.Background()); err != nil {
|
if err := projectRepo.StartWatching(context.Background()); err != nil {
|
||||||
logger.Warn("failed to start project watcher", "error", err)
|
logger.Warn("failed to start project watcher", "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize audit logger
|
|
||||||
auditLogger := postgres.NewAuditLogger(database.DB)
|
auditLogger := postgres.NewAuditLogger(database.DB)
|
||||||
|
|
||||||
// Initialize rate limiter
|
|
||||||
rateLimiter := postgres.NewRateLimiter(database.DB)
|
rateLimiter := postgres.NewRateLimiter(database.DB)
|
||||||
stopRateLimitCleanup := rateLimiter.StartCleanupWorker(context.Background(), 5*time.Minute)
|
stopRateLimitCleanup := rateLimiter.StartCleanupWorker(context.Background(), 5*time.Minute)
|
||||||
|
|
||||||
// Initialize command queue
|
|
||||||
commandQueue := postgres.NewCommandQueueRepository(database.DB)
|
commandQueue := postgres.NewCommandQueueRepository(database.DB)
|
||||||
|
|
||||||
// Initialize work queue (for worker pool tasks)
|
|
||||||
workQueueRepo := postgres.NewWorkQueueRepository(database.DB)
|
workQueueRepo := postgres.NewWorkQueueRepository(database.DB)
|
||||||
|
|
||||||
// Initialize webhook repository and dispatcher
|
|
||||||
webhookRepo := postgres.NewWebhookRepository(database.DB)
|
webhookRepo := postgres.NewWebhookRepository(database.DB)
|
||||||
webhookDispatcher := webhook.NewDispatcher(webhookRepo, &webhook.DispatcherConfig{
|
webhookDispatcher := webhook.NewDispatcher(webhookRepo, &webhook.DispatcherConfig{
|
||||||
WorkerCount: 10,
|
WorkerCount: 10,
|
||||||
@ -172,8 +132,7 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize infrastructure adapters (optional - only if configured)
|
// Infrastructure adapters (optional - only if configured)
|
||||||
// Uses infraCfg which loads from credential store with env var fallback
|
|
||||||
var giteaClient *gitea.Client
|
var giteaClient *gitea.Client
|
||||||
if infraCfg.GiteaToken != "" && infraCfg.GiteaURL != "" {
|
if infraCfg.GiteaToken != "" && infraCfg.GiteaURL != "" {
|
||||||
var err error
|
var err error
|
||||||
@ -384,6 +343,23 @@ func main() {
|
|||||||
// Initialize project management handler
|
// Initialize project management handler
|
||||||
projectMgmtHandler := handlers.NewProjectManagementHandler(projectInfraService, logger)
|
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)
|
// Initialize Woodpecker webhook handler (for CI/CD auto-deploy)
|
||||||
woodpeckerHandler := handlers.NewWoodpeckerWebhookHandler(
|
woodpeckerHandler := handlers.NewWoodpeckerWebhookHandler(
|
||||||
deployerAdapter,
|
deployerAdapter,
|
||||||
@ -425,6 +401,9 @@ func main() {
|
|||||||
workHandler.Mount(app.Router())
|
workHandler.Mount(app.Router())
|
||||||
infraHandler.Mount(app.Router())
|
infraHandler.Mount(app.Router())
|
||||||
projectMgmtHandler.Mount(app.Router())
|
projectMgmtHandler.Mount(app.Router())
|
||||||
|
if componentsHandler != nil {
|
||||||
|
componentsHandler.Mount(app.Router())
|
||||||
|
}
|
||||||
woodpeckerHandler.Mount(app.Router())
|
woodpeckerHandler.Mount(app.Router())
|
||||||
credentialsHandler.Mount(app.Router())
|
credentialsHandler.Mount(app.Router())
|
||||||
agentsHandler.Mount(app.Router())
|
agentsHandler.Mount(app.Router())
|
||||||
@ -493,47 +472,18 @@ func main() {
|
|||||||
// Enable API documentation
|
// Enable API documentation
|
||||||
app.EnableDocs(buildOpenAPISpec())
|
app.EnableDocs(buildOpenAPISpec())
|
||||||
|
|
||||||
// Cleanup on shutdown
|
|
||||||
app.OnShutdown(func(ctx context.Context) error {
|
app.OnShutdown(func(ctx context.Context) error {
|
||||||
// Stop work executor (deregisters worker)
|
|
||||||
workExecutor.Stop()
|
workExecutor.Stop()
|
||||||
|
|
||||||
// Stop queue maintenance worker
|
|
||||||
queueMaintenance.Stop()
|
queueMaintenance.Stop()
|
||||||
|
|
||||||
// Stop queue processor
|
|
||||||
queueProcessor.Stop()
|
queueProcessor.Stop()
|
||||||
|
|
||||||
// Stop webhook dispatcher
|
|
||||||
webhookDispatcher.Stop()
|
webhookDispatcher.Stop()
|
||||||
|
|
||||||
// Stop project watcher
|
|
||||||
projectRepo.StopWatching()
|
projectRepo.StopWatching()
|
||||||
|
|
||||||
// Stop rate limit cleanup worker
|
|
||||||
stopRateLimitCleanup()
|
stopRateLimitCleanup()
|
||||||
|
closeProvisioner(dbProvisioner, "database", logger)
|
||||||
// Close database and cache provisioners
|
closeProvisioner(cacheProvisioner, "cache", logger)
|
||||||
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)
|
|
||||||
if err := tel.Shutdown(ctx); err != nil {
|
if err := tel.Shutdown(ctx); err != nil {
|
||||||
logger.Error("telemetry shutdown error", "error", err)
|
logger.Error("telemetry shutdown error", "error", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return database.Close()
|
return database.Close()
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -547,5 +497,4 @@ func main() {
|
|||||||
app.Run()
|
app.Run()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Config, InfraConfig, loadConfig, loadInfraConfig, and getEnv
|
// Config, InfraConfig, loadConfig, loadInfraConfig are defined in config.go.
|
||||||
// are defined in config.go.
|
|
||||||
|
|||||||
423
cookbooks/composable-app.md
Normal file
423
cookbooks/composable-app.md
Normal file
@ -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="<your-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)
|
||||||
@ -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="<your-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)
|
|
||||||
@ -1,28 +1,26 @@
|
|||||||
# Landing Page Cookbook
|
# 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
|
## 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 at https://{slug}.threesix.ai
|
||||||
↓
|
|
||||||
Live site
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**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="<your-api-key>"
|
|||||||
- rdev-api running with embedded worker
|
- rdev-api running with embedded worker
|
||||||
- Gitea at https://git.threesix.ai
|
- Gitea at https://git.threesix.ai
|
||||||
- Woodpecker CI at https://ci.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
|
## Step 1: Create Project (Monorepo Skeleton)
|
||||||
|
|
||||||
Single API call that creates infrastructure AND enqueues agent work:
|
|
||||||
|
|
||||||
```bash
|
```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 "X-API-Key: $RDEV_API_KEY" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{
|
-d '{
|
||||||
"name": "my-landing",
|
"name": "my-landing",
|
||||||
"description": "Company landing page",
|
"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
|
|
||||||
}'
|
}'
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -71,53 +63,79 @@ curl -X POST "$RDEV_API_URL/project/create-and-build" \
|
|||||||
"owner": "jordan",
|
"owner": "jordan",
|
||||||
"name": "my-landing",
|
"name": "my-landing",
|
||||||
"html_url": "https://git.threesix.ai/jordan/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
|
```bash
|
||||||
curl -s "$RDEV_API_URL/builds/{task_id}" \
|
curl -s "$RDEV_API_URL/builds/{task_id}" \
|
||||||
-H "X-API-Key: $RDEV_API_KEY" | jq .data
|
-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
|
```bash
|
||||||
curl -s "$RDEV_API_URL/projects/my-landing/pipelines" \
|
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
|
```bash
|
||||||
# Check site is live
|
# 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
|
## Iterating on the Site
|
||||||
|
|
||||||
Submit additional builds to modify 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 "X-API-Key: $RDEV_API_KEY" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{
|
-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_commit": true,
|
||||||
"auto_push": true
|
"auto_push": true
|
||||||
}'
|
}'
|
||||||
@ -211,7 +200,7 @@ curl -s "$RDEV_API_URL/projects/my-landing/domains" \
|
|||||||
## Teardown
|
## Teardown
|
||||||
|
|
||||||
```bash
|
```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"
|
-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 │
|
│ ├──► Cloudflare: creates DNS │
|
||||||
│ ├──► Woodpecker: activates CI │
|
│ ├──► Woodpecker: activates CI │
|
||||||
│ ├──► K8s: creates Deployment/Service/Ingress │
|
│ └──► Skeleton includes .woodpecker.yml (template-provided) │
|
||||||
│ └──► 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 │
|
|
||||||
│ │
|
│ │
|
||||||
|
│ 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
|
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
|
## 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)
|
- [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)
|
||||||
|
|||||||
194
cookbooks/scripts/composable-test.sh
Executable file
194
cookbooks/scripts/composable-test.sh
Executable file
@ -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 <command> <project-name>
|
||||||
|
# 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 <command> <project-name>"
|
||||||
|
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
|
||||||
@ -1,202 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# Full-Stack App E2E Test Script
|
|
||||||
# Usage: ./cookbooks/scripts/fullstack-test.sh <command> <project-name>
|
|
||||||
# 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 <command> <project-name>"
|
|
||||||
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
|
|
||||||
@ -1,6 +1,12 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Landing Page Cookbook Test Script
|
# 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:
|
# Usage:
|
||||||
# ./cookbooks/scripts/landing-test.sh run [name] # Run the full flow
|
# ./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"; }
|
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
|
||||||
|
|
||||||
# Timeouts
|
# 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_TIMEOUT=300 # 5 minutes max wait for CI pipeline
|
||||||
PIPELINE_POLL_INTERVAL=10
|
PIPELINE_POLL_INTERVAL=10
|
||||||
SITE_TIMEOUT=60 # 1 minute max wait for site to be live
|
SITE_TIMEOUT=120 # 2 minutes max wait for site to be live
|
||||||
|
|
||||||
# Streaming mode (set to true to stream live build output via SSE)
|
|
||||||
STREAM_MODE="${STREAM_MODE:-false}"
|
|
||||||
|
|
||||||
api_call() {
|
api_call() {
|
||||||
local method="$1"
|
local method="$1"
|
||||||
@ -65,136 +66,7 @@ check_health() {
|
|||||||
fi
|
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
|
# Wait for pipeline to appear and complete
|
||||||
# Returns: 0 on success, 1 on failure/timeout
|
|
||||||
wait_for_pipeline() {
|
wait_for_pipeline() {
|
||||||
local project_name="$1"
|
local project_name="$1"
|
||||||
local start_time=$(date +%s)
|
local start_time=$(date +%s)
|
||||||
@ -214,7 +86,7 @@ wait_for_pipeline() {
|
|||||||
local response
|
local response
|
||||||
response=$(api_call GET "/projects/$project_name/pipelines" 2>/dev/null || echo "{}")
|
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
|
local pipeline_count
|
||||||
pipeline_count=$(echo "$response" | jq -r '.data | length' 2>/dev/null || echo "0")
|
pipeline_count=$(echo "$response" | jq -r '.data | length' 2>/dev/null || echo "0")
|
||||||
|
|
||||||
@ -230,10 +102,12 @@ wait_for_pipeline() {
|
|||||||
|
|
||||||
case "$pipeline_status" in
|
case "$pipeline_status" in
|
||||||
success)
|
success)
|
||||||
|
echo ""
|
||||||
log_success "Pipeline #$pipeline_number completed successfully (${elapsed}s)"
|
log_success "Pipeline #$pipeline_number completed successfully (${elapsed}s)"
|
||||||
return 0
|
return 0
|
||||||
;;
|
;;
|
||||||
failure|error|killed|declined)
|
failure|error|killed|declined)
|
||||||
|
echo ""
|
||||||
log_error "Pipeline #$pipeline_number failed with status: $pipeline_status"
|
log_error "Pipeline #$pipeline_number failed with status: $pipeline_status"
|
||||||
echo "$response" | jq '.data[0]'
|
echo "$response" | jq '.data[0]'
|
||||||
return 1
|
return 1
|
||||||
@ -254,7 +128,6 @@ wait_for_pipeline() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Wait for site to be accessible
|
# Wait for site to be accessible
|
||||||
# Returns: 0 on success, 1 on failure/timeout
|
|
||||||
wait_for_site() {
|
wait_for_site() {
|
||||||
local domain="$1"
|
local domain="$1"
|
||||||
local start_time=$(date +%s)
|
local start_time=$(date +%s)
|
||||||
@ -285,393 +158,167 @@ wait_for_site() {
|
|||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
# Test adding a DNS alias
|
# Main run flow
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
run_flow() {
|
run_flow() {
|
||||||
local project_name="${1:-landing-test}"
|
local project_name="$1"
|
||||||
|
|
||||||
# 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."
|
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "=========================================="
|
echo "=========================================="
|
||||||
echo " Landing Page Cookbook Test"
|
echo " Landing Page E2E Test (Composable)"
|
||||||
echo " Project: $project_name"
|
|
||||||
echo " Flow: Agent-driven (create-and-build)"
|
|
||||||
echo "=========================================="
|
echo "=========================================="
|
||||||
echo ""
|
echo ""
|
||||||
|
echo "Project: $project_name"
|
||||||
|
echo ""
|
||||||
|
|
||||||
# Step 0: Health check
|
# Step 0: Health check
|
||||||
check_health || exit 1
|
check_health || exit 1
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Step 1: Create project AND enqueue build in one call
|
# Step 1: Create project (monorepo skeleton)
|
||||||
log_info "Step 1: Creating project and enqueuing build task..."
|
log_info "Step 1: Creating project skeleton..."
|
||||||
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
|
|
||||||
}')
|
|
||||||
|
|
||||||
local create_response
|
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"
|
log_error "Failed to create project"
|
||||||
echo "$create_response" | jq .
|
echo "$create_response" | jq .
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
log_success "Project created and build enqueued"
|
log_success "Project created: $project_name"
|
||||||
echo "$create_response" | jq '.data | {
|
echo " Domain: $domain"
|
||||||
project_id,
|
echo " Git: https://git.threesix.ai/jordan/$project_name"
|
||||||
domain,
|
echo ""
|
||||||
url,
|
|
||||||
git: .git.html_url,
|
|
||||||
task_id,
|
|
||||||
status,
|
|
||||||
status_url
|
|
||||||
}'
|
|
||||||
|
|
||||||
# Extract key info
|
# Step 2: Add app-astro component
|
||||||
local primary_domain
|
log_info "Step 2: Adding landing page component (app-astro)..."
|
||||||
local task_id
|
|
||||||
primary_domain=$(echo "$create_response" | jq -r '.data.domain')
|
|
||||||
task_id=$(echo "$create_response" | jq -r '.data.task_id')
|
|
||||||
|
|
||||||
if [[ -z "$task_id" || "$task_id" == "null" ]]; then
|
local component_response
|
||||||
log_error "No task_id returned - build was not enqueued"
|
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
|
exit 1
|
||||||
fi
|
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 ""
|
echo ""
|
||||||
|
|
||||||
# Step 2: Monitor build progress (Claude building the site)
|
# Step 3: Wait for pipeline
|
||||||
log_info "Step 2: Monitoring build progress..."
|
log_info "Step 3: Waiting for CI pipeline..."
|
||||||
echo ""
|
echo ""
|
||||||
local build_success=false
|
|
||||||
if wait_for_build "$task_id" "$project_name"; then
|
if ! wait_for_pipeline "$project_name"; then
|
||||||
build_success=true
|
log_warn "Pipeline failed, but continuing to check if site is accessible..."
|
||||||
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 ."
|
|
||||||
fi
|
fi
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Step 3: Monitor CI pipeline (only if build succeeded)
|
# Step 4: Wait for site
|
||||||
local pipeline_success=false
|
log_info "Step 4: Verifying site is accessible..."
|
||||||
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: Verify site is live
|
if wait_for_site "$domain"; then
|
||||||
local site_live=false
|
log_success "Site verified!"
|
||||||
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 '<title>|<h1' || true
|
|
||||||
fi
|
|
||||||
else
|
else
|
||||||
log_info "Step 4: Skipping site verification (pipeline not successful)"
|
log_warn "Site not accessible yet, may need more time"
|
||||||
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}'
|
|
||||||
fi
|
fi
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Summary
|
# Summary
|
||||||
echo "=========================================="
|
echo "=========================================="
|
||||||
echo " Test Results Summary"
|
echo " Test Complete"
|
||||||
echo "=========================================="
|
echo "=========================================="
|
||||||
echo ""
|
echo ""
|
||||||
echo " Project: $project_name"
|
echo " Site URL: https://$domain"
|
||||||
echo " Task ID: $task_id"
|
echo " Git: https://git.threesix.ai/jordan/$project_name"
|
||||||
echo " Git repo: $(echo "$create_response" | jq -r '.data.git.html_url // "N/A"')"
|
echo " CI: https://ci.threesix.ai/jordan/$project_name"
|
||||||
echo " Primary: https://$primary_domain"
|
|
||||||
echo ""
|
echo ""
|
||||||
echo " Test Results:"
|
echo " To customize: POST /projects/$project_name/builds with a prompt"
|
||||||
echo -e " Project created: ${GREEN}PASS${NC}"
|
echo " To teardown: $0 teardown $project_name"
|
||||||
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 ""
|
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() {
|
teardown() {
|
||||||
local project_name="${1:-landing-test}"
|
local project_name="$1"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "=========================================="
|
log_info "Tearing down project: $project_name"
|
||||||
echo " Teardown: $project_name"
|
|
||||||
echo "=========================================="
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# 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
|
local response
|
||||||
response=$(api_call DELETE "/project/$project_name")
|
response=$(api_call DELETE "/projects/$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")
|
|
||||||
|
|
||||||
if echo "$response" | jq -e '.error' > /dev/null 2>&1; then
|
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 .
|
echo "$response" | jq .
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "$response" | jq '.data | {
|
log_success "Project deleted (Gitea repo preserved)"
|
||||||
name,
|
echo "$response" | jq '.data // .'
|
||||||
description,
|
|
||||||
domain,
|
|
||||||
url,
|
|
||||||
git: .git.html_url,
|
|
||||||
deployment
|
|
||||||
}'
|
|
||||||
|
|
||||||
echo ""
|
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
|
# Parse command
|
||||||
case "${1:-}" in
|
COMMAND="${1:-}"
|
||||||
|
PROJECT_NAME="${2:-landing-test-$(date +%s)}"
|
||||||
|
|
||||||
|
case "$COMMAND" in
|
||||||
run)
|
run)
|
||||||
shift
|
run_flow "$PROJECT_NAME"
|
||||||
run_flow "${1:-landing-test}"
|
|
||||||
;;
|
|
||||||
teardown)
|
|
||||||
shift
|
|
||||||
teardown "${1:-landing-test}"
|
|
||||||
;;
|
;;
|
||||||
status)
|
status)
|
||||||
shift
|
check_status "$PROJECT_NAME"
|
||||||
status "${1:-landing-test}"
|
;;
|
||||||
|
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 ""
|
||||||
echo "Commands:"
|
echo "Commands:"
|
||||||
echo " run [name] Create project with agent-driven build and run full E2E flow"
|
echo " run Run the full composable landing page flow"
|
||||||
echo " teardown [name] Delete project and clean up"
|
echo " status Check project and component status"
|
||||||
echo " status [name] Check current project status, builds, and pipelines"
|
echo " teardown Delete project (preserves git repo)"
|
||||||
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 ""
|
echo ""
|
||||||
echo "Examples:"
|
echo "Examples:"
|
||||||
echo " $0 run # Run with default project name 'landing-test'"
|
echo " $0 run my-landing"
|
||||||
echo " $0 run my-landing # Run with custom project name"
|
echo " $0 status my-landing"
|
||||||
echo " STREAM_MODE=true $0 run # Run with live build output streaming"
|
echo " $0 teardown my-landing"
|
||||||
echo " $0 status my-landing # Check status, builds, and pipelines"
|
echo ""
|
||||||
echo " $0 teardown my-landing # Clean up project"
|
|
||||||
exit 1
|
exit 1
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|||||||
303
internal/adapter/deployer/deployer_components.go
Normal file
303
internal/adapter/deployer/deployer_components.go
Normal file
@ -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
|
||||||
|
}
|
||||||
@ -41,7 +41,8 @@ func (d *Deployer) ensureNamespace(ctx context.Context) error {
|
|||||||
|
|
||||||
// createOrUpdateSecret manages the secret for environment variables.
|
// createOrUpdateSecret manages the secret for environment variables.
|
||||||
func (d *Deployer) createOrUpdateSecret(ctx context.Context, spec domain.DeploySpec) error {
|
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
|
ns := d.config.Namespace
|
||||||
|
|
||||||
secret := &corev1.Secret{
|
secret := &corev1.Secret{
|
||||||
@ -49,7 +50,7 @@ func (d *Deployer) createOrUpdateSecret(ctx context.Context, spec domain.DeployS
|
|||||||
Name: secretName,
|
Name: secretName,
|
||||||
Namespace: ns,
|
Namespace: ns,
|
||||||
Labels: map[string]string{
|
Labels: map[string]string{
|
||||||
"app": spec.ProjectName,
|
"app": deploymentName,
|
||||||
"project": spec.ProjectName,
|
"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 {
|
func (d *Deployer) createOrUpdateDeployment(ctx context.Context, spec domain.DeploySpec) error {
|
||||||
ns := d.config.Namespace
|
ns := d.config.Namespace
|
||||||
replicas := int32(spec.Replicas)
|
replicas := int32(spec.Replicas)
|
||||||
|
deploymentName := spec.DeploymentName()
|
||||||
|
|
||||||
// Build env vars
|
// Build env vars
|
||||||
var envVars []corev1.EnvVar
|
var envVars []corev1.EnvVar
|
||||||
@ -82,7 +84,7 @@ func (d *Deployer) createOrUpdateDeployment(ctx context.Context, spec domain.Dep
|
|||||||
envFrom = append(envFrom, corev1.EnvFromSource{
|
envFrom = append(envFrom, corev1.EnvFromSource{
|
||||||
SecretRef: &corev1.SecretEnvSource{
|
SecretRef: &corev1.SecretEnvSource{
|
||||||
LocalObjectReference: corev1.LocalObjectReference{
|
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)
|
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) {
|
if errors.IsNotFound(err) {
|
||||||
_, err = d.client.AppsV1().Deployments(ns).Create(ctx, deployment, metav1.CreateOptions{})
|
_, err = d.client.AppsV1().Deployments(ns).Create(ctx, deployment, metav1.CreateOptions{})
|
||||||
} else if err == nil {
|
} 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 {
|
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{
|
return &appsv1.Deployment{
|
||||||
ObjectMeta: metav1.ObjectMeta{
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
Name: spec.ProjectName,
|
Name: deploymentName,
|
||||||
Namespace: ns,
|
Namespace: ns,
|
||||||
Labels: map[string]string{
|
Labels: labels,
|
||||||
"app": spec.ProjectName,
|
|
||||||
"project": spec.ProjectName,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
Spec: appsv1.DeploymentSpec{
|
Spec: appsv1.DeploymentSpec{
|
||||||
Replicas: &replicas,
|
Replicas: &replicas,
|
||||||
Selector: &metav1.LabelSelector{
|
Selector: &metav1.LabelSelector{
|
||||||
MatchLabels: map[string]string{
|
MatchLabels: map[string]string{
|
||||||
"app": spec.ProjectName,
|
"app": deploymentName,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Template: corev1.PodTemplateSpec{
|
Template: corev1.PodTemplateSpec{
|
||||||
ObjectMeta: metav1.ObjectMeta{
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
Labels: map[string]string{
|
Labels: labels,
|
||||||
"app": spec.ProjectName,
|
|
||||||
"project": spec.ProjectName,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
Spec: corev1.PodSpec{
|
Spec: corev1.PodSpec{
|
||||||
Containers: []corev1.Container{
|
Containers: []corev1.Container{
|
||||||
{
|
{
|
||||||
Name: spec.ProjectName,
|
Name: deploymentName,
|
||||||
Image: spec.Image,
|
Image: spec.Image,
|
||||||
Env: envVars,
|
Env: envVars,
|
||||||
EnvFrom: envFrom,
|
EnvFrom: envFrom,
|
||||||
@ -157,19 +164,26 @@ func (d *Deployer) buildDeployment(spec domain.DeploySpec, ns string, replicas i
|
|||||||
// createOrUpdateService manages the Kubernetes Service resource.
|
// createOrUpdateService manages the Kubernetes Service resource.
|
||||||
func (d *Deployer) createOrUpdateService(ctx context.Context, spec domain.DeploySpec) error {
|
func (d *Deployer) createOrUpdateService(ctx context.Context, spec domain.DeploySpec) error {
|
||||||
ns := d.config.Namespace
|
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{
|
service := &corev1.Service{
|
||||||
ObjectMeta: metav1.ObjectMeta{
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
Name: spec.ProjectName,
|
Name: deploymentName,
|
||||||
Namespace: ns,
|
Namespace: ns,
|
||||||
Labels: map[string]string{
|
Labels: labels,
|
||||||
"app": spec.ProjectName,
|
|
||||||
"project": spec.ProjectName,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
Spec: corev1.ServiceSpec{
|
Spec: corev1.ServiceSpec{
|
||||||
Selector: map[string]string{
|
Selector: map[string]string{
|
||||||
"app": spec.ProjectName,
|
"app": deploymentName,
|
||||||
},
|
},
|
||||||
Ports: []corev1.ServicePort{
|
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) {
|
if errors.IsNotFound(err) {
|
||||||
_, err = d.client.CoreV1().Services(ns).Create(ctx, service, metav1.CreateOptions{})
|
_, err = d.client.CoreV1().Services(ns).Create(ctx, service, metav1.CreateOptions{})
|
||||||
} else if err == nil {
|
} else if err == nil {
|
||||||
@ -195,6 +209,7 @@ func (d *Deployer) createOrUpdateIngress(ctx context.Context, spec domain.Deploy
|
|||||||
ns := d.config.Namespace
|
ns := d.config.Namespace
|
||||||
pathType := networkingv1.PathTypePrefix
|
pathType := networkingv1.PathTypePrefix
|
||||||
ingressClass := d.config.IngressClass
|
ingressClass := d.config.IngressClass
|
||||||
|
deploymentName := spec.DeploymentName()
|
||||||
|
|
||||||
// Build TLS secret name from domain
|
// Build TLS secret name from domain
|
||||||
tlsSecretName := strings.ReplaceAll(spec.Domain, ".", "-") + "-tls"
|
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)
|
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) {
|
if errors.IsNotFound(err) {
|
||||||
_, err = d.client.NetworkingV1().Ingresses(ns).Create(ctx, ingress, metav1.CreateOptions{})
|
_, err = d.client.NetworkingV1().Ingresses(ns).Create(ctx, ingress, metav1.CreateOptions{})
|
||||||
} else if err == nil {
|
} 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 {
|
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{
|
return &networkingv1.Ingress{
|
||||||
ObjectMeta: metav1.ObjectMeta{
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
Name: spec.ProjectName,
|
Name: deploymentName,
|
||||||
Namespace: ns,
|
Namespace: ns,
|
||||||
Labels: map[string]string{
|
Labels: labels,
|
||||||
"app": spec.ProjectName,
|
|
||||||
"project": spec.ProjectName,
|
|
||||||
},
|
|
||||||
Annotations: annotations,
|
Annotations: annotations,
|
||||||
},
|
},
|
||||||
Spec: networkingv1.IngressSpec{
|
Spec: networkingv1.IngressSpec{
|
||||||
@ -245,7 +268,7 @@ func (d *Deployer) buildIngress(spec domain.DeploySpec, ns string, pathType netw
|
|||||||
PathType: &pathType,
|
PathType: &pathType,
|
||||||
Backend: networkingv1.IngressBackend{
|
Backend: networkingv1.IngressBackend{
|
||||||
Service: &networkingv1.IngressServiceBackend{
|
Service: &networkingv1.IngressServiceBackend{
|
||||||
Name: spec.ProjectName,
|
Name: deploymentName,
|
||||||
Port: networkingv1.ServiceBackendPort{
|
Port: networkingv1.ServiceBackendPort{
|
||||||
Number: int32(spec.Port),
|
Number: int32(spec.Port),
|
||||||
},
|
},
|
||||||
|
|||||||
@ -4,6 +4,7 @@ package gitea
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@ -88,7 +89,9 @@ func NewBulkFileClient(baseURL, token string) *BulkFileClient {
|
|||||||
return &BulkFileClient{
|
return &BulkFileClient{
|
||||||
baseURL: strings.TrimSuffix(baseURL, "/"),
|
baseURL: strings.TrimSuffix(baseURL, "/"),
|
||||||
token: token,
|
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(), "timeout") ||
|
||||||
strings.Contains(err.Error(), "EOF")
|
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
|
||||||
|
}
|
||||||
|
|||||||
@ -36,6 +36,52 @@ var availableTemplates = []port.TemplateInfo{
|
|||||||
{Name: "go-api", Description: "Go REST API with chi router", Stack: "go"},
|
{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).
|
// templateNameRegex validates template names (alphanumeric, dash only).
|
||||||
var templateNameRegex = regexp.MustCompile(`^[a-z][a-z0-9-]*$`)
|
var templateNameRegex = regexp.MustCompile(`^[a-z][a-z0-9-]*$`)
|
||||||
|
|
||||||
@ -247,3 +293,195 @@ func listTemplateFiles(templateName string) ([]string, error) {
|
|||||||
|
|
||||||
return files, err
|
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
|
||||||
|
}
|
||||||
|
|||||||
@ -141,3 +141,48 @@ func TestListTemplateFiles_TmplExtensionStripped(t *testing.T) {
|
|||||||
assert.NotContains(t, f, ".tmpl", "file %s should not have .tmpl extension", f)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@ -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
|
||||||
@ -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;"]
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from 'astro/config';
|
||||||
|
import tailwind from '@astrojs/tailwind';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
integrations: [tailwind()],
|
||||||
|
output: 'static',
|
||||||
|
server: {
|
||||||
|
port: {{PORT}},
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
name: {{COMPONENT_NAME}}
|
||||||
|
type: app
|
||||||
|
port: {{PORT}}
|
||||||
|
path: apps/{{COMPONENT_NAME}}
|
||||||
|
stack: astro
|
||||||
|
dependencies: []
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
After Width: | Height: | Size: 434 B |
@ -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}</title>
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen bg-slate-900 text-slate-100 antialiased">
|
||||||
|
<slot />
|
||||||
|
<script>
|
||||||
|
import '../lib/logger';
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -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);
|
||||||
|
}
|
||||||
@ -0,0 +1,37 @@
|
|||||||
|
---
|
||||||
|
import Layout from '../layouts/Layout.astro';
|
||||||
|
---
|
||||||
|
|
||||||
|
<Layout title="{{COMPONENT_NAME}} | {{PROJECT_NAME}}">
|
||||||
|
<main class="min-h-screen bg-gradient-to-br from-slate-900 to-slate-800">
|
||||||
|
<div class="container mx-auto px-4 py-16">
|
||||||
|
<div class="text-center">
|
||||||
|
<h1 class="text-5xl font-bold text-white mb-6">
|
||||||
|
{{COMPONENT_NAME}}
|
||||||
|
</h1>
|
||||||
|
<p class="text-xl text-slate-300 mb-8 max-w-2xl mx-auto">
|
||||||
|
Welcome to your Astro app. This is part of the
|
||||||
|
<code class="bg-slate-700 px-2 py-1 rounded">{{PROJECT_NAME}}</code> monorepo.
|
||||||
|
</p>
|
||||||
|
<p class="text-slate-400 mb-8">
|
||||||
|
Edit this file at
|
||||||
|
<code class="bg-slate-700 px-2 py-1 rounded">apps/{{COMPONENT_NAME}}/src/pages/index.astro</code>
|
||||||
|
</p>
|
||||||
|
<div class="flex gap-4 justify-center">
|
||||||
|
<a
|
||||||
|
href="https://docs.astro.build"
|
||||||
|
class="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition"
|
||||||
|
>
|
||||||
|
Astro Docs
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="{{GIT_URL}}"
|
||||||
|
class="px-6 py-3 bg-slate-700 text-white rounded-lg hover:bg-slate-600 transition"
|
||||||
|
>
|
||||||
|
View Source
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</Layout>
|
||||||
@ -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: [],
|
||||||
|
};
|
||||||
@ -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
|
||||||
@ -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;"]
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
name: {{COMPONENT_NAME}}
|
||||||
|
type: app
|
||||||
|
port: {{PORT}}
|
||||||
|
path: apps/{{COMPONENT_NAME}}
|
||||||
|
stack: react
|
||||||
|
dependencies: []
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>{{COMPONENT_NAME}} | {{PROJECT_NAME}}</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFBD4F"></stop><stop offset="100%" stop-color="#FF980E"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
@ -0,0 +1,46 @@
|
|||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-slate-900 to-slate-800">
|
||||||
|
<div className="container mx-auto px-4 py-16">
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-5xl font-bold text-white mb-6">
|
||||||
|
{{COMPONENT_NAME}}
|
||||||
|
</h1>
|
||||||
|
<p className="text-xl text-slate-300 mb-8 max-w-2xl mx-auto">
|
||||||
|
Welcome to your React app. This is part of the{' '}
|
||||||
|
<code className="bg-slate-700 px-2 py-1 rounded">{{PROJECT_NAME}}</code>{' '}
|
||||||
|
monorepo.
|
||||||
|
</p>
|
||||||
|
<p className="text-slate-400 mb-8">
|
||||||
|
Edit this file at{' '}
|
||||||
|
<code className="bg-slate-700 px-2 py-1 rounded">
|
||||||
|
apps/{{COMPONENT_NAME}}/src/App.tsx
|
||||||
|
</code>
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-4 justify-center">
|
||||||
|
<a
|
||||||
|
href="https://react.dev"
|
||||||
|
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition"
|
||||||
|
>
|
||||||
|
React Docs
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://vitejs.dev"
|
||||||
|
className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition"
|
||||||
|
>
|
||||||
|
Vite Docs
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="{{GIT_URL}}"
|
||||||
|
className="px-6 py-3 bg-slate-700 text-white rounded-lg hover:bg-slate-600 transition"
|
||||||
|
>
|
||||||
|
View Source
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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);
|
||||||
@ -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(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
1
internal/adapter/templates/templates/components/app-react/src/vite-env.d.ts
vendored
Normal file
1
internal/adapter/templates/templates/components/app-react/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
@ -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" }]
|
||||||
|
}
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@ -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}},
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -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
|
||||||
@ -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
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,4 @@
|
|||||||
|
name: {{COMPONENT_NAME}}
|
||||||
|
type: cli
|
||||||
|
path: cli/{{COMPONENT_NAME}}
|
||||||
|
dependencies: []
|
||||||
@ -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
|
||||||
|
)
|
||||||
@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
@ -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
|
||||||
@ -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
|
||||||
@ -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}}"]
|
||||||
@ -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/
|
||||||
@ -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()
|
||||||
|
}
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
name: {{COMPONENT_NAME}}
|
||||||
|
type: service
|
||||||
|
port: {{PORT}}
|
||||||
|
path: services/{{COMPONENT_NAME}}
|
||||||
|
dependencies: []
|
||||||
|
# Add dependencies as needed:
|
||||||
|
# - postgres
|
||||||
|
# - redis
|
||||||
|
# - other-service
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
module {{GO_MODULE}}/services/{{COMPONENT_NAME}}
|
||||||
|
|
||||||
|
go 1.23
|
||||||
|
|
||||||
|
require {{GO_MODULE}}/pkg v0.0.0
|
||||||
@ -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",
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
@ -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
|
||||||
@ -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
|
||||||
@ -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}}"]
|
||||||
@ -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/
|
||||||
@ -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")
|
||||||
|
}
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
name: {{COMPONENT_NAME}}
|
||||||
|
type: worker
|
||||||
|
path: workers/{{COMPONENT_NAME}}
|
||||||
|
dependencies: []
|
||||||
|
# Add dependencies as needed:
|
||||||
|
# - postgres
|
||||||
|
# - redis
|
||||||
|
# - rabbitmq
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
module {{GO_MODULE}}/workers/{{COMPONENT_NAME}}
|
||||||
|
|
||||||
|
go 1.23
|
||||||
|
|
||||||
|
require {{GO_MODULE}}/pkg v0.0.0
|
||||||
@ -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
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
}
|
||||||
@ -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
|
||||||
@ -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
|
||||||
48
internal/adapter/templates/templates/skeleton/.gitignore
vendored
Normal file
48
internal/adapter/templates/templates/skeleton/.gitignore
vendored
Normal file
@ -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
|
||||||
@ -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
|
||||||
@ -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
|
||||||
49
internal/adapter/templates/templates/skeleton/CLAUDE.md.tmpl
Normal file
49
internal/adapter/templates/templates/skeleton/CLAUDE.md.tmpl
Normal file
@ -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
|
||||||
|
|
||||||
|
<!-- Components will be listed here as they're added -->
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
# Local development processes
|
||||||
|
# Components will be added below as they're created
|
||||||
55
internal/adapter/templates/templates/skeleton/README.md.tmpl
Normal file
55
internal/adapter/templates/templates/skeleton/README.md.tmpl
Normal file
@ -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"}'
|
||||||
|
```
|
||||||
@ -0,0 +1 @@
|
|||||||
|
# Frontend applications go here
|
||||||
@ -0,0 +1 @@
|
|||||||
|
# CLI tools go here
|
||||||
@ -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:
|
||||||
@ -0,0 +1,4 @@
|
|||||||
|
go 1.23
|
||||||
|
|
||||||
|
use ./pkg
|
||||||
|
// Component modules will be added below
|
||||||
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
export { createLogger, Logger } from './logger';
|
||||||
|
export { installGlobalHandlers } from './handlers';
|
||||||
|
export type { LogLevel, LogContext, LogEntry, LoggerConfig, LogTransport } from './types';
|
||||||
@ -0,0 +1,170 @@
|
|||||||
|
import type { LogLevel, LogContext, LogEntry, LoggerConfig, LogTransport } from './types';
|
||||||
|
|
||||||
|
const LEVEL_PRIORITY: Record<LogLevel, number> = {
|
||||||
|
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<typeof setTimeout> | 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);
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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"]
|
||||||
|
}
|
||||||
286
internal/adapter/templates/templates/skeleton/pkg/README.md
Normal file
286
internal/adapter/templates/templates/skeleton/pkg/README.md
Normal file
@ -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/<package>` 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
|
||||||
@ -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)
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
)
|
||||||
@ -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)
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
@ -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"
|
||||||
|
)
|
||||||
@ -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)
|
||||||
|
}
|
||||||
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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...)
|
||||||
|
}
|
||||||
@ -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()
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user