# Composable Monorepo Templates **When to use:** Implementing the monorepo skeleton, component templates, or the component addition API. ## Overview Composable Monorepo Templates evolve rdev from single-template projects to full monorepo scaffolding: 1. **Skeleton**: Every `POST /projects` creates the monorepo base (shared `pkg/`, scripts, CI) 2. **Component Templates**: `POST /projects/{id}/components` adds services/workers/apps/cli 3. **Deployment**: Target whole monorepo or individual components **Terminology:** "skeleton" = generated project code; "platform" = rdev itself. See [ai-lookup/terminology.md](../../../ai-lookup/terminology.md). **Plan Document:** `tmp/template-monorepo-plan.md` ## Implementation Phases ### Phase 1: Skeleton Template Create `templates/skeleton/` with base monorepo structure. **Files to create:** ``` internal/adapter/templates/templates/skeleton/ ├── CLAUDE.md.tmpl ├── README.md.tmpl ├── Procfile.tmpl ├── docker-compose.yml.tmpl ├── go.work.tmpl ├── .golangci.yml ├── .gitignore ├── .woodpecker.yml.tmpl # ⚠️ Template-provided CI (never AI-generated) ├── .claude/ │ ├── settings.local.json │ ├── guides/ │ └── skills/ ├── scripts/ │ ├── install.sh │ ├── quality.sh │ ├── dev.sh │ └── discover.sh └── pkg/ ├── go.mod.tmpl ├── app/ ├── middleware/ ├── httpcontext/ ├── httpresponse/ ├── httpvalidation/ ├── logging/ ├── config/ └── httpclient/ ``` **Critical: CI Must Be Templated** AI-generated `.woodpecker.yml` produces invalid YAML (broken anchor syntax). All CI comes from templates: - Skeleton provides base `.woodpecker.yml.tmpl` - Components provide `.woodpecker.step.yml.tmpl` - AddComponent service inserts component steps into main pipeline **Template Variables:** ```go {{.ProjectName}} // "acme" {{.GoModule}} // "github.com/orchard9/acme" {{.Description}} // "Project description" ``` **Provider Changes:** ```go // internal/port/template_provider.go type TemplateProvider interface { // Existing GetTemplate(name string) (*Template, error) ListTemplates() []Template // New GetSkeleton() (*Template, error) GetComponentTemplate(componentType, templateName string) (*Template, error) ListComponentTemplates(componentType string) []Template } ``` ### Phase 1.5: Shared Packages (pkg/) Extract and combine packages from Aeries + Colix: | Package | Source | Key Files | |---------|--------|-----------| | `app/` | Aeries `pkg/chassis/` | `app.go` | | `middleware/` | Colix `pkg/middleware/` | `cors.go`, `recovery.go`, `request_id.go`, `logger.go` | | `httpcontext/` | Colix `pkg/httpcontext/` | `keys.go` | | `httpresponse/` | Both | `response.go`, `envelope.go` | | `httpvalidation/` | Colix `pkg/httpvalidation/` | `validator.go`, `errors.go` | | `logging/` | Both | `logger.go` | | `config/` | Aeries `pkg/config/` | `config.go` | | `httpclient/` | Both | `client.go` | ### Phase 2: Component Templates Migrate existing templates to component format: ``` internal/adapter/templates/templates/components/ ├── service/ # Renamed from go-api │ ├── cmd/server/main.go.tmpl │ ├── internal/ │ ├── Makefile.tmpl │ ├── Dockerfile.tmpl │ ├── component.yaml.tmpl │ └── .woodpecker.step.yml.tmpl # CI step for this component type ├── worker/ # NEW ├── app-astro/ # Renamed from astro-landing ├── app-react/ # NEW └── cli/ # NEW ``` Each component includes `.woodpecker.step.yml.tmpl` that defines its Kaniko build step. When components are added, the service renders this template and inserts it into the main pipeline. **Component CI Step Rules:** - ALL component build steps MUST use `depends_on: [preflight]` (not `[deps]`) — this ensures registry health is verified before any image build - Components with Docker images (service, app-*, worker) MUST have a 3-step chain: `build` → `verify` (registry check) → `deploy` with explicit `depends_on` between each - Build steps MUST NOT use `failure: ignore` — build failures should be visible, not swallowed - The `verify` step uses `failure: ignore` so deploy is skipped (not failed) when the image doesn't exist - `updateWoodpeckerYml` automatically wires each new `deploy-{name}` step into `build-complete`'s `depends_on` via the `# BUILD_COMPLETE_DEPS` marker **Component Variables:** ```go {{.ProjectName}} // "acme" {{.ComponentName}} // "auth-api" {{.ComponentNameCamel}} // "AuthApi" {{.GoModule}} // "github.com/orchard9/acme" {{.Port}} // 8080 ``` ### Phase 3: Add Component API Create endpoint for adding components to existing projects. **Handler:** ```go // internal/handlers/components.go func (h *Handler) AddComponent(w http.ResponseWriter, r *http.Request) { projectID := chi.URLParam(r, "projectID") var req AddComponentRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { api.Error(w, http.StatusBadRequest, "invalid request") return } component, err := h.svc.AddComponent(r.Context(), projectID, req.Type, req.Name, req.Template) if err != nil { api.Error(w, http.StatusInternalServerError, err.Error()) return } api.OK(w, component) } ``` **Request/Response:** ```go type AddComponentRequest struct { Type string `json:"type"` // service, worker, app, cli Name string `json:"name"` // auth-api, dashboard, etc. Template string `json:"template"` // optional: specific variant } ``` **Service Logic:** ```go func (s *Service) AddComponent(ctx context.Context, projectID, compType, name, template string) (*domain.Component, error) { // 1. Render component template // 2. Commit files to project repo // 3. Update Procfile with new entry // 4. Update go.work if Go component // 5. Update CLAUDE.md routing // 6. Update .woodpecker.yml with component's build step // 7. Return component metadata } ``` ### Phase 4: Component-Aware Deployment Extend deploy to support component targets: ```go type DeployRequest struct { Component string `json:"component,omitempty"` // e.g., "services/auth-api" } // POST /projects/{id}/deploy → full monorepo // POST /projects/{id}/deploy/services/auth-api → single component ``` ### Phase 5: Discovery Scripts Scripts that walk the monorepo and operate on all components: ```bash # scripts/discover.sh #!/bin/bash for type in services workers apps cli; do for dir in "$type"/*/; do [ -d "$dir" ] && echo "$type/$(basename $dir)" done done # scripts/install.sh #!/bin/bash for service in services/*/; do [ -f "$service/go.mod" ] && (cd "$service" && go mod download) done for app in apps/*/; do [ -f "$app/package.json" ] && (cd "$app" && npm install) done # scripts/quality.sh #!/bin/bash golangci-lint run ./services/... ./workers/... ./cli/... for app in apps/*/; do [ -f "$app/package.json" ] && (cd "$app" && npm run lint) done # scripts/dev.sh #!/bin/bash docker-compose up -d overmind start ``` ### Phase 6: Quality Hooks Pre-commit hooks for monorepo quality: ```bash # .githooks/pre-commit #!/bin/bash # File length check (500 lines) for file in $(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(go|ts|tsx)$'); do lines=$(wc -l < "$file") if [ "$lines" -gt 500 ]; then echo "Error: $file exceeds 500 lines ($lines)" exit 1 fi done # Go formatting gofmt -l -w $(git diff --cached --name-only --diff-filter=ACM | grep '\.go$') # Linting golangci-lint run ./services/... ./workers/... ./cli/... ``` ## Domain Model ```go // internal/domain/component.go type ComponentType string const ( // Code components (scaffold template files) ComponentTypeService ComponentType = "service" ComponentTypeWorker ComponentType = "worker" ComponentTypeAppAstro ComponentType = "app-astro" ComponentTypeAppReact ComponentType = "app-react" ComponentTypeCLI ComponentType = "cli" // Infrastructure components (provision resources) ComponentTypePostgres ComponentType = "postgres" ComponentTypeRedis ComponentType = "redis" ) type Component struct { Type ComponentType Name string Template string Path string // "services/auth-api" or "infra/postgres" Port int // from component.yaml or auto-assigned Dependencies []string // postgres, redis, etc. BuildOrder int } ``` ## Infrastructure Components Infrastructure components (`postgres`, `redis`) don't scaffold files — they provision actual resources: ### Adding Database (CockroachDB) ```bash POST /projects/acme/components { "type": "postgres", "name": "main-db" } ``` This: 1. Creates a CockroachDB database: `project_acme` 2. Creates a database user with full permissions 3. Stores `DATABASE_URL` and `DATABASE_URL_STAGING` in credential store ### Adding Cache (Redis) ```bash POST /projects/acme/components { "type": "redis", "name": "job-queue" } ``` This: 1. Creates a Redis ACL user: `proj-acme` 2. Scopes access to keys matching `project:acme:*` 3. Stores `REDIS_URL`, `REDIS_URL_STAGING`, and `REDIS_PREFIX` in credential store ## Credential Injection **Critical:** When code components are deployed, credentials are automatically injected. The `createInitialComponentDeployment()` function: 1. Calls `fetchProjectCredentials(projectID)` to retrieve stored credentials 2. Populates `DeploySpec.Secrets` with `DATABASE_URL`, `REDIS_URL`, etc. 3. Deployer creates K8s Secret and mounts it as environment variables **Available credentials (if provisioned):** - `DATABASE_URL` - CockroachDB connection string - `DATABASE_URL_STAGING` - Staging database (same as prod currently) - `REDIS_URL` - Redis connection string with auth - `REDIS_URL_STAGING` - Staging Redis (same as prod currently) - `REDIS_PREFIX` - Key prefix for isolation (e.g., `project:acme:`) **Service Discovery:** Sibling services are also injected as env vars: - `AUTH_SVC_URL=http://acme-auth-svc:8001` - `CHAT_SVC_URL=http://acme-chat-svc:8002` **File Pointers:** - Credential injection: `internal/service/component_deploy.go:35-48, 160-212` - Infrastructure provisioning: `internal/service/component_infra.go` - Deployer secret creation: `internal/adapter/deployer/resources.go:81-135` ``` ## API Reference **Important Route Note:** Project CRUD uses `/project` (singular), but component/build/pipeline operations use `/projects/{id}/...` (plural). ### Create Project (Skeleton) ```bash POST /project { "name": "acme", "description": "Acme Corp platform" } # Response: Creates monorepo skeleton in repo ``` ### Add Component ```bash POST /projects/acme/components { "type": "service", "name": "auth-api", "template": "service" # optional } # Response: { "type": "service", "name": "auth-api", "path": "services/auth-api", "port": 8001 } ``` ### Deploy ```bash # Component deploy (single component at a time) POST /projects/acme/deploy { "component": "services/auth-api" } # Note: Full monorepo deploy is handled by CI pipeline, not a single API call ``` ### List Components ```bash GET /projects/acme/components # Response: { "components": [ {"type": "service", "name": "auth-api", "path": "services/auth-api", "port": 8001}, {"type": "app-react", "name": "dashboard", "path": "apps/dashboard", "port": 3001} ] } ``` ### Delete Project ```bash DELETE /project/acme ``` ### List Component Templates ```bash GET /templates/components # Response shows available component types: service, worker, app-astro, app-react, cli ``` ## Routing Patterns ### Service URL Convention Services are accessed via a consistent URL pattern: ``` https://{project-domain}/api/{service-name}/... ``` **Examples:** - `https://acme.threesix.ai/api/auth/login` → auth-api service - `https://acme.threesix.ai/api/chat/messages` → chat-api service - `https://acme.threesix.ai/` → frontend app (no `/api/` prefix) ### How Routing Works ``` [Client] → [Cloudflare DNS] → [Ingress Controller] → [K8s Service] → [Pod] 1. DNS: *.threesix.ai → cluster IP 2. Ingress: Routes by host + path prefix 3. Service: Load balances to pods 4. Pod: Runs your container on assigned port ``` ### Configuring Routes in Trees When adding services via cookbook trees, routes are configured automatically based on component type and name: ```yaml # Adding a service creates these routes: add-auth-service: action: api method: POST endpoint: "/projects/{{ .outputs.create-project.project_id }}/components" body: type: service name: auth # Routes to /api/auth/* ``` **Port assignment:** Services get auto-assigned ports (8001, 8002, ...). The ingress handles external-to-internal port mapping. ### Common Routing Mistakes | Mistake | Symptom | Fix | |---------|---------|-----| | Missing `/api/` prefix in client | 404 on service calls | Use `/api/{service}/...` | | Hardcoded localhost:8001 | Works locally, fails in K8s | Use relative paths or env vars | | Wrong service name in path | 404 or wrong service | Match name from component add | | CORS errors | Browser blocks requests | Ensure middleware/cors.go is configured | | Trailing slash mismatch | 301 redirect loops | Be consistent: `/api/auth` not `/api/auth/` | ### Multi-Service Routing When multiple services exist, they share the domain but have isolated path prefixes: ```yaml # Project with 3 services components: - type: service, name: auth # /api/auth/* - type: service, name: chat # /api/chat/* - type: service, name: billing # /api/billing/* - type: app-react, name: web # /* (catch-all for frontend) ``` **Frontend API calls:** ```typescript // In React app - use relative paths fetch('/api/auth/login', { method: 'POST', body: ... }) fetch('/api/chat/messages') fetch('/api/billing/invoices') ``` ### Internal Service Communication Services communicate internally via K8s service names, not external URLs: ```yaml # Service discovery environment variables (auto-injected) AUTH_SVC_URL=http://acme-auth-svc:8001 CHAT_SVC_URL=http://acme-chat-svc:8002 # In code - use env vars authURL := os.Getenv("AUTH_SVC_URL") resp, err := http.Get(authURL + "/internal/validate-token") ``` **Internal vs External:** - **External** (from browser): `https://acme.threesix.ai/api/auth/...` - **Internal** (service-to-service): `http://acme-auth-svc:8001/...` Internal routes can have endpoints not exposed externally (e.g., `/internal/*`). ## Testing ```go func TestAddComponent(t *testing.T) { // Setup project first project := createTestProject(t, "test-monorepo") // Add service component req := AddComponentRequest{ Type: "service", Name: "auth-api", Template: "go-api", } component, err := svc.AddComponent(ctx, project.ID, req.Type, req.Name, req.Template) require.NoError(t, err) assert.Equal(t, "service", string(component.Type)) assert.Equal(t, "auth-api", component.Name) assert.Equal(t, "services/auth-api", component.Path) // Verify files created in repo files, err := gitea.ListFiles(project.Owner, project.Name, "services/auth-api") require.NoError(t, err) assert.Contains(t, files, "cmd/server/main.go") assert.Contains(t, files, "Makefile") } ``` ## Troubleshooting ### Component endpoint returns 404 The component service is only initialized when `GITEA_URL`, `GITEA_TOKEN`, and template provider are all configured. Check startup logs for "component service initialized": ```bash kubectl logs -n rdev deployment/rdev-api | grep "component service" ``` If missing, verify the rdev-api deployment has the required env vars. ### Component addition fails with "directory exists" The component path already exists. Either delete it or choose a different name. ### Component addition fails with "connection refused" **Hairpin NAT issue.** rdev-api is trying to reach Gitea via external URL which doesn't work from within the cluster. Use internal service hostnames: ```yaml # In deployments/k8s/base/rdev-api.yaml - name: GITEA_URL value: "http://gitea.threesix.svc.cluster.local" ``` See [ops/networking.md](../ops/networking.md) for details. ### CI pipeline linter errors Woodpecker may show linter warnings about oneOf/anyOf validation. These are usually benign. Check for actual schema violations like invalid `when.event` values. **Critical:** The `.woodpecker.yml` marker must be exactly `# COMPONENT_STEPS_BELOW` on its own line. If the marker has trailing text (like `- Do not remove`), it will be appended to the last line of inserted component steps, corrupting the YAML. **File Pointer:** `internal/adapter/templates/templates/skeleton/.woodpecker.yml.tmpl:11` ### Procfile not updating Check that the template includes the Procfile.tmpl and the service has write access to the repo. ### go.work not syncing Run `./scripts/discover.sh` to verify component detection, then manually run `go work sync`. ## Related - [Project Templates](./templates.md) - Current single-template system - [Build Orchestration](./build-orchestration.md) - Component builds - [ai-lookup: Composable Monorepo](../../ai-lookup/features/composable-monorepo.md) - Quick facts