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
|
||||
- **Tests:** All handlers and services require tests
|
||||
- **Multi-step ops:** NEVER log-and-continue after partial failure. Rollback or document partial state.
|
||||
- **Logging:** Use injected `*slog.Logger` only. NEVER `fmt.Println`, `log.Fatal`, `log.Printf`, or bare `slog.Info()`. Error key is ALWAYS `"error"` (not `"err"`). Log once at boundary (handlers/workers log, services return errors).
|
||||
- **HTTP clients:** NEVER create `&http.Client{}` without a `Timeout` field. All HTTP clients must have explicit timeouts (30s standard, 5s for health checks). A bare client can hang indefinitely.
|
||||
- **Config:** Use `envutil.GetEnv()` / `GetEnvInt()` / `GetEnvBool()` from `internal/envutil` for all env var reads with defaults. NEVER define local `getEnv` helpers — they duplicate and drift. Raw `os.Getenv()` is fine for required values with no default (secrets, passwords).
|
||||
- **Handler timeouts:** NEVER use inline `time.Duration` in `context.WithTimeout` inside handlers. Use constants from `internal/handlers/timeouts.go`: `TimeoutFastLookup` (5s), `TimeoutLookup` (10s), `TimeoutStandard` (30s), `TimeoutHeavyWrite` (60s), `TimeoutOrchestration` (90s), `TimeoutLongRunning` (10m).
|
||||
- **Response helpers:** Use `api.WriteUnauthorized`, `api.WriteForbidden`, `api.WriteBadRequest`, `api.WriteNotFound`, `api.WriteInternalError` instead of bare `api.WriteError` with status codes. Only use `api.WriteError` directly for custom error codes (e.g., KEY_REVOKED, IP_NOT_ALLOWED).
|
||||
- **JSON decoding:** ALWAYS use `api.DecodeJSON(r, &req)` to decode request bodies. NEVER use raw `json.NewDecoder(r.Body).Decode()`. The helper handles nil body, EOF, and returns typed errors. Decode error message is always `"invalid request body"`.
|
||||
- **Validation:** Use `validate.New()` accumulator for 2+ field checks in handlers: `v := validate.New(); v.Required(req.Name, "name"); v.Required(req.Type, "type"); if err := v.Error() { ... }`. Single-field checks can stay inline. NEVER duplicate validation logic that exists in `internal/validate`.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
|
||||
@ -14,6 +14,11 @@ Composable Monorepo Templates evolve rdev's project scaffolding from single temp
|
||||
- Optional `component.yaml` per component for ports, dependencies, build order
|
||||
- Shared `pkg/` from Aeries chassis + Colix patterns (8 packages)
|
||||
- Deployment supports whole-monorepo or individual-component targets
|
||||
- **CI is template-provided** - skeleton has `.woodpecker.yml.tmpl`, components have `.woodpecker.step.yml.tmpl`
|
||||
|
||||
**Critical Design Decision:**
|
||||
CI/CD configuration MUST come from templates, never AI-generated. Claude Code produces invalid
|
||||
Woodpecker YAML when generating from scratch (broken YAML anchor syntax).
|
||||
|
||||
**File Pointers:**
|
||||
- Plan: `tmp/template-monorepo-plan.md`
|
||||
@ -30,6 +35,7 @@ POST /projects {"name": "acme"}
|
||||
Creates monorepo skeleton:
|
||||
- CLAUDE.md, README.md, Procfile
|
||||
- docker-compose.yml, go.work, .golangci.yml
|
||||
- .woodpecker.yml (template-provided CI)
|
||||
- scripts/ (discover, install, quality, dev)
|
||||
- pkg/ (8 shared packages from Aeries + Colix)
|
||||
- .claude/ (guides, skills, commands)
|
||||
@ -49,6 +55,7 @@ Auto-updates:
|
||||
- Procfile (add service entry)
|
||||
- go.work (add module)
|
||||
- CLAUDE.md (add routing)
|
||||
- .woodpecker.yml (add build step for component)
|
||||
```
|
||||
|
||||
### Monorepo Structure
|
||||
|
||||
@ -4,9 +4,9 @@ import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"github.com/orchard9/rdev/internal/domain"
|
||||
"github.com/orchard9/rdev/internal/envutil"
|
||||
"github.com/orchard9/rdev/internal/port"
|
||||
)
|
||||
|
||||
@ -75,28 +75,14 @@ type InfraConfig struct {
|
||||
}
|
||||
|
||||
func loadConfig() Config {
|
||||
port := 8080
|
||||
if v := os.Getenv("PORT"); v != "" {
|
||||
if p, err := strconv.Atoi(v); err == nil {
|
||||
port = p
|
||||
}
|
||||
}
|
||||
|
||||
dbPort := 5432
|
||||
if v := os.Getenv("DB_PORT"); v != "" {
|
||||
if p, err := strconv.Atoi(v); err == nil {
|
||||
dbPort = p
|
||||
}
|
||||
}
|
||||
|
||||
return Config{
|
||||
Port: port,
|
||||
DBHost: getEnv("DB_HOST", "postgres.databases.svc"),
|
||||
DBPort: dbPort,
|
||||
DBUser: getEnv("DB_USER", "appuser"),
|
||||
Port: envutil.GetEnvInt("PORT", 8080),
|
||||
DBHost: envutil.GetEnv("DB_HOST", "postgres.databases.svc"),
|
||||
DBPort: envutil.GetEnvInt("DB_PORT", 5432),
|
||||
DBUser: envutil.GetEnv("DB_USER", "appuser"),
|
||||
DBPassword: os.Getenv("DB_PASSWORD"),
|
||||
DBName: getEnv("DB_NAME", "rdev"),
|
||||
DBSSLMode: getEnv("DB_SSL_MODE", "disable"),
|
||||
DBName: envutil.GetEnv("DB_NAME", "rdev"),
|
||||
DBSSLMode: envutil.GetEnv("DB_SSL_MODE", "disable"),
|
||||
AdminKey: os.Getenv("RDEV_ADMIN_KEY"),
|
||||
|
||||
// Encryption key for credential store (generate with: openssl rand -base64 32)
|
||||
@ -105,33 +91,26 @@ func loadConfig() Config {
|
||||
|
||||
// OpenCode (optional alternative code agent)
|
||||
OpenCodeURL: os.Getenv("OPENCODE_URL"), // e.g., "http://opencode:4096"
|
||||
OpenCodeUsername: getEnv("OPENCODE_USERNAME", "opencode"),
|
||||
OpenCodeUsername: envutil.GetEnv("OPENCODE_USERNAME", "opencode"),
|
||||
OpenCodePassword: os.Getenv("OPENCODE_PASSWORD"),
|
||||
|
||||
// Infrastructure adapters (fallback if not in credential store)
|
||||
GiteaURL: getEnv("GITEA_URL", "https://git.threesix.ai"),
|
||||
GiteaURL: envutil.GetEnv("GITEA_URL", "https://git.threesix.ai"),
|
||||
GiteaToken: os.Getenv("GITEA_TOKEN"),
|
||||
GiteaDefaultOrg: getEnv("GITEA_DEFAULT_ORG", "jordan"),
|
||||
GiteaDefaultOrg: envutil.GetEnv("GITEA_DEFAULT_ORG", "jordan"),
|
||||
CloudflareToken: os.Getenv("CLOUDFLARE_API_TOKEN"),
|
||||
CloudflareZoneID: os.Getenv("CLOUDFLARE_ZONE_ID"),
|
||||
DefaultDomain: getEnv("DEFAULT_DOMAIN", "threesix.ai"),
|
||||
DeployNamespace: getEnv("DEPLOY_NAMESPACE", "projects"),
|
||||
DeployTLSIssuer: getEnv("DEPLOY_TLS_ISSUER", "letsencrypt-prod"),
|
||||
ClusterIP: getEnv("CLUSTER_IP", "208.122.204.172"),
|
||||
RegistryURL: getEnv("REGISTRY_URL", "zot.threesix.svc.cluster.local:5000"),
|
||||
WoodpeckerURL: getEnv("WOODPECKER_URL", "https://ci.threesix.ai"),
|
||||
DefaultDomain: envutil.GetEnv("DEFAULT_DOMAIN", "threesix.ai"),
|
||||
DeployNamespace: envutil.GetEnv("DEPLOY_NAMESPACE", "projects"),
|
||||
DeployTLSIssuer: envutil.GetEnv("DEPLOY_TLS_ISSUER", "letsencrypt-prod"),
|
||||
ClusterIP: envutil.GetEnv("CLUSTER_IP", "208.122.204.172"),
|
||||
RegistryURL: envutil.GetEnv("REGISTRY_URL", "zot.threesix.svc.cluster.local:5000"),
|
||||
WoodpeckerURL: envutil.GetEnv("WOODPECKER_URL", "https://ci.threesix.ai"),
|
||||
WoodpeckerAPIToken: os.Getenv("WOODPECKER_API_TOKEN"),
|
||||
WoodpeckerWebhookSecret: os.Getenv("WOODPECKER_WEBHOOK_SECRET"),
|
||||
}
|
||||
}
|
||||
|
||||
func getEnv(key, defaultVal string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return defaultVal
|
||||
}
|
||||
|
||||
// loadInfraConfig loads infrastructure configuration from credential store,
|
||||
// falling back to environment variables if not found in the store.
|
||||
func loadInfraConfig(ctx context.Context, store port.CredentialStore, cfg Config, logger *slog.Logger) InfraConfig {
|
||||
@ -159,20 +138,6 @@ func loadInfraConfig(ctx context.Context, store port.CredentialStore, cfg Config
|
||||
return envFallback
|
||||
}
|
||||
|
||||
// Parse CRDB and Redis ports
|
||||
crdbPort := 26257
|
||||
if v := os.Getenv("CRDB_PORT"); v != "" {
|
||||
if p, err := strconv.Atoi(v); err == nil {
|
||||
crdbPort = p
|
||||
}
|
||||
}
|
||||
redisPort := 6379
|
||||
if v := os.Getenv("REDIS_PORT"); v != "" {
|
||||
if p, err := strconv.Atoi(v); err == nil {
|
||||
redisPort = p
|
||||
}
|
||||
}
|
||||
|
||||
infraCfg := InfraConfig{
|
||||
GiteaURL: getOrFallback(domain.CredKeyGiteaURL, cfg.GiteaURL),
|
||||
GiteaToken: getOrFallback(domain.CredKeyGiteaToken, cfg.GiteaToken),
|
||||
@ -190,11 +155,11 @@ func loadInfraConfig(ctx context.Context, store port.CredentialStore, cfg Config
|
||||
|
||||
// CockroachDB and Redis provisioners (env-only for now)
|
||||
CRDBHost: os.Getenv("CRDB_HOST"), // e.g., "cockroachdb-public.databases.svc"
|
||||
CRDBPort: crdbPort,
|
||||
CRDBUser: getEnv("CRDB_USER", "root"),
|
||||
CRDBSSLMode: getEnv("CRDB_SSL_MODE", "disable"),
|
||||
CRDBPort: envutil.GetEnvInt("CRDB_PORT", 26257),
|
||||
CRDBUser: envutil.GetEnv("CRDB_USER", "root"),
|
||||
CRDBSSLMode: envutil.GetEnv("CRDB_SSL_MODE", "disable"),
|
||||
RedisHost: os.Getenv("REDIS_HOST"), // e.g., "redis.threesix.svc"
|
||||
RedisPort: redisPort,
|
||||
RedisPort: envutil.GetEnvInt("REDIS_PORT", 6379),
|
||||
RedisPassword: os.Getenv("REDIS_PASSWORD"),
|
||||
}
|
||||
|
||||
@ -211,3 +176,15 @@ func loadInfraConfig(ctx context.Context, store port.CredentialStore, cfg Config
|
||||
|
||||
return infraCfg
|
||||
}
|
||||
|
||||
// closeProvisioner attempts to close a provisioner that implements io.Closer.
|
||||
func closeProvisioner(p any, name string, logger *slog.Logger) {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
if closer, ok := p.(interface{ Close() error }); ok {
|
||||
if err := closer.Close(); err != nil {
|
||||
logger.Warn("failed to close "+name+" provisioner", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,36 +1,4 @@
|
||||
// Package main provides the entry point for the rdev API server.
|
||||
//
|
||||
// rdev (Remote Developer) provides a REST API for controlling Claude Code
|
||||
// instances running in Kubernetes pods. External clients (Discord bots,
|
||||
// CLI tools, etc.) connect via this API.
|
||||
//
|
||||
// Authentication:
|
||||
// - All endpoints (except /health, /ready, /docs) require X-API-Key header
|
||||
// - Admin key from RDEV_ADMIN_KEY env var for key management
|
||||
// - Create additional keys via POST /keys
|
||||
//
|
||||
// Endpoints:
|
||||
// - GET /health - Health check (no auth)
|
||||
// - GET /ready - Readiness check (no auth)
|
||||
// - GET /docs - Scalar API documentation (no auth)
|
||||
// - GET /openapi.json - OpenAPI 3.0 specification (no auth)
|
||||
// - GET /keys - List API keys
|
||||
// - POST /keys - Create API key
|
||||
// - GET /keys/{id} - Get API key details
|
||||
// - DELETE /keys/{id} - Revoke API key
|
||||
// - GET /projects - List available projects
|
||||
// - GET /projects/{id} - Get project details
|
||||
// - POST /projects/{id}/claude - Run Claude command
|
||||
// - POST /projects/{id}/shell - Run shell command
|
||||
// - POST /projects/{id}/git - Run git command
|
||||
// - GET /projects/{id}/events - SSE stream for output
|
||||
// - GET /projects/{id}/claude-config - List commands/skills/agents
|
||||
// - GET /projects/{id}/claude-config/commands - List commands
|
||||
// - POST /projects/{id}/claude-config/commands - Create command
|
||||
// - GET /projects/{id}/claude-config/commands/{name} - Get command
|
||||
// - PUT /projects/{id}/claude-config/commands/{name} - Update command
|
||||
// - DELETE /projects/{id}/claude-config/commands/{name} - Delete command
|
||||
// (same pattern for /skills and /agents)
|
||||
package main
|
||||
|
||||
import (
|
||||
@ -54,6 +22,7 @@ import (
|
||||
"github.com/orchard9/rdev/internal/adapter/woodpecker"
|
||||
"github.com/orchard9/rdev/internal/auth"
|
||||
"github.com/orchard9/rdev/internal/db"
|
||||
"github.com/orchard9/rdev/internal/envutil"
|
||||
"github.com/orchard9/rdev/internal/handlers"
|
||||
"github.com/orchard9/rdev/internal/metrics"
|
||||
"github.com/orchard9/rdev/internal/middleware"
|
||||
@ -120,7 +89,7 @@ func main() {
|
||||
infraCfg := loadInfraConfig(context.Background(), credentialStore, cfg, logger)
|
||||
|
||||
// Create adapters (dependency injection)
|
||||
namespace := getEnv("K8S_NAMESPACE", "rdev")
|
||||
namespace := envutil.GetEnv("K8S_NAMESPACE", "rdev")
|
||||
|
||||
// Initialize K8s client for dynamic project discovery
|
||||
// Falls back gracefully if K8s is unavailable (e.g., local development)
|
||||
@ -138,27 +107,18 @@ func main() {
|
||||
k8sExecutor := kubernetes.NewExecutor(namespace)
|
||||
streamPub := memory.NewStreamPublisher()
|
||||
|
||||
// Start watching for project pod changes if K8s client is available
|
||||
if k8sClient != nil {
|
||||
if err := projectRepo.StartWatching(context.Background()); err != nil {
|
||||
logger.Warn("failed to start project watcher", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize audit logger
|
||||
auditLogger := postgres.NewAuditLogger(database.DB)
|
||||
|
||||
// Initialize rate limiter
|
||||
rateLimiter := postgres.NewRateLimiter(database.DB)
|
||||
stopRateLimitCleanup := rateLimiter.StartCleanupWorker(context.Background(), 5*time.Minute)
|
||||
|
||||
// Initialize command queue
|
||||
commandQueue := postgres.NewCommandQueueRepository(database.DB)
|
||||
|
||||
// Initialize work queue (for worker pool tasks)
|
||||
workQueueRepo := postgres.NewWorkQueueRepository(database.DB)
|
||||
|
||||
// Initialize webhook repository and dispatcher
|
||||
webhookRepo := postgres.NewWebhookRepository(database.DB)
|
||||
webhookDispatcher := webhook.NewDispatcher(webhookRepo, &webhook.DispatcherConfig{
|
||||
WorkerCount: 10,
|
||||
@ -172,8 +132,7 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Initialize infrastructure adapters (optional - only if configured)
|
||||
// Uses infraCfg which loads from credential store with env var fallback
|
||||
// Infrastructure adapters (optional - only if configured)
|
||||
var giteaClient *gitea.Client
|
||||
if infraCfg.GiteaToken != "" && infraCfg.GiteaURL != "" {
|
||||
var err error
|
||||
@ -384,6 +343,23 @@ func main() {
|
||||
// Initialize project management handler
|
||||
projectMgmtHandler := handlers.NewProjectManagementHandler(projectInfraService, logger)
|
||||
|
||||
// Initialize component service and handler (for monorepo component management)
|
||||
var componentsHandler *handlers.ComponentsHandler
|
||||
if infraCfg.GiteaToken != "" && infraCfg.GiteaURL != "" && templateProvider != nil {
|
||||
bulkFileClient := gitea.NewBulkFileClient(infraCfg.GiteaURL, infraCfg.GiteaToken)
|
||||
componentService := service.NewComponentService(
|
||||
database.DB,
|
||||
templateProvider,
|
||||
bulkFileClient,
|
||||
service.ComponentServiceConfig{
|
||||
DefaultGitOwner: infraCfg.GiteaDefaultOrg,
|
||||
Logger: logger,
|
||||
},
|
||||
)
|
||||
componentsHandler = handlers.NewComponentsHandler(componentService, logger)
|
||||
logger.Info("component service initialized")
|
||||
}
|
||||
|
||||
// Initialize Woodpecker webhook handler (for CI/CD auto-deploy)
|
||||
woodpeckerHandler := handlers.NewWoodpeckerWebhookHandler(
|
||||
deployerAdapter,
|
||||
@ -425,6 +401,9 @@ func main() {
|
||||
workHandler.Mount(app.Router())
|
||||
infraHandler.Mount(app.Router())
|
||||
projectMgmtHandler.Mount(app.Router())
|
||||
if componentsHandler != nil {
|
||||
componentsHandler.Mount(app.Router())
|
||||
}
|
||||
woodpeckerHandler.Mount(app.Router())
|
||||
credentialsHandler.Mount(app.Router())
|
||||
agentsHandler.Mount(app.Router())
|
||||
@ -493,47 +472,18 @@ func main() {
|
||||
// Enable API documentation
|
||||
app.EnableDocs(buildOpenAPISpec())
|
||||
|
||||
// Cleanup on shutdown
|
||||
app.OnShutdown(func(ctx context.Context) error {
|
||||
// Stop work executor (deregisters worker)
|
||||
workExecutor.Stop()
|
||||
|
||||
// Stop queue maintenance worker
|
||||
queueMaintenance.Stop()
|
||||
|
||||
// Stop queue processor
|
||||
queueProcessor.Stop()
|
||||
|
||||
// Stop webhook dispatcher
|
||||
webhookDispatcher.Stop()
|
||||
|
||||
// Stop project watcher
|
||||
projectRepo.StopWatching()
|
||||
|
||||
// Stop rate limit cleanup worker
|
||||
stopRateLimitCleanup()
|
||||
|
||||
// Close database and cache provisioners
|
||||
if dbProvisioner != nil {
|
||||
if closer, ok := dbProvisioner.(interface{ Close() error }); ok {
|
||||
if err := closer.Close(); err != nil {
|
||||
logger.Warn("failed to close database provisioner", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if cacheProvisioner != nil {
|
||||
if closer, ok := cacheProvisioner.(interface{ Close() error }); ok {
|
||||
if err := closer.Close(); err != nil {
|
||||
logger.Warn("failed to close cache provisioner", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Shutdown telemetry (flush pending traces)
|
||||
closeProvisioner(dbProvisioner, "database", logger)
|
||||
closeProvisioner(cacheProvisioner, "cache", logger)
|
||||
if err := tel.Shutdown(ctx); err != nil {
|
||||
logger.Error("telemetry shutdown error", "error", err)
|
||||
}
|
||||
|
||||
return database.Close()
|
||||
})
|
||||
|
||||
@ -547,5 +497,4 @@ func main() {
|
||||
app.Run()
|
||||
}
|
||||
|
||||
// Config, InfraConfig, loadConfig, loadInfraConfig, and getEnv
|
||||
// are defined in config.go.
|
||||
// Config, InfraConfig, loadConfig, loadInfraConfig are defined in config.go.
|
||||
|
||||
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
|
||||
|
||||
> Deploy a landing page built by a Claude agent through the threesix.ai infrastructure.
|
||||
> Deploy a landing page using composable templates through the threesix.ai infrastructure.
|
||||
|
||||
## Overview
|
||||
|
||||
This cookbook creates and deploys a landing page using **agent-driven development**:
|
||||
This cookbook creates and deploys a landing page using **composable monorepo templates**:
|
||||
|
||||
```
|
||||
POST /project/create-and-build
|
||||
POST /projects
|
||||
↓
|
||||
Creates: Gitea repo + DNS + Woodpecker CI + K8s deployment
|
||||
Creates: Monorepo skeleton + Gitea repo + DNS + CI
|
||||
↓
|
||||
Enqueues build task with prompt
|
||||
POST /projects/{id}/components (type: app, template: app-astro)
|
||||
↓
|
||||
Worker picks up task → Claude builds the site
|
||||
Adds: Astro landing page component with valid CI step
|
||||
↓
|
||||
Agent commits + pushes
|
||||
Git push triggers Woodpecker CI
|
||||
↓
|
||||
CI builds and deploys
|
||||
↓
|
||||
Live site
|
||||
Live site at https://{slug}.threesix.ai
|
||||
```
|
||||
|
||||
**No templates. Claude builds it from scratch based on your prompt.**
|
||||
**Template-driven. CI is pre-configured. No AI-generated YAML.**
|
||||
|
||||
---
|
||||
|
||||
@ -38,24 +36,18 @@ export RDEV_API_KEY="<your-api-key>"
|
||||
- rdev-api running with embedded worker
|
||||
- Gitea at https://git.threesix.ai
|
||||
- Woodpecker CI at https://ci.threesix.ai
|
||||
- claudebox-0 pod running in rdev namespace
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Create Project and Build in One Call
|
||||
|
||||
Single API call that creates infrastructure AND enqueues agent work:
|
||||
## Step 1: Create Project (Monorepo Skeleton)
|
||||
|
||||
```bash
|
||||
curl -X POST "$RDEV_API_URL/project/create-and-build" \
|
||||
curl -X POST "$RDEV_API_URL/projects" \
|
||||
-H "X-API-Key: $RDEV_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "my-landing",
|
||||
"description": "Company landing page",
|
||||
"prompt": "Build a modern landing page with: dark gradient background, centered hero section with company name and tagline, email signup form, responsive design. Use vanilla HTML/CSS/JS. Create index.html, styles.css, and a simple Dockerfile that serves with nginx.",
|
||||
"auto_commit": true,
|
||||
"auto_push": true
|
||||
"description": "Company landing page"
|
||||
}'
|
||||
```
|
||||
|
||||
@ -71,53 +63,79 @@ curl -X POST "$RDEV_API_URL/project/create-and-build" \
|
||||
"owner": "jordan",
|
||||
"name": "my-landing",
|
||||
"html_url": "https://git.threesix.ai/jordan/my-landing"
|
||||
},
|
||||
"task_id": "task-uuid",
|
||||
"status": "pending",
|
||||
"status_url": "/builds/task-uuid"
|
||||
},
|
||||
"meta": { "timestamp": "..." }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This creates a monorepo skeleton with:
|
||||
- `README.md`, `CLAUDE.md`, `Procfile`
|
||||
- `.woodpecker.yml` (template-provided, valid CI)
|
||||
- `scripts/` (discover, install, quality, dev)
|
||||
- `pkg/` (shared Go packages)
|
||||
- Empty component directories (`services/`, `apps/`, `workers/`, `cli/`)
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Monitor Build Progress
|
||||
## Step 2: Add Landing Page Component
|
||||
|
||||
Poll the build status:
|
||||
```bash
|
||||
curl -X POST "$RDEV_API_URL/projects/my-landing/components" \
|
||||
-H "X-API-Key: $RDEV_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"type": "app",
|
||||
"name": "landing",
|
||||
"template": "app-astro"
|
||||
}'
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"type": "app",
|
||||
"name": "landing",
|
||||
"path": "apps/landing",
|
||||
"port": 3001,
|
||||
"template": "app-astro"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This adds:
|
||||
- `apps/landing/` - Astro project with Tailwind CSS
|
||||
- Updates `.woodpecker.yml` with build step for this component
|
||||
- Updates `Procfile` with dev server entry
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Customize with Claude (Optional)
|
||||
|
||||
Submit a build task to customize the landing page:
|
||||
|
||||
```bash
|
||||
curl -X POST "$RDEV_API_URL/projects/my-landing/builds" \
|
||||
-H "X-API-Key: $RDEV_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"prompt": "Update apps/landing to have: dark gradient background, centered hero section with company name \"Acme Corp\" and tagline \"Building the future\", email signup form with shadcn styling. Keep the existing Astro structure.",
|
||||
"auto_commit": true,
|
||||
"auto_push": true
|
||||
}'
|
||||
```
|
||||
|
||||
Monitor the build:
|
||||
```bash
|
||||
curl -s "$RDEV_API_URL/builds/{task_id}" \
|
||||
-H "X-API-Key: $RDEV_API_KEY" | jq .data
|
||||
```
|
||||
|
||||
**Status progression:** `pending` → `running` → `completed` (or `failed`)
|
||||
|
||||
When completed:
|
||||
```json
|
||||
{
|
||||
"task_id": "task-uuid",
|
||||
"project_id": "my-landing",
|
||||
"status": "completed",
|
||||
"prompt": "Build a modern landing page...",
|
||||
"auto_commit": true,
|
||||
"auto_push": true,
|
||||
"started_at": "2025-01-29T10:00:00Z",
|
||||
"completed_at": "2025-01-29T10:00:45Z",
|
||||
"result": {
|
||||
"success": true,
|
||||
"commit_sha": "abc123",
|
||||
"files_changed": ["index.html", "styles.css", "Dockerfile", "nginx.conf"],
|
||||
"duration_ms": 45000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Monitor CI Pipeline
|
||||
## Step 4: Monitor CI Pipeline
|
||||
|
||||
The agent's push triggers Woodpecker CI:
|
||||
The push triggers Woodpecker CI automatically:
|
||||
|
||||
```bash
|
||||
curl -s "$RDEV_API_URL/projects/my-landing/pipelines" \
|
||||
@ -128,7 +146,7 @@ Wait for `status: "success"`.
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Verify Deployment
|
||||
## Step 5: Verify Deployment
|
||||
|
||||
```bash
|
||||
# Check site is live
|
||||
@ -140,35 +158,6 @@ open https://abc123xy.threesix.ai
|
||||
|
||||
---
|
||||
|
||||
## Alternative: Two-Step Flow
|
||||
|
||||
If you prefer to create the project first, then submit builds separately:
|
||||
|
||||
### Create Project (empty repo)
|
||||
```bash
|
||||
curl -X POST "$RDEV_API_URL/project" \
|
||||
-H "X-API-Key: $RDEV_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "my-landing",
|
||||
"description": "Company landing page"
|
||||
}'
|
||||
```
|
||||
|
||||
### Submit Build Task
|
||||
```bash
|
||||
curl -X POST "$RDEV_API_URL/projects/my-landing/builds" \
|
||||
-H "X-API-Key: $RDEV_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"prompt": "Build a modern landing page with dark theme, hero section, and email signup form. Use HTML/CSS/JS with nginx Dockerfile.",
|
||||
"auto_commit": true,
|
||||
"auto_push": true
|
||||
}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Iterating on the Site
|
||||
|
||||
Submit additional builds to modify the site:
|
||||
@ -178,7 +167,7 @@ curl -X POST "$RDEV_API_URL/projects/my-landing/builds" \
|
||||
-H "X-API-Key: $RDEV_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"prompt": "Add a pricing section with three tiers: Free, Pro ($29/mo), Enterprise (contact us). Match the existing dark theme.",
|
||||
"prompt": "Add a pricing section to apps/landing with three tiers: Free, Pro ($29/mo), Enterprise (contact us). Match the existing dark theme.",
|
||||
"auto_commit": true,
|
||||
"auto_push": true
|
||||
}'
|
||||
@ -211,7 +200,7 @@ curl -s "$RDEV_API_URL/projects/my-landing/domains" \
|
||||
## Teardown
|
||||
|
||||
```bash
|
||||
curl -X DELETE "$RDEV_API_URL/project/my-landing" \
|
||||
curl -X DELETE "$RDEV_API_URL/projects/my-landing" \
|
||||
-H "X-API-Key: $RDEV_API_KEY"
|
||||
```
|
||||
|
||||
@ -242,34 +231,31 @@ Cleanup:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ Agent-Driven Landing Page │
|
||||
│ Composable Landing Page Deployment │
|
||||
│ │
|
||||
│ POST /project/create-and-build │
|
||||
│ POST /projects │
|
||||
│ │ │
|
||||
│ ├──► Gitea: creates repo │
|
||||
│ ├──► Gitea: creates repo with skeleton │
|
||||
│ ├──► Cloudflare: creates DNS │
|
||||
│ ├──► Woodpecker: activates CI │
|
||||
│ ├──► K8s: creates Deployment/Service/Ingress │
|
||||
│ └──► Work Queue: enqueues build task │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ Worker polls queue, claims task │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ Claude Code executes in claudebox-0: │
|
||||
│ - Clones repo │
|
||||
│ - Builds site from prompt │
|
||||
│ - Commits and pushes │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ Woodpecker CI triggered by push: │
|
||||
│ - Builds Docker image │
|
||||
│ - Pushes to registry │
|
||||
│ - Updates K8s deployment │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ Site live at https://{slug}.threesix.ai │
|
||||
│ └──► Skeleton includes .woodpecker.yml (template-provided) │
|
||||
│ │
|
||||
│ POST /projects/{id}/components │
|
||||
│ │ │
|
||||
│ ├──► Adds apps/landing/ from app-astro template │
|
||||
│ ├──► Updates .woodpecker.yml with build step │
|
||||
│ └──► Updates Procfile │
|
||||
│ │
|
||||
│ POST /projects/{id}/builds (optional customization) │
|
||||
│ │ │
|
||||
│ └──► Claude modifies existing files, commits, pushes │
|
||||
│ │
|
||||
│ Git push triggers Woodpecker CI: │
|
||||
│ ├──► Builds Docker image via Kaniko │
|
||||
│ ├──► Pushes to registry.threesix.ai │
|
||||
│ └──► Deploys to K8s │
|
||||
│ │
|
||||
│ Site live at https://{slug}.threesix.ai │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
@ -303,10 +289,17 @@ curl -s "https://git.threesix.ai/api/v1/repos/jordan/my-landing/commits" | jq '.
|
||||
open https://ci.threesix.ai/jordan/my-landing
|
||||
```
|
||||
|
||||
### Component not appearing
|
||||
```bash
|
||||
# List components
|
||||
curl -s "$RDEV_API_URL/projects/my-landing/components" \
|
||||
-H "X-API-Key: $RDEV_API_KEY" | jq '.data'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Related
|
||||
|
||||
- [Full-Stack App Cookbook](./fullstack-app.md) - Next.js + Go backend
|
||||
- [Composable App Cookbook](./composable-app.md) - Full-stack apps with multiple components
|
||||
- [Worker Pool Guide](../.claude/guides/services/worker-pool.md)
|
||||
- [Build Orchestration](../.claude/guides/services/build-orchestration.md)
|
||||
- [Composable Monorepo Guide](../.claude/guides/services/composable-monorepo.md)
|
||||
|
||||
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
|
||||
# Landing Page Cookbook Test Script
|
||||
# Tests the full agent-driven landing page flow from cookbooks/landing-page.md
|
||||
# Tests the composable landing page flow from cookbooks/landing-page.md
|
||||
#
|
||||
# Flow:
|
||||
# 1. Create project (monorepo skeleton)
|
||||
# 2. Add app-astro component
|
||||
# 3. Wait for CI pipeline
|
||||
# 4. Verify site is live
|
||||
#
|
||||
# Usage:
|
||||
# ./cookbooks/scripts/landing-test.sh run [name] # Run the full flow
|
||||
@ -26,14 +32,9 @@ log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
|
||||
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
|
||||
|
||||
# Timeouts
|
||||
BUILD_TIMEOUT=600 # 10 minutes for Claude to build the site
|
||||
BUILD_POLL_INTERVAL=5 # Check every 5 seconds
|
||||
PIPELINE_TIMEOUT=300 # 5 minutes max wait for CI pipeline
|
||||
PIPELINE_POLL_INTERVAL=10
|
||||
SITE_TIMEOUT=60 # 1 minute max wait for site to be live
|
||||
|
||||
# Streaming mode (set to true to stream live build output via SSE)
|
||||
STREAM_MODE="${STREAM_MODE:-false}"
|
||||
SITE_TIMEOUT=120 # 2 minutes max wait for site to be live
|
||||
|
||||
api_call() {
|
||||
local method="$1"
|
||||
@ -65,136 +66,7 @@ check_health() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Stream build events via SSE (real-time output)
|
||||
# Arguments: project_id, task_id
|
||||
stream_build_events() {
|
||||
local project_id="$1"
|
||||
local task_id="$2"
|
||||
local stream_url="${API_URL}/projects/${project_id}/events?stream_id=${task_id}"
|
||||
|
||||
log_info "Streaming build events from: $stream_url"
|
||||
echo ""
|
||||
|
||||
# Use curl to stream SSE events
|
||||
curl -s -N \
|
||||
-H "X-API-Key: ${API_KEY}" \
|
||||
-H "Accept: text/event-stream" \
|
||||
"$stream_url" 2>/dev/null | while IFS= read -r line; do
|
||||
# Skip empty lines and event headers
|
||||
if [[ -z "$line" || "$line" == "event:"* || "$line" == "id:"* ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Parse data lines
|
||||
if [[ "$line" == "data:"* ]]; then
|
||||
local data="${line#data: }"
|
||||
|
||||
# Parse event type and content
|
||||
local event_type content
|
||||
event_type=$(echo "$data" | jq -r '.type // "unknown"' 2>/dev/null)
|
||||
|
||||
case "$event_type" in
|
||||
build.started)
|
||||
echo -e "${GREEN}[BUILD STARTED]${NC}"
|
||||
;;
|
||||
build.output)
|
||||
content=$(echo "$data" | jq -r '.content // ""' 2>/dev/null)
|
||||
[[ -n "$content" ]] && echo "$content"
|
||||
;;
|
||||
build.tool_use)
|
||||
local tool_name
|
||||
tool_name=$(echo "$data" | jq -r '.tool_name // "unknown"' 2>/dev/null)
|
||||
echo -e "${YELLOW}[TOOL: $tool_name]${NC}"
|
||||
;;
|
||||
build.completed)
|
||||
echo -e "${GREEN}[BUILD COMPLETED]${NC}"
|
||||
return 0
|
||||
;;
|
||||
build.failed)
|
||||
local error
|
||||
error=$(echo "$data" | jq -r '.error // "unknown error"' 2>/dev/null)
|
||||
echo -e "${RED}[BUILD FAILED]${NC} $error"
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# Wait for build to complete (Claude building the site)
|
||||
# Returns: 0 on success, 1 on failure/timeout
|
||||
wait_for_build() {
|
||||
local task_id="$1"
|
||||
local project_id="${2:-}"
|
||||
local start_time=$(date +%s)
|
||||
|
||||
log_info "Waiting for Claude to build the site (timeout: ${BUILD_TIMEOUT}s)..."
|
||||
|
||||
# If streaming mode is enabled and we have a project_id, use SSE
|
||||
if [[ "$STREAM_MODE" == "true" && -n "$project_id" ]]; then
|
||||
log_info "Streaming mode enabled - showing live build output"
|
||||
stream_build_events "$project_id" "$task_id" &
|
||||
local stream_pid=$!
|
||||
fi
|
||||
|
||||
while true; do
|
||||
local elapsed=$(($(date +%s) - start_time))
|
||||
if [[ $elapsed -ge $BUILD_TIMEOUT ]]; then
|
||||
[[ -n "${stream_pid:-}" ]] && kill "$stream_pid" 2>/dev/null || true
|
||||
log_error "Build timeout after ${BUILD_TIMEOUT}s"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local response
|
||||
response=$(api_call GET "/builds/$task_id" 2>/dev/null || echo "{}")
|
||||
|
||||
local status
|
||||
status=$(echo "$response" | jq -r '.data.status // "unknown"' 2>/dev/null)
|
||||
|
||||
case "$status" in
|
||||
completed)
|
||||
[[ -n "${stream_pid:-}" ]] && kill "$stream_pid" 2>/dev/null || true
|
||||
local success
|
||||
success=$(echo "$response" | jq -r '.data.result.success // false')
|
||||
if [[ "$success" == "true" ]]; then
|
||||
log_success "Build completed successfully (${elapsed}s)"
|
||||
echo "$response" | jq '.data.result | {success, commit_sha, files_changed, duration_ms}'
|
||||
return 0
|
||||
else
|
||||
log_error "Build completed but failed"
|
||||
echo "$response" | jq '.data.result'
|
||||
return 1
|
||||
fi
|
||||
;;
|
||||
failed)
|
||||
[[ -n "${stream_pid:-}" ]] && kill "$stream_pid" 2>/dev/null || true
|
||||
log_error "Build failed"
|
||||
echo "$response" | jq '.data.result // .data'
|
||||
return 1
|
||||
;;
|
||||
running)
|
||||
if [[ "$STREAM_MODE" != "true" ]]; then
|
||||
echo -ne "\r${BLUE}[INFO]${NC} Build status: running (${elapsed}s)... "
|
||||
fi
|
||||
;;
|
||||
pending)
|
||||
if [[ "$STREAM_MODE" != "true" ]]; then
|
||||
echo -ne "\r${BLUE}[INFO]${NC} Build status: pending (${elapsed}s)... "
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
if [[ "$STREAM_MODE" != "true" ]]; then
|
||||
echo -ne "\r${BLUE}[INFO]${NC} Build status: $status (${elapsed}s)... "
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
sleep $BUILD_POLL_INTERVAL
|
||||
done
|
||||
}
|
||||
|
||||
# Wait for pipeline to appear and complete
|
||||
# Returns: 0 on success, 1 on failure/timeout
|
||||
wait_for_pipeline() {
|
||||
local project_name="$1"
|
||||
local start_time=$(date +%s)
|
||||
@ -214,7 +86,7 @@ wait_for_pipeline() {
|
||||
local response
|
||||
response=$(api_call GET "/projects/$project_name/pipelines" 2>/dev/null || echo "{}")
|
||||
|
||||
# Check if we have pipelines (API returns array at .data)
|
||||
# Check if we have pipelines
|
||||
local pipeline_count
|
||||
pipeline_count=$(echo "$response" | jq -r '.data | length' 2>/dev/null || echo "0")
|
||||
|
||||
@ -230,10 +102,12 @@ wait_for_pipeline() {
|
||||
|
||||
case "$pipeline_status" in
|
||||
success)
|
||||
echo ""
|
||||
log_success "Pipeline #$pipeline_number completed successfully (${elapsed}s)"
|
||||
return 0
|
||||
;;
|
||||
failure|error|killed|declined)
|
||||
echo ""
|
||||
log_error "Pipeline #$pipeline_number failed with status: $pipeline_status"
|
||||
echo "$response" | jq '.data[0]'
|
||||
return 1
|
||||
@ -254,7 +128,6 @@ wait_for_pipeline() {
|
||||
}
|
||||
|
||||
# Wait for site to be accessible
|
||||
# Returns: 0 on success, 1 on failure/timeout
|
||||
wait_for_site() {
|
||||
local domain="$1"
|
||||
local start_time=$(date +%s)
|
||||
@ -285,393 +158,167 @@ wait_for_site() {
|
||||
done
|
||||
}
|
||||
|
||||
# Test adding a DNS alias
|
||||
test_dns_alias() {
|
||||
local project_name="$1"
|
||||
local alias_domain="$2"
|
||||
|
||||
log_info "Testing DNS alias: $alias_domain"
|
||||
|
||||
local response
|
||||
response=$(api_call POST "/projects/$project_name/domains" "{\"domain\": \"$alias_domain\"}")
|
||||
|
||||
if echo "$response" | jq -e '.error' > /dev/null 2>&1; then
|
||||
local error_code
|
||||
error_code=$(echo "$response" | jq -r '.error.code // "UNKNOWN"')
|
||||
if [[ "$error_code" == "DOMAIN_EXISTS" ]]; then
|
||||
log_warn "Domain alias already exists: $alias_domain"
|
||||
return 0
|
||||
fi
|
||||
log_error "Failed to add DNS alias"
|
||||
echo "$response" | jq .
|
||||
return 1
|
||||
fi
|
||||
|
||||
log_success "DNS alias added: $alias_domain"
|
||||
echo "$response" | jq '.data | {domain, type, record_type}'
|
||||
return 0
|
||||
}
|
||||
|
||||
# Remove a DNS alias
|
||||
remove_dns_alias() {
|
||||
local project_name="$1"
|
||||
local alias_domain="$2"
|
||||
|
||||
log_info "Removing DNS alias: $alias_domain"
|
||||
|
||||
local response
|
||||
response=$(api_call DELETE "/projects/$project_name/domains/$alias_domain")
|
||||
|
||||
if echo "$response" | jq -e '.error' > /dev/null 2>&1; then
|
||||
local error_code
|
||||
error_code=$(echo "$response" | jq -r '.error.code // "UNKNOWN"')
|
||||
if [[ "$error_code" == "NOT_FOUND" ]]; then
|
||||
log_warn "Domain alias not found (already deleted?): $alias_domain"
|
||||
return 0
|
||||
fi
|
||||
log_warn "Failed to remove DNS alias: $alias_domain"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log_success "DNS alias removed: $alias_domain"
|
||||
return 0
|
||||
}
|
||||
|
||||
# Main run flow
|
||||
run_flow() {
|
||||
local project_name="${1:-landing-test}"
|
||||
|
||||
# Default prompt for building a landing page
|
||||
local build_prompt="Build a modern landing page with: dark gradient background (#1a1a2e to #16213e), centered hero section with company name 'Acme Corp' and tagline 'Building the future', email signup form with a submit button, responsive design for mobile. Use vanilla HTML/CSS/JS. Create index.html, styles.css, and a Dockerfile that serves with nginx on port 80."
|
||||
local project_name="$1"
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo " Landing Page Cookbook Test"
|
||||
echo " Project: $project_name"
|
||||
echo " Flow: Agent-driven (create-and-build)"
|
||||
echo " Landing Page E2E Test (Composable)"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "Project: $project_name"
|
||||
echo ""
|
||||
|
||||
# Step 0: Health check
|
||||
check_health || exit 1
|
||||
echo ""
|
||||
|
||||
# Step 1: Create project AND enqueue build in one call
|
||||
log_info "Step 1: Creating project and enqueuing build task..."
|
||||
log_info "Prompt: ${build_prompt:0:80}..."
|
||||
|
||||
local create_payload
|
||||
create_payload=$(jq -n \
|
||||
--arg name "$project_name" \
|
||||
--arg desc "Cookbook test: agent-driven landing page" \
|
||||
--arg prompt "$build_prompt" \
|
||||
'{
|
||||
name: $name,
|
||||
description: $desc,
|
||||
prompt: $prompt,
|
||||
auto_commit: true,
|
||||
auto_push: true
|
||||
}')
|
||||
# Step 1: Create project (monorepo skeleton)
|
||||
log_info "Step 1: Creating project skeleton..."
|
||||
|
||||
local create_response
|
||||
create_response=$(api_call POST "/project/create-and-build" "$create_payload")
|
||||
create_response=$(api_call POST "/projects" "{\"name\": \"$project_name\", \"description\": \"Landing page E2E test\"}")
|
||||
|
||||
if echo "$create_response" | jq -e '.error' > /dev/null 2>&1; then
|
||||
local domain
|
||||
domain=$(echo "$create_response" | jq -r '.data.domain // ""')
|
||||
|
||||
if [[ -z "$domain" || "$domain" == "null" ]]; then
|
||||
log_error "Failed to create project"
|
||||
echo "$create_response" | jq .
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_success "Project created and build enqueued"
|
||||
echo "$create_response" | jq '.data | {
|
||||
project_id,
|
||||
domain,
|
||||
url,
|
||||
git: .git.html_url,
|
||||
task_id,
|
||||
status,
|
||||
status_url
|
||||
}'
|
||||
log_success "Project created: $project_name"
|
||||
echo " Domain: $domain"
|
||||
echo " Git: https://git.threesix.ai/jordan/$project_name"
|
||||
echo ""
|
||||
|
||||
# Extract key info
|
||||
local primary_domain
|
||||
local task_id
|
||||
primary_domain=$(echo "$create_response" | jq -r '.data.domain')
|
||||
task_id=$(echo "$create_response" | jq -r '.data.task_id')
|
||||
# Step 2: Add app-astro component
|
||||
log_info "Step 2: Adding landing page component (app-astro)..."
|
||||
|
||||
if [[ -z "$task_id" || "$task_id" == "null" ]]; then
|
||||
log_error "No task_id returned - build was not enqueued"
|
||||
local component_response
|
||||
component_response=$(api_call POST "/projects/$project_name/components" '{"type": "app", "name": "landing", "template": "app-astro"}')
|
||||
|
||||
local component_path
|
||||
component_path=$(echo "$component_response" | jq -r '.data.path // ""')
|
||||
|
||||
if [[ -z "$component_path" || "$component_path" == "null" ]]; then
|
||||
log_error "Failed to add component"
|
||||
echo "$component_response" | jq .
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_success "Build task ID: $task_id"
|
||||
local component_port
|
||||
component_port=$(echo "$component_response" | jq -r '.data.port // "N/A"')
|
||||
|
||||
log_success "Component added: $component_path (port: $component_port)"
|
||||
echo ""
|
||||
|
||||
# Step 2: Monitor build progress (Claude building the site)
|
||||
log_info "Step 2: Monitoring build progress..."
|
||||
# Step 3: Wait for pipeline
|
||||
log_info "Step 3: Waiting for CI pipeline..."
|
||||
echo ""
|
||||
local build_success=false
|
||||
if wait_for_build "$task_id" "$project_name"; then
|
||||
build_success=true
|
||||
else
|
||||
log_error "Build did not complete successfully"
|
||||
log_info "Check build details: curl -s \"\$RDEV_API_URL/builds/$task_id\" -H \"X-API-Key: \$RDEV_API_KEY\" | jq ."
|
||||
|
||||
if ! wait_for_pipeline "$project_name"; then
|
||||
log_warn "Pipeline failed, but continuing to check if site is accessible..."
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Step 3: Monitor CI pipeline (only if build succeeded)
|
||||
local pipeline_success=false
|
||||
if [[ "$build_success" == "true" ]]; then
|
||||
log_info "Step 3: Monitoring CI pipeline..."
|
||||
if wait_for_pipeline "$project_name"; then
|
||||
pipeline_success=true
|
||||
else
|
||||
log_warn "Pipeline did not complete successfully"
|
||||
log_info "Check Woodpecker: https://ci.threesix.ai/threesix/$project_name"
|
||||
fi
|
||||
else
|
||||
log_info "Step 3: Skipping pipeline monitoring (build failed)"
|
||||
fi
|
||||
echo ""
|
||||
# Step 4: Wait for site
|
||||
log_info "Step 4: Verifying site is accessible..."
|
||||
|
||||
# Step 4: Verify site is live
|
||||
local site_live=false
|
||||
if [[ "$pipeline_success" == "true" ]]; then
|
||||
log_info "Step 4: Verifying site is accessible..."
|
||||
if wait_for_site "$primary_domain"; then
|
||||
site_live=true
|
||||
# Show a snippet of the response
|
||||
log_info "Fetching site content preview..."
|
||||
curl -s "https://$primary_domain" | head -20 | grep -E '<title>|<h1' || true
|
||||
fi
|
||||
if wait_for_site "$domain"; then
|
||||
log_success "Site verified!"
|
||||
else
|
||||
log_info "Step 4: Skipping site verification (pipeline not successful)"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Step 5: Test adding custom domains
|
||||
local test_alias="${project_name}-alias.threesix.ai"
|
||||
log_info "Step 5: Testing custom domain functionality..."
|
||||
if test_dns_alias "$project_name" "$test_alias"; then
|
||||
log_success "Domain alias test passed"
|
||||
# Clean up test alias
|
||||
sleep 2
|
||||
remove_dns_alias "$project_name" "$test_alias"
|
||||
else
|
||||
log_warn "Domain alias test failed - check Cloudflare permissions"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# List all domains
|
||||
log_info "Listing all project domains..."
|
||||
local domains_response
|
||||
domains_response=$(api_call GET "/projects/$project_name/domains")
|
||||
if echo "$domains_response" | jq -e '.data.domains' > /dev/null 2>&1; then
|
||||
echo "$domains_response" | jq '.data.domains[] | {domain, type, verified}'
|
||||
log_warn "Site not accessible yet, may need more time"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Summary
|
||||
echo "=========================================="
|
||||
echo " Test Results Summary"
|
||||
echo " Test Complete"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo " Project: $project_name"
|
||||
echo " Task ID: $task_id"
|
||||
echo " Git repo: $(echo "$create_response" | jq -r '.data.git.html_url // "N/A"')"
|
||||
echo " Primary: https://$primary_domain"
|
||||
echo " Site URL: https://$domain"
|
||||
echo " Git: https://git.threesix.ai/jordan/$project_name"
|
||||
echo " CI: https://ci.threesix.ai/jordan/$project_name"
|
||||
echo ""
|
||||
echo " Test Results:"
|
||||
echo -e " Project created: ${GREEN}PASS${NC}"
|
||||
if [[ "$build_success" == "true" ]]; then
|
||||
echo -e " Agent build: ${GREEN}PASS${NC}"
|
||||
else
|
||||
echo -e " Agent build: ${RED}FAIL${NC}"
|
||||
fi
|
||||
if [[ "$pipeline_success" == "true" ]]; then
|
||||
echo -e " CI Pipeline: ${GREEN}PASS${NC}"
|
||||
elif [[ "$build_success" == "true" ]]; then
|
||||
echo -e " CI Pipeline: ${RED}FAIL${NC}"
|
||||
else
|
||||
echo -e " CI Pipeline: ${YELLOW}SKIPPED${NC}"
|
||||
fi
|
||||
if [[ "$site_live" == "true" ]]; then
|
||||
echo -e " Site accessible: ${GREEN}PASS${NC}"
|
||||
elif [[ "$pipeline_success" == "true" ]]; then
|
||||
echo -e " Site accessible: ${YELLOW}PENDING${NC}"
|
||||
else
|
||||
echo -e " Site accessible: ${YELLOW}SKIPPED${NC}"
|
||||
fi
|
||||
echo -e " Custom domains: ${GREEN}TESTED${NC}"
|
||||
echo " To customize: POST /projects/$project_name/builds with a prompt"
|
||||
echo " To teardown: $0 teardown $project_name"
|
||||
echo ""
|
||||
echo " Useful commands:"
|
||||
echo " Build status: curl -s \"\$RDEV_API_URL/builds/$task_id\" -H \"X-API-Key: \$RDEV_API_KEY\" | jq .data"
|
||||
echo " Check status: ./cookbooks/scripts/landing-test.sh status $project_name"
|
||||
echo " View logs: ./scripts/logs.sh -e"
|
||||
echo " Woodpecker: https://ci.threesix.ai/threesix/$project_name"
|
||||
echo " Teardown: ./cookbooks/scripts/landing-test.sh teardown $project_name"
|
||||
echo ""
|
||||
|
||||
# Return appropriate exit code
|
||||
if [[ "$build_success" == "true" && "$pipeline_success" == "true" && "$site_live" == "true" ]]; then
|
||||
log_success "Full E2E test PASSED"
|
||||
return 0
|
||||
elif [[ "$build_success" == "true" && "$pipeline_success" == "true" ]]; then
|
||||
log_warn "Partial success - build and pipeline passed but site not yet live"
|
||||
return 0
|
||||
elif [[ "$build_success" == "true" ]]; then
|
||||
log_warn "Partial success - build passed but pipeline failed"
|
||||
return 1
|
||||
else
|
||||
log_error "E2E test FAILED - build did not complete"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Check status
|
||||
check_status() {
|
||||
local project_name="$1"
|
||||
|
||||
echo ""
|
||||
echo "=== Project Status: $project_name ==="
|
||||
echo ""
|
||||
|
||||
log_info "Project info:"
|
||||
api_call GET "/projects/$project_name" | jq '.data // .'
|
||||
echo ""
|
||||
|
||||
log_info "Components:"
|
||||
api_call GET "/projects/$project_name/components" | jq '.data // .'
|
||||
echo ""
|
||||
|
||||
log_info "Latest pipelines:"
|
||||
api_call GET "/projects/$project_name/pipelines" | jq '.data[:3] // .'
|
||||
echo ""
|
||||
}
|
||||
|
||||
# Teardown
|
||||
teardown() {
|
||||
local project_name="${1:-landing-test}"
|
||||
local project_name="$1"
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo " Teardown: $project_name"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
log_info "Tearing down project: $project_name"
|
||||
|
||||
# First, list domains that will be deleted
|
||||
log_info "Checking domains to be deleted..."
|
||||
local domains_response
|
||||
domains_response=$(api_call GET "/projects/$project_name/domains")
|
||||
|
||||
if echo "$domains_response" | jq -e '.data.total' > /dev/null 2>&1; then
|
||||
local domain_count
|
||||
domain_count=$(echo "$domains_response" | jq -r '.data.total')
|
||||
log_info "Will delete $domain_count domain(s):"
|
||||
echo "$domains_response" | jq -r '.data.domains[]? | " - \(.domain)"'
|
||||
echo ""
|
||||
fi
|
||||
|
||||
log_info "Deleting project $project_name..."
|
||||
local response
|
||||
response=$(api_call DELETE "/project/$project_name")
|
||||
|
||||
if echo "$response" | jq -e '.data.status == "deleted"' > /dev/null 2>&1; then
|
||||
log_success "Project deleted"
|
||||
echo "$response" | jq .data
|
||||
elif echo "$response" | jq -e '.error.code == "NOT_FOUND"' > /dev/null 2>&1; then
|
||||
log_warn "Project not found (already deleted?)"
|
||||
else
|
||||
log_error "Failed to delete project"
|
||||
echo "$response" | jq .
|
||||
exit 1
|
||||
fi
|
||||
echo ""
|
||||
|
||||
log_info "Note: Gitea repo is preserved for safety. Delete manually if needed:"
|
||||
echo " https://git.threesix.ai/threesix/$project_name/settings"
|
||||
echo ""
|
||||
}
|
||||
|
||||
status() {
|
||||
local project_name="${1:-landing-test}"
|
||||
|
||||
echo ""
|
||||
log_info "Fetching status for: $project_name"
|
||||
echo ""
|
||||
|
||||
# Get project info
|
||||
local response
|
||||
response=$(api_call GET "/project/$project_name")
|
||||
response=$(api_call DELETE "/projects/$project_name")
|
||||
|
||||
if echo "$response" | jq -e '.error' > /dev/null 2>&1; then
|
||||
log_error "Project not found or error"
|
||||
log_error "Teardown failed"
|
||||
echo "$response" | jq .
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "$response" | jq '.data | {
|
||||
name,
|
||||
description,
|
||||
domain,
|
||||
url,
|
||||
git: .git.html_url,
|
||||
deployment
|
||||
}'
|
||||
|
||||
log_success "Project deleted (Gitea repo preserved)"
|
||||
echo "$response" | jq '.data // .'
|
||||
echo ""
|
||||
log_info "Listing all domains..."
|
||||
local domains_response
|
||||
domains_response=$(api_call GET "/projects/$project_name/domains")
|
||||
|
||||
if echo "$domains_response" | jq -e '.data.domains' > /dev/null 2>&1; then
|
||||
echo "$domains_response" | jq '.data.domains'
|
||||
fi
|
||||
|
||||
echo ""
|
||||
log_info "Checking recent builds..."
|
||||
local builds_response
|
||||
builds_response=$(api_call GET "/projects/$project_name/builds?limit=3")
|
||||
|
||||
if echo "$builds_response" | jq -e '.data.builds' > /dev/null 2>&1; then
|
||||
echo "$builds_response" | jq '.data.builds[] | {task_id, status, started_at, result: .result.success}'
|
||||
else
|
||||
log_info "No builds found"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
log_info "Checking recent pipelines..."
|
||||
local pipelines_response
|
||||
pipelines_response=$(api_call GET "/projects/$project_name/pipelines")
|
||||
|
||||
if echo "$pipelines_response" | jq -e '.data | length > 0' > /dev/null 2>&1; then
|
||||
echo "$pipelines_response" | jq '.data[0:3][] | {number, status, branch, commit: .commit[0:8]}'
|
||||
else
|
||||
log_info "No pipelines found"
|
||||
fi
|
||||
}
|
||||
|
||||
# Main
|
||||
case "${1:-}" in
|
||||
# Parse command
|
||||
COMMAND="${1:-}"
|
||||
PROJECT_NAME="${2:-landing-test-$(date +%s)}"
|
||||
|
||||
case "$COMMAND" in
|
||||
run)
|
||||
shift
|
||||
run_flow "${1:-landing-test}"
|
||||
;;
|
||||
teardown)
|
||||
shift
|
||||
teardown "${1:-landing-test}"
|
||||
run_flow "$PROJECT_NAME"
|
||||
;;
|
||||
status)
|
||||
shift
|
||||
status "${1:-landing-test}"
|
||||
check_status "$PROJECT_NAME"
|
||||
;;
|
||||
teardown)
|
||||
teardown "$PROJECT_NAME"
|
||||
;;
|
||||
*)
|
||||
echo "Usage: $0 {run|teardown|status} [project-name]"
|
||||
echo "Landing Page E2E Test Script"
|
||||
echo ""
|
||||
echo "Usage: $0 <command> [project-name]"
|
||||
echo ""
|
||||
echo "Commands:"
|
||||
echo " run [name] Create project with agent-driven build and run full E2E flow"
|
||||
echo " teardown [name] Delete project and clean up"
|
||||
echo " status [name] Check current project status, builds, and pipelines"
|
||||
echo ""
|
||||
echo "E2E Flow (matches cookbooks/landing-page.md):"
|
||||
echo " 1. POST /project/create-and-build - Create project + enqueue agent build"
|
||||
echo " 2. GET /builds/{task_id} - Monitor Claude building the site"
|
||||
echo " 3. GET /projects/{id}/pipelines - Monitor CI pipeline"
|
||||
echo " 4. Verify site is live (HTTP 200)"
|
||||
echo " 5. Test custom domains (POST/DELETE /projects/{id}/domains)"
|
||||
echo " 6. DELETE /project/{name} - Teardown"
|
||||
echo ""
|
||||
echo "Timeouts:"
|
||||
echo " Build: ${BUILD_TIMEOUT}s, Pipeline: ${PIPELINE_TIMEOUT}s, Site: ${SITE_TIMEOUT}s"
|
||||
echo ""
|
||||
echo "Environment:"
|
||||
echo " RDEV_API_URL API endpoint (default: https://rdev.masq-ops.orchard9.ai)"
|
||||
echo " RDEV_API_KEY API key (required)"
|
||||
echo " STREAM_MODE Set to 'true' for live SSE streaming of build output"
|
||||
echo " run Run the full composable landing page flow"
|
||||
echo " status Check project and component status"
|
||||
echo " teardown Delete project (preserves git repo)"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " $0 run # Run with default project name 'landing-test'"
|
||||
echo " $0 run my-landing # Run with custom project name"
|
||||
echo " STREAM_MODE=true $0 run # Run with live build output streaming"
|
||||
echo " $0 status my-landing # Check status, builds, and pipelines"
|
||||
echo " $0 teardown my-landing # Clean up project"
|
||||
echo " $0 run my-landing"
|
||||
echo " $0 status my-landing"
|
||||
echo " $0 teardown my-landing"
|
||||
echo ""
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
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.
|
||||
func (d *Deployer) createOrUpdateSecret(ctx context.Context, spec domain.DeploySpec) error {
|
||||
secretName := spec.ProjectName + "-env"
|
||||
deploymentName := spec.DeploymentName()
|
||||
secretName := deploymentName + "-env"
|
||||
ns := d.config.Namespace
|
||||
|
||||
secret := &corev1.Secret{
|
||||
@ -49,7 +50,7 @@ func (d *Deployer) createOrUpdateSecret(ctx context.Context, spec domain.DeployS
|
||||
Name: secretName,
|
||||
Namespace: ns,
|
||||
Labels: map[string]string{
|
||||
"app": spec.ProjectName,
|
||||
"app": deploymentName,
|
||||
"project": spec.ProjectName,
|
||||
},
|
||||
},
|
||||
@ -69,6 +70,7 @@ func (d *Deployer) createOrUpdateSecret(ctx context.Context, spec domain.DeployS
|
||||
func (d *Deployer) createOrUpdateDeployment(ctx context.Context, spec domain.DeploySpec) error {
|
||||
ns := d.config.Namespace
|
||||
replicas := int32(spec.Replicas)
|
||||
deploymentName := spec.DeploymentName()
|
||||
|
||||
// Build env vars
|
||||
var envVars []corev1.EnvVar
|
||||
@ -82,7 +84,7 @@ func (d *Deployer) createOrUpdateDeployment(ctx context.Context, spec domain.Dep
|
||||
envFrom = append(envFrom, corev1.EnvFromSource{
|
||||
SecretRef: &corev1.SecretEnvSource{
|
||||
LocalObjectReference: corev1.LocalObjectReference{
|
||||
Name: spec.ProjectName + "-env",
|
||||
Name: deploymentName + "-env",
|
||||
},
|
||||
},
|
||||
})
|
||||
@ -90,7 +92,7 @@ func (d *Deployer) createOrUpdateDeployment(ctx context.Context, spec domain.Dep
|
||||
|
||||
deployment := d.buildDeployment(spec, ns, replicas, envVars, envFrom)
|
||||
|
||||
_, err := d.client.AppsV1().Deployments(ns).Get(ctx, spec.ProjectName, metav1.GetOptions{})
|
||||
_, err := d.client.AppsV1().Deployments(ns).Get(ctx, deploymentName, metav1.GetOptions{})
|
||||
if errors.IsNotFound(err) {
|
||||
_, err = d.client.AppsV1().Deployments(ns).Create(ctx, deployment, metav1.CreateOptions{})
|
||||
} else if err == nil {
|
||||
@ -100,33 +102,38 @@ func (d *Deployer) createOrUpdateDeployment(ctx context.Context, spec domain.Dep
|
||||
}
|
||||
|
||||
func (d *Deployer) buildDeployment(spec domain.DeploySpec, ns string, replicas int32, envVars []corev1.EnvVar, envFrom []corev1.EnvFromSource) *appsv1.Deployment {
|
||||
deploymentName := spec.DeploymentName()
|
||||
|
||||
// Build labels - always include project, component if present
|
||||
labels := map[string]string{
|
||||
"app": deploymentName,
|
||||
"project": spec.ProjectName,
|
||||
}
|
||||
if spec.ComponentPath != "" {
|
||||
labels["component"] = spec.ComponentPath
|
||||
}
|
||||
|
||||
return &appsv1.Deployment{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: spec.ProjectName,
|
||||
Name: deploymentName,
|
||||
Namespace: ns,
|
||||
Labels: map[string]string{
|
||||
"app": spec.ProjectName,
|
||||
"project": spec.ProjectName,
|
||||
},
|
||||
Labels: labels,
|
||||
},
|
||||
Spec: appsv1.DeploymentSpec{
|
||||
Replicas: &replicas,
|
||||
Selector: &metav1.LabelSelector{
|
||||
MatchLabels: map[string]string{
|
||||
"app": spec.ProjectName,
|
||||
"app": deploymentName,
|
||||
},
|
||||
},
|
||||
Template: corev1.PodTemplateSpec{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Labels: map[string]string{
|
||||
"app": spec.ProjectName,
|
||||
"project": spec.ProjectName,
|
||||
},
|
||||
Labels: labels,
|
||||
},
|
||||
Spec: corev1.PodSpec{
|
||||
Containers: []corev1.Container{
|
||||
{
|
||||
Name: spec.ProjectName,
|
||||
Name: deploymentName,
|
||||
Image: spec.Image,
|
||||
Env: envVars,
|
||||
EnvFrom: envFrom,
|
||||
@ -157,19 +164,26 @@ func (d *Deployer) buildDeployment(spec domain.DeploySpec, ns string, replicas i
|
||||
// createOrUpdateService manages the Kubernetes Service resource.
|
||||
func (d *Deployer) createOrUpdateService(ctx context.Context, spec domain.DeploySpec) error {
|
||||
ns := d.config.Namespace
|
||||
deploymentName := spec.DeploymentName()
|
||||
|
||||
// Build labels
|
||||
labels := map[string]string{
|
||||
"app": deploymentName,
|
||||
"project": spec.ProjectName,
|
||||
}
|
||||
if spec.ComponentPath != "" {
|
||||
labels["component"] = spec.ComponentPath
|
||||
}
|
||||
|
||||
service := &corev1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: spec.ProjectName,
|
||||
Name: deploymentName,
|
||||
Namespace: ns,
|
||||
Labels: map[string]string{
|
||||
"app": spec.ProjectName,
|
||||
"project": spec.ProjectName,
|
||||
},
|
||||
Labels: labels,
|
||||
},
|
||||
Spec: corev1.ServiceSpec{
|
||||
Selector: map[string]string{
|
||||
"app": spec.ProjectName,
|
||||
"app": deploymentName,
|
||||
},
|
||||
Ports: []corev1.ServicePort{
|
||||
{
|
||||
@ -181,7 +195,7 @@ func (d *Deployer) createOrUpdateService(ctx context.Context, spec domain.Deploy
|
||||
},
|
||||
}
|
||||
|
||||
_, err := d.client.CoreV1().Services(ns).Get(ctx, spec.ProjectName, metav1.GetOptions{})
|
||||
_, err := d.client.CoreV1().Services(ns).Get(ctx, deploymentName, metav1.GetOptions{})
|
||||
if errors.IsNotFound(err) {
|
||||
_, err = d.client.CoreV1().Services(ns).Create(ctx, service, metav1.CreateOptions{})
|
||||
} else if err == nil {
|
||||
@ -195,6 +209,7 @@ func (d *Deployer) createOrUpdateIngress(ctx context.Context, spec domain.Deploy
|
||||
ns := d.config.Namespace
|
||||
pathType := networkingv1.PathTypePrefix
|
||||
ingressClass := d.config.IngressClass
|
||||
deploymentName := spec.DeploymentName()
|
||||
|
||||
// Build TLS secret name from domain
|
||||
tlsSecretName := strings.ReplaceAll(spec.Domain, ".", "-") + "-tls"
|
||||
@ -206,7 +221,7 @@ func (d *Deployer) createOrUpdateIngress(ctx context.Context, spec domain.Deploy
|
||||
|
||||
ingress := d.buildIngress(spec, ns, pathType, ingressClass, tlsSecretName, annotations)
|
||||
|
||||
_, err := d.client.NetworkingV1().Ingresses(ns).Get(ctx, spec.ProjectName, metav1.GetOptions{})
|
||||
_, err := d.client.NetworkingV1().Ingresses(ns).Get(ctx, deploymentName, metav1.GetOptions{})
|
||||
if errors.IsNotFound(err) {
|
||||
_, err = d.client.NetworkingV1().Ingresses(ns).Create(ctx, ingress, metav1.CreateOptions{})
|
||||
} else if err == nil {
|
||||
@ -216,14 +231,22 @@ func (d *Deployer) createOrUpdateIngress(ctx context.Context, spec domain.Deploy
|
||||
}
|
||||
|
||||
func (d *Deployer) buildIngress(spec domain.DeploySpec, ns string, pathType networkingv1.PathType, ingressClass, tlsSecretName string, annotations map[string]string) *networkingv1.Ingress {
|
||||
deploymentName := spec.DeploymentName()
|
||||
|
||||
// Build labels
|
||||
labels := map[string]string{
|
||||
"app": deploymentName,
|
||||
"project": spec.ProjectName,
|
||||
}
|
||||
if spec.ComponentPath != "" {
|
||||
labels["component"] = spec.ComponentPath
|
||||
}
|
||||
|
||||
return &networkingv1.Ingress{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: spec.ProjectName,
|
||||
Namespace: ns,
|
||||
Labels: map[string]string{
|
||||
"app": spec.ProjectName,
|
||||
"project": spec.ProjectName,
|
||||
},
|
||||
Name: deploymentName,
|
||||
Namespace: ns,
|
||||
Labels: labels,
|
||||
Annotations: annotations,
|
||||
},
|
||||
Spec: networkingv1.IngressSpec{
|
||||
@ -245,7 +268,7 @@ func (d *Deployer) buildIngress(spec domain.DeploySpec, ns string, pathType netw
|
||||
PathType: &pathType,
|
||||
Backend: networkingv1.IngressBackend{
|
||||
Service: &networkingv1.IngressServiceBackend{
|
||||
Name: spec.ProjectName,
|
||||
Name: deploymentName,
|
||||
Port: networkingv1.ServiceBackendPort{
|
||||
Number: int32(spec.Port),
|
||||
},
|
||||
|
||||
@ -4,6 +4,7 @@ package gitea
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@ -88,7 +89,9 @@ func NewBulkFileClient(baseURL, token string) *BulkFileClient {
|
||||
return &BulkFileClient{
|
||||
baseURL: strings.TrimSuffix(baseURL, "/"),
|
||||
token: token,
|
||||
client: &http.Client{},
|
||||
client: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -183,3 +186,53 @@ func isRetryableError(err error) bool {
|
||||
strings.Contains(err.Error(), "timeout") ||
|
||||
strings.Contains(err.Error(), "EOF")
|
||||
}
|
||||
|
||||
// GetFileContent retrieves the content of a file from a repository.
|
||||
// Returns the decoded content and the file's SHA (needed for updates).
|
||||
// Returns nil, nil if the file doesn't exist (404).
|
||||
func (c *BulkFileClient) GetFileContent(ctx context.Context, owner, repo, filepath string) ([]byte, string, error) {
|
||||
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/contents/%s", c.baseURL, owner, repo, filepath)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "token "+c.token)
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode == 404 {
|
||||
return nil, "", nil // File doesn't exist
|
||||
}
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, "", &apiError{StatusCode: resp.StatusCode, Body: string(respBody)}
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Content string `json:"content"`
|
||||
Encoding string `json:"encoding"`
|
||||
SHA string `json:"sha"`
|
||||
}
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
return nil, "", fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
// Decode base64 content
|
||||
content, err := base64.StdEncoding.DecodeString(result.Content)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to decode content: %w", err)
|
||||
}
|
||||
|
||||
return content, result.SHA, nil
|
||||
}
|
||||
|
||||
@ -36,6 +36,52 @@ var availableTemplates = []port.TemplateInfo{
|
||||
{Name: "go-api", Description: "Go REST API with chi router", Stack: "go"},
|
||||
}
|
||||
|
||||
// skeletonTemplate is the monorepo skeleton template used for composable projects.
|
||||
var skeletonTemplate = port.TemplateInfo{
|
||||
Name: "skeleton",
|
||||
Description: "Composable monorepo skeleton with services, workers, apps, and CLI directories",
|
||||
Stack: "monorepo",
|
||||
}
|
||||
|
||||
// availableComponentTemplates lists all supported component templates.
|
||||
var availableComponentTemplates = []port.ComponentTemplateInfo{
|
||||
{
|
||||
Type: "service",
|
||||
Description: "Go API service using pkg/ shared packages",
|
||||
Stack: "go",
|
||||
DefaultPort: 8080,
|
||||
DestDir: "services",
|
||||
},
|
||||
{
|
||||
Type: "worker",
|
||||
Description: "Go background worker for async job processing",
|
||||
Stack: "go",
|
||||
DefaultPort: 0, // Workers don't expose ports
|
||||
DestDir: "workers",
|
||||
},
|
||||
{
|
||||
Type: "app-astro",
|
||||
Description: "Astro landing page with Tailwind CSS",
|
||||
Stack: "astro",
|
||||
DefaultPort: 4321,
|
||||
DestDir: "apps",
|
||||
},
|
||||
{
|
||||
Type: "app-react",
|
||||
Description: "React SPA with Vite, TypeScript, and Tailwind",
|
||||
Stack: "react",
|
||||
DefaultPort: 5173,
|
||||
DestDir: "apps",
|
||||
},
|
||||
{
|
||||
Type: "cli",
|
||||
Description: "Go CLI tool using Cobra",
|
||||
Stack: "go",
|
||||
DefaultPort: 0, // CLIs don't expose ports
|
||||
DestDir: "cli",
|
||||
},
|
||||
}
|
||||
|
||||
// templateNameRegex validates template names (alphanumeric, dash only).
|
||||
var templateNameRegex = regexp.MustCompile(`^[a-z][a-z0-9-]*$`)
|
||||
|
||||
@ -247,3 +293,195 @@ func listTemplateFiles(templateName string) ([]string, error) {
|
||||
|
||||
return files, err
|
||||
}
|
||||
|
||||
// SeedSkeleton populates a repository with the monorepo skeleton template.
|
||||
// This creates the base monorepo structure without any components.
|
||||
func (p *Provider) SeedSkeleton(ctx context.Context, owner, repo string, vars map[string]string) error {
|
||||
return p.SeedRepo(ctx, owner, repo, "skeleton", vars)
|
||||
}
|
||||
|
||||
// GetSkeleton returns info about the monorepo skeleton template.
|
||||
func (p *Provider) GetSkeleton(ctx context.Context) (*port.TemplateInfo, error) {
|
||||
// Check for context cancellation
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
result := skeletonTemplate
|
||||
files, err := listTemplateFiles("skeleton")
|
||||
if err == nil {
|
||||
result.Files = files
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// GetComponentTemplate returns info about a specific component template.
|
||||
func (p *Provider) GetComponentTemplate(ctx context.Context, componentType string) (*port.ComponentTemplateInfo, error) {
|
||||
// Check for context cancellation
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
for _, t := range availableComponentTemplates {
|
||||
if t.Type == componentType {
|
||||
result := t
|
||||
files, err := listComponentTemplateFiles(componentType)
|
||||
if err == nil {
|
||||
result.Files = files
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("%w: component type %s", domain.ErrTemplateNotFound, componentType)
|
||||
}
|
||||
|
||||
// ListComponentTemplates returns available component templates.
|
||||
// If componentType is empty, returns all templates; otherwise filters by type.
|
||||
func (p *Provider) ListComponentTemplates(ctx context.Context, componentType string) ([]port.ComponentTemplateInfo, error) {
|
||||
// Check for context cancellation
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
var result []port.ComponentTemplateInfo
|
||||
for _, t := range availableComponentTemplates {
|
||||
if componentType != "" && t.Type != componentType {
|
||||
continue
|
||||
}
|
||||
info := t
|
||||
files, err := listComponentTemplateFiles(t.Type)
|
||||
if err == nil {
|
||||
info.Files = files
|
||||
}
|
||||
result = append(result, info)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetComponentFiles returns the files for a component template with variables interpolated.
|
||||
func (p *Provider) GetComponentFiles(ctx context.Context, componentType string, destPath string, vars map[string]string) ([]port.ComponentFile, error) {
|
||||
// Check for context cancellation
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
// Validate component type exists
|
||||
found := false
|
||||
for _, t := range availableComponentTemplates {
|
||||
if t.Type == componentType {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return nil, fmt.Errorf("%w: component type %s", domain.ErrTemplateNotFound, componentType)
|
||||
}
|
||||
|
||||
templateDir := "templates/components/" + componentType
|
||||
var files []port.ComponentFile
|
||||
|
||||
err := fs.WalkDir(templatesFS, templateDir, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Read file content
|
||||
content, err := templatesFS.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read component template file %s: %w", path, err)
|
||||
}
|
||||
|
||||
// Interpolate variables
|
||||
interpolated := interpolateVars(string(content), vars)
|
||||
|
||||
// Calculate relative path from component template root
|
||||
relPath, err := filepath.Rel(templateDir, path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get relative path: %w", err)
|
||||
}
|
||||
|
||||
// Strip .tmpl extension
|
||||
relPath = strings.TrimSuffix(relPath, ".tmpl")
|
||||
|
||||
// Prepend destination path
|
||||
fullPath := filepath.Join(destPath, relPath)
|
||||
|
||||
files = append(files, port.ComponentFile{
|
||||
Path: fullPath,
|
||||
Content: interpolated,
|
||||
})
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to collect component template files: %w", err)
|
||||
}
|
||||
|
||||
if len(files) == 0 {
|
||||
return nil, fmt.Errorf("component template %s contains no files", componentType)
|
||||
}
|
||||
|
||||
p.logger.Debug("prepared component files",
|
||||
"component_type", componentType,
|
||||
"dest_path", destPath,
|
||||
"file_count", len(files),
|
||||
)
|
||||
|
||||
return files, nil
|
||||
}
|
||||
|
||||
// listComponentTemplateFiles returns the list of files in a component template.
|
||||
func listComponentTemplateFiles(componentType string) ([]string, error) {
|
||||
templateDir := "templates/components/" + componentType
|
||||
var files []string
|
||||
|
||||
err := fs.WalkDir(templatesFS, templateDir, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
relPath, err := filepath.Rel(templateDir, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Strip .tmpl extension for display
|
||||
relPath = strings.TrimSuffix(relPath, ".tmpl")
|
||||
files = append(files, relPath)
|
||||
return nil
|
||||
})
|
||||
|
||||
return files, err
|
||||
}
|
||||
|
||||
// GetComponentWoodpeckerStep returns the .woodpecker.step.yml content for a component.
|
||||
// This is the CI step that should be inserted into the main .woodpecker.yml file.
|
||||
func (p *Provider) GetComponentWoodpeckerStep(ctx context.Context, componentType string, vars map[string]string) (string, error) {
|
||||
// Check for context cancellation
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
stepPath := "templates/components/" + componentType + "/.woodpecker.step.yml.tmpl"
|
||||
content, err := templatesFS.ReadFile(stepPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read woodpecker step template: %w", err)
|
||||
}
|
||||
|
||||
return interpolateVars(string(content), vars), nil
|
||||
}
|
||||
|
||||
@ -141,3 +141,48 @@ func TestListTemplateFiles_TmplExtensionStripped(t *testing.T) {
|
||||
assert.NotContains(t, f, ".tmpl", "file %s should not have .tmpl extension", f)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkeletonTemplate(t *testing.T) {
|
||||
// Test that skeleton template exists and has expected files
|
||||
files, err := listTemplateFiles("skeleton")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should have the core monorepo files
|
||||
assert.Contains(t, files, "CLAUDE.md", "skeleton template should have CLAUDE.md")
|
||||
assert.Contains(t, files, "README.md", "skeleton template should have README.md")
|
||||
assert.Contains(t, files, ".woodpecker.yml", "skeleton template should have .woodpecker.yml")
|
||||
assert.Contains(t, files, "docker-compose.yml", "skeleton template should have docker-compose.yml")
|
||||
assert.Contains(t, files, "go.work", "skeleton template should have go.work")
|
||||
assert.Contains(t, files, "Procfile", "skeleton template should have Procfile")
|
||||
assert.Contains(t, files, ".gitignore", "skeleton template should have .gitignore")
|
||||
assert.Contains(t, files, ".golangci.yml", "skeleton template should have .golangci.yml")
|
||||
|
||||
// Should have scripts
|
||||
assert.Contains(t, files, "scripts/dev.sh", "skeleton template should have scripts/dev.sh")
|
||||
assert.Contains(t, files, "scripts/install.sh", "skeleton template should have scripts/install.sh")
|
||||
assert.Contains(t, files, "scripts/quality.sh", "skeleton template should have scripts/quality.sh")
|
||||
assert.Contains(t, files, "scripts/discover.sh", "skeleton template should have scripts/discover.sh")
|
||||
|
||||
// Should have .claude structure
|
||||
assert.Contains(t, files, ".claude/settings.local.json", "skeleton template should have .claude/settings.local.json")
|
||||
assert.Contains(t, files, ".claude/guides/local/setup.md", "skeleton template should have .claude/guides/local/setup.md")
|
||||
assert.Contains(t, files, ".claude/guides/ops/deploying.md", "skeleton template should have .claude/guides/ops/deploying.md")
|
||||
assert.Contains(t, files, ".claude/skills/code-review/SKILL.md", "skeleton template should have .claude/skills/code-review/SKILL.md")
|
||||
|
||||
// Should have component directory placeholders
|
||||
assert.Contains(t, files, "services/.gitkeep", "skeleton template should have services/.gitkeep")
|
||||
assert.Contains(t, files, "workers/.gitkeep", "skeleton template should have workers/.gitkeep")
|
||||
assert.Contains(t, files, "apps/.gitkeep", "skeleton template should have apps/.gitkeep")
|
||||
assert.Contains(t, files, "cli/.gitkeep", "skeleton template should have cli/.gitkeep")
|
||||
|
||||
// Should have pkg directory files
|
||||
assert.Contains(t, files, "pkg/go.mod", "skeleton template should have pkg/go.mod")
|
||||
assert.Contains(t, files, "pkg/README.md", "skeleton template should have pkg/README.md")
|
||||
}
|
||||
|
||||
func TestSkeletonTemplateInfo(t *testing.T) {
|
||||
// Verify skeleton template metadata
|
||||
assert.Equal(t, "skeleton", skeletonTemplate.Name)
|
||||
assert.Equal(t, "monorepo", skeletonTemplate.Stack)
|
||||
assert.NotEmpty(t, skeletonTemplate.Description)
|
||||
}
|
||||
|
||||
@ -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