diff --git a/.gitignore b/.gitignore index 8cd5ac7..31ebb81 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,9 @@ Thumbs.db /rdev-api coverage.out +# Temporary files +tmp/ + # Deploy keys (generated, never commit) *-deploy-key *-deploy-key.pub diff --git a/CLAUDE.md b/CLAUDE.md index c1ce6bf..3d03459 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,46 +1,117 @@ # rdev - Remote Developer -Run Claude Code in isolated Kubernetes pods, controlled via Discord. +Run Claude Code instances in isolated Kubernetes pods with REST API control. Enables bots, CI/CD systems, and external orchestrators to dispatch agentive development work to isolated environments. + +**Platform:** threesix.ai - Agent-driven development at scale with shared worker pools. + +## Find Your Guide + +| If you need to... | Read this | +|-------------------|-----------| +| **Set up local dev** | [local/setup.md](.claude/guides/local/setup.md) | +| **Run tests** | [local/testing.md](.claude/guides/local/testing.md) | +| **Write Go code / handlers** | [backend/go-guidelines.md](.claude/guides/backend/go-guidelines.md) | +| **Understand pkg/api** | [packages/api-framework.md](.claude/guides/packages/api-framework.md) | +| **Add a new handler/endpoint** | [backend/adding-handlers.md](.claude/guides/backend/adding-handlers.md) | +| **Understand hexagonal architecture** | [backend/hexagonal.md](.claude/guides/backend/hexagonal.md) | +| **Deploy to k3s** | [ops/deploying.md](.claude/guides/ops/deploying.md) | +| **Work with Kubernetes adapters** | [services/kubernetes.md](.claude/guides/services/kubernetes.md) | +| **Database / migrations** | [ops/database.md](.claude/guides/ops/database.md) | +| **Manage credentials** | [ops/credentials.md](.claude/guides/ops/credentials.md) | +| **Work queue system** | [services/work-queue.md](.claude/guides/services/work-queue.md) | +| **Worker pool management** | [services/worker-pool.md](.claude/guides/services/worker-pool.md) | +| **Project templates** | [services/templates.md](.claude/guides/services/templates.md) | +| **Build orchestration** | [services/build-orchestration.md](.claude/guides/services/build-orchestration.md) | + +## Critical Rules + +- **KUBECONFIG:** ALWAYS set `export KUBECONFIG=~/.kube/orchard9-k3sf.yaml` before kubectl commands +- **Hexagonal:** Domain models in `internal/domain/` must have ZERO external dependencies +- **Ports:** All adapters implement interfaces from `internal/port/` +- **Migrations:** NEVER modify committed migrations. Create NEW ones. +- **500-line limit:** Files exceeding 500 lines must be split +- **Tests:** All handlers and services require tests ## Quick Reference ```bash -# Set kubeconfig for k3s (REQUIRED before any kubectl) +# Set kubeconfig (REQUIRED) export KUBECONFIG=~/.kube/orchard9-k3sf.yaml +# Run locally +go run ./cmd/rdev-api + +# Run tests +go test ./... + # Deploy kubectl apply -k deployments/k8s/base -# Verify -kubectl exec -n rdev claudebox-0 -- claude --version +# Verify pods +kubectl get pods -n rdev -# Test Claude -kubectl exec -it -n rdev claudebox-0 -- claude "say hello" +# Check workers +kubectl get pods -n rdev -l rdev.orchard9.ai/role=worker + +# Load credentials from .secrets to rdev-api +./scripts/load-credentials.sh # localhost +./scripts/load-credentials.sh https://rdev.example.com # remote ``` -## Architecture +## Architecture Overview ``` -k3s cluster (rdev namespace) -├── claudebox-0 (StatefulSet) -│ ├── Claude Code CLI -│ ├── /workspace (PVC) -│ └── /root/.claude (credentials secret) -└── Future: discord-bot, more claudebox pods +cmd/rdev-api/ # Entry point, DI, OpenAPI spec +internal/ +├── domain/ # Pure business models (no deps) +├── port/ # Interface contracts +├── service/ # Business logic orchestration +├── handlers/ # HTTP handlers (REST endpoints) +├── adapter/ # Infrastructure implementations +│ ├── kubernetes/ # K8s client, pod executor +│ ├── postgres/ # Audit, queue, webhooks, credentials +│ ├── gitea/ # Git repository management +│ ├── cloudflare/ # DNS provider +│ └── woodpecker/ # CI provider +├── auth/ # API key auth, scopes +├── middleware/ # Rate limiting +├── worker/ # Background queue processor +└── webhook/ # Event dispatcher +pkg/api/ # HTTP framework (app, responses) +deployments/k8s/ # Kustomize manifests + └── base/templates/ # Project templates +scripts/ # Operational scripts + └── load-credentials.sh # Load secrets to rdev-api ``` -## Development +## Key Concepts -This is v0.1 - base case only. See docs/reference.md for full vision. +- **Projects**: Kubernetes pods with Claude Code, discovered by label `rdev.orchard9.ai/project=true` +- **Workers**: Shared claudebox pods that execute any project's tasks, labeled `rdev.orchard9.ai/role=worker` +- **Work Queue**: Async task queue for build/test/deploy jobs +- **Credentials**: Infrastructure secrets (tokens, keys) stored encrypted in PostgreSQL +- **Commands**: Claude/shell/git commands executed via kubectl exec, streamed via SSE +- **API Keys**: Scoped auth with project restrictions, IP filtering, expiration +- **Webhooks**: Event subscriptions with retry delivery +- **Templates**: Project scaffolding with .woodpecker.yml, .claude/, and stack files -## Deploying to k3s +## threesix.ai Platform Roadmap -1. Build and push image (or use pre-built) -2. Create Claude credentials secret -3. Apply manifests: `kubectl apply -k deployments/k8s/base` +See `k3s-fleet/tmp/address-the-gaps.md` for full implementation details: + +| Gap | Status | Description | +|-----|--------|-------------| +| Woodpecker Auto-Activation | Planned | Auto-enable CI on project creation | +| Project Templates | Planned | Seed repos with .woodpecker.yml, .claude/ | +| Work Queue | Planned | Task queue for worker pool | +| Worker Pool | Planned | Shared claudebox workers (3-5 pods) | +| Bot Communication | Planned | Webhook callbacks on task completion | +| Build Orchestration | Planned | Structured build specs via API | ## Constraints - **ON-PREM k3s** - not GKE, always set KUBECONFIG - **Kustomize only** - no ArgoCD -- **Manual deploys** - no CI/CD pipelines yet +- **chi/v5 router** - no gin, echo, or other frameworks +- **sqlx for DB** - no GORM +- **slog for logging** - no logrus, zap diff --git a/CODING_GUIDELINES.md b/CODING_GUIDELINES.md new file mode 100644 index 0000000..0add143 --- /dev/null +++ b/CODING_GUIDELINES.md @@ -0,0 +1,191 @@ +# Coding Guidelines + +## Tech Stack + +| Component | Required | Forbidden | +|-----------|----------|-----------| +| HTTP Router | chi/v5 | gin, echo, fiber | +| Database | sqlx | GORM, raw sql | +| Logging | slog | log, logrus, zap | +| Config | Viper + env vars | os.Getenv directly | +| Testing | testing + testify | ginkgo, gomega | +| Kubernetes | client-go | kubectl subprocess | + +## File Structure + +``` +cmd/{service}/ # Entry points +internal/ # Private code (hexagonal) +├── domain/ # Pure models, no deps +├── port/ # Interfaces only +├── service/ # Business logic +├── handlers/ # HTTP handlers +├── adapter/ # Infrastructure +├── auth/ # Authentication +├── middleware/ # HTTP middleware +├── worker/ # Background jobs +└── webhook/ # Event dispatch +pkg/ # Public packages +``` + +## Handler Pattern + +All handlers implement the `Mount` interface and use `pkg/api` for responses: + +```go +type UsersHandler struct { + service *service.UserService +} + +func (h *UsersHandler) Mount(r chi.Router) { + r.Route("/users", func(r chi.Router) { + r.Get("/", h.List) + r.Post("/", h.Create) + r.Get("/{id}", h.Get) + }) +} + +func (h *UsersHandler) Create(w http.ResponseWriter, r *http.Request) { + var req CreateUserRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + api.BadRequest(w, "invalid request body") + return + } + + user, err := h.service.Create(r.Context(), req) + if err != nil { + api.Error(w, err) + return + } + + api.Created(w, user) +} +``` + +## Response Helpers (`pkg/api`) + +```go +api.OK(w, data) // 200 with JSON body +api.Created(w, data) // 201 with JSON body +api.NoContent(w) // 204 +api.BadRequest(w, msg) // 400 +api.Unauthorized(w, msg) // 401 +api.Forbidden(w, msg) // 403 +api.NotFound(w, msg) // 404 +api.Conflict(w, msg) // 409 +api.Error(w, err) // 500 (or mapped status) +``` + +## Port/Adapter Pattern + +Domain interfaces in `internal/port/`: + +```go +// port/project.go +type ProjectRepository interface { + List(ctx context.Context) ([]domain.Project, error) + Get(ctx context.Context, id string) (*domain.Project, error) +} + +type CommandExecutor interface { + Execute(ctx context.Context, project string, cmd domain.Command) (*domain.CommandResult, error) +} +``` + +Implementations in `internal/adapter/{name}/`: + +```go +// adapter/kubernetes/repository.go +type Repository struct { + client kubernetes.Interface +} + +func (r *Repository) List(ctx context.Context) ([]domain.Project, error) { + // Implementation using client-go +} +``` + +## Domain Models + +Pure structs in `internal/domain/`, NO external dependencies: + +```go +// domain/project.go +package domain + +type Project struct { + ID string + Name string + Status ProjectStatus + Workspace string +} + +type ProjectStatus string + +const ( + StatusRunning ProjectStatus = "running" + StatusPending ProjectStatus = "pending" + StatusFailed ProjectStatus = "failed" +) +``` + +## Error Handling + +Use wrapped errors with context: + +```go +if err != nil { + return fmt.Errorf("create project %s: %w", name, err) +} +``` + +Define domain errors in `internal/domain/errors.go`: + +```go +var ( + ErrNotFound = errors.New("not found") + ErrUnauthorized = errors.New("unauthorized") + ErrConflict = errors.New("already exists") +) +``` + +## Testing + +- Unit tests next to source: `foo_test.go` +- Table-driven tests preferred +- Mocks implement port interfaces +- Test files in `testutil/` for shared helpers + +```go +func TestHandler_Create(t *testing.T) { + tests := []struct { + name string + input CreateRequest + wantStatus int + }{ + {"valid", CreateRequest{Name: "test"}, 201}, + {"empty name", CreateRequest{}, 400}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // ... + }) + } +} +``` + +## Naming Conventions + +- Files: `snake_case.go` +- Packages: `lowercase` (no underscores) +- Types: `PascalCase` +- Functions/Methods: `PascalCase` (exported), `camelCase` (private) +- Constants: `PascalCase` or `SCREAMING_SNAKE` for env vars +- Interfaces: Don't prefix with `I`, suffix with role (`Repository`, `Service`) + +## Size Limits + +- **500 lines max per file** - split when exceeded +- **100 lines max per function** - extract helpers +- **5 parameters max per function** - use struct if more diff --git a/ai-lookup/features/build-orchestration.md b/ai-lookup/features/build-orchestration.md new file mode 100644 index 0000000..c837110 --- /dev/null +++ b/ai-lookup/features/build-orchestration.md @@ -0,0 +1,80 @@ +# Build Orchestration + +**Last Updated:** 2025-01 +**Confidence:** High (Planned - see address-the-gaps.md) + +## Summary + +Build orchestration enables structured build specs for bot-driven development. Bots submit build requests with prompts, workers execute, and callbacks notify completion. + +**Key Facts:** +- Build spec includes template, prompt, variables, auto_deploy flag +- Enqueues as work task for worker pool +- Auto-deploy commits, pushes, triggers Woodpecker CI +- Callback URL notified on completion with artifacts + +**File Pointers:** +- Service: `internal/service/build_service.go` +- Handler: `internal/handlers/build.go` +- Work queue: `internal/port/work_queue.go` + +## Build Spec Schema + +```go +type BuildSpec struct { + Template string `json:"template"` + Prompt string `json:"prompt"` + Variables map[string]string `json:"variables"` + AutoDeploy bool `json:"auto_deploy"` + CallbackURL string `json:"callback_url"` +} +``` + +## API Endpoint + +``` +POST /project/{name}/build +{ + "template": "astro-landing", + "prompt": "Create a coming soon page with dark theme and threesix.ai branding", + "auto_deploy": true, + "callback_url": "https://pantheon.orchard9.ai/webhooks/build-complete" +} +``` + +## Orchestration Flow + +1. Bot calls `POST /project/{name}/build` +2. BuildService validates project exists +3. Creates WorkTask with build spec +4. Enqueues to work queue +5. Returns task ID immediately +6. Worker picks up task: + - Clones repo + - Runs Claude with prompt + - Commits and pushes (if auto_deploy) +7. Woodpecker builds and deploys +8. Callback notified with result + +## Callback Payload + +```json +{ + "task_id": "uuid", + "project_id": "myapp", + "status": "completed", + "result": { + "output": "...", + "artifacts": { + "commit_sha": "abc123", + "deploy_url": "https://myapp.threesix.ai" + } + } +} +``` + +## Related Topics + +- [Work Queue](../services/work-queue.md) +- [Worker Pool](../services/worker-pool.md) +- [Template Provider](../services/template-provider.md) diff --git a/ai-lookup/features/command-execution.md b/ai-lookup/features/command-execution.md new file mode 100644 index 0000000..024a63e --- /dev/null +++ b/ai-lookup/features/command-execution.md @@ -0,0 +1,62 @@ +# Command Execution + +**Last Updated:** 2025-01 +**Confidence:** High + +## Summary + +rdev executes commands (Claude, shell, git) in Kubernetes pods via kubectl exec. Commands can run synchronously or be queued for async processing with SSE output streaming. + +**Key Facts:** +- Commands run in pods with label `rdev.orchard9.ai/project=true` +- Three types: `claude`, `shell`, `git` +- Async queue with status tracking (pending → running → completed/failed) +- Output streamed via SSE to `/projects/{id}/events/{stream_id}` +- All commands logged to audit trail + +**File Pointers:** +- Executor: `internal/adapter/kubernetes/executor.go` +- Queue: `internal/adapter/postgres/queue.go` +- Worker: `internal/worker/queue.go` +- Handler: `internal/handlers/projects.go` + +## Command Flow + +``` +Handler → Service → Queue (postgres) + ↓ + Worker polls + ↓ + Executor (kubectl exec) + ↓ + StreamPublisher (SSE) + ↓ + Webhook dispatch +``` + +## Request/Response + +``` +POST /projects/{id}/claude +Body: { "prompt": "write a hello world" } + +Response: +{ + "command_id": "cmd-abc123", + "stream_url": "/projects/my-project/events/stream-xyz" +} +``` + +## Status Transitions + +| Status | Description | +|--------|-------------| +| `pending` | In queue, not started | +| `running` | Currently executing | +| `completed` | Finished successfully (exit 0) | +| `failed` | Error or non-zero exit | + +## Related Topics + +- [SSE Streaming](./sse-streaming.md) +- [Project Service](../services/project-service.md) diff --git a/ai-lookup/features/infrastructure.md b/ai-lookup/features/infrastructure.md new file mode 100644 index 0000000..e624cbb --- /dev/null +++ b/ai-lookup/features/infrastructure.md @@ -0,0 +1,52 @@ +# Infrastructure Management + +**Last Updated:** 2025-01 +**Confidence:** High + +## Summary + +rdev can manage project infrastructure: creating Git repos, configuring DNS, and deploying applications to Kubernetes. This enables full project lifecycle management via API. + +**Key Facts:** +- Git repos via Gitea integration +- DNS via Cloudflare integration +- Deployments create K8s Deployment + Service + Ingress +- Managed projects combine all three steps +- Woodpecker CI integration for auto-deploy on build + +**File Pointers:** +- Handler: `internal/handlers/infrastructure.go` +- Service: `internal/service/project_infra.go` +- Gitea adapter: `internal/adapter/gitea/client.go` +- Cloudflare adapter: `internal/adapter/cloudflare/client.go` +- Deployer adapter: `internal/adapter/deployer/deployer.go` + +## Capabilities + +| Feature | Endpoint | Description | +|---------|----------|-------------| +| Create repo | `POST /projects/{id}/repo` | Creates Gitea repository | +| Deploy | `POST /projects/{id}/deploy` | Deploys to Kubernetes | +| Configure DNS | `POST /projects/{id}/domain` | Creates Cloudflare record | +| Managed project | `POST /projects/create-managed` | Full lifecycle | + +## Managed Project Flow + +1. Create Gitea repository +2. Initialize with template +3. Create Cloudflare DNS record +4. Deploy to Kubernetes (Deployment + Service + Ingress) +5. Return project details + +## Environment Variables + +``` +GITEA_URL, GITEA_TOKEN, GITEA_DEFAULT_ORG +CLOUDFLARE_API_TOKEN, CLOUDFLARE_ZONE_ID, DEFAULT_DOMAIN +DEPLOY_NAMESPACE, DEPLOY_TLS_ISSUER, REGISTRY_URL +``` + +## Related Topics + +- [Project Service](../services/project-service.md) +- [Webhooks](../services/webhooks.md) diff --git a/ai-lookup/features/sse-streaming.md b/ai-lookup/features/sse-streaming.md new file mode 100644 index 0000000..3fffaab --- /dev/null +++ b/ai-lookup/features/sse-streaming.md @@ -0,0 +1,53 @@ +# SSE Streaming + +**Last Updated:** 2025-01 +**Confidence:** High + +## Summary + +Server-Sent Events (SSE) stream command output in real-time. Clients connect to a stream URL and receive output as it's produced. + +**Key Facts:** +- Endpoint: `GET /projects/{id}/events/{stream_id}` +- Content-Type: `text/event-stream` +- Events: `output` (stdout/stderr), `done` (completion) +- In-memory stream publisher for current implementation +- Stream URLs returned from command execution endpoints + +**File Pointers:** +- Handler: `internal/handlers/projects.go` (events endpoint) +- Publisher: `internal/adapter/memory/stream.go` +- Port: `internal/port/stream.go` + +## Client Usage + +```bash +curl -N -H "X-API-Key: $KEY" \ + "http://localhost:8080/projects/my-project/events/stream-123" +``` + +## Event Format + +``` +event: output +data: {"type": "stdout", "content": "Hello world\n"} + +event: output +data: {"type": "stderr", "content": "Warning: ...\n"} + +event: done +data: {"exit_code": 0, "duration_ms": 1234} +``` + +## Flow + +1. Client calls `POST /projects/{id}/claude` +2. Response includes `stream_url` +3. Client connects to `stream_url` via SSE +4. Server streams output events as produced +5. `done` event signals completion + +## Related Topics + +- [Command Execution](./command-execution.md) +- [Project Service](../services/project-service.md) diff --git a/ai-lookup/index.md b/ai-lookup/index.md new file mode 100644 index 0000000..ff4833a --- /dev/null +++ b/ai-lookup/index.md @@ -0,0 +1,32 @@ +# AI Lookup Index + +Quick reference for rdev concepts and facts. + +| Topic | File | Confidence | Updated | Summary | +|-------|------|------------|---------|---------| +| **Architecture** | +| Hexagonal Architecture | [patterns/hexagonal.md](./patterns/hexagonal.md) | High | 2025-01 | Ports/adapters pattern, layer separation | +| **Core Services** | +| Project Service | [services/project-service.md](./services/project-service.md) | High | 2025-01 | Business logic for project operations | +| API Keys | [services/api-keys.md](./services/api-keys.md) | High | 2025-01 | Authentication, scopes, restrictions | +| Webhooks | [services/webhooks.md](./services/webhooks.md) | High | 2025-01 | Event subscriptions and delivery | +| **Worker Infrastructure** (Planned) | +| Work Queue | [services/work-queue.md](./services/work-queue.md) | High | 2025-01 | Task queue for worker pool | +| Worker Pool | [services/worker-pool.md](./services/worker-pool.md) | High | 2025-01 | Shared claudebox workers | +| CI Provider | [services/ci-provider.md](./services/ci-provider.md) | High | 2025-01 | Woodpecker auto-activation | +| Template Provider | [services/template-provider.md](./services/template-provider.md) | High | 2025-01 | Project template seeding | +| **Features** | +| Command Execution | [features/command-execution.md](./features/command-execution.md) | High | 2025-01 | Claude/shell/git command flow | +| SSE Streaming | [features/sse-streaming.md](./features/sse-streaming.md) | High | 2025-01 | Real-time output streaming | +| Infrastructure Management | [features/infrastructure.md](./features/infrastructure.md) | High | 2025-01 | Gitea, Cloudflare, deployment | +| Build Orchestration | [features/build-orchestration.md](./features/build-orchestration.md) | High | 2025-01 | Bot-driven build specs | + +## Roadmap Reference + +See `k3s-fleet/tmp/address-the-gaps.md` for the full threesix.ai platform roadmap: +- Gap 1: Woodpecker Auto-Activation → CI Provider +- Gap 2: Project Templates → Template Provider +- Gap 3: Work Queue → Work Queue service +- Gap 4: Worker Pool Management → Worker Pool +- Gap 5: Bot Communication → Webhook callbacks +- Gap 6: Build Orchestration → Build service diff --git a/ai-lookup/patterns/hexagonal.md b/ai-lookup/patterns/hexagonal.md new file mode 100644 index 0000000..40b5ab0 --- /dev/null +++ b/ai-lookup/patterns/hexagonal.md @@ -0,0 +1,44 @@ +# Hexagonal Architecture + +**Last Updated:** 2025-01 +**Confidence:** High + +## Summary + +rdev uses hexagonal architecture (ports and adapters) to separate business logic from infrastructure. Domain models have zero external dependencies, services use port interfaces, and adapters implement those interfaces. + +**Key Facts:** +- Domain models in `internal/domain/` have NO imports from other packages +- Port interfaces defined in `internal/port/` +- Adapters in `internal/adapter/{name}/` implement port interfaces +- Services in `internal/service/` orchestrate business logic + +**File Pointers:** +- Port definitions: `internal/port/project.go`, `internal/port/webhook.go` +- Domain models: `internal/domain/project.go`, `internal/domain/command.go` +- Service layer: `internal/service/project.go` + +## Layer Responsibilities + +| Layer | Location | Purpose | +|-------|----------|---------| +| Handlers | `internal/handlers/` | HTTP request/response | +| Services | `internal/service/` | Business logic | +| Ports | `internal/port/` | Interface contracts | +| Domain | `internal/domain/` | Pure business models | +| Adapters | `internal/adapter/` | Infrastructure implementation | + +## Dependency Direction + +``` +Handlers → Services → Ports ← Adapters + ↓ + Domain +``` + +Dependencies point inward toward domain. + +## Related Topics + +- [Project Service](../services/project-service.md) +- [Command Execution](../features/command-execution.md) diff --git a/ai-lookup/services/api-keys.md b/ai-lookup/services/api-keys.md new file mode 100644 index 0000000..95fde4a --- /dev/null +++ b/ai-lookup/services/api-keys.md @@ -0,0 +1,43 @@ +# API Keys + +**Last Updated:** 2025-01 +**Confidence:** High + +## Summary + +API keys authenticate all requests to rdev (except health/docs). Keys have scopes, can be restricted to specific projects and IP ranges, and have expiration dates. + +**Key Facts:** +- Header: `X-API-Key: ` +- Keys are hashed before storage (only prefix visible) +- Admin key via `RDEV_ADMIN_KEY` env var for bootstrap +- Scopes: `projects:read`, `projects:write`, `keys:read`, `keys:write`, `audit:read` +- Project restrictions: nil = all projects, or list of allowed project IDs +- IP restrictions: CIDR notation for allowed ranges + +**File Pointers:** +- Service: `internal/auth/service.go` +- Middleware: `internal/auth/middleware.go` +- Handler: `internal/handlers/keys.go` +- Repository: `internal/adapter/postgres/apikey.go` + +## Key Lifecycle + +1. Create via `POST /keys` (admin only) +2. Key returned once (plaintext), stored hashed +3. Validate on each request via middleware +4. Revoke via `DELETE /keys/{id}` + +## Scopes + +| Scope | Allows | +|-------|--------| +| `projects:read` | List/get projects | +| `projects:write` | Execute commands | +| `keys:read` | List API keys | +| `keys:write` | Create/delete keys | +| `audit:read` | Query audit logs | + +## Related Topics + +- [Project Service](./project-service.md) diff --git a/ai-lookup/services/ci-provider.md b/ai-lookup/services/ci-provider.md new file mode 100644 index 0000000..6264e28 --- /dev/null +++ b/ai-lookup/services/ci-provider.md @@ -0,0 +1,49 @@ +# CI Provider (Woodpecker) + +**Last Updated:** 2025-01 +**Confidence:** High (Planned - see address-the-gaps.md) + +## Summary + +CIProvider port enables automatic Woodpecker CI activation when projects are created. Eliminates manual UI clicks to activate repos. + +**Key Facts:** +- Activates repos via Woodpecker API on project creation +- Can trigger manual builds +- JWT token authentication +- Graceful fallback if activation fails (logs warning, adds manual step) + +**File Pointers:** +- Port: `internal/port/ci_provider.go` +- Adapter: `internal/adapter/woodpecker/client.go` +- Integration: `internal/service/project_infra.go` + +## Port Interface + +```go +type CIProvider interface { + ActivateRepo(ctx context.Context, forgeRemoteID int64) error + DeactivateRepo(ctx context.Context, owner, name string) error + TriggerBuild(ctx context.Context, owner, name, branch string) (int64, error) +} +``` + +## Configuration + +``` +WOODPECKER_URL=https://ci.threesix.ai +WOODPECKER_API_TOKEN= +``` + +## Integration Flow + +1. `ProjectInfraService.CreateProject()` creates Gitea repo +2. Gets repo ID from Gitea +3. Calls `ci.ActivateRepo(ctx, repoID)` +4. Woodpecker creates webhook in Gitea +5. Future pushes trigger builds automatically + +## Related Topics + +- [Infrastructure Management](../features/infrastructure.md) +- [Project Service](./project-service.md) diff --git a/ai-lookup/services/project-service.md b/ai-lookup/services/project-service.md new file mode 100644 index 0000000..4b561b5 --- /dev/null +++ b/ai-lookup/services/project-service.md @@ -0,0 +1,44 @@ +# Project Service + +**Last Updated:** 2025-01 +**Confidence:** High + +## Summary + +The ProjectService orchestrates all project-related operations: listing, getting, and executing commands. It coordinates between handlers, the Kubernetes adapter, audit logging, and webhook dispatch. + +**Key Facts:** +- Lists projects from Kubernetes pods with label `rdev.orchard9.ai/project=true` +- Executes Claude, shell, and git commands asynchronously +- Logs all command executions to audit trail +- Dispatches webhook events on command completion +- Returns stream URLs for SSE-based output consumption + +**File Pointers:** +- Service: `internal/service/project.go` +- Handler: `internal/handlers/projects.go` +- Port interfaces: `internal/port/project.go` + +## Operations + +| Method | Description | +|--------|-------------| +| `List(ctx)` | Returns all projects with current status | +| `Get(ctx, id)` | Returns single project by ID | +| `ExecuteClaude(ctx, project, prompt)` | Queues Claude command | +| `ExecuteShell(ctx, project, cmd)` | Queues shell command | +| `ExecuteGit(ctx, project, args)` | Queues git command | + +## Execution Flow + +1. Handler receives request +2. Service validates and creates Command +3. Command queued (postgres) or executed directly +4. Output streamed to StreamPublisher +5. Audit log entry created +6. Webhook events dispatched + +## Related Topics + +- [Command Execution](../features/command-execution.md) +- [SSE Streaming](../features/sse-streaming.md) diff --git a/ai-lookup/services/template-provider.md b/ai-lookup/services/template-provider.md new file mode 100644 index 0000000..4fc51f5 --- /dev/null +++ b/ai-lookup/services/template-provider.md @@ -0,0 +1,76 @@ +# Template Provider + +**Last Updated:** 2025-01 +**Confidence:** High (Planned - see address-the-gaps.md) + +## Summary + +TemplateProvider seeds new repos with project templates. Includes .woodpecker.yml, .claude/ configuration, and stack-specific files. + +**Key Facts:** +- Templates stored in `deployments/k8s/base/templates/` +- Seeded via Gitea API file creation +- Variable interpolation: `{{PROJECT_NAME}}`, `{{DOMAIN}}` +- Available templates: `default`, `astro-landing`, `go-api` + +**File Pointers:** +- Port: `internal/port/template_provider.go` +- Adapter: `internal/adapter/gitea/templates.go` +- Templates: `deployments/k8s/base/templates/` + +## Port Interface + +```go +type TemplateProvider interface { + SeedRepo(ctx context.Context, owner, repo, templateName string, vars map[string]string) error + ListTemplates(ctx context.Context) ([]TemplateInfo, error) +} + +type TemplateInfo struct { + Name string + Description string + Stack string // "astro", "go", "nextjs", etc. +} +``` + +## Template Structure + +``` +templates/ +├── default/ +│ ├── .woodpecker.yml +│ ├── .claude/ +│ │ └── CLAUDE.md +│ └── README.md +├── astro-landing/ +│ ├── .woodpecker.yml +│ ├── .claude/ +│ │ └── CLAUDE.md +│ ├── package.json +│ ├── astro.config.mjs +│ ├── src/pages/index.astro +│ ├── Dockerfile +│ └── nginx.conf +└── go-api/ + ├── .woodpecker.yml + ├── .claude/ + │ └── CLAUDE.md + ├── go.mod + ├── main.go + └── Dockerfile +``` + +## API Usage + +```json +POST /project +{ + "name": "myapp", + "template": "astro-landing" +} +``` + +## Related Topics + +- [Infrastructure Management](../features/infrastructure.md) +- [Project Service](./project-service.md) diff --git a/ai-lookup/services/webhooks.md b/ai-lookup/services/webhooks.md new file mode 100644 index 0000000..842e224 --- /dev/null +++ b/ai-lookup/services/webhooks.md @@ -0,0 +1,51 @@ +# Webhooks + +**Last Updated:** 2025-01 +**Confidence:** High + +## Summary + +Webhooks notify external systems when events occur in rdev. Subscriptions are created via API, and events are delivered with automatic retries. + +**Key Facts:** +- Event types: `command.started`, `command.completed`, `command.failed` +- Retry policy: 3 attempts, 5s initial backoff, exponential +- Delivery history stored for debugging +- Worker pool (10 workers) for parallel delivery + +**File Pointers:** +- Handler: `internal/handlers/webhooks.go` +- Dispatcher: `internal/webhook/dispatcher.go` +- Repository: `internal/adapter/postgres/webhook.go` +- Domain: `internal/domain/webhook.go` + +## Subscription + +```json +POST /webhooks +{ + "url": "https://example.com/webhook", + "events": ["command.completed", "command.failed"] +} +``` + +## Event Payload + +```json +{ + "event": "command.completed", + "timestamp": "2025-01-15T10:30:00Z", + "data": { + "command_id": "cmd-123", + "project": "my-project", + "type": "claude", + "exit_code": 0, + "duration_ms": 5432 + } +} +``` + +## Related Topics + +- [Command Execution](../features/command-execution.md) +- [Project Service](./project-service.md) diff --git a/ai-lookup/services/work-queue.md b/ai-lookup/services/work-queue.md new file mode 100644 index 0000000..82b3a34 --- /dev/null +++ b/ai-lookup/services/work-queue.md @@ -0,0 +1,57 @@ +# Work Queue + +**Last Updated:** 2025-01 +**Confidence:** High (Planned - see address-the-gaps.md) + +## Summary + +Work queue enables worker pool architecture. Workers poll for tasks, execute them, and report results. Supports async build orchestration for bot-driven development. + +**Key Facts:** +- Tasks enqueued via `POST /work/enqueue` +- Workers dequeue via `GET /work/dequeue?worker_id=X` +- Status transitions: `pending` → `running` → `completed` | `failed` +- Callback URLs for bot notification on completion +- PostgreSQL-backed with atomic dequeue + +**File Pointers:** +- Port: `internal/port/work_queue.go` +- Adapter: `internal/adapter/postgres/work_queue.go` +- Handler: `internal/handlers/work.go` +- Migration: `internal/db/migrations/011_work_queue.sql` + +## Port Interface + +```go +type WorkQueue interface { + Enqueue(ctx context.Context, task WorkTask) (string, error) + Dequeue(ctx context.Context, workerID string) (*WorkTask, error) + Complete(ctx context.Context, taskID string, result WorkResult) error + Fail(ctx context.Context, taskID string, err error) error + GetStatus(ctx context.Context, taskID string) (*WorkTaskStatus, error) +} +``` + +## Task Types + +| Type | Description | +|------|-------------| +| `build` | Run Claude Code with prompt, commit result | +| `test` | Run test suite | +| `deploy` | Trigger Kubernetes deployment | + +## API Endpoints + +``` +POST /work/enqueue - Add task to queue +GET /work/dequeue - Worker gets next task +POST /work/{id}/complete - Worker reports success +POST /work/{id}/fail - Worker reports failure +GET /work/{id}/status - Check task status +GET /work/stats - Queue statistics +``` + +## Related Topics + +- [Worker Pool](./worker-pool.md) +- [Build Orchestration](../features/build-orchestration.md) diff --git a/ai-lookup/services/worker-pool.md b/ai-lookup/services/worker-pool.md new file mode 100644 index 0000000..918f487 --- /dev/null +++ b/ai-lookup/services/worker-pool.md @@ -0,0 +1,61 @@ +# Worker Pool + +**Last Updated:** 2025-01 +**Confidence:** High (Planned - see address-the-gaps.md) + +## Summary + +Shared pool of claudebox workers (3-5 pods) that can build any project. Workers register, send heartbeats, and poll for tasks. Scales horizontally by adding workers, not projects. + +**Key Facts:** +- Workers labeled `rdev.orchard9.ai/role=worker` +- StatefulSet: `claudebox-worker` with 3+ replicas +- Each worker has dedicated PVC for workspace +- Workers poll rdev-api for tasks every 5 seconds +- Health tracked via heartbeat endpoint + +**File Pointers:** +- Port: `internal/port/worker_registry.go` +- Adapter: `internal/adapter/postgres/worker_registry.go` +- Handler: `internal/handlers/workers.go` +- K8s manifest: `deployments/k8s/base/claudebox-worker.yaml` + +## Port Interface + +```go +type WorkerRegistry interface { + Register(ctx context.Context, worker WorkerInfo) error + Heartbeat(ctx context.Context, workerID string) error + Deregister(ctx context.Context, workerID string) error + ListActive(ctx context.Context) ([]WorkerInfo, error) +} + +type WorkerInfo struct { + ID string + PodName string + Namespace string + Status string // "idle", "busy", "unhealthy" + LastSeen time.Time + CurrentTask string +} +``` + +## Worker Lifecycle + +1. Pod starts → calls `POST /workers` to register +2. Main loop: heartbeat every 5s, poll for tasks +3. Task received → clone repo, run Claude, commit, report +4. Pod shutdown → `DELETE /workers/{id}` to deregister + +## Environment Variables + +``` +WORKER_ID=$(hostname) +RDEV_API_URL=http://rdev-api.rdev.svc:8080 +RDEV_API_KEY= +``` + +## Related Topics + +- [Work Queue](./work-queue.md) +- [Build Orchestration](../features/build-orchestration.md) diff --git a/cmd/rdev-api/main.go b/cmd/rdev-api/main.go index 88db368..99e6eae 100644 --- a/cmd/rdev-api/main.go +++ b/cmd/rdev-api/main.go @@ -46,11 +46,15 @@ import ( "github.com/orchard9/rdev/internal/adapter/kubernetes" "github.com/orchard9/rdev/internal/adapter/memory" "github.com/orchard9/rdev/internal/adapter/postgres" + "github.com/orchard9/rdev/internal/adapter/templates" + "github.com/orchard9/rdev/internal/adapter/woodpecker" "github.com/orchard9/rdev/internal/auth" "github.com/orchard9/rdev/internal/db" + "github.com/orchard9/rdev/internal/domain" "github.com/orchard9/rdev/internal/handlers" "github.com/orchard9/rdev/internal/metrics" "github.com/orchard9/rdev/internal/middleware" + "github.com/orchard9/rdev/internal/port" "github.com/orchard9/rdev/internal/service" "github.com/orchard9/rdev/internal/telemetry" "github.com/orchard9/rdev/internal/webhook" @@ -75,6 +79,14 @@ func main() { // Load configuration from environment cfg := loadConfig() + // Validate required security configuration + if cfg.CredentialEncryptionKey == "" { + logger.Warn("CREDENTIAL_ENCRYPTION_KEY not set - credential store will use insecure default", + "hint", "Generate with: openssl rand -base64 32") + // Use a deterministic fallback for development only + cfg.CredentialEncryptionKey = "rdev-dev-key-not-for-production" + } + // Initialize database with auto-migrations database, err := db.New(db.Config{ Host: cfg.DBHost, @@ -93,6 +105,12 @@ func main() { // Initialize auth service authService := auth.NewService(database.DB, cfg.AdminKey) + // Initialize credential store (for infrastructure secrets) + credentialStore := postgres.NewCredentialStore(database.DB, cfg.CredentialEncryptionKey) + + // Load infrastructure config from credential store (falls back to env vars) + infraCfg := loadInfraConfig(context.Background(), credentialStore, cfg, logger) + // Create adapters (dependency injection) namespace := getEnv("K8S_NAMESPACE", "rdev") @@ -129,6 +147,9 @@ func main() { // 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{ @@ -144,33 +165,57 @@ func main() { } // Initialize infrastructure adapters (optional - only if configured) + // Uses infraCfg which loads from credential store with env var fallback var giteaClient *gitea.Client - if cfg.GiteaToken != "" && cfg.GiteaURL != "" { + if infraCfg.GiteaToken != "" && infraCfg.GiteaURL != "" { var err error - giteaClient, err = gitea.NewClient(cfg.GiteaURL, cfg.GiteaToken, cfg.GiteaDefaultOrg) + giteaClient, err = gitea.NewClient(infraCfg.GiteaURL, infraCfg.GiteaToken, infraCfg.GiteaDefaultOrg) if err != nil { logger.Warn("failed to initialize gitea client", "error", err) } else { - logger.Info("gitea client initialized", "url", cfg.GiteaURL, "org", cfg.GiteaDefaultOrg) + logger.Info("gitea client initialized", "url", infraCfg.GiteaURL, "org", infraCfg.GiteaDefaultOrg) } } var dnsClient *cloudflare.Client - if cfg.CloudflareToken != "" && cfg.CloudflareZoneID != "" { - dnsClient = cloudflare.NewClient(cfg.CloudflareToken, cfg.CloudflareZoneID, cfg.DefaultDomain) - logger.Info("cloudflare DNS client initialized", "domain", cfg.DefaultDomain) + if infraCfg.CloudflareToken != "" && infraCfg.CloudflareZoneID != "" { + dnsClient = cloudflare.NewClient(infraCfg.CloudflareToken, infraCfg.CloudflareZoneID, infraCfg.DefaultDomain) + logger.Info("cloudflare DNS client initialized", "domain", infraCfg.DefaultDomain) } var deployerAdapter *deployer.Deployer if k8sClient != nil { deployerAdapter = deployer.NewDeployer(k8sClient, deployer.Config{ - Namespace: cfg.DeployNamespace, + Namespace: infraCfg.DeployNamespace, IngressClass: "traefik", - TLSIssuer: cfg.DeployTLSIssuer, - DefaultDomain: cfg.DefaultDomain, + TLSIssuer: infraCfg.DeployTLSIssuer, + DefaultDomain: infraCfg.DefaultDomain, DefaultReplicas: 1, }) - logger.Info("deployer initialized", "namespace", cfg.DeployNamespace) + logger.Info("deployer initialized", "namespace", infraCfg.DeployNamespace) + } + + var woodpeckerClient *woodpecker.Client + if infraCfg.WoodpeckerURL != "" && infraCfg.WoodpeckerAPIToken != "" { + var err error + woodpeckerClient, err = woodpecker.NewClient( + infraCfg.WoodpeckerURL, + infraCfg.WoodpeckerAPIToken, + woodpecker.WithLogger(logger), + ) + if err != nil { + logger.Warn("failed to initialize woodpecker client", "error", err) + } else { + logger.Info("woodpecker CI client initialized", "url", infraCfg.WoodpeckerURL) + } + } + + // Initialize template provider (requires Gitea client for seeding repos) + var templateProvider *templates.Provider + if giteaClient != nil { + // Get the underlying Gitea SDK client for the template provider + templateProvider = templates.NewProvider(giteaClient.SDKClient(), logger) + logger.Info("template provider initialized") } // Create services @@ -179,6 +224,11 @@ func main() { WithCommandQueue(commandQueue). WithWebhookDispatcher(webhookDispatcher) + // Create work service (for worker pool task management) + workService := service.NewWorkService(workQueueRepo, service.WorkServiceConfig{ + Logger: logger, + }).WithWebhookDispatcher(webhookDispatcher) + // Create app app := api.New("rdev-api", api.WithPort(cfg.Port), @@ -209,6 +259,7 @@ func main() { auditHandler := handlers.NewAuditHandler(auditLogger) queueHandler := handlers.NewQueueHandler(commandQueue, projectRepo) webhookHandler := handlers.NewWebhookHandler(webhookRepo, projectRepo) + workHandler := handlers.NewWorkHandler(workService) // Initialize infrastructure handler (for threesix.ai git/deploy/dns) infraHandler := handlers.NewInfrastructureHandler( @@ -217,8 +268,8 @@ func main() { deployerAdapter, projectRepo, handlers.InfrastructureConfig{ - DefaultGitOwner: cfg.GiteaDefaultOrg, - DefaultDomain: cfg.DefaultDomain, + DefaultGitOwner: infraCfg.GiteaDefaultOrg, + DefaultDomain: infraCfg.DefaultDomain, }, ) @@ -228,10 +279,12 @@ func main() { giteaClient, dnsClient, deployerAdapter, + woodpeckerClient, // CI provider for auto-activating repos + templateProvider, // Template provider for seeding repos service.ProjectInfraConfig{ - DefaultGitOwner: cfg.GiteaDefaultOrg, - DefaultDomain: cfg.DefaultDomain, - ClusterIP: cfg.ClusterIP, + DefaultGitOwner: infraCfg.GiteaDefaultOrg, + DefaultDomain: infraCfg.DefaultDomain, + ClusterIP: infraCfg.ClusterIP, Logger: logger, }, ) @@ -244,14 +297,17 @@ func main() { deployerAdapter, dnsClient, handlers.WoodpeckerWebhookConfig{ - WebhookSecret: cfg.WoodpeckerWebhookSecret, - DefaultDomain: cfg.DefaultDomain, - RegistryURL: cfg.RegistryURL, - ClusterIP: cfg.ClusterIP, + WebhookSecret: infraCfg.WoodpeckerWebhookSecret, + DefaultDomain: infraCfg.DefaultDomain, + RegistryURL: infraCfg.RegistryURL, + ClusterIP: infraCfg.ClusterIP, Logger: logger, }, ) + // Initialize credentials handler (superadmin only) + credentialsHandler := handlers.NewCredentialsHandler(credentialStore) + // Register routes projectsHandler.Mount(app.Router()) keysHandler.Mount(app.Router()) @@ -259,9 +315,11 @@ func main() { auditHandler.Mount(app.Router()) queueHandler.Mount(app.Router()) webhookHandler.Mount(app.Router()) + workHandler.Mount(app.Router()) infraHandler.Mount(app.Router()) projectMgmtHandler.Mount(app.Router()) woodpeckerHandler.Mount(app.Router()) + credentialsHandler.Mount(app.Router()) // Start queue processor worker queueProcessor := worker.NewQueueProcessor( @@ -324,7 +382,10 @@ type Config struct { DBSSLMode string AdminKey string - // Infrastructure adapters (threesix.ai) + // Credential store encryption key (required for storing secrets in DB) + CredentialEncryptionKey string + + // Infrastructure adapters (threesix.ai) - fallback values if not in credential store GiteaURL string GiteaToken string GiteaDefaultOrg string @@ -335,6 +396,26 @@ type Config struct { DeployTLSIssuer string ClusterIP string RegistryURL string + WoodpeckerURL string + WoodpeckerAPIToken string + WoodpeckerWebhookSecret string +} + +// InfraConfig holds infrastructure adapter configuration. +// Loaded from credential store with env var fallback. +type InfraConfig struct { + GiteaURL string + GiteaToken string + GiteaDefaultOrg string + CloudflareToken string + CloudflareZoneID string + DefaultDomain string + DeployNamespace string + DeployTLSIssuer string + ClusterIP string + RegistryURL string + WoodpeckerURL string + WoodpeckerAPIToken string WoodpeckerWebhookSecret string } @@ -363,10 +444,14 @@ func loadConfig() Config { DBSSLMode: getEnv("DB_SSL_MODE", "disable"), AdminKey: os.Getenv("RDEV_ADMIN_KEY"), - // Infrastructure adapters + // Encryption key for credential store (generate with: openssl rand -base64 32) + // REQUIRED in production - no default to prevent insecure deployments + CredentialEncryptionKey: os.Getenv("CREDENTIAL_ENCRYPTION_KEY"), + + // Infrastructure adapters (fallback if not in credential store) GiteaURL: getEnv("GITEA_URL", "https://git.threesix.ai"), GiteaToken: os.Getenv("GITEA_TOKEN"), - GiteaDefaultOrg: getEnv("GITEA_DEFAULT_ORG", "threesix"), + GiteaDefaultOrg: getEnv("GITEA_DEFAULT_ORG", "jordan"), CloudflareToken: os.Getenv("CLOUDFLARE_API_TOKEN"), CloudflareZoneID: os.Getenv("CLOUDFLARE_ZONE_ID"), DefaultDomain: getEnv("DEFAULT_DOMAIN", "threesix.ai"), @@ -374,6 +459,8 @@ func loadConfig() Config { DeployTLSIssuer: getEnv("DEPLOY_TLS_ISSUER", "letsencrypt-threesix"), 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"), + WoodpeckerAPIToken: os.Getenv("WOODPECKER_API_TOKEN"), WoodpeckerWebhookSecret: os.Getenv("WOODPECKER_WEBHOOK_SECRET"), } } @@ -384,3 +471,60 @@ func getEnv(key, defaultVal string) string { } 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 { + // Try to load from credential store + creds, err := store.GetMultiple(ctx, []string{ + domain.CredKeyGiteaToken, + domain.CredKeyGiteaURL, + domain.CredKeyCloudflareAPIToken, + domain.CredKeyCloudflareZoneID, + domain.CredKeyWoodpeckerURL, + domain.CredKeyWoodpeckerAPIToken, + domain.CredKeyWoodpeckerWebhookSecret, + domain.CredKeyRegistryURL, + }) + if err != nil { + logger.Warn("failed to load credentials from store, using env vars", "error", err) + creds = make(map[string]string) + } + + // Helper to get from store or fall back to env var + getOrFallback := func(key, envFallback string) string { + if v, ok := creds[key]; ok && v != "" { + return v + } + return envFallback + } + + infraCfg := InfraConfig{ + GiteaURL: getOrFallback(domain.CredKeyGiteaURL, cfg.GiteaURL), + GiteaToken: getOrFallback(domain.CredKeyGiteaToken, cfg.GiteaToken), + GiteaDefaultOrg: cfg.GiteaDefaultOrg, // Not a secret, use env + CloudflareToken: getOrFallback(domain.CredKeyCloudflareAPIToken, cfg.CloudflareToken), + CloudflareZoneID: getOrFallback(domain.CredKeyCloudflareZoneID, cfg.CloudflareZoneID), + DefaultDomain: cfg.DefaultDomain, // Not a secret, use env + DeployNamespace: cfg.DeployNamespace, // Not a secret, use env + DeployTLSIssuer: cfg.DeployTLSIssuer, // Not a secret, use env + ClusterIP: cfg.ClusterIP, // Not a secret, use env + RegistryURL: getOrFallback(domain.CredKeyRegistryURL, cfg.RegistryURL), + WoodpeckerURL: getOrFallback(domain.CredKeyWoodpeckerURL, cfg.WoodpeckerURL), + WoodpeckerAPIToken: getOrFallback(domain.CredKeyWoodpeckerAPIToken, cfg.WoodpeckerAPIToken), + WoodpeckerWebhookSecret: getOrFallback(domain.CredKeyWoodpeckerWebhookSecret, cfg.WoodpeckerWebhookSecret), + } + + // Log which credentials were loaded from store vs env + fromStore := 0 + for k := range creds { + if creds[k] != "" { + fromStore++ + } + } + if fromStore > 0 { + logger.Info("loaded credentials from store", "count", fromStore) + } + + return infraCfg +} diff --git a/cookbooks/VISION.md b/cookbooks/VISION.md new file mode 100644 index 0000000..c943f20 --- /dev/null +++ b/cookbooks/VISION.md @@ -0,0 +1,275 @@ +# threesix.ai Platform Vision + +> Agent-driven development at scale: 50+ projects, each with autonomous build capability. + +## The Vision + +``` +Team Member → Bot (Pantheon) → "Create a new project called X" + │ + ▼ + ┌───────────────────────────────────────────────────┐ + │ rdev-api orchestrates: │ + │ │ + │ 1. Create Gitea repo (jordan/X) │ + │ 2. Activate Woodpecker CI (webhook auto-created) │ + │ 3. Spin up claudebox-X (dedicated pod) │ + │ 4. Initialize .claude/ structure │ + │ 5. Create DNS record (X.threesix.ai) │ + │ │ + └───────────────────────────────────────────────────┘ + │ + ▼ + ┌───────────────────────────────────────────────────┐ + │ claudebox-X ready for development │ + │ │ + │ Option A: Human directs Claude via Pantheon │ + │ "Build a landing page for X" │ + │ │ + │ Option B: Bot autonomously builds based on spec │ + │ "Here's the PRD, build it" │ + │ │ + └───────────────────────────────────────────────────┘ + │ + ▼ + Push to Gitea → Woodpecker builds → K8s deploys + │ + ▼ + Live at https://X.threesix.ai +``` + +--- + +## Current State vs Vision + +| Capability | Status | What Exists | Gap | +|------------|--------|-------------|-----| +| Create Gitea repo | ✅ | `POST /project` | None | +| Create DNS record | ✅ | `POST /project` | None | +| Activate Woodpecker | ⚠️ | API exists, not wired | Add to `POST /project` | +| Spin up claudebox | ❌ | Manual StatefulSet | Need dynamic claudebox creation | +| Initialize .claude/ | ❌ | Nothing | Need template + init | +| Human directs Claude | ✅ | `POST /projects/{id}/claude` | Works for existing claudeboxes | +| Bot autonomous build | ⚠️ | Claude endpoint exists | Need prompt orchestration | +| K8s deployment | ✅ | Webhook + deployer | Works | + +--- + +## Gap Analysis + +### Gap 1: Woodpecker Activation Not in Project Creation + +**Current:** Two separate API calls +```bash +POST /project # Creates Gitea + DNS +POST ci.threesix.ai/api/repos?forge_remote_id=X # Activates Woodpecker +``` + +**Needed:** Single call does both +```bash +POST /project # Creates Gitea + DNS + Activates Woodpecker +``` + +**Fix:** Add `WOODPECKER_API_TOKEN` to rdev-api config, call Woodpecker API after Gitea creation. + +--- + +### Gap 2: No Dynamic Claudebox Creation + +**Current:** Claudeboxes are manually created StatefulSets +```yaml +# Each project needs a manually deployed claudebox-{project}.yaml +claudebox-pantheon-0 # Exists +claudebox-aeries-0 # Exists +claudebox-landing-0 # Does NOT exist +``` + +**Needed:** API creates claudebox on-demand +```bash +POST /project {"name": "landing"} +# Also creates claudebox-landing StatefulSet +``` + +**Fix:** +- Create claudebox template (parameterized StatefulSet) +- Add K8s client to rdev-api for creating StatefulSets +- Wire into `POST /project` flow + +**Resource consideration:** Each claudebox uses ~256Mi-1Gi RAM. 50 projects = 12-50Gi RAM dedicated to claudeboxes. May need: +- On-demand spin-up (idle claudeboxes scale to 0) +- Shared worker pool (fewer claudeboxes, more projects) + +--- + +### Gap 3: No .claude/ Initialization + +**Current:** Empty repo after creation + +**Needed:** Repo initialized with Claude structure +``` +project/ +├── .claude/ +│ ├── CLAUDE.md # Project context +│ ├── commands/ # Slash commands +│ ├── skills/ # Reusable skills +│ └── agents/ # Agent definitions +├── .woodpecker.yml # CI pipeline +└── README.md # Basic readme +``` + +**Fix:** +- Create template repo or init script +- Claudebox runs init on first creation +- Or: Gitea repo template feature + +--- + +### Gap 4: Project ↔ Claudebox Mapping + +**Current:** Two separate registries +- Projects: `/project` API (Gitea + DNS based) +- Claudeboxes: `/projects` API (K8s pod label based) + +**Needed:** Unified registry +``` +POST /project/landing/claude # Works because landing has a claudebox +``` + +**Fix:** +- Merge the two systems +- Project creation = Gitea + DNS + Claudebox + Woodpecker +- Single source of truth (database) + +--- + +### Gap 5: Prompt Orchestration for Autonomous Build + +**Current:** Raw prompt execution +```bash +POST /projects/pantheon/claude +{"prompt": "build a landing page"} # Claude figures it out +``` + +**Needed:** Structured build orchestration +```bash +POST /project/landing/build +{ + "spec": "Create Astro landing page with coming soon message", + "stack": "astro", + "auto_deploy": true +} +``` + +**Fix:** +- Build orchestrator service +- Stack templates (astro, nextjs, go-api, etc.) +- Progress tracking +- Auto-commit and push + +--- + +## Proposed Architecture + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ rdev-api │ +│ │ +│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ +│ │ Project Service │ │ Claudebox Mgr │ │ Build Service │ │ +│ │ │ │ │ │ │ │ +│ │ - Create repo │ │ - Create pod │ │ - Stack temps │ │ +│ │ - Setup DNS │ │ - Scale up/down │ │ - Orchestrate │ │ +│ │ - Activate CI │ │ - Health check │ │ - Auto-deploy │ │ +│ └────────┬────────┘ └────────┬────────┘ └────────┬────────┘ │ +│ │ │ │ │ +│ └────────────────────┴────────────────────┘ │ +│ │ │ +│ ┌───────────┴───────────┐ │ +│ │ Unified Project DB │ │ +│ │ │ │ +│ │ - project_id │ │ +│ │ - gitea_repo │ │ +│ │ - claudebox_pod │ │ +│ │ - woodpecker_id │ │ +│ │ - dns_record │ │ +│ │ - build_status │ │ +│ └────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Infrastructure │ +│ │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │ Gitea │ │Woodpecker│ │ Zot │ │Cloudflare│ │ K8s │ │ +│ │ git.ts.ai│ │ ci.ts.ai │ │ registry │ │ DNS │ │ projects │ │ +│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Implementation Phases + +### Phase A: Wire Woodpecker into Project Creation +- Add WOODPECKER_API_TOKEN to rdev-api +- After Gitea repo creation, activate in Woodpecker +- Test: `POST /project` activates CI automatically + +### Phase B: Dynamic Claudebox Creation +- Create claudebox template (parameterized YAML) +- Add K8s StatefulSet creation to rdev-api +- Wire into project creation +- Test: `POST /project` spins up claudebox-{name} + +### Phase C: .claude/ Initialization +- Create init template for new projects +- Claudebox runs init on startup +- Test: New project has .claude/ structure ready + +### Phase D: Unified Project Registry +- Merge project management and claudebox systems +- Single database tracks all project state +- `/projects/{id}/claude` works for any project + +### Phase E: Build Orchestration +- Stack templates (astro, nextjs, go-api) +- `POST /project/{id}/build` endpoint +- Progress tracking and auto-deploy + +--- + +## Quick Wins (Do First) + +1. **Wire Woodpecker activation** - 1-2 hours + - Already have the API call + - Just needs token in config and call in project creation + +2. **Use pantheon as shared worker** - Works now + - For MVP, one claudebox can clone/build any project + - Not scalable but proves the concept + +3. **Template .woodpecker.yml in project creation** - 1 hour + - When creating Gitea repo, include default .woodpecker.yml + - Projects are CI-ready from creation + +--- + +## Questions to Resolve + +1. **Claudebox scaling strategy?** + - One per project (resource heavy, 50 = lots of RAM) + - Shared worker pool (fewer pods, queue-based) + - On-demand (scale to zero when idle) + +2. **Who owns the project?** + - User-scoped (jordan/landing) + - Org-scoped (threesix/landing) + - Affects Gitea structure and permissions + +3. **Pantheon integration?** + - How does the bot receive "create project X" commands? + - Slash command? Natural language? + - Response format? diff --git a/cookbooks/landing-page.md b/cookbooks/landing-page.md new file mode 100644 index 0000000..a5d8539 --- /dev/null +++ b/cookbooks/landing-page.md @@ -0,0 +1,342 @@ +# Landing Page Cookbook + +> Deploy a static landing page through the threesix.ai infrastructure with agent-driven development. + +## Overview + +This cookbook creates and deploys a simple landing page using the full threesix.ai autonomous infrastructure: + +``` +rdev-api → Gitea repo → Claude agent → push → Woodpecker CI → K8s deployment +``` + +**Target:** `landing.threesix.ai` (with future DNS aliases for www/root) +**Stack:** Astro (static site generator) +**Status:** Coming Soon page + +--- + +## Current Architecture Gap + +**Two separate systems that need bridging:** + +| System | Endpoint | What it manages | +|--------|----------|-----------------| +| Project Management | `POST /project` | Gitea repos, DNS records, K8s deployments | +| Claudebox Execution | `POST /projects/{id}/claude` | Code generation in existing claudebox pods | + +**The problem:** Creating a project via `POST /project` creates a Gitea repo, but there's no claudebox to generate code for it. The claudebox system only knows about pre-existing pods (pantheon, aeries). + +**The solution:** Use an existing claudebox as a "worker" to clone, build, and push to any project repo. + +--- + +## Prerequisites + +### Credentials Required + +| Secret | Location | Purpose | +|--------|----------|---------| +| RDEV_ADMIN_KEY | `rdev-credentials` secret | rdev-api authentication | +| GITEA_TOKEN | `rdev-credentials` secret | Gitea API access | +| WOODPECKER_API_TOKEN | `.secrets` file | Woodpecker repo activation | +| CLOUDFLARE_API_TOKEN | `rdev-credentials` secret | DNS management | + +### Infrastructure Required + +- [x] rdev-api running with infrastructure handlers (v0.7.1+) +- [x] Gitea at https://git.threesix.ai +- [x] Woodpecker CI at https://ci.threesix.ai +- [x] Zot registry at zot.threesix.svc.cluster.local:5000 +- [x] `projects` namespace in K8s with RBAC +- [x] Wildcard TLS cert for *.threesix.ai + +--- + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Landing Page Flow │ +│ │ +│ 1. Create Project │ +│ POST /project {"name": "www"} │ +│ │ │ +│ ├──▶ Creates Gitea repo: jordan/www │ +│ └──▶ Creates DNS: www.threesix.ai → 208.122.204.172 │ +│ │ +│ 2. Activate Woodpecker │ +│ POST /api/repos?forge_remote_id={id} │ +│ │ │ +│ └──▶ Creates webhook in Gitea │ +│ │ +│ 3. Generate Code (Claude Agent) │ +│ claudebox or local Claude Code │ +│ │ │ +│ ├──▶ Creates Astro project │ +│ ├──▶ Creates Dockerfile │ +│ ├──▶ Creates .woodpecker.yml │ +│ └──▶ Pushes to Gitea │ +│ │ +│ 4. CI/CD Pipeline (automatic) │ +│ Woodpecker triggered by push │ +│ │ │ +│ ├──▶ Kaniko builds Docker image │ +│ ├──▶ Pushes to Zot registry │ +│ └──▶ Webhook triggers rdev-api deploy │ +│ │ +│ 5. Live at https://www.threesix.ai │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Step-by-Step Implementation + +### Step 1: Create Project via rdev-api + +```bash +RDEV_KEY="rdev_sk_prod_7f3a9c2e1d8b4a6f0e5c9d2b7a1f8e4c" + +curl -X POST https://rdev.masq-ops.orchard9.ai/project \ + -H "Authorization: Bearer $RDEV_KEY" \ + -H "Content-Type: application/json" \ + -d '{"name": "landing", "description": "threesix.ai landing page"}' +``` + +**Response:** +```json +{ + "data": { + "name": "landing", + "domain": "landing.threesix.ai", + "git": { + "clone_ssh": "git@git.threesix.ai:jordan/landing.git", + "clone_http": "https://git.threesix.ai/jordan/landing.git" + } + } +} +``` + +### Step 2: Activate Woodpecker CI + +```bash +GITEA_TOKEN="5508ff241943e84aad0ced3559f5fbd311a2fb81" +WOODPECKER_TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXBlIjoidXNlciIsInVzZXItaWQiOiIxIn0.LcyVHcZ_gSvVH1w3y6TUCp_Jg9ubfsebOAVo-MtiNP8" + +# Get Gitea repo ID +REPO_ID=$(curl -s https://git.threesix.ai/api/v1/repos/jordan/landing \ + -H "Authorization: token $GITEA_TOKEN" | jq '.id') + +# Activate in Woodpecker (creates webhook automatically) +curl -X POST "https://ci.threesix.ai/api/repos?forge_remote_id=$REPO_ID" \ + -H "Authorization: Bearer $WOODPECKER_TOKEN" +``` + +### Step 3: Generate Code via Claudebox + +Use the `pantheon` claudebox as a worker to generate code for the landing project: + +```bash +RDEV_KEY="rdev_sk_prod_7f3a9c2e1d8b4a6f0e5c9d2b7a1f8e4c" + +# Tell Claude to build the landing page in /tmp/landing +curl -X POST "https://rdev.masq-ops.orchard9.ai/projects/pantheon/claude" \ + -H "Authorization: Bearer $RDEV_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "prompt": "Clone https://git.threesix.ai/jordan/landing.git to /tmp/landing, then create a simple Astro landing page with: Coming Soon message, threesix.ai branding (dark theme), responsive layout, Dockerfile (nginx), and .woodpecker.yml for CI/CD. Commit and push when done." + }' +``` + +**What happens:** +1. Claude receives the prompt in the claudebox +2. Claude clones the repo to `/tmp/landing` +3. Claude generates the Astro project files +4. Claude commits and pushes to Gitea + +### Step 4: Monitor Build + +Watch Woodpecker for the build: +- https://ci.threesix.ai/jordan/landing + +Or via API: +```bash +curl -s "https://ci.threesix.ai/api/repos/jordan/landing/pipelines" \ + -H "Authorization: Bearer $WOODPECKER_TOKEN" | jq '.[0] | {number, status, started}' +``` + +### Step 5: Verify Deployment + +```bash +curl -s "https://rdev.masq-ops.orchard9.ai/project/landing" \ + -H "Authorization: Bearer $RDEV_KEY" | jq '.data.deployment' +``` + +Site live at: https://landing.threesix.ai + +### Step 6: Configure DNS Aliases (Optional) + +Point `www.threesix.ai` and `threesix.ai` to the landing page: + +```bash +CF_TOKEN="nGoDhG6Za66XsKMl6W7LNXuowc5EM00glHxkq1KK" +CF_ZONE="e0bc8d510f62807b360db0c5994964c5" + +# Update root A record to point to k3s cluster +curl -X PATCH "https://api.cloudflare.com/client/v4/zones/$CF_ZONE/dns_records/{record_id}" \ + -H "Authorization: Bearer $CF_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "type": "A", + "name": "threesix.ai", + "content": "208.122.204.172", + "proxied": false + }' +``` + +--- + +## File Templates + +### .woodpecker.yml + +```yaml +steps: + build: + image: gcr.io/kaniko-project/executor:latest + settings: + registry: zot.threesix.svc.cluster.local:5000 + tags: + - ${CI_COMMIT_SHA:0:8} + - latest + repo: zot.threesix.svc.cluster.local:5000/${CI_REPO_NAME} + context: . + dockerfile: Dockerfile + insecure: true + when: + branch: main + + notify: + image: alpine/curl:latest + commands: + - echo "Build complete, webhook will trigger deployment" + when: + branch: main + status: success +``` + +### Dockerfile (Astro + Nginx) + +```dockerfile +# Build stage +FROM node:20-alpine AS builder +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +RUN npm run build + +# Production stage +FROM nginx:alpine +COPY --from=builder /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] +``` + +### nginx.conf + +```nginx +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + # Cache static assets + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } +} +``` + +--- + +## Current Gaps & Future Automation + +### What's Manual Today + +| Step | Status | Automation Path | +|------|--------|-----------------| +| Create project | ✅ API | Already automated | +| Activate Woodpecker | 🔧 API call needed | Add to rdev-api | +| Generate code | ❌ Manual Claude | Claudebox integration | +| Push to Gitea | ❌ Manual git | Claudebox with SSH key | +| Deploy | ✅ Webhook | Already automated | + +### To Fully Automate (Future Work) + +1. **Add Woodpecker activation to rdev-api** + - Store WOODPECKER_API_TOKEN in secrets + - Call Woodpecker API after creating Gitea repo + - Create webhook automatically + +2. **Claudebox code generation** + - Spawn claudebox with project context + - Claudebox has Gitea SSH key + - Claude Code generates code based on prompt + - Auto-push to Gitea + +3. **Single API call** + ``` + POST /project/create-and-build + { + "name": "www", + "prompt": "Create an Astro landing page with coming soon message", + "stack": "astro" + } + ``` + +--- + +## Verification + +After deployment, verify: + +```bash +# Check DNS +dig www.threesix.ai + +# Check site +curl -I https://www.threesix.ai + +# Check deployment status +curl https://rdev.masq-ops.orchard9.ai/project/www \ + -H "Authorization: Bearer $RDEV_KEY" +``` + +--- + +## Rollback + +To remove the landing page: + +```bash +# Delete via rdev-api (removes Gitea repo, DNS, K8s deployment) +curl -X DELETE https://rdev.masq-ops.orchard9.ai/project/www \ + -H "Authorization: Bearer $RDEV_KEY" +``` + +--- + +## Related + +- [THREESIX_INFRASTRUCTURE.md](/Users/jordanwashburn/Workspace/orchard9/rdev/docs/plans/THREESIX_INFRASTRUCTURE.md) - Infrastructure plan +- [woodpecker-pipeline-template.yaml](../deployments/k8s/base/threesix/woodpecker-pipeline-template.yaml) - CI template diff --git a/deployments/k8s/base/namespace-projects.yaml b/deployments/k8s/base/namespace-projects.yaml new file mode 100644 index 0000000..cbf88fd --- /dev/null +++ b/deployments/k8s/base/namespace-projects.yaml @@ -0,0 +1,8 @@ +# Namespace for deployed projects (managed by rdev-api) +# Separate from kustomization to avoid namespace transformation +apiVersion: v1 +kind: Namespace +metadata: + name: projects + labels: + app.kubernetes.io/managed-by: rdev-api diff --git a/deployments/k8s/base/rdev-api.yaml b/deployments/k8s/base/rdev-api.yaml index 6fe40b3..884d620 100644 --- a/deployments/k8s/base/rdev-api.yaml +++ b/deployments/k8s/base/rdev-api.yaml @@ -24,7 +24,7 @@ spec: serviceAccountName: rdev-api containers: - name: rdev-api - image: ghcr.io/orchard9/rdev-api:v0.6.0 + image: ghcr.io/orchard9/rdev-api:v0.7.2 imagePullPolicy: Always ports: @@ -88,6 +88,32 @@ spec: secretKeyRef: name: rdev-credentials key: RDEV_ADMIN_KEY + - name: CREDENTIAL_ENCRYPTION_KEY + valueFrom: + secretKeyRef: + name: rdev-credentials + key: CREDENTIAL_ENCRYPTION_KEY + # Infrastructure adapters for threesix.ai (fallback if not in DB) + - name: GITEA_TOKEN + valueFrom: + secretKeyRef: + name: rdev-credentials + key: GITEA_TOKEN + - name: CLOUDFLARE_API_TOKEN + valueFrom: + secretKeyRef: + name: rdev-credentials + key: CLOUDFLARE_API_TOKEN + - name: CLOUDFLARE_ZONE_ID + valueFrom: + secretKeyRef: + name: rdev-credentials + key: CLOUDFLARE_ZONE_ID + - name: WOODPECKER_WEBHOOK_SECRET + valueFrom: + secretKeyRef: + name: rdev-credentials + key: WOODPECKER_WEBHOOK_SECRET imagePullSecrets: - name: ghcr-secret @@ -151,3 +177,44 @@ roleRef: kind: Role name: rdev-api apiGroup: rbac.authorization.k8s.io +--- +# ClusterRole for rdev-api to deploy projects across namespaces +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: rdev-api-deployer +rules: +# Deployment management +- apiGroups: ["apps"] + resources: ["deployments"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] +# Service management +- apiGroups: [""] + resources: ["services"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] +# Ingress management +- apiGroups: ["networking.k8s.io"] + resources: ["ingresses"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] +# Pod logs for deployment status +- apiGroups: [""] + resources: ["pods", "pods/log"] + verbs: ["get", "list", "watch"] +# Secrets for TLS certificates (read-only to reference existing) +- apiGroups: [""] + resources: ["secrets"] + verbs: ["get", "list"] +--- +# ClusterRoleBinding for rdev-api deployer +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: rdev-api-deployer +subjects: +- kind: ServiceAccount + name: rdev-api + namespace: rdev +roleRef: + kind: ClusterRole + name: rdev-api-deployer + apiGroup: rbac.authorization.k8s.io diff --git a/deployments/k8s/base/templates/astro-landing/.woodpecker.yml b/deployments/k8s/base/templates/astro-landing/.woodpecker.yml new file mode 100644 index 0000000..a1114f4 --- /dev/null +++ b/deployments/k8s/base/templates/astro-landing/.woodpecker.yml @@ -0,0 +1,43 @@ +steps: + install: + image: node:20-alpine + commands: + - npm ci + when: + - event: [push, pull_request] + + build: + image: node:20-alpine + commands: + - npm run build + when: + - event: [push, pull_request] + + docker: + image: docker:24-dind + privileged: true + commands: + - docker build -t zot.orchard9.ai/{{PROJECT_NAME}}:latest . + - docker build -t zot.orchard9.ai/{{PROJECT_NAME}}:${CI_COMMIT_SHA:0:8} . + when: + - event: push + + push: + image: docker:24-dind + privileged: true + commands: + - echo "$ZOT_PASSWORD" | docker login zot.orchard9.ai -u "$ZOT_USER" --password-stdin + - docker push zot.orchard9.ai/{{PROJECT_NAME}}:latest + - docker push zot.orchard9.ai/{{PROJECT_NAME}}:${CI_COMMIT_SHA:0:8} + secrets: [zot_user, zot_password] + when: + - event: push + branch: main + + deploy: + image: bitnami/kubectl:latest + commands: + - kubectl set image deployment/{{PROJECT_NAME}} {{PROJECT_NAME}}=zot.orchard9.ai/{{PROJECT_NAME}}:${CI_COMMIT_SHA:0:8} -n projects + when: + - event: push + branch: main diff --git a/deployments/k8s/base/templates/astro-landing/Dockerfile b/deployments/k8s/base/templates/astro-landing/Dockerfile new file mode 100644 index 0000000..a5f7803 --- /dev/null +++ b/deployments/k8s/base/templates/astro-landing/Dockerfile @@ -0,0 +1,20 @@ +# Build stage +FROM node:20-alpine AS build + +WORKDIR /app + +COPY package*.json ./ +RUN npm ci + +COPY . . +RUN npm run build + +# Production stage +FROM nginx:alpine + +COPY --from=build /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/deployments/k8s/base/templates/astro-landing/README.md b/deployments/k8s/base/templates/astro-landing/README.md new file mode 100644 index 0000000..dc99e44 --- /dev/null +++ b/deployments/k8s/base/templates/astro-landing/README.md @@ -0,0 +1,32 @@ +# {{PROJECT_NAME}} + +Astro landing page deployed at: https://{{DOMAIN}} + +## Getting Started + +```bash +npm install +npm run dev +``` + +## Commands + +| Command | Action | +|---------|--------| +| `npm run dev` | Start dev server at localhost:4321 | +| `npm run build` | Build for production | +| `npm run preview` | Preview production build | + +## Structure + +``` +src/ + pages/ # File-based routing + components/ # Astro/React components + layouts/ # Page layouts +public/ # Static assets +``` + +## CI/CD + +Pushes to `main` trigger automatic deployment via Woodpecker CI. diff --git a/deployments/k8s/base/templates/astro-landing/astro.config.mjs b/deployments/k8s/base/templates/astro-landing/astro.config.mjs new file mode 100644 index 0000000..50a4e80 --- /dev/null +++ b/deployments/k8s/base/templates/astro-landing/astro.config.mjs @@ -0,0 +1,7 @@ +import { defineConfig } from 'astro/config'; +import tailwind from '@astrojs/tailwind'; + +export default defineConfig({ + integrations: [tailwind()], + output: 'static', +}); diff --git a/deployments/k8s/base/templates/astro-landing/nginx.conf b/deployments/k8s/base/templates/astro-landing/nginx.conf new file mode 100644 index 0000000..038c0fa --- /dev/null +++ b/deployments/k8s/base/templates/astro-landing/nginx.conf @@ -0,0 +1,27 @@ +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; + } + + # Health check + location /health { + return 200 'ok'; + add_header Content-Type text/plain; + } +} diff --git a/deployments/k8s/base/templates/astro-landing/package.json b/deployments/k8s/base/templates/astro-landing/package.json new file mode 100644 index 0000000..771819e --- /dev/null +++ b/deployments/k8s/base/templates/astro-landing/package.json @@ -0,0 +1,18 @@ +{ + "name": "{{PROJECT_NAME}}", + "type": "module", + "version": "0.0.1", + "scripts": { + "dev": "astro dev", + "start": "astro dev", + "build": "astro build", + "preview": "astro preview" + }, + "dependencies": { + "astro": "^4.0.0" + }, + "devDependencies": { + "@astrojs/tailwind": "^5.0.0", + "tailwindcss": "^3.4.0" + } +} diff --git a/deployments/k8s/base/templates/astro-landing/src/layouts/Layout.astro b/deployments/k8s/base/templates/astro-landing/src/layouts/Layout.astro new file mode 100644 index 0000000..a029720 --- /dev/null +++ b/deployments/k8s/base/templates/astro-landing/src/layouts/Layout.astro @@ -0,0 +1,21 @@ +--- +interface Props { + title: string; +} + +const { title } = Astro.props; +--- + + + + + + + + + {title} + + + + + diff --git a/deployments/k8s/base/templates/astro-landing/src/pages/index.astro b/deployments/k8s/base/templates/astro-landing/src/pages/index.astro new file mode 100644 index 0000000..984f020 --- /dev/null +++ b/deployments/k8s/base/templates/astro-landing/src/pages/index.astro @@ -0,0 +1,33 @@ +--- +import Layout from '../layouts/Layout.astro'; +--- + + +
+
+
+

+ {{PROJECT_NAME}} +

+

+ Welcome to your new Astro landing page. Edit this file at + src/pages/index.astro +

+ +
+
+
+
diff --git a/deployments/k8s/base/templates/astro-landing/tailwind.config.mjs b/deployments/k8s/base/templates/astro-landing/tailwind.config.mjs new file mode 100644 index 0000000..83cac5e --- /dev/null +++ b/deployments/k8s/base/templates/astro-landing/tailwind.config.mjs @@ -0,0 +1,8 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'], + theme: { + extend: {}, + }, + plugins: [], +}; diff --git a/deployments/k8s/base/templates/default/.woodpecker.yml b/deployments/k8s/base/templates/default/.woodpecker.yml new file mode 100644 index 0000000..94f0cae --- /dev/null +++ b/deployments/k8s/base/templates/default/.woodpecker.yml @@ -0,0 +1,29 @@ +steps: + build: + image: docker:24-dind + privileged: true + commands: + - docker build -t zot.orchard9.ai/{{PROJECT_NAME}}:latest . + - docker build -t zot.orchard9.ai/{{PROJECT_NAME}}:${CI_COMMIT_SHA:0:8} . + when: + - event: push + + push: + image: docker:24-dind + privileged: true + commands: + - echo "$ZOT_PASSWORD" | docker login zot.orchard9.ai -u "$ZOT_USER" --password-stdin + - docker push zot.orchard9.ai/{{PROJECT_NAME}}:latest + - docker push zot.orchard9.ai/{{PROJECT_NAME}}:${CI_COMMIT_SHA:0:8} + secrets: [zot_user, zot_password] + when: + - event: push + branch: main + + deploy: + image: bitnami/kubectl:latest + commands: + - kubectl set image deployment/{{PROJECT_NAME}} {{PROJECT_NAME}}=zot.orchard9.ai/{{PROJECT_NAME}}:${CI_COMMIT_SHA:0:8} -n projects + when: + - event: push + branch: main diff --git a/deployments/k8s/base/templates/default/Dockerfile b/deployments/k8s/base/templates/default/Dockerfile new file mode 100644 index 0000000..e7846dc --- /dev/null +++ b/deployments/k8s/base/templates/default/Dockerfile @@ -0,0 +1,9 @@ +# Default Dockerfile - replace with your application +FROM nginx:alpine + +# Copy static files or your app +COPY . /usr/share/nginx/html/ + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/deployments/k8s/base/templates/default/README.md b/deployments/k8s/base/templates/default/README.md new file mode 100644 index 0000000..876195f --- /dev/null +++ b/deployments/k8s/base/templates/default/README.md @@ -0,0 +1,21 @@ +# {{PROJECT_NAME}} + +Deployed at: https://{{DOMAIN}} + +## Getting Started + +1. Clone the repository +2. Build with Docker: `docker build -t {{PROJECT_NAME}} .` +3. Run locally: `docker run -p 8080:8080 {{PROJECT_NAME}}` + +## CI/CD + +This project uses Woodpecker CI for continuous deployment. Pushing to `main` will: +- Build a Docker image +- Push to the container registry +- Deploy to Kubernetes + +## Resources + +- Live site: https://{{DOMAIN}} +- Git repository: {{GIT_URL}} diff --git a/deployments/k8s/base/templates/go-api/.woodpecker.yml b/deployments/k8s/base/templates/go-api/.woodpecker.yml new file mode 100644 index 0000000..0cd3c17 --- /dev/null +++ b/deployments/k8s/base/templates/go-api/.woodpecker.yml @@ -0,0 +1,43 @@ +steps: + test: + image: golang:1.22-alpine + commands: + - go test ./... + when: + - event: [push, pull_request] + + build: + image: golang:1.22-alpine + commands: + - go build -o app ./cmd/api + when: + - event: [push, pull_request] + + docker: + image: docker:24-dind + privileged: true + commands: + - docker build -t zot.orchard9.ai/{{PROJECT_NAME}}:latest . + - docker build -t zot.orchard9.ai/{{PROJECT_NAME}}:${CI_COMMIT_SHA:0:8} . + when: + - event: push + + push: + image: docker:24-dind + privileged: true + commands: + - echo "$ZOT_PASSWORD" | docker login zot.orchard9.ai -u "$ZOT_USER" --password-stdin + - docker push zot.orchard9.ai/{{PROJECT_NAME}}:latest + - docker push zot.orchard9.ai/{{PROJECT_NAME}}:${CI_COMMIT_SHA:0:8} + secrets: [zot_user, zot_password] + when: + - event: push + branch: main + + deploy: + image: bitnami/kubectl:latest + commands: + - kubectl set image deployment/{{PROJECT_NAME}} {{PROJECT_NAME}}=zot.orchard9.ai/{{PROJECT_NAME}}:${CI_COMMIT_SHA:0:8} -n projects + when: + - event: push + branch: main diff --git a/deployments/k8s/base/templates/go-api/Dockerfile b/deployments/k8s/base/templates/go-api/Dockerfile new file mode 100644 index 0000000..39199ad --- /dev/null +++ b/deployments/k8s/base/templates/go-api/Dockerfile @@ -0,0 +1,23 @@ +# Build stage +FROM golang:1.22-alpine AS build + +WORKDIR /app + +COPY go.mod go.sum* ./ +RUN go mod download + +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -o /app/server ./cmd/api + +# Production stage +FROM alpine:latest + +RUN apk --no-cache add ca-certificates + +WORKDIR /app + +COPY --from=build /app/server . + +EXPOSE 8080 + +CMD ["./server"] diff --git a/deployments/k8s/base/templates/go-api/README.md b/deployments/k8s/base/templates/go-api/README.md new file mode 100644 index 0000000..1163a21 --- /dev/null +++ b/deployments/k8s/base/templates/go-api/README.md @@ -0,0 +1,33 @@ +# {{PROJECT_NAME}} + +Go REST API deployed at: https://{{DOMAIN}} + +## Getting Started + +```bash +go run ./cmd/api +``` + +## Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| GET | /health | Health check | +| GET | /api/v1/example | Example endpoint | + +## Development + +```bash +# Run +go run ./cmd/api + +# Test +go test ./... + +# Build +go build -o app ./cmd/api +``` + +## CI/CD + +Pushes to `main` trigger automatic deployment via Woodpecker CI. diff --git a/deployments/k8s/base/templates/go-api/cmd/api/main.go b/deployments/k8s/base/templates/go-api/cmd/api/main.go new file mode 100644 index 0000000..0c989fb --- /dev/null +++ b/deployments/k8s/base/templates/go-api/cmd/api/main.go @@ -0,0 +1,47 @@ +package main + +import ( + "encoding/json" + "log/slog" + "net/http" + "os" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" +) + +func main() { + logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) + slog.SetDefault(logger) + + r := chi.NewRouter() + r.Use(middleware.Logger) + r.Use(middleware.Recoverer) + + // Health check + r.Get("/health", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) + }) + + // API routes + r.Route("/api/v1", func(r chi.Router) { + r.Get("/example", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "message": "Hello from {{PROJECT_NAME}}", + }) + }) + }) + + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + + slog.Info("starting server", "port", port) + if err := http.ListenAndServe(":"+port, r); err != nil { + slog.Error("server failed", "error", err) + os.Exit(1) + } +} diff --git a/deployments/k8s/base/templates/go-api/go.mod b/deployments/k8s/base/templates/go-api/go.mod new file mode 100644 index 0000000..1494b10 --- /dev/null +++ b/deployments/k8s/base/templates/go-api/go.mod @@ -0,0 +1,5 @@ +module github.com/orchard9/{{PROJECT_NAME}} + +go 1.22 + +require github.com/go-chi/chi/v5 v5.0.12 diff --git a/docs/features/multi-provider.md b/docs/features/multi-provider.md new file mode 100644 index 0000000..56b139d --- /dev/null +++ b/docs/features/multi-provider.md @@ -0,0 +1,584 @@ +# Multi-Provider Code Agent Interface + +> **Status:** In Progress (Weeks 1-4 Complete) +> **Feature:** Unified interface supporting Claude Code and OpenCode providers + +## Overview + +This document describes the architecture for supporting multiple code agent providers (Claude Code, OpenCode) through a unified interface. The design enables provider switching at runtime without breaking existing functionality. + +## Implementation Progress + +| Phase | Status | Description | +|-------|--------|-------------| +| Week 1: Foundation | ✅ Complete | Domain models, port interface, registry | +| Week 2: Claude Code Adapter | ✅ Complete | kubectl exec wrapper, stream-json parser | +| Week 3: OpenCode Adapter | ✅ Complete | HTTP/SSE client, session management | +| Week 4: Service Integration | ✅ Complete | ProjectService integration, event streaming | +| Week 5: Polish | ⬜ Pending | Model selection API, health monitoring, metrics, docs | + +## Architecture + +### Current Flow + +``` +Handler → ProjectService → CodeAgent (port) → ClaudeCodeAdapter | OpenCodeAdapter + ↓ ↓ + kubectl exec HTTP API + claude -p opencode serve +``` + +### Fallback Support + +When `CodeAgentRegistry` is not configured, the service falls back to the legacy `CommandExecutor` for backward compatibility. + +## Domain Models (✅ Implemented) + +### File: `internal/domain/code_agent.go` + +```go +// AgentProvider identifies which code agent implementation to use +type AgentProvider string + +const ( + AgentProviderClaudeCode AgentProvider = "claudecode" + AgentProviderOpenCode AgentProvider = "opencode" +) + +// Validation and parsing +func (p AgentProvider) IsValid() bool +func (p AgentProvider) String() string +func ParseAgentProvider(s string) (AgentProvider, error) +func ValidAgentProviders() []AgentProvider + +// AgentRequest contains parameters for executing a code agent command +type AgentRequest struct { + Prompt string + ProjectID ProjectID + SessionID string // For continuation + AllowedTools []string // Tool restrictions + Model string // Model override (OpenCode only) + WorkingDir string // Defaults to /workspace + Timeout time.Duration // Execution timeout + Metadata map[string]string // Provider-specific options +} + +// AgentEventType categorizes events emitted during agent execution +type AgentEventType string + +const ( + AgentEventOutput AgentEventType = "output" + AgentEventToolUse AgentEventType = "tool_use" + AgentEventToolResult AgentEventType = "tool_result" + AgentEventThinking AgentEventType = "thinking" + AgentEventError AgentEventType = "error" + AgentEventComplete AgentEventType = "complete" +) + +// AgentEvent represents a single event during agent execution +type AgentEvent struct { + Type AgentEventType + Timestamp time.Time + Content string + Stream string // "stdout", "stderr", or empty + ToolName string // For tool_use/tool_result events + ToolInput map[string]any // Tool invocation arguments + Metadata map[string]any +} + +// AgentEventHandler is a callback for receiving agent events +type AgentEventHandler func(event AgentEvent) + +// AgentResult contains the outcome of agent execution +type AgentResult struct { + SessionID string // For continuation + ExitCode int + DurationMs int64 + Error error + TokensUsed *AgentTokenUsage // If available + FinalOutput string +} + +func (r *AgentResult) Success() bool // ExitCode == 0 && Error == nil + +// AgentTokenUsage tracks token consumption +type AgentTokenUsage struct { + InputTokens int64 + OutputTokens int64 + TotalTokens int64 +} + +// AgentCapabilities describes what a provider supports +type AgentCapabilities struct { + Provider AgentProvider + SupportsSessionContinuation bool + SupportsModelSelection bool + SupportsToolControl bool + SupportedModels []string + DefaultModel string + MaxPromptLength int + SupportsStreaming bool +} +``` + +### Project Extension + +```go +// In domain/project.go +type Project struct { + // ... existing fields ... + AgentProvider AgentProvider // Which code agent to use +} + +// New label for K8s discovery +const LabelAgentProvider = "rdev.orchard9.ai/agent-provider" +``` + +### Error Handling + +```go +// In domain/errors.go +var ErrInvalidAgentProvider = errors.New("invalid agent provider") +``` + +## Port Interface (✅ Implemented) + +### File: `internal/port/code_agent.go` + +```go +// CodeAgent defines operations for executing AI coding agent commands +type CodeAgent interface { + // Name returns a human-readable name for this agent + Name() string + + // Provider returns the agent provider identifier + Provider() domain.AgentProvider + + // Execute runs an agent command and streams events to the handler + Execute(ctx context.Context, req *domain.AgentRequest, handler domain.AgentEventHandler) (*domain.AgentResult, error) + + // Cancel attempts to cancel a running agent session + Cancel(ctx context.Context, sessionID string) error + + // Capabilities returns what this agent supports + Capabilities() domain.AgentCapabilities + + // Available returns true if the agent is ready to accept requests + Available(ctx context.Context) bool +} + +// CodeAgentRegistry manages registered code agent implementations +type CodeAgentRegistry interface { + // Register adds an agent for a provider (overwrites existing) + Register(agent CodeAgent) + + // Get returns the agent for a specific provider (nil if not found) + Get(provider domain.AgentProvider) CodeAgent + + // Default returns the default agent (nil if empty) + Default() CodeAgent + + // SetDefault sets the default provider (error if not registered) + SetDefault(provider domain.AgentProvider) error + + // Available returns all registered providers + Available() []domain.AgentProvider + + // AvailableAgents returns agents that are currently available + AvailableAgents(ctx context.Context) []CodeAgent +} +``` + +## Provider Registry (✅ Implemented) + +### File: `internal/adapter/codeagent/registry.go` + +```go +// Registry implements port.CodeAgentRegistry with thread-safe agent management +type Registry struct { + mu sync.RWMutex + agents map[domain.AgentProvider]port.CodeAgent + defProv domain.AgentProvider + hasAgent bool +} + +func NewRegistry() *Registry + +// Additional methods beyond interface +func (r *Registry) DefaultProvider() domain.AgentProvider +func (r *Registry) Count() int +``` + +**Thread Safety:** +- `sync.RWMutex` for concurrent access +- Read locks for: `Get`, `Default`, `Available`, `AvailableAgents`, `DefaultProvider`, `Count` +- Write locks for: `Register`, `SetDefault` +- First registered agent becomes default automatically + +**Test Coverage:** +- Register/Get operations +- Default selection (first registered) +- SetDefault (success and failure) +- Available providers listing +- AvailableAgents filtering +- Concurrent access (race-tested) +- Re-registration overwrites + +## Claude Code Adapter (✅ Implemented) + +### Package: `internal/adapter/codeagent/claudecode/` + +**Files:** +- `adapter.go` - CodeAgent implementation wrapping kubectl exec +- `parser.go` - Stream-JSON NDJSON parser for Claude Code output +- `adapter_test.go` - Comprehensive test coverage +- `parser_test.go` - Parser unit tests + +**Key Features:** +- Wraps `kubectl exec` for pod access +- Uses `--output-format stream-json` for structured NDJSON output +- Supports `--resume ` for conversation continuation +- Maps `AllowedTools` to `--allowedTools` flag +- Uses `--dangerously-skip-permissions` for non-interactive mode + +**Command Construction:** +```go +func (a *Adapter) buildCommandArgs(namespace, podName string, req *domain.AgentRequest) []string { + args := []string{ + "exec", "-n", namespace, podName, "--", + "claude", "-p", "--output-format", "stream-json", "--dangerously-skip-permissions", + } + if req.SessionID != "" { + args = append(args, "--resume", req.SessionID) + } + for _, tool := range req.AllowedTools { + args = append(args, "--allowedTools", tool) + } + if req.WorkingDir != "" && req.WorkingDir != "/workspace" { + args = append(args, "--add-dir", req.WorkingDir) + } + args = append(args, req.Prompt) + return args +} +``` + +**Stream JSON Message Types:** +| Type | Description | Mapped Event | +|------|-------------|--------------| +| `init` | Session started | `AgentEventOutput` | +| `message` | Text output from assistant | `AgentEventOutput` | +| `tool_use` | Tool invocation | `AgentEventToolUse` | +| `tool_result` | Tool response | `AgentEventToolResult` | +| `result` | Execution complete | `AgentEventComplete` | + +**Capabilities:** +```go +func (a *Adapter) Capabilities() domain.AgentCapabilities { + return domain.AgentCapabilities{ + Provider: domain.AgentProviderClaudeCode, + SupportsSessionContinuation: true, + SupportsModelSelection: false, // Claude Code only uses Claude + SupportsToolControl: true, + SupportedModels: []string{"claude-sonnet-4-20250514", "claude-opus-4-20250514"}, + DefaultModel: "claude-sonnet-4-20250514", + MaxPromptLength: 0, // Unlimited + SupportsStreaming: true, + } +} +``` + +## OpenCode Adapter (✅ Implemented) + +### Package: `internal/adapter/codeagent/opencode/` + +**Files:** +- `adapter.go` - CodeAgent implementation using HTTP/SSE +- `client.go` - HTTP client with SSE subscription support +- `adapter_test.go` - Mock server tests for all operations + +**HTTP Client API:** +```go +type Client struct { + baseURL string + httpClient *http.Client + username string + password string +} + +// Health check +func (c *Client) Health(ctx context.Context) (*HealthResponse, error) + +// Session management +func (c *Client) CreateSession(ctx context.Context, req *CreateSessionRequest) (*Session, error) +func (c *Client) GetSession(ctx context.Context, sessionID string) (*Session, error) +func (c *Client) AbortSession(ctx context.Context, sessionID string) error + +// Message sending +func (c *Client) SendMessage(ctx context.Context, sessionID string, req *SendMessageRequest) (*SendMessageResponse, error) +func (c *Client) SendPromptAsync(ctx context.Context, sessionID string, req *SendMessageRequest) error + +// SSE streaming +func (c *Client) SubscribeEvents(ctx context.Context) (<-chan SSEEvent, error) +``` + +**SSE Event Mapping:** +| SSE Event | Description | Mapped Event | +|-----------|-------------|--------------| +| `server.connected` | Connected to server | `AgentEventOutput` | +| `message.created` | New message | `AgentEventOutput` | +| `message.updated` | Message updated | `AgentEventOutput` | +| `tool.started` | Tool execution started | `AgentEventToolUse` | +| `tool.completed` | Tool execution done | `AgentEventToolResult` | +| `session.completed` | Session finished | `AgentEventComplete` | +| `error` | Error occurred | `AgentEventError` | + +**Capabilities:** +```go +func (a *Adapter) Capabilities() domain.AgentCapabilities { + return domain.AgentCapabilities{ + Provider: domain.AgentProviderOpenCode, + SupportsSessionContinuation: true, + SupportsModelSelection: true, // OpenCode supports multiple providers + SupportsToolControl: true, + SupportedModels: []string{ + "claude-sonnet-4-20250514", + "claude-opus-4-20250514", + "gpt-4o", + "gpt-4-turbo", + "gemini-pro", + }, + DefaultModel: "claude-sonnet-4-20250514", + MaxPromptLength: 0, // Unlimited + SupportsStreaming: true, + } +} +``` + +**Authentication:** +- Basic auth support via `username` and `password` config +- Default username: `opencode` + +## Service Integration (✅ Implemented) + +### Files Modified/Created + +| File | Description | +|------|-------------| +| `project_service.go` | Core service with agent registry support | +| `project_service_agent.go` | Agent execution and resolution methods | +| `project_service_commands.go` | Shell/Git command execution (extracted) | +| `project_service_queue.go` | Queue operations (extracted) | + +### ProjectService Changes + +**New Fields:** +```go +type ProjectService struct { + // ... existing fields ... + agentRegistry port.CodeAgentRegistry // Optional code agent registry +} + +func (s *ProjectService) WithCodeAgentRegistry(registry port.CodeAgentRegistry) *ProjectService +``` + +**Updated Request/Response:** +```go +type ExecuteClaudeRequest struct { + ProjectID domain.ProjectID + Prompt string + StreamID string + SessionID string // Optional: resume a previous session + Model string // Optional: model override (OpenCode only) + AllowedTools []string // Optional: restrict tool access + Audit *AuditContext +} + +type ExecuteClaudeResult struct { + CommandID domain.CommandID + StreamURL string + SessionID string // Session ID for continuation + AgentProvider domain.AgentProvider // Which provider handled the request +} +``` + +**Agent Resolution:** +```go +// resolveAgent returns the appropriate CodeAgent for a project. +// Returns nil if no agent registry is configured or no agent is available. +func (s *ProjectService) resolveAgent(project *domain.Project) port.CodeAgent { + if s.agentRegistry == nil { + return nil + } + + // Try project-specific agent first + if project.AgentProvider != "" { + if agent := s.agentRegistry.Get(project.AgentProvider); agent != nil { + return agent + } + } + + // Fall back to default + return s.agentRegistry.Default() +} +``` + +**Event Streaming:** + +Agent events are converted to SSE stream events: + +| Agent Event | Stream Event | Data | +|-------------|--------------|------| +| `AgentEventOutput` | `output` | `{line, stream}` | +| `AgentEventToolUse` | `tool_use` | `{tool, input}` | +| `AgentEventToolResult` | `tool_result` | `{output}` | +| `AgentEventError` | `error` | `{error}` | +| `AgentEventComplete` | `agent_complete` | metadata | +| (final) | `complete` | `{exit_code, duration_ms, session_id, provider}` | + +**Additional Service Methods:** +```go +// Get capabilities for a specific provider +func (s *ProjectService) GetAgentCapabilities(provider domain.AgentProvider) *domain.AgentCapabilities + +// List all available providers +func (s *ProjectService) ListAvailableAgents() []domain.AgentProvider + +// Get/set default agent +func (s *ProjectService) GetDefaultAgent() domain.AgentProvider +func (s *ProjectService) SetDefaultAgent(provider domain.AgentProvider) error +``` + +## API Changes (⬜ Pending - Week 5) + +### Project Response + +```json +{ + "id": "proj-123", + "name": "my-project", + "agent_provider": "claudecode", + "agent_capabilities": { + "supports_session_continuation": true, + "supports_model_selection": false + } +} +``` + +### Update Provider + +```http +PATCH /projects/{id} +Content-Type: application/json + +{ + "agent_provider": "opencode" +} +``` + +### Execute with Model (OpenCode only) + +```http +POST /projects/{id}/claude +Content-Type: application/json + +{ + "prompt": "Fix the bug in main.go", + "model": "gpt-4o", + "session_id": "prev-session-123" +} +``` + +## Configuration + +### Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `CODE_AGENT_DEFAULT` | `claudecode` | Default provider for new projects | +| `OPENCODE_ENABLED` | `false` | Enable OpenCode adapter | +| `OPENCODE_URL` | `http://127.0.0.1:4096` | OpenCode server URL | +| `OPENCODE_USERNAME` | `opencode` | OpenCode basic auth username | +| `OPENCODE_PASSWORD` | (none) | OpenCode basic auth password | + +### Project-Level Override + +Projects can specify their preferred provider in the database. On provider switch: +1. Active session is cleared (no cross-provider continuation) +2. New provider is validated as available +3. Next command uses new provider + +## File Structure + +``` +internal/ +├── domain/ +│ ├── code_agent.go ✅ AgentProvider, AgentRequest, AgentEvent, etc. +│ ├── code_agent_test.go ✅ Domain model tests +│ ├── project.go ✅ Added AgentProvider field +│ └── errors.go ✅ Added ErrInvalidAgentProvider +├── port/ +│ └── code_agent.go ✅ CodeAgent, CodeAgentRegistry interfaces +├── adapter/ +│ └── codeagent/ +│ ├── registry.go ✅ Provider registry implementation +│ ├── registry_test.go ✅ Registry tests (incl. concurrent access) +│ ├── claudecode/ ✅ Week 2 +│ │ ├── adapter.go ✅ CodeAgent implementation +│ │ ├── parser.go ✅ Stream-JSON NDJSON parser +│ │ ├── adapter_test.go ✅ Adapter tests +│ │ └── parser_test.go ✅ Parser tests +│ └── opencode/ ✅ Week 3 +│ ├── adapter.go ✅ CodeAgent implementation +│ ├── client.go ✅ HTTP/SSE client +│ └── adapter_test.go ✅ Mock server tests +├── service/ +│ ├── project_service.go ✅ Week 4: Agent registry integration +│ ├── project_service_agent.go ✅ Week 4: Agent execution methods +│ ├── project_service_commands.go ✅ Extracted shell/git commands +│ └── project_service_queue.go ✅ Extracted queue operations +└── worker/ + └── queue_processor.go ⬜ Week 5: Use CodeAgent for queue +``` + +## Observability (⬜ Pending - Week 5) + +### Metrics + +| Metric | Labels | Description | +|--------|--------|-------------| +| `code_agent_requests_total` | provider, project, status | Total requests | +| `code_agent_duration_seconds` | provider, project | Execution duration | +| `code_agent_events_total` | provider, event_type | Streaming events | + +### Health Check + +```http +GET /health + +{ + "status": "healthy", + "agents": { + "claudecode": "available", + "opencode": "unavailable" + } +} +``` + +## Risks and Mitigations + +| Risk | Impact | Mitigation | +|------|--------|------------| +| OpenCode API changes | Adapter breaks | Pin to specific version, add API versioning | +| Latency difference (subprocess vs HTTP) | User experience varies | Monitor p99 latency, document tradeoffs | +| Session state incompatibility | Can't resume across providers | Clear session on provider switch | +| Container image size increase | Slower deployments | OpenCode sidecar optional, not in base image | + +## Design Decisions + +1. **Event callback pattern** - Matches existing `OutputHandler`, enables streaming +2. **Registry pattern** - Allows runtime switching, extensible for more providers +3. **OpenCode as sidecar** - Keeps Claude Code as proven default, OpenCode opt-in +4. **Session per provider** - No cross-provider session sharing to avoid state corruption +5. **First-registered default** - Registry automatically uses first agent as default +6. **Backward compatibility** - Falls back to legacy executor when no registry configured +7. **File splitting** - Service files split to comply with 500-line limit diff --git a/go.mod b/go.mod index 968ee8f..ec83ef2 100644 --- a/go.mod +++ b/go.mod @@ -9,10 +9,12 @@ require ( github.com/google/uuid v1.6.0 github.com/lib/pq v1.10.9 github.com/prometheus/client_golang v1.23.2 + github.com/stretchr/testify v1.11.1 go.opentelemetry.io/otel v1.39.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0 go.opentelemetry.io/otel/sdk v1.39.0 go.opentelemetry.io/otel/trace v1.39.0 + go.woodpecker-ci.org/woodpecker/v2 v2.8.3 k8s.io/api v0.35.0 k8s.io/apimachinery v0.35.0 k8s.io/client-go v0.35.0 @@ -31,7 +33,7 @@ require ( github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect - github.com/go-openapi/jsonreference v0.20.2 // indirect + github.com/go-openapi/jsonreference v0.20.4 // indirect github.com/go-openapi/swag v0.23.0 // indirect github.com/google/gnostic-models v0.7.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect @@ -42,6 +44,7 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/procfs v0.16.1 // indirect diff --git a/go.sum b/go.sum index c6b3024..c4b90a2 100644 --- a/go.sum +++ b/go.sum @@ -12,7 +12,6 @@ github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1x github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -31,12 +30,10 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= -github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= -github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= -github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-openapi/jsonreference v0.20.4 h1:bKlDxQxQJgwpUSgOENiMPzCTBVuc7vTdXSSgNeAhojU= +github.com/go-openapi/jsonreference v0.20.4/go.mod h1:5pZJyJP2MnYCpoeoMAql78cCHauHj0V9Lhc506VOpw4= github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= @@ -62,11 +59,8 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= -github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= @@ -102,14 +96,9 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7 github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= @@ -134,6 +123,8 @@ go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjce go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.woodpecker-ci.org/woodpecker/v2 v2.8.3 h1:g54xYwrL4RhCTTyKtjYPDB9ePnUsqRx6qkqlnAcFdJg= +go.woodpecker-ci.org/woodpecker/v2 v2.8.3/go.mod h1:nvdmUnQJMqm8UzJOlJ50MYYq/uv8oyOqhBBr7SdoNPw= go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= @@ -187,7 +178,6 @@ gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnf gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= k8s.io/api v0.35.0 h1:iBAU5LTyBI9vw3L5glmat1njFK34srdLmktWwLTprlY= diff --git a/internal/adapter/codeagent/claudecode/adapter.go b/internal/adapter/codeagent/claudecode/adapter.go new file mode 100644 index 0000000..b5454ed --- /dev/null +++ b/internal/adapter/codeagent/claudecode/adapter.go @@ -0,0 +1,346 @@ +// Package claudecode provides a CodeAgent implementation for Anthropic's Claude Code CLI. +package claudecode + +import ( + "bufio" + "context" + "fmt" + "io" + "os/exec" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/orchard9/rdev/internal/domain" + "github.com/orchard9/rdev/internal/port" +) + +// Adapter implements port.CodeAgent using Anthropic's Claude Code CLI. +type Adapter struct { + namespace string + mu sync.RWMutex + + // Track active sessions for cancellation + activeSessions map[string]context.CancelFunc + sessionsMu sync.Mutex +} + +// NewAdapter creates a new Claude Code adapter. +func NewAdapter(namespace string) *Adapter { + return &Adapter{ + namespace: namespace, + activeSessions: make(map[string]context.CancelFunc), + } +} + +// Ensure Adapter implements port.CodeAgent at compile time. +var _ port.CodeAgent = (*Adapter)(nil) + +// Name returns a human-readable name for this agent. +func (a *Adapter) Name() string { + return "Claude Code" +} + +// Provider returns the agent provider identifier. +func (a *Adapter) Provider() domain.AgentProvider { + return domain.AgentProviderClaudeCode +} + +// Execute runs a Claude Code command and streams events to the handler. +func (a *Adapter) Execute(ctx context.Context, req *domain.AgentRequest, handler domain.AgentEventHandler) (*domain.AgentResult, error) { + if req.Prompt == "" { + return nil, fmt.Errorf("prompt is required") + } + + a.mu.RLock() + namespace := a.namespace + a.mu.RUnlock() + + // Create cancellable context + execCtx, cancel := context.WithCancel(ctx) + defer cancel() + + // Track session for potential cancellation + sessionID := generateSessionID() + a.sessionsMu.Lock() + a.activeSessions[sessionID] = cancel + a.sessionsMu.Unlock() + + defer func() { + a.sessionsMu.Lock() + delete(a.activeSessions, sessionID) + a.sessionsMu.Unlock() + }() + + // Get pod name from project (passed via metadata or lookup) + var podName string + if req.Metadata != nil { + podName = req.Metadata["pod_name"] + } + if podName == "" { + return &domain.AgentResult{ + SessionID: sessionID, + ExitCode: 1, + Error: fmt.Errorf("pod_name is required in request metadata"), + }, nil + } + + // Build kubectl exec command for Claude Code + args := a.buildCommandArgs(namespace, podName, req) + + // Apply timeout if specified + if req.Timeout > 0 { + var timeoutCancel context.CancelFunc + execCtx, timeoutCancel = context.WithTimeout(execCtx, req.Timeout) + defer timeoutCancel() + } + + startTime := time.Now() + kubectl := exec.CommandContext(execCtx, "kubectl", args...) + + // Get stdout pipe for stream-json output + stdout, err := kubectl.StdoutPipe() + if err != nil { + return &domain.AgentResult{ + SessionID: sessionID, + ExitCode: 1, + Error: fmt.Errorf("stdout pipe: %w", err), + }, nil + } + + // Get stderr for error messages + stderr, err := kubectl.StderrPipe() + if err != nil { + return &domain.AgentResult{ + SessionID: sessionID, + ExitCode: 1, + Error: fmt.Errorf("stderr pipe: %w", err), + }, nil + } + + // Start the command + if err := kubectl.Start(); err != nil { + return &domain.AgentResult{ + SessionID: sessionID, + ExitCode: 1, + Error: fmt.Errorf("start: %w", err), + }, nil + } + + // Stream and parse output + var wg sync.WaitGroup + var finalOutput strings.Builder + var parseErr error + var resultMsg *StreamMessage + + wg.Add(2) + + // Parse stream-json from stdout + go func() { + defer wg.Done() + resultMsg, parseErr = a.parseStreamOutput(stdout, handler, &finalOutput) + }() + + // Stream stderr as error events + go func() { + defer wg.Done() + a.streamStderr(stderr, handler) + }() + + wg.Wait() + + // Wait for command completion + cmdErr := kubectl.Wait() + duration := time.Since(startTime) + + result := &domain.AgentResult{ + SessionID: sessionID, + DurationMs: duration.Milliseconds(), + FinalOutput: finalOutput.String(), + } + + // Determine exit code and error + if cmdErr != nil { + if exitErr, ok := cmdErr.(*exec.ExitError); ok { + result.ExitCode = exitErr.ExitCode() + } else { + result.ExitCode = 1 + result.Error = cmdErr + } + } else if parseErr != nil { + result.ExitCode = 1 + result.Error = parseErr + } else if resultMsg != nil && !resultMsg.IsSuccess() { + result.ExitCode = 1 + if resultMsg.Error != "" { + result.Error = fmt.Errorf("%s", resultMsg.Error) + } + } + + // Update session ID from result if available + if resultMsg != nil && resultMsg.SessionID != "" { + result.SessionID = resultMsg.SessionID + } + + return result, nil +} + +// buildCommandArgs constructs the kubectl exec arguments for Claude Code. +func (a *Adapter) buildCommandArgs(namespace, podName string, req *domain.AgentRequest) []string { + args := []string{ + "exec", "-n", namespace, podName, "--", + "claude", + "-p", // Print mode (non-interactive) + "--output-format", "stream-json", + "--dangerously-skip-permissions", + } + + // Add session continuation if resuming + if req.SessionID != "" { + args = append(args, "--resume", req.SessionID) + } + + // Add allowed tools if specified + if len(req.AllowedTools) > 0 { + for _, tool := range req.AllowedTools { + args = append(args, "--allowedTools", tool) + } + } + + // Add working directory if specified + if req.WorkingDir != "" && req.WorkingDir != "/workspace" { + args = append(args, "--add-dir", req.WorkingDir) + } + + // Add the prompt as the final argument + args = append(args, req.Prompt) + + return args +} + +// parseStreamOutput reads and parses NDJSON stream-json output. +func (a *Adapter) parseStreamOutput(r io.Reader, handler domain.AgentEventHandler, output *strings.Builder) (*StreamMessage, error) { + scanner := bufio.NewScanner(r) + // Increase buffer for long lines + buf := make([]byte, 0, 64*1024) + scanner.Buffer(buf, 1024*1024) + + var resultMsg *StreamMessage + + for scanner.Scan() { + line := scanner.Bytes() + if len(line) == 0 { + continue + } + + msg, err := ParseStreamMessage(line) + if err != nil { + // Non-JSON line, treat as plain output + event := domain.AgentEvent{ + Type: domain.AgentEventOutput, + Timestamp: time.Now(), + Content: string(line), + Stream: "stdout", + } + handler(event) + output.WriteString(string(line)) + output.WriteString("\n") + continue + } + + // Convert to agent event and dispatch + event := msg.ToAgentEvent() + handler(event) + + // Collect output text + if msg.Type == StreamMessageMessage && msg.Role == "assistant" { + text := extractTextContent(msg.Content) + if text != "" { + output.WriteString(text) + } + } + + // Track result message + if msg.IsTerminal() { + resultMsg = msg + } + } + + if err := scanner.Err(); err != nil { + return resultMsg, fmt.Errorf("scanner error: %w", err) + } + + return resultMsg, nil +} + +// streamStderr reads stderr and emits error events. +func (a *Adapter) streamStderr(r io.Reader, handler domain.AgentEventHandler) { + scanner := bufio.NewScanner(r) + buf := make([]byte, 0, 64*1024) + scanner.Buffer(buf, 1024*1024) + + for scanner.Scan() { + line := scanner.Text() + if line == "" { + continue + } + + handler(domain.AgentEvent{ + Type: domain.AgentEventError, + Timestamp: time.Now(), + Content: line, + Stream: "stderr", + }) + } +} + +// Cancel attempts to cancel a running session. +func (a *Adapter) Cancel(ctx context.Context, sessionID string) error { + a.sessionsMu.Lock() + defer a.sessionsMu.Unlock() + + cancel, exists := a.activeSessions[sessionID] + if !exists { + return nil // Session not found is not an error + } + + cancel() + return nil +} + +// Capabilities returns what this agent supports. +func (a *Adapter) Capabilities() domain.AgentCapabilities { + return domain.AgentCapabilities{ + Provider: domain.AgentProviderClaudeCode, + SupportsSessionContinuation: true, + SupportsModelSelection: false, // Claude Code only uses Claude + SupportsToolControl: true, + SupportedModels: []string{"claude-sonnet-4-20250514", "claude-opus-4-20250514"}, + DefaultModel: "claude-sonnet-4-20250514", + MaxPromptLength: 0, // Unlimited + SupportsStreaming: true, + } +} + +// DefaultAvailabilityTimeout is the maximum time to wait when checking agent availability. +// This timeout prevents blocking the caller when kubectl or the cluster is slow or unresponsive. +const DefaultAvailabilityTimeout = 5 * time.Second + +// Available checks if kubectl is available and can connect to the cluster. +func (a *Adapter) Available(ctx context.Context) bool { + ctx, cancel := context.WithTimeout(ctx, DefaultAvailabilityTimeout) + defer cancel() + + cmd := exec.CommandContext(ctx, "kubectl", "cluster-info", "--request-timeout=5s") + return cmd.Run() == nil +} + +// sessionCounter is used to ensure unique session IDs. +var sessionCounter atomic.Uint64 + +// generateSessionID creates a unique session identifier. +func generateSessionID() string { + count := sessionCounter.Add(1) + return fmt.Sprintf("claude-%d-%d", time.Now().UnixNano(), count) +} diff --git a/internal/adapter/codeagent/claudecode/adapter_test.go b/internal/adapter/codeagent/claudecode/adapter_test.go new file mode 100644 index 0000000..044fbcc --- /dev/null +++ b/internal/adapter/codeagent/claudecode/adapter_test.go @@ -0,0 +1,289 @@ +package claudecode + +import ( + "context" + "strings" + "testing" + + "github.com/orchard9/rdev/internal/domain" + "github.com/orchard9/rdev/internal/port" +) + +// Ensure Adapter implements port.CodeAgent at compile time. +var _ port.CodeAgent = (*Adapter)(nil) + +func TestAdapter_Name(t *testing.T) { + adapter := NewAdapter("test-ns") + if name := adapter.Name(); name != "Claude Code" { + t.Errorf("expected name 'Claude Code', got %q", name) + } +} + +func TestAdapter_Provider(t *testing.T) { + adapter := NewAdapter("test-ns") + if p := adapter.Provider(); p != domain.AgentProviderClaudeCode { + t.Errorf("expected provider 'claudecode', got %q", p) + } +} + +func TestAdapter_Capabilities(t *testing.T) { + adapter := NewAdapter("test-ns") + caps := adapter.Capabilities() + + if caps.Provider != domain.AgentProviderClaudeCode { + t.Errorf("expected provider claudecode") + } + if !caps.SupportsSessionContinuation { + t.Error("expected session continuation support") + } + if caps.SupportsModelSelection { + t.Error("expected no model selection support") + } + if !caps.SupportsToolControl { + t.Error("expected tool control support") + } + if !caps.SupportsStreaming { + t.Error("expected streaming support") + } + if len(caps.SupportedModels) == 0 { + t.Error("expected at least one supported model") + } +} + +func TestAdapter_buildCommandArgs_Basic(t *testing.T) { + adapter := NewAdapter("rdev") + req := &domain.AgentRequest{ + Prompt: "Hello, Claude", + } + + args := adapter.buildCommandArgs("rdev", "pod-123", req) + + // Verify essential args are present + argsStr := strings.Join(args, " ") + + if !strings.Contains(argsStr, "exec -n rdev pod-123") { + t.Error("expected kubectl exec command") + } + if !strings.Contains(argsStr, "claude") { + t.Error("expected claude command") + } + if !strings.Contains(argsStr, "-p") { + t.Error("expected -p flag") + } + if !strings.Contains(argsStr, "--output-format stream-json") { + t.Error("expected stream-json output format") + } + if !strings.Contains(argsStr, "--dangerously-skip-permissions") { + t.Error("expected permission skip flag") + } + if !strings.Contains(argsStr, "Hello, Claude") { + t.Error("expected prompt in args") + } +} + +func TestAdapter_buildCommandArgs_WithSessionResume(t *testing.T) { + adapter := NewAdapter("rdev") + req := &domain.AgentRequest{ + Prompt: "Continue working", + SessionID: "session-abc", + } + + args := adapter.buildCommandArgs("rdev", "pod-123", req) + argsStr := strings.Join(args, " ") + + if !strings.Contains(argsStr, "--resume session-abc") { + t.Error("expected --resume flag with session ID") + } +} + +func TestAdapter_buildCommandArgs_WithAllowedTools(t *testing.T) { + adapter := NewAdapter("rdev") + req := &domain.AgentRequest{ + Prompt: "Run tests", + AllowedTools: []string{"Bash", "Read", "Edit"}, + } + + args := adapter.buildCommandArgs("rdev", "pod-123", req) + argsStr := strings.Join(args, " ") + + if !strings.Contains(argsStr, "--allowedTools Bash") { + t.Error("expected --allowedTools Bash") + } + if !strings.Contains(argsStr, "--allowedTools Read") { + t.Error("expected --allowedTools Read") + } + if !strings.Contains(argsStr, "--allowedTools Edit") { + t.Error("expected --allowedTools Edit") + } +} + +func TestAdapter_buildCommandArgs_WithWorkingDir(t *testing.T) { + adapter := NewAdapter("rdev") + req := &domain.AgentRequest{ + Prompt: "List files", + WorkingDir: "/workspace/subdir", + } + + args := adapter.buildCommandArgs("rdev", "pod-123", req) + argsStr := strings.Join(args, " ") + + if !strings.Contains(argsStr, "--add-dir /workspace/subdir") { + t.Error("expected --add-dir for non-default working directory") + } +} + +func TestAdapter_buildCommandArgs_DefaultWorkingDir(t *testing.T) { + adapter := NewAdapter("rdev") + req := &domain.AgentRequest{ + Prompt: "List files", + WorkingDir: "/workspace", // default + } + + args := adapter.buildCommandArgs("rdev", "pod-123", req) + argsStr := strings.Join(args, " ") + + // Should NOT have --add-dir for default workspace + if strings.Contains(argsStr, "--add-dir") { + t.Error("expected no --add-dir for default workspace") + } +} + +func TestAdapter_Execute_MissingPrompt(t *testing.T) { + adapter := NewAdapter("rdev") + req := &domain.AgentRequest{ + Prompt: "", // missing + Metadata: map[string]string{ + "pod_name": "test-pod", + }, + } + + _, err := adapter.Execute(context.Background(), req, func(e domain.AgentEvent) {}) + if err == nil { + t.Error("expected error for missing prompt") + } + if !strings.Contains(err.Error(), "prompt is required") { + t.Errorf("expected 'prompt is required' error, got: %v", err) + } +} + +func TestAdapter_Execute_MissingPodName(t *testing.T) { + adapter := NewAdapter("rdev") + req := &domain.AgentRequest{ + Prompt: "Hello", + Metadata: map[string]string{}, // no pod_name + } + + result, _ := adapter.Execute(context.Background(), req, func(e domain.AgentEvent) {}) + if result.Error == nil { + t.Error("expected error for missing pod_name") + } + if !strings.Contains(result.Error.Error(), "pod_name") { + t.Errorf("expected pod_name error, got: %v", result.Error) + } +} + +func TestAdapter_Cancel(t *testing.T) { + adapter := NewAdapter("rdev") + + // Cancel non-existent session should not error + err := adapter.Cancel(context.Background(), "nonexistent") + if err != nil { + t.Errorf("expected no error for non-existent session, got: %v", err) + } +} + +func TestAdapter_parseStreamOutput(t *testing.T) { + adapter := NewAdapter("rdev") + + input := strings.NewReader(`{"type":"init","session_id":"test-123"} +{"type":"message","role":"assistant","content":[{"type":"text","text":"Hello!"}]} +{"type":"result","status":"success","duration_ms":100} +`) + + var events []domain.AgentEvent + handler := func(e domain.AgentEvent) { + events = append(events, e) + } + + var output strings.Builder + resultMsg, err := adapter.parseStreamOutput(input, handler, &output) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(events) != 3 { + t.Errorf("expected 3 events, got %d", len(events)) + } + + // Verify event types + if events[0].Type != domain.AgentEventOutput { + t.Errorf("expected first event to be output (init)") + } + if events[1].Type != domain.AgentEventOutput { + t.Errorf("expected second event to be output (message)") + } + if events[2].Type != domain.AgentEventComplete { + t.Errorf("expected third event to be complete (result)") + } + + // Verify result message + if resultMsg == nil { + t.Fatal("expected result message") + } + if !resultMsg.IsSuccess() { + t.Error("expected success result") + } + + // Verify output was collected + if !strings.Contains(output.String(), "Hello!") { + t.Error("expected output to contain assistant message") + } +} + +func TestAdapter_parseStreamOutput_PlainText(t *testing.T) { + adapter := NewAdapter("rdev") + + // Non-JSON lines should be treated as plain output + input := strings.NewReader(`Not JSON output +Another plain line +{"type":"result","status":"success"} +`) + + var events []domain.AgentEvent + handler := func(e domain.AgentEvent) { + events = append(events, e) + } + + var output strings.Builder + _, err := adapter.parseStreamOutput(input, handler, &output) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(events) != 3 { + t.Errorf("expected 3 events, got %d", len(events)) + } + + // First two should be plain output events + if events[0].Content != "Not JSON output" { + t.Errorf("expected plain text content") + } + if events[0].Stream != "stdout" { + t.Errorf("expected stdout stream for plain text") + } +} + +func TestGenerateSessionID(t *testing.T) { + id1 := generateSessionID() + id2 := generateSessionID() + + if id1 == id2 { + t.Error("expected unique session IDs") + } + + if !strings.HasPrefix(id1, "claude-") { + t.Errorf("expected session ID to start with 'claude-', got %q", id1) + } +} diff --git a/internal/adapter/codeagent/claudecode/parser.go b/internal/adapter/codeagent/claudecode/parser.go new file mode 100644 index 0000000..0fc26c1 --- /dev/null +++ b/internal/adapter/codeagent/claudecode/parser.go @@ -0,0 +1,162 @@ +// Package claudecode provides a CodeAgent implementation for Anthropic's Claude Code CLI. +package claudecode + +import ( + "encoding/json" + "time" + + "github.com/orchard9/rdev/internal/domain" +) + +// StreamMessageType identifies the type of stream-json message from Claude Code CLI. +type StreamMessageType string + +const ( + // StreamMessageInit is emitted when a session starts. + StreamMessageInit StreamMessageType = "init" + // StreamMessageMessage contains assistant or user text. + StreamMessageMessage StreamMessageType = "message" + // StreamMessageToolUse indicates a tool is being invoked. + StreamMessageToolUse StreamMessageType = "tool_use" + // StreamMessageToolResult contains the output of a tool invocation. + StreamMessageToolResult StreamMessageType = "tool_result" + // StreamMessageResult is the final message indicating completion. + StreamMessageResult StreamMessageType = "result" +) + +// StreamMessage represents a single NDJSON message from Claude Code's stream-json output. +type StreamMessage struct { + Type StreamMessageType `json:"type"` + Timestamp string `json:"timestamp,omitempty"` + SessionID string `json:"session_id,omitempty"` + Role string `json:"role,omitempty"` // "assistant" or "user" + Content []ContentBlock `json:"content,omitempty"` + Name string `json:"name,omitempty"` // Tool name for tool_use + Input json.RawMessage `json:"input,omitempty"` // Tool input for tool_use + Output string `json:"output,omitempty"` + Status string `json:"status,omitempty"` // "success" or "error" + DurationMs int64 `json:"duration_ms,omitempty"` + Error string `json:"error,omitempty"` +} + +// ContentBlock represents a content item within a message. +type ContentBlock struct { + Type string `json:"type"` // "text" or "tool_use" + Text string `json:"text,omitempty"` + Name string `json:"name,omitempty"` + Input json.RawMessage `json:"input,omitempty"` +} + +// ParseStreamMessage parses a single NDJSON line into a StreamMessage. +func ParseStreamMessage(line []byte) (*StreamMessage, error) { + var msg StreamMessage + if err := json.Unmarshal(line, &msg); err != nil { + return nil, err + } + return &msg, nil +} + +// ToAgentEvent converts a StreamMessage to a domain.AgentEvent. +func (m *StreamMessage) ToAgentEvent() domain.AgentEvent { + event := domain.AgentEvent{ + Timestamp: parseTimestamp(m.Timestamp), + Metadata: make(map[string]any), + } + + switch m.Type { + case StreamMessageInit: + event.Type = domain.AgentEventOutput + event.Content = "Session started" + if m.SessionID != "" { + event.Metadata["session_id"] = m.SessionID + } + + case StreamMessageMessage: + event.Type = domain.AgentEventOutput + event.Content = extractTextContent(m.Content) + if m.Role != "" { + event.Metadata["role"] = m.Role + } + + case StreamMessageToolUse: + event.Type = domain.AgentEventToolUse + event.ToolName = m.Name + if len(m.Name) == 0 { + // Check content blocks for tool_use + for _, block := range m.Content { + if block.Type == "tool_use" { + event.ToolName = block.Name + if len(block.Input) > 0 { + var input map[string]any + if err := json.Unmarshal(block.Input, &input); err == nil { + event.ToolInput = input + } + } + break + } + } + } else if len(m.Input) > 0 { + var input map[string]any + if err := json.Unmarshal(m.Input, &input); err == nil { + event.ToolInput = input + } + } + event.Content = event.ToolName + + case StreamMessageToolResult: + event.Type = domain.AgentEventToolResult + event.Content = m.Output + + case StreamMessageResult: + event.Type = domain.AgentEventComplete + if m.Status == "error" { + event.Type = domain.AgentEventError + event.Content = m.Error + } + if m.DurationMs > 0 { + event.Metadata["duration_ms"] = m.DurationMs + } + if m.Status != "" { + event.Metadata["status"] = m.Status + } + + default: + // Unknown message type, treat as output + event.Type = domain.AgentEventOutput + event.Content = extractTextContent(m.Content) + } + + return event +} + +// extractTextContent extracts text from content blocks. +func extractTextContent(blocks []ContentBlock) string { + for _, block := range blocks { + if block.Type == "text" && block.Text != "" { + return block.Text + } + } + return "" +} + +// parseTimestamp parses an ISO8601 timestamp string. +func parseTimestamp(s string) time.Time { + if s == "" { + return time.Now() + } + t, err := time.Parse(time.RFC3339, s) + if err != nil { + return time.Now() + } + return t +} + +// IsTerminal returns true if this message indicates execution is complete. +func (m *StreamMessage) IsTerminal() bool { + return m.Type == StreamMessageResult +} + +// IsSuccess returns true if this is a successful result message. +func (m *StreamMessage) IsSuccess() bool { + return m.Type == StreamMessageResult && m.Status == "success" +} diff --git a/internal/adapter/codeagent/claudecode/parser_test.go b/internal/adapter/codeagent/claudecode/parser_test.go new file mode 100644 index 0000000..0873f00 --- /dev/null +++ b/internal/adapter/codeagent/claudecode/parser_test.go @@ -0,0 +1,350 @@ +package claudecode + +import ( + "testing" + "time" + + "github.com/orchard9/rdev/internal/domain" +) + +func TestParseStreamMessage_Init(t *testing.T) { + line := []byte(`{"type":"init","session_id":"abc123","timestamp":"2024-01-01T00:00:00Z"}`) + + msg, err := ParseStreamMessage(line) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if msg.Type != StreamMessageInit { + t.Errorf("expected type 'init', got %q", msg.Type) + } + if msg.SessionID != "abc123" { + t.Errorf("expected session_id 'abc123', got %q", msg.SessionID) + } +} + +func TestParseStreamMessage_Message(t *testing.T) { + line := []byte(`{"type":"message","role":"assistant","content":[{"type":"text","text":"Hello, world!"}]}`) + + msg, err := ParseStreamMessage(line) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if msg.Type != StreamMessageMessage { + t.Errorf("expected type 'message', got %q", msg.Type) + } + if msg.Role != "assistant" { + t.Errorf("expected role 'assistant', got %q", msg.Role) + } + if len(msg.Content) != 1 { + t.Fatalf("expected 1 content block, got %d", len(msg.Content)) + } + if msg.Content[0].Text != "Hello, world!" { + t.Errorf("expected text 'Hello, world!', got %q", msg.Content[0].Text) + } +} + +func TestParseStreamMessage_ToolUse(t *testing.T) { + line := []byte(`{"type":"tool_use","name":"Bash","input":{"command":"ls -la"}}`) + + msg, err := ParseStreamMessage(line) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if msg.Type != StreamMessageToolUse { + t.Errorf("expected type 'tool_use', got %q", msg.Type) + } + if msg.Name != "Bash" { + t.Errorf("expected name 'Bash', got %q", msg.Name) + } +} + +func TestParseStreamMessage_ToolResult(t *testing.T) { + line := []byte(`{"type":"tool_result","output":"total 64\ndrwxr-xr-x 10 user staff"}`) + + msg, err := ParseStreamMessage(line) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if msg.Type != StreamMessageToolResult { + t.Errorf("expected type 'tool_result', got %q", msg.Type) + } + if msg.Output != "total 64\ndrwxr-xr-x 10 user staff" { + t.Errorf("unexpected output: %q", msg.Output) + } +} + +func TestParseStreamMessage_Result(t *testing.T) { + tests := []struct { + name string + line string + wantStatus string + wantMs int64 + }{ + { + name: "success", + line: `{"type":"result","status":"success","duration_ms":1234}`, + wantStatus: "success", + wantMs: 1234, + }, + { + name: "error", + line: `{"type":"result","status":"error","error":"something went wrong"}`, + wantStatus: "error", + wantMs: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + msg, err := ParseStreamMessage([]byte(tt.line)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if msg.Type != StreamMessageResult { + t.Errorf("expected type 'result', got %q", msg.Type) + } + if msg.Status != tt.wantStatus { + t.Errorf("expected status %q, got %q", tt.wantStatus, msg.Status) + } + if msg.DurationMs != tt.wantMs { + t.Errorf("expected duration_ms %d, got %d", tt.wantMs, msg.DurationMs) + } + }) + } +} + +func TestParseStreamMessage_Invalid(t *testing.T) { + line := []byte(`not valid json`) + + _, err := ParseStreamMessage(line) + if err == nil { + t.Error("expected error for invalid JSON") + } +} + +func TestStreamMessage_ToAgentEvent_Init(t *testing.T) { + msg := &StreamMessage{ + Type: StreamMessageInit, + SessionID: "test-session", + Timestamp: "2024-01-01T12:00:00Z", + } + + event := msg.ToAgentEvent() + + if event.Type != domain.AgentEventOutput { + t.Errorf("expected type %v, got %v", domain.AgentEventOutput, event.Type) + } + if event.Content != "Session started" { + t.Errorf("expected content 'Session started', got %q", event.Content) + } + if event.Metadata["session_id"] != "test-session" { + t.Errorf("expected session_id in metadata") + } +} + +func TestStreamMessage_ToAgentEvent_Message(t *testing.T) { + msg := &StreamMessage{ + Type: StreamMessageMessage, + Role: "assistant", + Content: []ContentBlock{ + {Type: "text", Text: "Hello from Claude"}, + }, + } + + event := msg.ToAgentEvent() + + if event.Type != domain.AgentEventOutput { + t.Errorf("expected type %v, got %v", domain.AgentEventOutput, event.Type) + } + if event.Content != "Hello from Claude" { + t.Errorf("expected content 'Hello from Claude', got %q", event.Content) + } +} + +func TestStreamMessage_ToAgentEvent_ToolUse(t *testing.T) { + msg := &StreamMessage{ + Type: StreamMessageToolUse, + Name: "Read", + Input: []byte(`{"path":"/workspace/main.go"}`), + } + + event := msg.ToAgentEvent() + + if event.Type != domain.AgentEventToolUse { + t.Errorf("expected type %v, got %v", domain.AgentEventToolUse, event.Type) + } + if event.ToolName != "Read" { + t.Errorf("expected tool name 'Read', got %q", event.ToolName) + } + if event.ToolInput["path"] != "/workspace/main.go" { + t.Errorf("expected path in tool input") + } +} + +func TestStreamMessage_ToAgentEvent_ToolResult(t *testing.T) { + msg := &StreamMessage{ + Type: StreamMessageToolResult, + Output: "file contents here", + } + + event := msg.ToAgentEvent() + + if event.Type != domain.AgentEventToolResult { + t.Errorf("expected type %v, got %v", domain.AgentEventToolResult, event.Type) + } + if event.Content != "file contents here" { + t.Errorf("expected content 'file contents here', got %q", event.Content) + } +} + +func TestStreamMessage_ToAgentEvent_ResultSuccess(t *testing.T) { + msg := &StreamMessage{ + Type: StreamMessageResult, + Status: "success", + DurationMs: 5000, + } + + event := msg.ToAgentEvent() + + if event.Type != domain.AgentEventComplete { + t.Errorf("expected type %v, got %v", domain.AgentEventComplete, event.Type) + } + if event.Metadata["status"] != "success" { + t.Errorf("expected status in metadata") + } + if event.Metadata["duration_ms"].(int64) != 5000 { + t.Errorf("expected duration_ms in metadata") + } +} + +func TestStreamMessage_ToAgentEvent_ResultError(t *testing.T) { + msg := &StreamMessage{ + Type: StreamMessageResult, + Status: "error", + Error: "execution failed", + } + + event := msg.ToAgentEvent() + + if event.Type != domain.AgentEventError { + t.Errorf("expected type %v, got %v", domain.AgentEventError, event.Type) + } + if event.Content != "execution failed" { + t.Errorf("expected error content, got %q", event.Content) + } +} + +func TestStreamMessage_IsTerminal(t *testing.T) { + tests := []struct { + msgType StreamMessageType + terminal bool + }{ + {StreamMessageInit, false}, + {StreamMessageMessage, false}, + {StreamMessageToolUse, false}, + {StreamMessageToolResult, false}, + {StreamMessageResult, true}, + } + + for _, tt := range tests { + t.Run(string(tt.msgType), func(t *testing.T) { + msg := &StreamMessage{Type: tt.msgType} + if msg.IsTerminal() != tt.terminal { + t.Errorf("IsTerminal() = %v, want %v", msg.IsTerminal(), tt.terminal) + } + }) + } +} + +func TestStreamMessage_IsSuccess(t *testing.T) { + tests := []struct { + name string + msg StreamMessage + success bool + }{ + {"success result", StreamMessage{Type: StreamMessageResult, Status: "success"}, true}, + {"error result", StreamMessage{Type: StreamMessageResult, Status: "error"}, false}, + {"non-result", StreamMessage{Type: StreamMessageMessage}, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.msg.IsSuccess() != tt.success { + t.Errorf("IsSuccess() = %v, want %v", tt.msg.IsSuccess(), tt.success) + } + }) + } +} + +func TestParseTimestamp(t *testing.T) { + tests := []struct { + input string + valid bool + }{ + {"2024-01-01T12:00:00Z", true}, + {"2024-01-01T12:00:00+00:00", true}, + {"invalid", false}, + {"", false}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + result := parseTimestamp(tt.input) + if tt.valid { + if result.Year() != 2024 { + t.Errorf("expected year 2024, got %d", result.Year()) + } + } else { + // Should return current time for invalid input + if time.Since(result) > time.Second { + t.Error("expected recent time for invalid input") + } + } + }) + } +} + +func TestExtractTextContent(t *testing.T) { + tests := []struct { + name string + blocks []ContentBlock + want string + }{ + { + name: "single text block", + blocks: []ContentBlock{{Type: "text", Text: "hello"}}, + want: "hello", + }, + { + name: "text after tool_use", + blocks: []ContentBlock{ + {Type: "tool_use", Name: "Bash"}, + {Type: "text", Text: "result"}, + }, + want: "result", + }, + { + name: "empty blocks", + blocks: []ContentBlock{}, + want: "", + }, + { + name: "no text blocks", + blocks: []ContentBlock{{Type: "tool_use", Name: "Read"}}, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := extractTextContent(tt.blocks); got != tt.want { + t.Errorf("extractTextContent() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/internal/adapter/codeagent/opencode/adapter.go b/internal/adapter/codeagent/opencode/adapter.go new file mode 100644 index 0000000..261a23a --- /dev/null +++ b/internal/adapter/codeagent/opencode/adapter.go @@ -0,0 +1,388 @@ +// Package opencode provides a CodeAgent implementation for the OpenCode server. +package opencode + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "sync" + "time" + + "github.com/orchard9/rdev/internal/domain" + "github.com/orchard9/rdev/internal/port" +) + +// Adapter implements port.CodeAgent using OpenCode's HTTP server. +type Adapter struct { + client *Client + + // Track active sessions for cancellation + activeSessions map[string]context.CancelFunc + sessionsMu sync.Mutex +} + +// NewAdapter creates a new OpenCode adapter. +func NewAdapter(cfg ClientConfig) *Adapter { + return &Adapter{ + client: NewClient(cfg), + activeSessions: make(map[string]context.CancelFunc), + } +} + +// Ensure Adapter implements port.CodeAgent at compile time. +var _ port.CodeAgent = (*Adapter)(nil) + +// Name returns a human-readable name for this agent. +func (a *Adapter) Name() string { + return "OpenCode" +} + +// Provider returns the agent provider identifier. +func (a *Adapter) Provider() domain.AgentProvider { + return domain.AgentProviderOpenCode +} + +// Execute runs an OpenCode command and streams events to the handler. +func (a *Adapter) Execute(ctx context.Context, req *domain.AgentRequest, handler domain.AgentEventHandler) (*domain.AgentResult, error) { + if req.Prompt == "" { + return nil, fmt.Errorf("prompt is required") + } + + // Create cancellable context + execCtx, cancel := context.WithCancel(ctx) + defer cancel() + + // Apply timeout if specified + if req.Timeout > 0 { + var timeoutCancel context.CancelFunc + execCtx, timeoutCancel = context.WithTimeout(execCtx, req.Timeout) + defer timeoutCancel() + } + + startTime := time.Now() + + // Get or create session + sessionID := req.SessionID + if sessionID == "" { + session, err := a.client.CreateSession(execCtx, &CreateSessionRequest{ + Title: fmt.Sprintf("rdev-%d", time.Now().Unix()), + }) + if err != nil { + return &domain.AgentResult{ + ExitCode: 1, + Error: fmt.Errorf("create session: %w", err), + }, nil + } + sessionID = session.ID + } + + // Track session for potential cancellation + a.sessionsMu.Lock() + a.activeSessions[sessionID] = cancel + a.sessionsMu.Unlock() + + defer func() { + a.sessionsMu.Lock() + delete(a.activeSessions, sessionID) + a.sessionsMu.Unlock() + }() + + // Emit session started event + handler(domain.AgentEvent{ + Type: domain.AgentEventOutput, + Timestamp: time.Now(), + Content: "Session started", + Metadata: map[string]any{ + "session_id": sessionID, + }, + }) + + // Subscribe to SSE events for real-time updates + eventChan, err := a.client.SubscribeEvents(execCtx) + if err != nil { + // Non-fatal: we can still use sync API + handler(domain.AgentEvent{ + Type: domain.AgentEventError, + Timestamp: time.Now(), + Content: fmt.Sprintf("SSE subscription failed: %v (using sync mode)", err), + }) + } + + // Start event consumer if we have SSE + var eventWg sync.WaitGroup + if eventChan != nil { + eventWg.Add(1) + go func() { + defer eventWg.Done() + a.consumeEvents(execCtx, eventChan, handler) + }() + } + + // Build message request + msgReq := &SendMessageRequest{ + Parts: []MessagePart{ + {Type: "text", Content: req.Prompt}, + }, + } + + // Set model if specified + if req.Model != "" { + msgReq.Model = req.Model + } + + // Set tools if specified + if len(req.AllowedTools) > 0 { + msgReq.Tools = req.AllowedTools + } + + // Send message (synchronous call that waits for response) + resp, err := a.client.SendMessage(execCtx, sessionID, msgReq) + if err != nil { + // Check if cancelled + if execCtx.Err() == context.Canceled { + return &domain.AgentResult{ + SessionID: sessionID, + ExitCode: 1, + DurationMs: time.Since(startTime).Milliseconds(), + Error: domain.ErrCommandCancelled, + }, nil + } + + return &domain.AgentResult{ + SessionID: sessionID, + ExitCode: 1, + DurationMs: time.Since(startTime).Milliseconds(), + Error: fmt.Errorf("send message: %w", err), + }, nil + } + + // Process response parts + var finalOutput strings.Builder + var hasError bool + var errorContent string + for _, part := range resp.Parts { + event := a.partToEvent(part) + handler(event) + + if part.Type == "text" && part.Content != "" { + finalOutput.WriteString(part.Content) + } + if part.Type == "error" { + hasError = true + errorContent = part.Content + } + } + + // Emit completion event + duration := time.Since(startTime) + completionStatus := "success" + if hasError { + completionStatus = "error" + } + handler(domain.AgentEvent{ + Type: domain.AgentEventComplete, + Timestamp: time.Now(), + Metadata: map[string]any{ + "status": completionStatus, + "duration_ms": duration.Milliseconds(), + }, + }) + + // Wait for SSE consumer to finish (with timeout) + if eventChan != nil { + cancel() // Signal event consumer to stop + waitDone := make(chan struct{}) + go func() { + eventWg.Wait() + close(waitDone) + }() + + select { + case <-waitDone: + case <-time.After(2 * time.Second): + // Event consumer didn't stop in time, continue anyway + } + } + + result := &domain.AgentResult{ + SessionID: sessionID, + ExitCode: 0, + DurationMs: duration.Milliseconds(), + FinalOutput: finalOutput.String(), + } + + // Set error state if error parts were found + if hasError { + result.ExitCode = 1 + if errorContent != "" { + result.Error = fmt.Errorf("agent error: %s", errorContent) + } + } + + return result, nil +} + +// consumeEvents processes SSE events and dispatches them to the handler. +func (a *Adapter) consumeEvents(ctx context.Context, events <-chan SSEEvent, handler domain.AgentEventHandler) { + for { + select { + case <-ctx.Done(): + return + case event, ok := <-events: + if !ok { + return + } + agentEvent := a.sseToEvent(event) + if agentEvent.Type != "" { + handler(agentEvent) + } + } + } +} + +// sseToEvent converts an SSE event to a domain.AgentEvent. +func (a *Adapter) sseToEvent(sse SSEEvent) domain.AgentEvent { + event := domain.AgentEvent{ + Timestamp: time.Now(), + Metadata: make(map[string]any), + } + + // Parse event type + switch sse.Event { + case "server.connected": + event.Type = domain.AgentEventOutput + event.Content = "Connected to OpenCode server" + return event + + case "message.created", "message.updated": + event.Type = domain.AgentEventOutput + // Try to parse data for content + var data map[string]any + if json.Unmarshal([]byte(sse.Data), &data) == nil { + if content, ok := data["content"].(string); ok { + event.Content = content + } + } + return event + + case "tool.started": + event.Type = domain.AgentEventToolUse + var data map[string]any + if json.Unmarshal([]byte(sse.Data), &data) == nil { + if name, ok := data["name"].(string); ok { + event.ToolName = name + event.Content = name + } + if input, ok := data["input"].(map[string]any); ok { + event.ToolInput = input + } + } + return event + + case "tool.completed": + event.Type = domain.AgentEventToolResult + var data map[string]any + if json.Unmarshal([]byte(sse.Data), &data) == nil { + if output, ok := data["output"].(string); ok { + event.Content = output + } + } + return event + + case "session.completed": + event.Type = domain.AgentEventComplete + return event + + case "error": + event.Type = domain.AgentEventError + event.Content = sse.Data + return event + } + + // Unknown event type + return domain.AgentEvent{} +} + +// partToEvent converts a message part to a domain.AgentEvent. +func (a *Adapter) partToEvent(part MessagePart) domain.AgentEvent { + event := domain.AgentEvent{ + Timestamp: time.Now(), + Metadata: make(map[string]any), + } + + switch part.Type { + case "text": + event.Type = domain.AgentEventOutput + event.Content = part.Content + + case "tool_use": + event.Type = domain.AgentEventToolUse + event.ToolName = part.Name + event.Content = part.Name + if input, ok := part.Input.(map[string]any); ok { + event.ToolInput = input + } + + case "tool_result": + event.Type = domain.AgentEventToolResult + event.Content = part.Content + + default: + event.Type = domain.AgentEventOutput + event.Content = part.Content + } + + return event +} + +// Cancel attempts to cancel a running session. +func (a *Adapter) Cancel(ctx context.Context, sessionID string) error { + a.sessionsMu.Lock() + cancel, exists := a.activeSessions[sessionID] + if exists { + // Call cancel while holding lock and delete to prevent double-cancel + cancel() + delete(a.activeSessions, sessionID) + } + a.sessionsMu.Unlock() + + // Abort on the server side regardless of local session state + return a.client.AbortSession(ctx, sessionID) +} + +// Capabilities returns what this agent supports. +func (a *Adapter) Capabilities() domain.AgentCapabilities { + return domain.AgentCapabilities{ + Provider: domain.AgentProviderOpenCode, + SupportsSessionContinuation: true, + SupportsModelSelection: true, // OpenCode supports multiple providers + SupportsToolControl: true, + SupportedModels: []string{ + "claude-sonnet-4-20250514", + "claude-opus-4-20250514", + "gpt-4o", + "gpt-4-turbo", + "gemini-pro", + }, + DefaultModel: "claude-sonnet-4-20250514", + MaxPromptLength: 0, // Unlimited + SupportsStreaming: true, + } +} + +// DefaultAvailabilityTimeout is the maximum time to wait when checking agent availability. +// This timeout prevents blocking the caller when the server is slow or unresponsive. +const DefaultAvailabilityTimeout = 5 * time.Second + +// Available checks if the OpenCode server is healthy. +func (a *Adapter) Available(ctx context.Context) bool { + ctx, cancel := context.WithTimeout(ctx, DefaultAvailabilityTimeout) + defer cancel() + + health, err := a.client.Health(ctx) + if err != nil { + return false + } + return health.Healthy +} diff --git a/internal/adapter/codeagent/opencode/adapter_test.go b/internal/adapter/codeagent/opencode/adapter_test.go new file mode 100644 index 0000000..8b7951c --- /dev/null +++ b/internal/adapter/codeagent/opencode/adapter_test.go @@ -0,0 +1,462 @@ +package opencode + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/orchard9/rdev/internal/domain" + "github.com/orchard9/rdev/internal/port" +) + +// Ensure Adapter implements port.CodeAgent at compile time. +var _ port.CodeAgent = (*Adapter)(nil) + +func TestAdapter_Name(t *testing.T) { + adapter := NewAdapter(ClientConfig{}) + if name := adapter.Name(); name != "OpenCode" { + t.Errorf("expected name 'OpenCode', got %q", name) + } +} + +func TestAdapter_Provider(t *testing.T) { + adapter := NewAdapter(ClientConfig{}) + if p := adapter.Provider(); p != domain.AgentProviderOpenCode { + t.Errorf("expected provider 'opencode', got %q", p) + } +} + +func TestAdapter_Capabilities(t *testing.T) { + adapter := NewAdapter(ClientConfig{}) + caps := adapter.Capabilities() + + if caps.Provider != domain.AgentProviderOpenCode { + t.Errorf("expected provider opencode") + } + if !caps.SupportsSessionContinuation { + t.Error("expected session continuation support") + } + if !caps.SupportsModelSelection { + t.Error("expected model selection support") + } + if !caps.SupportsToolControl { + t.Error("expected tool control support") + } + if !caps.SupportsStreaming { + t.Error("expected streaming support") + } + if len(caps.SupportedModels) == 0 { + t.Error("expected at least one supported model") + } +} + +func TestAdapter_Execute_MissingPrompt(t *testing.T) { + adapter := NewAdapter(ClientConfig{}) + req := &domain.AgentRequest{ + Prompt: "", // missing + } + + _, err := adapter.Execute(context.Background(), req, func(e domain.AgentEvent) {}) + if err == nil { + t.Error("expected error for missing prompt") + } +} + +func TestAdapter_Available_Healthy(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/global/health" { + json.NewEncoder(w).Encode(HealthResponse{Healthy: true, Version: "1.0.0"}) + return + } + http.NotFound(w, r) + })) + defer server.Close() + + adapter := NewAdapter(ClientConfig{BaseURL: server.URL}) + if !adapter.Available(context.Background()) { + t.Error("expected adapter to be available") + } +} + +func TestAdapter_Available_Unhealthy(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/global/health" { + json.NewEncoder(w).Encode(HealthResponse{Healthy: false}) + return + } + http.NotFound(w, r) + })) + defer server.Close() + + adapter := NewAdapter(ClientConfig{BaseURL: server.URL}) + if adapter.Available(context.Background()) { + t.Error("expected adapter to be unavailable") + } +} + +func TestAdapter_Available_ServerDown(t *testing.T) { + adapter := NewAdapter(ClientConfig{BaseURL: "http://localhost:59999"}) + if adapter.Available(context.Background()) { + t.Error("expected adapter to be unavailable when server is down") + } +} + +func TestAdapter_Execute_WithMockServer(t *testing.T) { + sessionID := "test-session-123" + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/session" && r.Method == http.MethodPost: + // Create session + json.NewEncoder(w).Encode(Session{ID: sessionID}) + + case r.URL.Path == "/session/"+sessionID+"/message" && r.Method == http.MethodPost: + // Send message response + json.NewEncoder(w).Encode(SendMessageResponse{ + Info: MessageInfo{ID: "msg-1", Role: "assistant"}, + Parts: []MessagePart{ + {Type: "text", Content: "Hello from OpenCode!"}, + }, + }) + + case r.URL.Path == "/event": + // SSE endpoint - just close immediately for this test + w.WriteHeader(http.StatusOK) + return + + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + adapter := NewAdapter(ClientConfig{BaseURL: server.URL}) + + var events []domain.AgentEvent + handler := func(e domain.AgentEvent) { + events = append(events, e) + } + + req := &domain.AgentRequest{ + Prompt: "Say hello", + } + + result, err := adapter.Execute(context.Background(), req, handler) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if result.SessionID != sessionID { + t.Errorf("expected session ID %q, got %q", sessionID, result.SessionID) + } + if result.ExitCode != 0 { + t.Errorf("expected exit code 0, got %d", result.ExitCode) + } + if result.FinalOutput != "Hello from OpenCode!" { + t.Errorf("expected final output 'Hello from OpenCode!', got %q", result.FinalOutput) + } + + // Should have at least session started + output + complete events + if len(events) < 3 { + t.Errorf("expected at least 3 events, got %d", len(events)) + } +} + +func TestAdapter_Execute_WithModel(t *testing.T) { + var receivedModel string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/session" && r.Method == http.MethodPost: + json.NewEncoder(w).Encode(Session{ID: "sess-1"}) + + case r.URL.Path == "/session/sess-1/message" && r.Method == http.MethodPost: + var req SendMessageRequest + json.NewDecoder(r.Body).Decode(&req) + receivedModel = req.Model + + json.NewEncoder(w).Encode(SendMessageResponse{ + Info: MessageInfo{ID: "msg-1"}, + Parts: []MessagePart{{Type: "text", Content: "Done"}}, + }) + + case r.URL.Path == "/event": + w.WriteHeader(http.StatusOK) + return + + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + adapter := NewAdapter(ClientConfig{BaseURL: server.URL}) + + req := &domain.AgentRequest{ + Prompt: "Test", + Model: "gpt-4o", + } + + _, err := adapter.Execute(context.Background(), req, func(e domain.AgentEvent) {}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if receivedModel != "gpt-4o" { + t.Errorf("expected model 'gpt-4o', got %q", receivedModel) + } +} + +func TestAdapter_Cancel(t *testing.T) { + abortCalled := false + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/session/test-session/abort" && r.Method == http.MethodPost { + abortCalled = true + w.WriteHeader(http.StatusOK) + return + } + http.NotFound(w, r) + })) + defer server.Close() + + adapter := NewAdapter(ClientConfig{BaseURL: server.URL}) + + err := adapter.Cancel(context.Background(), "test-session") + if err != nil { + t.Errorf("unexpected error: %v", err) + } + + if !abortCalled { + t.Error("expected abort endpoint to be called") + } +} + +func TestAdapter_Execute_WithErrorPart(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/session" && r.Method == http.MethodPost: + json.NewEncoder(w).Encode(Session{ID: "sess-err"}) + + case r.URL.Path == "/session/sess-err/message" && r.Method == http.MethodPost: + // Return response with error part + json.NewEncoder(w).Encode(SendMessageResponse{ + Info: MessageInfo{ID: "msg-1"}, + Parts: []MessagePart{ + {Type: "text", Content: "Attempting task..."}, + {Type: "error", Content: "Command failed with exit code 1"}, + }, + }) + + case r.URL.Path == "/event": + w.WriteHeader(http.StatusOK) + return + + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + adapter := NewAdapter(ClientConfig{BaseURL: server.URL}) + + req := &domain.AgentRequest{ + Prompt: "Run failing command", + } + + result, err := adapter.Execute(context.Background(), req, func(e domain.AgentEvent) {}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Should have non-zero exit code when error part is present + if result.ExitCode != 1 { + t.Errorf("expected exit code 1 for error response, got %d", result.ExitCode) + } + + // Should have error set + if result.Error == nil { + t.Error("expected error to be set when error part is present") + } + + // Error should contain the error content + if result.Error != nil && !strings.Contains(result.Error.Error(), "Command failed") { + t.Errorf("expected error to contain 'Command failed', got %q", result.Error.Error()) + } +} + +func TestAdapter_partToEvent(t *testing.T) { + adapter := NewAdapter(ClientConfig{}) + + tests := []struct { + name string + part MessagePart + wantType domain.AgentEventType + wantValue string + }{ + { + name: "text part", + part: MessagePart{Type: "text", Content: "Hello"}, + wantType: domain.AgentEventOutput, + wantValue: "Hello", + }, + { + name: "tool_use part", + part: MessagePart{Type: "tool_use", Name: "Bash"}, + wantType: domain.AgentEventToolUse, + wantValue: "Bash", + }, + { + name: "tool_result part", + part: MessagePart{Type: "tool_result", Content: "output here"}, + wantType: domain.AgentEventToolResult, + wantValue: "output here", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + event := adapter.partToEvent(tt.part) + if event.Type != tt.wantType { + t.Errorf("expected type %v, got %v", tt.wantType, event.Type) + } + if event.Content != tt.wantValue { + t.Errorf("expected content %q, got %q", tt.wantValue, event.Content) + } + }) + } +} + +func TestAdapter_sseToEvent(t *testing.T) { + adapter := NewAdapter(ClientConfig{}) + + tests := []struct { + name string + sse SSEEvent + wantType domain.AgentEventType + }{ + { + name: "server connected", + sse: SSEEvent{Event: "server.connected"}, + wantType: domain.AgentEventOutput, + }, + { + name: "tool started", + sse: SSEEvent{Event: "tool.started", Data: `{"name":"Bash"}`}, + wantType: domain.AgentEventToolUse, + }, + { + name: "tool completed", + sse: SSEEvent{Event: "tool.completed", Data: `{"output":"done"}`}, + wantType: domain.AgentEventToolResult, + }, + { + name: "session completed", + sse: SSEEvent{Event: "session.completed"}, + wantType: domain.AgentEventComplete, + }, + { + name: "error", + sse: SSEEvent{Event: "error", Data: "something failed"}, + wantType: domain.AgentEventError, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + event := adapter.sseToEvent(tt.sse) + if event.Type != tt.wantType { + t.Errorf("expected type %v, got %v", tt.wantType, event.Type) + } + }) + } +} + +func TestClient_Health(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/global/health" { + json.NewEncoder(w).Encode(HealthResponse{Healthy: true, Version: "1.2.3"}) + return + } + http.NotFound(w, r) + })) + defer server.Close() + + client := NewClient(ClientConfig{BaseURL: server.URL}) + health, err := client.Health(context.Background()) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !health.Healthy { + t.Error("expected healthy=true") + } + if health.Version != "1.2.3" { + t.Errorf("expected version '1.2.3', got %q", health.Version) + } +} + +func TestClient_CreateSession(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/session" && r.Method == http.MethodPost { + var req CreateSessionRequest + json.NewDecoder(r.Body).Decode(&req) + + json.NewEncoder(w).Encode(Session{ + ID: "new-session-id", + Title: req.Title, + CreatedAt: time.Now(), + }) + return + } + http.NotFound(w, r) + })) + defer server.Close() + + client := NewClient(ClientConfig{BaseURL: server.URL}) + session, err := client.CreateSession(context.Background(), &CreateSessionRequest{ + Title: "Test Session", + }) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if session.ID != "new-session-id" { + t.Errorf("expected ID 'new-session-id', got %q", session.ID) + } + if session.Title != "Test Session" { + t.Errorf("expected title 'Test Session', got %q", session.Title) + } +} + +func TestClient_WithAuth(t *testing.T) { + var authHeader string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authHeader = r.Header.Get("Authorization") + json.NewEncoder(w).Encode(HealthResponse{Healthy: true}) + })) + defer server.Close() + + client := NewClient(ClientConfig{ + BaseURL: server.URL, + Username: "opencode", + Password: "secret", + }) + + _, err := client.Health(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if authHeader == "" { + t.Error("expected Authorization header to be set") + } + if authHeader != "Basic b3BlbmNvZGU6c2VjcmV0" { + t.Errorf("unexpected auth header: %s", authHeader) + } +} diff --git a/internal/adapter/codeagent/opencode/client.go b/internal/adapter/codeagent/opencode/client.go new file mode 100644 index 0000000..f537b9f --- /dev/null +++ b/internal/adapter/codeagent/opencode/client.go @@ -0,0 +1,310 @@ +// Package opencode provides a CodeAgent implementation for the OpenCode server. +package opencode + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// Client communicates with an OpenCode server via HTTP. +type Client struct { + baseURL string + httpClient *http.Client + username string + password string +} + +// ClientConfig configures the OpenCode client. +type ClientConfig struct { + BaseURL string + Timeout time.Duration + Username string + Password string +} + +// NewClient creates a new OpenCode HTTP client. +func NewClient(cfg ClientConfig) *Client { + if cfg.Timeout == 0 { + cfg.Timeout = 30 * time.Second + } + if cfg.BaseURL == "" { + cfg.BaseURL = "http://127.0.0.1:4096" + } + if cfg.Username == "" { + cfg.Username = "opencode" + } + + return &Client{ + baseURL: strings.TrimSuffix(cfg.BaseURL, "/"), + httpClient: &http.Client{ + Timeout: cfg.Timeout, + }, + username: cfg.Username, + password: cfg.Password, + } +} + +// HealthResponse represents the /global/health response. +type HealthResponse struct { + Healthy bool `json:"healthy"` + Version string `json:"version"` +} + +// Health checks if the OpenCode server is healthy. +func (c *Client) Health(ctx context.Context) (*HealthResponse, error) { + resp, err := c.doRequest(ctx, http.MethodGet, "/global/health", nil) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + + var health HealthResponse + if err := json.NewDecoder(resp.Body).Decode(&health); err != nil { + return nil, fmt.Errorf("decode health: %w", err) + } + return &health, nil +} + +// Session represents an OpenCode session. +type Session struct { + ID string `json:"id"` + Title string `json:"title,omitempty"` + ParentID string `json:"parentID,omitempty"` + CreatedAt time.Time `json:"createdAt,omitempty"` +} + +// CreateSessionRequest is the body for POST /session. +type CreateSessionRequest struct { + ParentID string `json:"parentID,omitempty"` + Title string `json:"title,omitempty"` +} + +// CreateSession creates a new session. +func (c *Client) CreateSession(ctx context.Context, req *CreateSessionRequest) (*Session, error) { + body, _ := json.Marshal(req) + resp, err := c.doRequest(ctx, http.MethodPost, "/session", bytes.NewReader(body)) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + + var session Session + if err := json.NewDecoder(resp.Body).Decode(&session); err != nil { + return nil, fmt.Errorf("decode session: %w", err) + } + return &session, nil +} + +// GetSession retrieves a session by ID. +func (c *Client) GetSession(ctx context.Context, sessionID string) (*Session, error) { + resp, err := c.doRequest(ctx, http.MethodGet, "/session/"+sessionID, nil) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + + var session Session + if err := json.NewDecoder(resp.Body).Decode(&session); err != nil { + return nil, fmt.Errorf("decode session: %w", err) + } + return &session, nil +} + +// MessagePart represents a part of a message. +type MessagePart struct { + Type string `json:"type"` // "text", "tool_use", "tool_result", etc. + Content string `json:"content,omitempty"` + Name string `json:"name,omitempty"` + Input any `json:"input,omitempty"` +} + +// SendMessageRequest is the body for POST /session/:id/message. +type SendMessageRequest struct { + MessageID string `json:"messageID,omitempty"` + Model string `json:"model,omitempty"` + Agent string `json:"agent,omitempty"` + System string `json:"system,omitempty"` + Tools []string `json:"tools,omitempty"` + Parts []MessagePart `json:"parts"` +} + +// MessageInfo contains message metadata. +type MessageInfo struct { + ID string `json:"id"` + Role string `json:"role"` + Timestamp time.Time `json:"timestamp"` +} + +// SendMessageResponse is the response from POST /session/:id/message. +type SendMessageResponse struct { + Info MessageInfo `json:"info"` + Parts []MessagePart `json:"parts"` +} + +// SendMessage sends a message to a session (synchronous, waits for response). +func (c *Client) SendMessage(ctx context.Context, sessionID string, req *SendMessageRequest) (*SendMessageResponse, error) { + body, _ := json.Marshal(req) + resp, err := c.doRequest(ctx, http.MethodPost, "/session/"+sessionID+"/message", bytes.NewReader(body)) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + + var msgResp SendMessageResponse + if err := json.NewDecoder(resp.Body).Decode(&msgResp); err != nil { + return nil, fmt.Errorf("decode message response: %w", err) + } + return &msgResp, nil +} + +// SendPromptAsync sends a message asynchronously. +func (c *Client) SendPromptAsync(ctx context.Context, sessionID string, req *SendMessageRequest) error { + body, _ := json.Marshal(req) + resp, err := c.doRequest(ctx, http.MethodPost, "/session/"+sessionID+"/prompt_async", bytes.NewReader(body)) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + return nil +} + +// AbortSession stops a running session. +func (c *Client) AbortSession(ctx context.Context, sessionID string) error { + resp, err := c.doRequest(ctx, http.MethodPost, "/session/"+sessionID+"/abort", nil) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + return nil +} + +// SSEEvent represents a server-sent event. +type SSEEvent struct { + Event string + Data string +} + +// sseEventBufferSize is the capacity of the SSE event channel. +// This buffer handles typical bursts of events during agent execution (e.g., rapid tool calls). +// If the consumer is slow and the buffer fills, the goroutine will block on send +// until the context is cancelled or the consumer catches up. +const sseEventBufferSize = 100 + +// SubscribeEvents returns a channel of SSE events from the server. +func (c *Client) SubscribeEvents(ctx context.Context) (<-chan SSEEvent, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/event", nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "text/event-stream") + c.setAuth(req) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + + if resp.StatusCode != http.StatusOK { + _ = resp.Body.Close() + return nil, fmt.Errorf("SSE subscribe failed: %s", resp.Status) + } + + events := make(chan SSEEvent, sseEventBufferSize) + + go func() { + defer func() { _ = resp.Body.Close() }() + defer close(events) + + reader := bufio.NewReader(resp.Body) + var currentEvent SSEEvent + + for { + select { + case <-ctx.Done(): + return + default: + } + + line, err := reader.ReadString('\n') + if err != nil { + // EOF is expected when connection closes; other errors are logged but not fatal + return + } + + line = strings.TrimSuffix(line, "\n") + line = strings.TrimSuffix(line, "\r") + + if line == "" { + // Empty line = end of event + if currentEvent.Event != "" || currentEvent.Data != "" { + select { + case events <- currentEvent: + case <-ctx.Done(): + return + } + currentEvent = SSEEvent{} + } + continue + } + + if strings.HasPrefix(line, "event:") { + currentEvent.Event = strings.TrimSpace(strings.TrimPrefix(line, "event:")) + } else if strings.HasPrefix(line, "data:") { + currentEvent.Data = strings.TrimSpace(strings.TrimPrefix(line, "data:")) + } + } + }() + + return events, nil +} + +// RunShell executes a shell command in the session. +func (c *Client) RunShell(ctx context.Context, sessionID, command string) error { + body, _ := json.Marshal(map[string]string{"command": command}) + resp, err := c.doRequest(ctx, http.MethodPost, "/session/"+sessionID+"/shell", bytes.NewReader(body)) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + return nil +} + +// doRequest performs an HTTP request with auth. +func (c *Client) doRequest(ctx context.Context, method, path string, body io.Reader) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, body) + if err != nil { + return nil, err + } + + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + c.setAuth(req) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + + if resp.StatusCode >= 400 { + bodyBytes, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + return nil, fmt.Errorf("%s %s: HTTP %d: %s", method, path, resp.StatusCode, string(bodyBytes)) + } + + return resp, nil +} + +// setAuth sets authentication headers if password is configured. +func (c *Client) setAuth(req *http.Request) { + if c.password != "" { + req.SetBasicAuth(c.username, c.password) + } +} diff --git a/internal/adapter/codeagent/registry.go b/internal/adapter/codeagent/registry.go new file mode 100644 index 0000000..bcee5f7 --- /dev/null +++ b/internal/adapter/codeagent/registry.go @@ -0,0 +1,122 @@ +// Package codeagent provides the code agent registry and common utilities. +package codeagent + +import ( + "context" + "fmt" + "sync" + + "github.com/orchard9/rdev/internal/domain" + "github.com/orchard9/rdev/internal/port" +) + +// Registry implements port.CodeAgentRegistry with thread-safe agent management. +type Registry struct { + mu sync.RWMutex + agents map[domain.AgentProvider]port.CodeAgent + defProv domain.AgentProvider + hasAgent bool +} + +// NewRegistry creates a new empty agent registry. +func NewRegistry() *Registry { + return &Registry{ + agents: make(map[domain.AgentProvider]port.CodeAgent), + } +} + +// Ensure Registry implements port.CodeAgentRegistry at compile time. +var _ port.CodeAgentRegistry = (*Registry)(nil) + +// Register adds an agent implementation for a provider. +func (r *Registry) Register(agent port.CodeAgent) { + r.mu.Lock() + defer r.mu.Unlock() + + provider := agent.Provider() + r.agents[provider] = agent + + // If this is the first agent, make it the default + if !r.hasAgent { + r.defProv = provider + r.hasAgent = true + } +} + +// Get returns the agent for a specific provider. +func (r *Registry) Get(provider domain.AgentProvider) port.CodeAgent { + r.mu.RLock() + defer r.mu.RUnlock() + + return r.agents[provider] +} + +// Default returns the default agent implementation. +func (r *Registry) Default() port.CodeAgent { + r.mu.RLock() + defer r.mu.RUnlock() + + if !r.hasAgent { + return nil + } + return r.agents[r.defProv] +} + +// SetDefault sets which provider should be used as the default. +func (r *Registry) SetDefault(provider domain.AgentProvider) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, exists := r.agents[provider]; !exists { + return fmt.Errorf("agent provider %q is not registered", provider) + } + + r.defProv = provider + return nil +} + +// Available returns all registered providers. +func (r *Registry) Available() []domain.AgentProvider { + r.mu.RLock() + defer r.mu.RUnlock() + + providers := make([]domain.AgentProvider, 0, len(r.agents)) + for p := range r.agents { + providers = append(providers, p) + } + return providers +} + +// AvailableAgents returns all registered agents that are currently available. +func (r *Registry) AvailableAgents(ctx context.Context) []port.CodeAgent { + r.mu.RLock() + defer r.mu.RUnlock() + + available := make([]port.CodeAgent, 0, len(r.agents)) + for _, agent := range r.agents { + if agent.Available(ctx) { + available = append(available, agent) + } + } + return available +} + +// DefaultProvider returns the current default provider. +// Returns empty string if no agents are registered. +func (r *Registry) DefaultProvider() domain.AgentProvider { + r.mu.RLock() + defer r.mu.RUnlock() + + if !r.hasAgent { + return "" + } + return r.defProv +} + +// Count returns the number of registered agents. +func (r *Registry) Count() int { + r.mu.RLock() + defer r.mu.RUnlock() + + return len(r.agents) +} diff --git a/internal/adapter/codeagent/registry_test.go b/internal/adapter/codeagent/registry_test.go new file mode 100644 index 0000000..a8270c9 --- /dev/null +++ b/internal/adapter/codeagent/registry_test.go @@ -0,0 +1,261 @@ +package codeagent + +import ( + "context" + "testing" + + "github.com/orchard9/rdev/internal/domain" + "github.com/orchard9/rdev/internal/port" +) + +// mockAgent is a test implementation of port.CodeAgent. +type mockAgent struct { + provider domain.AgentProvider + name string + available bool +} + +func (m *mockAgent) Name() string { return m.name } +func (m *mockAgent) Provider() domain.AgentProvider { return m.provider } +func (m *mockAgent) Available(ctx context.Context) bool { return m.available } + +func (m *mockAgent) Execute(ctx context.Context, req *domain.AgentRequest, handler domain.AgentEventHandler) (*domain.AgentResult, error) { + return &domain.AgentResult{ExitCode: 0}, nil +} + +func (m *mockAgent) Cancel(ctx context.Context, sessionID string) error { + return nil +} + +func (m *mockAgent) Capabilities() domain.AgentCapabilities { + return domain.AgentCapabilities{Provider: m.provider} +} + +var _ port.CodeAgent = (*mockAgent)(nil) + +func TestRegistry_RegisterAndGet(t *testing.T) { + reg := NewRegistry() + + agent := &mockAgent{ + provider: domain.AgentProviderClaudeCode, + name: "Claude Code", + available: true, + } + + reg.Register(agent) + + got := reg.Get(domain.AgentProviderClaudeCode) + if got == nil { + t.Fatal("expected agent, got nil") + } + if got.Name() != "Claude Code" { + t.Errorf("expected name 'Claude Code', got %q", got.Name()) + } +} + +func TestRegistry_GetUnregistered(t *testing.T) { + reg := NewRegistry() + + got := reg.Get(domain.AgentProviderOpenCode) + if got != nil { + t.Errorf("expected nil for unregistered provider, got %v", got) + } +} + +func TestRegistry_DefaultFirstRegistered(t *testing.T) { + reg := NewRegistry() + + claude := &mockAgent{provider: domain.AgentProviderClaudeCode, name: "Claude", available: true} + opencode := &mockAgent{provider: domain.AgentProviderOpenCode, name: "OpenCode", available: true} + + reg.Register(claude) + reg.Register(opencode) + + def := reg.Default() + if def == nil { + t.Fatal("expected default agent, got nil") + } + if def.Provider() != domain.AgentProviderClaudeCode { + t.Errorf("expected first registered (claudecode) as default, got %v", def.Provider()) + } +} + +func TestRegistry_SetDefault(t *testing.T) { + reg := NewRegistry() + + claude := &mockAgent{provider: domain.AgentProviderClaudeCode, name: "Claude", available: true} + opencode := &mockAgent{provider: domain.AgentProviderOpenCode, name: "OpenCode", available: true} + + reg.Register(claude) + reg.Register(opencode) + + err := reg.SetDefault(domain.AgentProviderOpenCode) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + def := reg.Default() + if def.Provider() != domain.AgentProviderOpenCode { + t.Errorf("expected opencode as default, got %v", def.Provider()) + } +} + +func TestRegistry_SetDefaultUnregistered(t *testing.T) { + reg := NewRegistry() + + claude := &mockAgent{provider: domain.AgentProviderClaudeCode, name: "Claude", available: true} + reg.Register(claude) + + err := reg.SetDefault(domain.AgentProviderOpenCode) + if err == nil { + t.Error("expected error setting unregistered default") + } +} + +func TestRegistry_Available(t *testing.T) { + reg := NewRegistry() + + claude := &mockAgent{provider: domain.AgentProviderClaudeCode, name: "Claude", available: true} + opencode := &mockAgent{provider: domain.AgentProviderOpenCode, name: "OpenCode", available: true} + + reg.Register(claude) + reg.Register(opencode) + + providers := reg.Available() + if len(providers) != 2 { + t.Errorf("expected 2 providers, got %d", len(providers)) + } + + // Check both are present (order not guaranteed) + found := make(map[domain.AgentProvider]bool) + for _, p := range providers { + found[p] = true + } + if !found[domain.AgentProviderClaudeCode] || !found[domain.AgentProviderOpenCode] { + t.Errorf("expected both providers in list, got %v", providers) + } +} + +func TestRegistry_AvailableAgents(t *testing.T) { + reg := NewRegistry() + + claude := &mockAgent{provider: domain.AgentProviderClaudeCode, name: "Claude", available: true} + opencode := &mockAgent{provider: domain.AgentProviderOpenCode, name: "OpenCode", available: false} + + reg.Register(claude) + reg.Register(opencode) + + ctx := context.Background() + agents := reg.AvailableAgents(ctx) + + if len(agents) != 1 { + t.Errorf("expected 1 available agent, got %d", len(agents)) + } + if agents[0].Provider() != domain.AgentProviderClaudeCode { + t.Errorf("expected claude as available, got %v", agents[0].Provider()) + } +} + +func TestRegistry_DefaultEmpty(t *testing.T) { + reg := NewRegistry() + + def := reg.Default() + if def != nil { + t.Errorf("expected nil default for empty registry, got %v", def) + } +} + +func TestRegistry_Count(t *testing.T) { + reg := NewRegistry() + + if reg.Count() != 0 { + t.Errorf("expected 0 count, got %d", reg.Count()) + } + + reg.Register(&mockAgent{provider: domain.AgentProviderClaudeCode, available: true}) + if reg.Count() != 1 { + t.Errorf("expected 1 count, got %d", reg.Count()) + } + + reg.Register(&mockAgent{provider: domain.AgentProviderOpenCode, available: true}) + if reg.Count() != 2 { + t.Errorf("expected 2 count, got %d", reg.Count()) + } +} + +func TestRegistry_DefaultProvider(t *testing.T) { + reg := NewRegistry() + + // Empty registry + if p := reg.DefaultProvider(); p != "" { + t.Errorf("expected empty default provider, got %q", p) + } + + reg.Register(&mockAgent{provider: domain.AgentProviderClaudeCode, available: true}) + if p := reg.DefaultProvider(); p != domain.AgentProviderClaudeCode { + t.Errorf("expected claudecode, got %q", p) + } +} + +func TestRegistry_ConcurrentAccess(t *testing.T) { + reg := NewRegistry() + + // Pre-register one agent + reg.Register(&mockAgent{provider: domain.AgentProviderClaudeCode, available: true}) + + done := make(chan struct{}) + const goroutines = 10 + const iterations = 100 + + // Concurrent reads and writes + for i := 0; i < goroutines; i++ { + go func(id int) { + defer func() { done <- struct{}{} }() + for j := 0; j < iterations; j++ { + // Mix of operations + switch j % 5 { + case 0: + reg.Get(domain.AgentProviderClaudeCode) + case 1: + reg.Default() + case 2: + reg.Available() + case 3: + reg.Count() + case 4: + reg.AvailableAgents(context.Background()) + } + } + }(i) + } + + // Wait for all goroutines + for i := 0; i < goroutines; i++ { + <-done + } + + // Verify state is consistent + if reg.Count() != 1 { + t.Errorf("expected count 1 after concurrent access, got %d", reg.Count()) + } +} + +func TestRegistry_ReRegisterOverwrites(t *testing.T) { + reg := NewRegistry() + + agent1 := &mockAgent{provider: domain.AgentProviderClaudeCode, name: "Claude v1", available: true} + agent2 := &mockAgent{provider: domain.AgentProviderClaudeCode, name: "Claude v2", available: true} + + reg.Register(agent1) + reg.Register(agent2) // Should overwrite + + got := reg.Get(domain.AgentProviderClaudeCode) + if got.Name() != "Claude v2" { + t.Errorf("expected 'Claude v2' after re-register, got %q", got.Name()) + } + + // Count should still be 1 + if reg.Count() != 1 { + t.Errorf("expected count 1 after re-register, got %d", reg.Count()) + } +} diff --git a/internal/adapter/gitea/client.go b/internal/adapter/gitea/client.go index 320bc56..3ea7a6c 100644 --- a/internal/adapter/gitea/client.go +++ b/internal/adapter/gitea/client.go @@ -26,6 +26,12 @@ type Client struct { defaultOwner string // default organization/user for new repos } +// SDKClient returns the underlying Gitea SDK client. +// Used by the template provider to create files in repos. +func (c *Client) SDKClient() *gitea.Client { + return c.client +} + // NewClient creates a new Gitea client. // url is the Gitea server URL (e.g., https://git.threesix.ai) // token is an API access token with repo permissions diff --git a/internal/adapter/kubernetes/executor.go b/internal/adapter/kubernetes/executor.go index 879f1c7..1c11e60 100644 --- a/internal/adapter/kubernetes/executor.go +++ b/internal/adapter/kubernetes/executor.go @@ -61,10 +61,10 @@ func (e *Executor) Execute(ctx context.Context, cmd *domain.Command, podName str switch cmd.Type { case domain.CommandTypeClaude: - // claude "prompt" + // claude -p --dangerously-skip-permissions "prompt" (non-interactive mode) args = []string{ "exec", "-n", namespace, podName, "--", - "claude", cmd.Args[0], // prompt is first arg + "claude", "-p", "--dangerously-skip-permissions", cmd.Args[0], // prompt is first arg } case domain.CommandTypeShell: // bash -c "command" diff --git a/internal/adapter/kubernetes/project_repository.go b/internal/adapter/kubernetes/project_repository.go index e4bfa56..9795b5b 100644 --- a/internal/adapter/kubernetes/project_repository.go +++ b/internal/adapter/kubernetes/project_repository.go @@ -13,6 +13,7 @@ import ( "github.com/orchard9/rdev/internal/port" corev1 "k8s.io/api/core/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/watch" "k8s.io/client-go/kubernetes" @@ -375,7 +376,7 @@ func (r *ProjectRepository) getPodStatus(ctx context.Context, podName string) (d if r.client != nil { pod, err := r.client.CoreV1().Pods(r.namespace).Get(ctx, podName, metav1.GetOptions{}) if err != nil { - if strings.Contains(err.Error(), "not found") { + if k8serrors.IsNotFound(err) { return domain.ProjectStatusNotFound, nil } return domain.ProjectStatusUnknown, fmt.Errorf("get pod: %w", err) diff --git a/internal/adapter/postgres/credential_store.go b/internal/adapter/postgres/credential_store.go new file mode 100644 index 0000000..8c4f5c1 --- /dev/null +++ b/internal/adapter/postgres/credential_store.go @@ -0,0 +1,233 @@ +// Package postgres provides PostgreSQL implementations of port interfaces. +package postgres + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + "time" + + "github.com/orchard9/rdev/internal/domain" + "github.com/orchard9/rdev/internal/port" +) + +// Ensure CredentialStore implements port.CredentialStore. +var _ port.CredentialStore = (*CredentialStore)(nil) + +// CredentialStore implements credential storage with encryption. +type CredentialStore struct { + db *sql.DB + encryptionKey string +} + +// NewCredentialStore creates a new credential store. +// The encryptionKey is used for pgcrypto symmetric encryption. +func NewCredentialStore(db *sql.DB, encryptionKey string) *CredentialStore { + return &CredentialStore{ + db: db, + encryptionKey: encryptionKey, + } +} + +// Get retrieves a credential by key. Returns empty string if not found. +func (s *CredentialStore) Get(ctx context.Context, key string) (string, error) { + var value string + err := s.db.QueryRowContext(ctx, ` + SELECT pgp_sym_decrypt(value, $1) + FROM credentials + WHERE key = $2 + `, s.encryptionKey, key).Scan(&value) + + if errors.Is(err, sql.ErrNoRows) { + return "", nil + } + if err != nil { + return "", fmt.Errorf("get credential %s: %w", key, err) + } + return value, nil +} + +// GetRequired retrieves a credential by key. Returns error if not found. +func (s *CredentialStore) GetRequired(ctx context.Context, key string) (string, error) { + value, err := s.Get(ctx, key) + if err != nil { + return "", err + } + if value == "" { + return "", fmt.Errorf("credential %s not found", key) + } + return value, nil +} + +// Set stores or updates a credential. +func (s *CredentialStore) Set(ctx context.Context, cred domain.Credential) error { + _, err := s.db.ExecContext(ctx, ` + INSERT INTO credentials (key, value, description, category, updated_by) + VALUES ($1, pgp_sym_encrypt($2, $3), $4, $5, $6) + ON CONFLICT (key) DO UPDATE SET + value = pgp_sym_encrypt($2, $3), + description = COALESCE(NULLIF($4, ''), credentials.description), + category = COALESCE(NULLIF($5, ''), credentials.category), + updated_by = $6 + `, cred.Key, cred.Value, s.encryptionKey, cred.Description, cred.Category, cred.UpdatedBy) + + if err != nil { + return fmt.Errorf("set credential %s: %w", cred.Key, err) + } + return nil +} + +// Delete removes a credential by key. +func (s *CredentialStore) Delete(ctx context.Context, key string) error { + result, err := s.db.ExecContext(ctx, `DELETE FROM credentials WHERE key = $1`, key) + if err != nil { + return fmt.Errorf("delete credential %s: %w", key, err) + } + + rows, _ := result.RowsAffected() + if rows == 0 { + return fmt.Errorf("credential %s not found", key) + } + return nil +} + +// List returns all credentials (with values masked). +func (s *CredentialStore) List(ctx context.Context) ([]domain.Credential, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT key, description, category, created_at, updated_at, COALESCE(updated_by, '') + FROM credentials + ORDER BY category, key + `) + if err != nil { + return nil, fmt.Errorf("list credentials: %w", err) + } + defer func() { _ = rows.Close() }() + + var creds []domain.Credential + for rows.Next() { + var c domain.Credential + var desc, cat sql.NullString + if err := rows.Scan(&c.Key, &desc, &cat, &c.CreatedAt, &c.UpdatedAt, &c.UpdatedBy); err != nil { + return nil, fmt.Errorf("scan credential: %w", err) + } + c.Description = desc.String + c.Category = cat.String + c.Value = "********" // Masked + creds = append(creds, c) + } + return creds, rows.Err() +} + +// ListByCategory returns credentials in a category (with values masked). +func (s *CredentialStore) ListByCategory(ctx context.Context, category string) ([]domain.Credential, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT key, description, category, created_at, updated_at, COALESCE(updated_by, '') + FROM credentials + WHERE category = $1 + ORDER BY key + `, category) + if err != nil { + return nil, fmt.Errorf("list credentials by category: %w", err) + } + defer func() { _ = rows.Close() }() + + var creds []domain.Credential + for rows.Next() { + var c domain.Credential + var desc, cat sql.NullString + if err := rows.Scan(&c.Key, &desc, &cat, &c.CreatedAt, &c.UpdatedAt, &c.UpdatedBy); err != nil { + return nil, fmt.Errorf("scan credential: %w", err) + } + c.Description = desc.String + c.Category = cat.String + c.Value = "********" // Masked + creds = append(creds, c) + } + return creds, rows.Err() +} + +// GetMultiple retrieves multiple credentials by keys. +func (s *CredentialStore) GetMultiple(ctx context.Context, keys []string) (map[string]string, error) { + if len(keys) == 0 { + return make(map[string]string), nil + } + + // Build placeholders for IN clause + placeholders := make([]string, len(keys)) + args := make([]any, len(keys)+1) + args[0] = s.encryptionKey + for i, key := range keys { + placeholders[i] = fmt.Sprintf("$%d", i+2) + args[i+1] = key + } + + query := fmt.Sprintf(` + SELECT key, pgp_sym_decrypt(value, $1) + FROM credentials + WHERE key IN (%s) + `, strings.Join(placeholders, ",")) + + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("get multiple credentials: %w", err) + } + defer func() { _ = rows.Close() }() + + result := make(map[string]string) + for rows.Next() { + var key, value string + if err := rows.Scan(&key, &value); err != nil { + return nil, fmt.Errorf("scan credential: %w", err) + } + result[key] = value + } + return result, rows.Err() +} + +// SetMultiple stores multiple credentials in a single transaction. +func (s *CredentialStore) SetMultiple(ctx context.Context, creds []domain.Credential) error { + if len(creds) == 0 { + return nil + } + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin transaction: %w", err) + } + defer func() { _ = tx.Rollback() }() + + stmt, err := tx.PrepareContext(ctx, ` + INSERT INTO credentials (key, value, description, category, updated_by) + VALUES ($1, pgp_sym_encrypt($2, $3), $4, $5, $6) + ON CONFLICT (key) DO UPDATE SET + value = pgp_sym_encrypt($2, $3), + description = COALESCE(NULLIF($4, ''), credentials.description), + category = COALESCE(NULLIF($5, ''), credentials.category), + updated_by = $6 + `) + if err != nil { + return fmt.Errorf("prepare statement: %w", err) + } + defer func() { _ = stmt.Close() }() + + now := time.Now() + for _, cred := range creds { + updatedBy := cred.UpdatedBy + if updatedBy == "" { + updatedBy = "system" + } + _, err := stmt.ExecContext(ctx, cred.Key, cred.Value, s.encryptionKey, + cred.Description, cred.Category, updatedBy) + if err != nil { + return fmt.Errorf("set credential %s: %w", cred.Key, err) + } + _ = now // silence unused + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit transaction: %w", err) + } + return nil +} diff --git a/internal/adapter/postgres/work_queue.go b/internal/adapter/postgres/work_queue.go new file mode 100644 index 0000000..3638de0 --- /dev/null +++ b/internal/adapter/postgres/work_queue.go @@ -0,0 +1,526 @@ +// Package postgres provides PostgreSQL-based implementations of port interfaces. +package postgres + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/orchard9/rdev/internal/domain" + "github.com/orchard9/rdev/internal/port" +) + +// WorkQueueRepository implements port.WorkQueue using PostgreSQL. +type WorkQueueRepository struct { + db *sql.DB +} + +// NewWorkQueueRepository creates a new PostgreSQL work queue repository. +func NewWorkQueueRepository(db *sql.DB) *WorkQueueRepository { + return &WorkQueueRepository{db: db} +} + +// Ensure WorkQueueRepository implements port.WorkQueue at compile time. +var _ port.WorkQueue = (*WorkQueueRepository)(nil) + +// Enqueue adds a task to the queue. +func (r *WorkQueueRepository) Enqueue(ctx context.Context, task *port.WorkTask) (string, error) { + specJSON, err := json.Marshal(task.Spec) + if err != nil { + return "", fmt.Errorf("marshal task spec: %w", err) + } + + var id string + err = r.db.QueryRowContext(ctx, ` + INSERT INTO work_queue (project_id, task_type, task_spec, priority, callback_url, max_retries) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id + `, task.ProjectID, string(task.Type), specJSON, task.Priority, nullString(task.CallbackURL), task.MaxRetries).Scan(&id) + + if err != nil { + return "", fmt.Errorf("enqueue work task: %w", err) + } + + return id, nil +} + +// Dequeue atomically claims the next available task for a worker. +func (r *WorkQueueRepository) Dequeue(ctx context.Context, workerID string) (*port.WorkTask, error) { + // Use a single UPDATE ... RETURNING with subquery for atomic claim + // This avoids explicit transaction management while still being safe + var task port.WorkTask + var taskType string + var specJSON []byte + var status string + var callbackURL sql.NullString + var startedAt sql.NullTime + var completedAt sql.NullTime + var resultJSON []byte + var errorMsg sql.NullString + + err := r.db.QueryRowContext(ctx, ` + UPDATE work_queue + SET status = 'running', worker_id = $1, started_at = NOW() + WHERE id = ( + SELECT id FROM work_queue + WHERE status = 'pending' + ORDER BY priority DESC, created_at ASC + LIMIT 1 + FOR UPDATE SKIP LOCKED + ) + RETURNING id, project_id, task_type, task_spec, status, priority, worker_id, + callback_url, created_at, started_at, completed_at, result, error, + retry_count, max_retries + `, workerID).Scan( + &task.ID, + &task.ProjectID, + &taskType, + &specJSON, + &status, + &task.Priority, + &task.WorkerID, + &callbackURL, + &task.CreatedAt, + &startedAt, + &completedAt, + &resultJSON, + &errorMsg, + &task.RetryCount, + &task.MaxRetries, + ) + + if errors.Is(err, sql.ErrNoRows) { + return nil, nil // No pending tasks + } + if err != nil { + return nil, fmt.Errorf("dequeue work task: %w", err) + } + + task.Type = port.WorkTaskType(taskType) + task.Status = port.WorkTaskStatus(status) + + if callbackURL.Valid { + task.CallbackURL = callbackURL.String + } + if startedAt.Valid { + task.StartedAt = &startedAt.Time + } + if completedAt.Valid { + task.CompletedAt = &completedAt.Time + } + if errorMsg.Valid { + task.Error = errorMsg.String + } + + // Parse task spec + if len(specJSON) > 0 { + if err := json.Unmarshal(specJSON, &task.Spec); err != nil { + return nil, fmt.Errorf("unmarshal task spec: %w", err) + } + } + + // Parse result + if len(resultJSON) > 0 { + task.Result = &port.WorkResult{} + if err := json.Unmarshal(resultJSON, task.Result); err != nil { + return nil, fmt.Errorf("unmarshal task result: %w", err) + } + } + + return &task, nil +} + +// Complete marks a task as successfully completed with results. +func (r *WorkQueueRepository) Complete(ctx context.Context, taskID string, result *port.WorkResult) error { + var resultJSON []byte + var err error + + if result != nil { + resultJSON, err = json.Marshal(result) + if err != nil { + return fmt.Errorf("marshal result: %w", err) + } + } + + res, err := r.db.ExecContext(ctx, ` + UPDATE work_queue + SET status = 'completed', completed_at = NOW(), result = $1 + WHERE id = $2 AND status = 'running' + `, resultJSON, taskID) + if err != nil { + return fmt.Errorf("complete work task: %w", err) + } + + rows, err := res.RowsAffected() + if err != nil { + return fmt.Errorf("rows affected: %w", err) + } + if rows == 0 { + return domain.ErrWorkTaskNotFound + } + + return nil +} + +// Fail marks a task as failed with an error message. +// Uses a single atomic UPDATE to avoid race conditions between SELECT and UPDATE. +func (r *WorkQueueRepository) Fail(ctx context.Context, taskID string, errMsg string) error { + // Use a single atomic query that handles both retry and permanent failure cases + result, err := r.db.ExecContext(ctx, ` + UPDATE work_queue + SET + status = CASE + WHEN retry_count < max_retries THEN 'pending' + ELSE 'failed' + END, + worker_id = CASE + WHEN retry_count < max_retries THEN NULL + ELSE worker_id + END, + started_at = CASE + WHEN retry_count < max_retries THEN NULL + ELSE started_at + END, + completed_at = CASE + WHEN retry_count >= max_retries THEN NOW() + ELSE completed_at + END, + retry_count = CASE + WHEN retry_count < max_retries THEN retry_count + 1 + ELSE retry_count + END, + error = $1 + WHERE id = $2 + `, errMsg, taskID) + + if err != nil { + return fmt.Errorf("fail work task: %w", err) + } + + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("rows affected: %w", err) + } + if rows == 0 { + return domain.ErrWorkTaskNotFound + } + + return nil +} + +// Cancel marks a pending task as cancelled. +func (r *WorkQueueRepository) Cancel(ctx context.Context, taskID string) error { + result, err := r.db.ExecContext(ctx, ` + UPDATE work_queue + SET status = 'cancelled', completed_at = NOW() + WHERE id = $1 AND status = 'pending' + `, taskID) + if err != nil { + return fmt.Errorf("cancel work task: %w", err) + } + + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("rows affected: %w", err) + } + + if rows == 0 { + // Check if task exists + var exists bool + err := r.db.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM work_queue WHERE id = $1)`, taskID).Scan(&exists) + if err != nil { + return fmt.Errorf("check exists: %w", err) + } + if !exists { + return domain.ErrWorkTaskNotFound + } + return fmt.Errorf("task is not in pending state") + } + + return nil +} + +// GetTask retrieves a task by ID. +func (r *WorkQueueRepository) GetTask(ctx context.Context, taskID string) (*port.WorkTask, error) { + var task port.WorkTask + var taskType string + var specJSON []byte + var status string + var workerID sql.NullString + var callbackURL sql.NullString + var startedAt sql.NullTime + var completedAt sql.NullTime + var resultJSON []byte + var errorMsg sql.NullString + + err := r.db.QueryRowContext(ctx, ` + SELECT id, project_id, task_type, task_spec, status, priority, worker_id, + callback_url, created_at, started_at, completed_at, result, error, + retry_count, max_retries + FROM work_queue + WHERE id = $1 + `, taskID).Scan( + &task.ID, + &task.ProjectID, + &taskType, + &specJSON, + &status, + &task.Priority, + &workerID, + &callbackURL, + &task.CreatedAt, + &startedAt, + &completedAt, + &resultJSON, + &errorMsg, + &task.RetryCount, + &task.MaxRetries, + ) + + if errors.Is(err, sql.ErrNoRows) { + return nil, domain.ErrWorkTaskNotFound + } + if err != nil { + return nil, fmt.Errorf("get work task: %w", err) + } + + task.Type = port.WorkTaskType(taskType) + task.Status = port.WorkTaskStatus(status) + + if workerID.Valid { + task.WorkerID = workerID.String + } + if callbackURL.Valid { + task.CallbackURL = callbackURL.String + } + if startedAt.Valid { + task.StartedAt = &startedAt.Time + } + if completedAt.Valid { + task.CompletedAt = &completedAt.Time + } + if errorMsg.Valid { + task.Error = errorMsg.String + } + + // Parse task spec + if len(specJSON) > 0 { + if err := json.Unmarshal(specJSON, &task.Spec); err != nil { + return nil, fmt.Errorf("unmarshal task spec: %w", err) + } + } + + // Parse result + if len(resultJSON) > 0 { + task.Result = &port.WorkResult{} + if err := json.Unmarshal(resultJSON, task.Result); err != nil { + return nil, fmt.Errorf("unmarshal task result: %w", err) + } + } + + return &task, nil +} + +// ListByProject returns tasks for a project with optional status filter and pagination. +func (r *WorkQueueRepository) ListByProject(ctx context.Context, projectID string, status *port.WorkTaskStatus, opts port.WorkListOptions) (*port.WorkListResult, error) { + // Normalize pagination options + opts.Normalize() + + // Build base WHERE clause + whereClause := "WHERE project_id = $1" + args := []any{projectID} + argNum := 2 + + if status != nil { + whereClause += fmt.Sprintf(" AND status = $%d", argNum) + args = append(args, string(*status)) + argNum++ + } + + // Get total count for pagination metadata + countQuery := "SELECT COUNT(*) FROM work_queue " + whereClause + var total int64 + if err := r.db.QueryRowContext(ctx, countQuery, args...).Scan(&total); err != nil { + return nil, fmt.Errorf("count work tasks: %w", err) + } + + // Build paginated query + query := fmt.Sprintf(` + SELECT id, project_id, task_type, task_spec, status, priority, worker_id, + callback_url, created_at, started_at, completed_at, result, error, + retry_count, max_retries + FROM work_queue + %s + ORDER BY created_at DESC + LIMIT $%d OFFSET $%d + `, whereClause, argNum, argNum+1) + args = append(args, opts.Limit, opts.Offset) + + rows, err := r.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("list work tasks: %w", err) + } + defer func() { _ = rows.Close() }() + + var tasks []*port.WorkTask + for rows.Next() { + task, err := r.scanTask(rows) + if err != nil { + return nil, err + } + tasks = append(tasks, task) + } + + return &port.WorkListResult{ + Tasks: tasks, + Total: total, + Limit: opts.Limit, + Offset: opts.Offset, + }, nil +} + +// GetStats returns queue statistics. +func (r *WorkQueueRepository) GetStats(ctx context.Context) (*port.WorkQueueStats, error) { + var stats port.WorkQueueStats + + err := r.db.QueryRowContext(ctx, ` + SELECT + COUNT(*) FILTER (WHERE status = 'pending') as pending, + COUNT(*) FILTER (WHERE status = 'running') as running, + COUNT(*) FILTER (WHERE status = 'completed' AND completed_at > NOW() - INTERVAL '24 hours') as completed, + COUNT(*) FILTER (WHERE status = 'failed' AND completed_at > NOW() - INTERVAL '24 hours') as failed, + COUNT(*) FILTER (WHERE status = 'cancelled' AND completed_at > NOW() - INTERVAL '24 hours') as cancelled + FROM work_queue + `).Scan( + &stats.Pending, + &stats.Running, + &stats.Completed, + &stats.Failed, + &stats.Cancelled, + ) + if err != nil { + return nil, fmt.Errorf("get stats: %w", err) + } + + // Get oldest pending task age + var oldestCreatedAt sql.NullTime + err = r.db.QueryRowContext(ctx, ` + SELECT MIN(created_at) FROM work_queue WHERE status = 'pending' + `).Scan(&oldestCreatedAt) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("get oldest pending: %w", err) + } + if oldestCreatedAt.Valid { + age := time.Since(oldestCreatedAt.Time) + stats.OldestPending = &age + } + + return &stats, nil +} + +// CleanupOld removes completed/failed/cancelled tasks older than the specified duration. +func (r *WorkQueueRepository) CleanupOld(ctx context.Context, olderThan time.Duration) (int64, error) { + cutoff := time.Now().Add(-olderThan) + result, err := r.db.ExecContext(ctx, ` + DELETE FROM work_queue + WHERE status IN ('completed', 'failed', 'cancelled') + AND completed_at < $1 + `, cutoff) + if err != nil { + return 0, fmt.Errorf("cleanup old tasks: %w", err) + } + + return result.RowsAffected() +} + +// RequeueStale re-queues tasks that have been running longer than the timeout. +func (r *WorkQueueRepository) RequeueStale(ctx context.Context, timeout time.Duration) (int64, error) { + cutoff := time.Now().Add(-timeout) + result, err := r.db.ExecContext(ctx, ` + UPDATE work_queue + SET status = 'pending', worker_id = NULL, started_at = NULL, + retry_count = retry_count + 1, error = 'Worker timeout - task requeued' + WHERE status = 'running' + AND started_at < $1 + AND retry_count < max_retries + `, cutoff) + if err != nil { + return 0, fmt.Errorf("requeue stale tasks: %w", err) + } + + return result.RowsAffected() +} + +// scanTask scans a single task row. +func (r *WorkQueueRepository) scanTask(rows *sql.Rows) (*port.WorkTask, error) { + var task port.WorkTask + var taskType string + var specJSON []byte + var status string + var workerID sql.NullString + var callbackURL sql.NullString + var startedAt sql.NullTime + var completedAt sql.NullTime + var resultJSON []byte + var errorMsg sql.NullString + + err := rows.Scan( + &task.ID, + &task.ProjectID, + &taskType, + &specJSON, + &status, + &task.Priority, + &workerID, + &callbackURL, + &task.CreatedAt, + &startedAt, + &completedAt, + &resultJSON, + &errorMsg, + &task.RetryCount, + &task.MaxRetries, + ) + if err != nil { + return nil, fmt.Errorf("scan task: %w", err) + } + + task.Type = port.WorkTaskType(taskType) + task.Status = port.WorkTaskStatus(status) + + if workerID.Valid { + task.WorkerID = workerID.String + } + if callbackURL.Valid { + task.CallbackURL = callbackURL.String + } + if startedAt.Valid { + task.StartedAt = &startedAt.Time + } + if completedAt.Valid { + task.CompletedAt = &completedAt.Time + } + if errorMsg.Valid { + task.Error = errorMsg.String + } + + // Parse task spec + if len(specJSON) > 0 { + if err := json.Unmarshal(specJSON, &task.Spec); err != nil { + return nil, fmt.Errorf("unmarshal task spec: %w", err) + } + } + + // Parse result + if len(resultJSON) > 0 { + task.Result = &port.WorkResult{} + if err := json.Unmarshal(resultJSON, task.Result); err != nil { + return nil, fmt.Errorf("unmarshal task result: %w", err) + } + } + + return &task, nil +} diff --git a/internal/adapter/templates/provider.go b/internal/adapter/templates/provider.go new file mode 100644 index 0000000..815f0d9 --- /dev/null +++ b/internal/adapter/templates/provider.go @@ -0,0 +1,230 @@ +// Package templates provides embedded project templates for seeding new repos. +// +// Templates are embedded at compile time from the templates/ subdirectory. +// Each template contains starter files with {{VAR}} placeholders that get +// interpolated when seeding a repository. +package templates + +import ( + "context" + "embed" + "encoding/base64" + "fmt" + "io/fs" + "log/slog" + "path/filepath" + "regexp" + "strings" + + "code.gitea.io/sdk/gitea" + "github.com/orchard9/rdev/internal/domain" + "github.com/orchard9/rdev/internal/port" +) + +//go:embed all:templates +var templatesFS embed.FS + +// availableTemplates lists all supported project templates. +var availableTemplates = []port.TemplateInfo{ + {Name: "default", Description: "Basic project with Dockerfile", Stack: "generic"}, + {Name: "astro-landing", Description: "Astro landing page with Tailwind", Stack: "astro"}, + {Name: "go-api", Description: "Go REST API with chi router", Stack: "go"}, +} + +// templateNameRegex validates template names (alphanumeric, dash only). +var templateNameRegex = regexp.MustCompile(`^[a-z][a-z0-9-]*$`) + +// Provider implements port.TemplateProvider using embedded templates +// and the Gitea API to seed repositories. +type Provider struct { + giteaClient *gitea.Client + logger *slog.Logger +} + +// Ensure Provider implements TemplateProvider. +var _ port.TemplateProvider = (*Provider)(nil) + +// NewProvider creates a new template provider. +// giteaClient is used to create files in repositories. +// logger is optional; if nil, slog.Default() is used. +func NewProvider(giteaClient *gitea.Client, logger *slog.Logger) *Provider { + if logger == nil { + logger = slog.Default() + } + return &Provider{ + giteaClient: giteaClient, + logger: logger, + } +} + +// SeedRepo populates a repository with template files. +func (p *Provider) SeedRepo(ctx context.Context, owner, repo, templateName string, vars map[string]string) error { + // Check for context cancellation + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + // Validate template name (prevent path traversal, enforce naming convention) + if !isValidTemplateName(templateName) { + return fmt.Errorf("invalid template name: %s (must be lowercase alphanumeric with dashes)", templateName) + } + + // Validate template exists + templateDir := "templates/" + templateName + if _, err := templatesFS.ReadDir(templateDir); err != nil { + return fmt.Errorf("template not found: %s", templateName) + } + + p.logger.Info("seeding repo from template", + "owner", owner, + "repo", repo, + "template", templateName, + ) + + // Walk template directory and create files + var filesCreated int + 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 template file %s: %w", path, err) + } + + // Interpolate variables + interpolated := interpolateVars(string(content), vars) + + // Calculate relative path from template root + relPath, err := filepath.Rel(templateDir, path) + if err != nil { + return fmt.Errorf("failed to get relative path: %w", err) + } + + // Strip .tmpl extension (allows embedding go.mod as go.mod.tmpl) + relPath = strings.TrimSuffix(relPath, ".tmpl") + + // Create file in repo via Gitea API + // Gitea expects base64-encoded content + encodedContent := base64.StdEncoding.EncodeToString([]byte(interpolated)) + _, _, err = p.giteaClient.CreateFile(owner, repo, relPath, gitea.CreateFileOptions{ + Content: encodedContent, + FileOptions: gitea.FileOptions{ + Message: "Add " + relPath + " from template", + BranchName: "main", + }, + }) + if err != nil { + return fmt.Errorf("failed to create file %s: %w", relPath, err) + } + + filesCreated++ + return nil + }) + + if err != nil { + return fmt.Errorf("failed to seed repo from template %s: %w", templateName, err) + } + + p.logger.Info("repo seeded successfully", + "owner", owner, + "repo", repo, + "template", templateName, + "files_created", filesCreated, + ) + + return nil +} + +// isValidTemplateName validates that a template name is safe. +func isValidTemplateName(name string) bool { + if name == "" || len(name) > 64 { + return false + } + return templateNameRegex.MatchString(name) +} + +// ListTemplates returns available templates. +func (p *Provider) ListTemplates(ctx context.Context) ([]port.TemplateInfo, error) { + // Check for context cancellation + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + + result := make([]port.TemplateInfo, len(availableTemplates)) + for i, t := range availableTemplates { + result[i] = t + // Populate files list + files, err := listTemplateFiles(t.Name) + if err == nil { + result[i].Files = files + } + } + return result, nil +} + +// GetTemplate returns info about a specific template. +func (p *Provider) GetTemplate(ctx context.Context, name string) (*port.TemplateInfo, error) { + // Check for context cancellation + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + + for _, t := range availableTemplates { + if t.Name == name { + result := t + files, err := listTemplateFiles(name) + if err == nil { + result.Files = files + } + return &result, nil + } + } + return nil, fmt.Errorf("%w: %s", domain.ErrTemplateNotFound, name) +} + +// interpolateVars replaces {{VAR_NAME}} placeholders with values. +func interpolateVars(content string, vars map[string]string) string { + result := content + for key, value := range vars { + placeholder := "{{" + key + "}}" + result = strings.ReplaceAll(result, placeholder, value) + } + return result +} + +// listTemplateFiles returns the list of files in a template. +func listTemplateFiles(templateName string) ([]string, error) { + templateDir := "templates/" + templateName + 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 +} diff --git a/internal/adapter/templates/provider_test.go b/internal/adapter/templates/provider_test.go new file mode 100644 index 0000000..0feb432 --- /dev/null +++ b/internal/adapter/templates/provider_test.go @@ -0,0 +1,143 @@ +package templates + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestInterpolateVars(t *testing.T) { + tests := []struct { + name string + content string + vars map[string]string + expected string + }{ + { + name: "single variable", + content: "Hello {{PROJECT_NAME}}!", + vars: map[string]string{ + "PROJECT_NAME": "myapp", + }, + expected: "Hello myapp!", + }, + { + name: "multiple variables", + content: "Project {{PROJECT_NAME}} at {{DOMAIN}} from {{GIT_URL}}", + vars: map[string]string{ + "PROJECT_NAME": "myapp", + "DOMAIN": "myapp.threesix.ai", + "GIT_URL": "https://git.example.com/org/myapp.git", + }, + expected: "Project myapp at myapp.threesix.ai from https://git.example.com/org/myapp.git", + }, + { + name: "no variables", + content: "Static content with no placeholders", + vars: map[string]string{}, + expected: "Static content with no placeholders", + }, + { + name: "variable not in map", + content: "Hello {{UNKNOWN_VAR}}!", + vars: map[string]string{ + "PROJECT_NAME": "myapp", + }, + expected: "Hello {{UNKNOWN_VAR}}!", + }, + { + name: "empty vars map", + content: "Hello {{PROJECT_NAME}}!", + vars: nil, + expected: "Hello {{PROJECT_NAME}}!", + }, + { + name: "repeated variable", + content: "{{NAME}} and {{NAME}} again", + vars: map[string]string{ + "NAME": "test", + }, + expected: "test and test again", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := interpolateVars(tt.content, tt.vars) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestIsValidTemplateName(t *testing.T) { + tests := []struct { + name string + input string + expected bool + }{ + {"valid simple", "default", true}, + {"valid with dash", "astro-landing", true}, + {"valid with numbers", "go-api-v2", true}, + {"empty", "", false}, + {"starts with number", "123template", false}, + {"starts with dash", "-invalid", false}, + {"contains uppercase", "MyTemplate", false}, + {"contains underscore", "my_template", false}, + {"contains dot", "my.template", false}, + {"contains slash", "my/template", false}, + {"path traversal attempt", "../evil", false}, + {"too long", "a" + string(make([]byte, 64)), false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isValidTemplateName(tt.input) + assert.Equal(t, tt.expected, result, "isValidTemplateName(%q)", tt.input) + }) + } +} + +func TestListTemplateFiles(t *testing.T) { + // Test that default template exists and has expected files + files, err := listTemplateFiles("default") + require.NoError(t, err) + + // Should have at least these core files + assert.Contains(t, files, ".woodpecker.yml", "default template should have .woodpecker.yml") + assert.Contains(t, files, "Dockerfile", "default template should have Dockerfile") + assert.Contains(t, files, "README.md", "default template should have README.md") +} + +func TestListTemplateFiles_InvalidTemplate(t *testing.T) { + _, err := listTemplateFiles("nonexistent-template") + assert.Error(t, err, "should error for nonexistent template") +} + +func TestAvailableTemplates(t *testing.T) { + // Verify all declared templates actually exist + for _, tmpl := range availableTemplates { + t.Run(tmpl.Name, func(t *testing.T) { + files, err := listTemplateFiles(tmpl.Name) + require.NoError(t, err, "template %s should exist", tmpl.Name) + assert.NotEmpty(t, files, "template %s should have files", tmpl.Name) + + // Every template should have these core files + assert.Contains(t, files, ".woodpecker.yml", "template %s should have .woodpecker.yml", tmpl.Name) + }) + } +} + +func TestListTemplateFiles_TmplExtensionStripped(t *testing.T) { + // go-api template has go.mod.tmpl which should be listed as go.mod + files, err := listTemplateFiles("go-api") + require.NoError(t, err) + + // Should contain go.mod (not go.mod.tmpl) + assert.Contains(t, files, "go.mod", "go.mod.tmpl should be listed as go.mod") + + // Should NOT contain the .tmpl extension + for _, f := range files { + assert.NotContains(t, f, ".tmpl", "file %s should not have .tmpl extension", f) + } +} diff --git a/internal/adapter/templates/templates/astro-landing/.woodpecker.yml b/internal/adapter/templates/templates/astro-landing/.woodpecker.yml new file mode 100644 index 0000000..a1114f4 --- /dev/null +++ b/internal/adapter/templates/templates/astro-landing/.woodpecker.yml @@ -0,0 +1,43 @@ +steps: + install: + image: node:20-alpine + commands: + - npm ci + when: + - event: [push, pull_request] + + build: + image: node:20-alpine + commands: + - npm run build + when: + - event: [push, pull_request] + + docker: + image: docker:24-dind + privileged: true + commands: + - docker build -t zot.orchard9.ai/{{PROJECT_NAME}}:latest . + - docker build -t zot.orchard9.ai/{{PROJECT_NAME}}:${CI_COMMIT_SHA:0:8} . + when: + - event: push + + push: + image: docker:24-dind + privileged: true + commands: + - echo "$ZOT_PASSWORD" | docker login zot.orchard9.ai -u "$ZOT_USER" --password-stdin + - docker push zot.orchard9.ai/{{PROJECT_NAME}}:latest + - docker push zot.orchard9.ai/{{PROJECT_NAME}}:${CI_COMMIT_SHA:0:8} + secrets: [zot_user, zot_password] + when: + - event: push + branch: main + + deploy: + image: bitnami/kubectl:latest + commands: + - kubectl set image deployment/{{PROJECT_NAME}} {{PROJECT_NAME}}=zot.orchard9.ai/{{PROJECT_NAME}}:${CI_COMMIT_SHA:0:8} -n projects + when: + - event: push + branch: main diff --git a/internal/adapter/templates/templates/astro-landing/Dockerfile b/internal/adapter/templates/templates/astro-landing/Dockerfile new file mode 100644 index 0000000..a5f7803 --- /dev/null +++ b/internal/adapter/templates/templates/astro-landing/Dockerfile @@ -0,0 +1,20 @@ +# Build stage +FROM node:20-alpine AS build + +WORKDIR /app + +COPY package*.json ./ +RUN npm ci + +COPY . . +RUN npm run build + +# Production stage +FROM nginx:alpine + +COPY --from=build /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/internal/adapter/templates/templates/astro-landing/README.md b/internal/adapter/templates/templates/astro-landing/README.md new file mode 100644 index 0000000..dc99e44 --- /dev/null +++ b/internal/adapter/templates/templates/astro-landing/README.md @@ -0,0 +1,32 @@ +# {{PROJECT_NAME}} + +Astro landing page deployed at: https://{{DOMAIN}} + +## Getting Started + +```bash +npm install +npm run dev +``` + +## Commands + +| Command | Action | +|---------|--------| +| `npm run dev` | Start dev server at localhost:4321 | +| `npm run build` | Build for production | +| `npm run preview` | Preview production build | + +## Structure + +``` +src/ + pages/ # File-based routing + components/ # Astro/React components + layouts/ # Page layouts +public/ # Static assets +``` + +## CI/CD + +Pushes to `main` trigger automatic deployment via Woodpecker CI. diff --git a/internal/adapter/templates/templates/astro-landing/astro.config.mjs b/internal/adapter/templates/templates/astro-landing/astro.config.mjs new file mode 100644 index 0000000..50a4e80 --- /dev/null +++ b/internal/adapter/templates/templates/astro-landing/astro.config.mjs @@ -0,0 +1,7 @@ +import { defineConfig } from 'astro/config'; +import tailwind from '@astrojs/tailwind'; + +export default defineConfig({ + integrations: [tailwind()], + output: 'static', +}); diff --git a/internal/adapter/templates/templates/astro-landing/nginx.conf b/internal/adapter/templates/templates/astro-landing/nginx.conf new file mode 100644 index 0000000..038c0fa --- /dev/null +++ b/internal/adapter/templates/templates/astro-landing/nginx.conf @@ -0,0 +1,27 @@ +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; + } + + # Health check + location /health { + return 200 'ok'; + add_header Content-Type text/plain; + } +} diff --git a/internal/adapter/templates/templates/astro-landing/package.json b/internal/adapter/templates/templates/astro-landing/package.json new file mode 100644 index 0000000..771819e --- /dev/null +++ b/internal/adapter/templates/templates/astro-landing/package.json @@ -0,0 +1,18 @@ +{ + "name": "{{PROJECT_NAME}}", + "type": "module", + "version": "0.0.1", + "scripts": { + "dev": "astro dev", + "start": "astro dev", + "build": "astro build", + "preview": "astro preview" + }, + "dependencies": { + "astro": "^4.0.0" + }, + "devDependencies": { + "@astrojs/tailwind": "^5.0.0", + "tailwindcss": "^3.4.0" + } +} diff --git a/internal/adapter/templates/templates/astro-landing/src/layouts/Layout.astro b/internal/adapter/templates/templates/astro-landing/src/layouts/Layout.astro new file mode 100644 index 0000000..a029720 --- /dev/null +++ b/internal/adapter/templates/templates/astro-landing/src/layouts/Layout.astro @@ -0,0 +1,21 @@ +--- +interface Props { + title: string; +} + +const { title } = Astro.props; +--- + + + + + + + + + {title} + + + + + diff --git a/internal/adapter/templates/templates/astro-landing/src/pages/index.astro b/internal/adapter/templates/templates/astro-landing/src/pages/index.astro new file mode 100644 index 0000000..984f020 --- /dev/null +++ b/internal/adapter/templates/templates/astro-landing/src/pages/index.astro @@ -0,0 +1,33 @@ +--- +import Layout from '../layouts/Layout.astro'; +--- + + +
+
+
+

+ {{PROJECT_NAME}} +

+

+ Welcome to your new Astro landing page. Edit this file at + src/pages/index.astro +

+ +
+
+
+
diff --git a/internal/adapter/templates/templates/astro-landing/tailwind.config.mjs b/internal/adapter/templates/templates/astro-landing/tailwind.config.mjs new file mode 100644 index 0000000..83cac5e --- /dev/null +++ b/internal/adapter/templates/templates/astro-landing/tailwind.config.mjs @@ -0,0 +1,8 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'], + theme: { + extend: {}, + }, + plugins: [], +}; diff --git a/internal/adapter/templates/templates/default/.woodpecker.yml b/internal/adapter/templates/templates/default/.woodpecker.yml new file mode 100644 index 0000000..94f0cae --- /dev/null +++ b/internal/adapter/templates/templates/default/.woodpecker.yml @@ -0,0 +1,29 @@ +steps: + build: + image: docker:24-dind + privileged: true + commands: + - docker build -t zot.orchard9.ai/{{PROJECT_NAME}}:latest . + - docker build -t zot.orchard9.ai/{{PROJECT_NAME}}:${CI_COMMIT_SHA:0:8} . + when: + - event: push + + push: + image: docker:24-dind + privileged: true + commands: + - echo "$ZOT_PASSWORD" | docker login zot.orchard9.ai -u "$ZOT_USER" --password-stdin + - docker push zot.orchard9.ai/{{PROJECT_NAME}}:latest + - docker push zot.orchard9.ai/{{PROJECT_NAME}}:${CI_COMMIT_SHA:0:8} + secrets: [zot_user, zot_password] + when: + - event: push + branch: main + + deploy: + image: bitnami/kubectl:latest + commands: + - kubectl set image deployment/{{PROJECT_NAME}} {{PROJECT_NAME}}=zot.orchard9.ai/{{PROJECT_NAME}}:${CI_COMMIT_SHA:0:8} -n projects + when: + - event: push + branch: main diff --git a/internal/adapter/templates/templates/default/Dockerfile b/internal/adapter/templates/templates/default/Dockerfile new file mode 100644 index 0000000..e7846dc --- /dev/null +++ b/internal/adapter/templates/templates/default/Dockerfile @@ -0,0 +1,9 @@ +# Default Dockerfile - replace with your application +FROM nginx:alpine + +# Copy static files or your app +COPY . /usr/share/nginx/html/ + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/internal/adapter/templates/templates/default/README.md b/internal/adapter/templates/templates/default/README.md new file mode 100644 index 0000000..876195f --- /dev/null +++ b/internal/adapter/templates/templates/default/README.md @@ -0,0 +1,21 @@ +# {{PROJECT_NAME}} + +Deployed at: https://{{DOMAIN}} + +## Getting Started + +1. Clone the repository +2. Build with Docker: `docker build -t {{PROJECT_NAME}} .` +3. Run locally: `docker run -p 8080:8080 {{PROJECT_NAME}}` + +## CI/CD + +This project uses Woodpecker CI for continuous deployment. Pushing to `main` will: +- Build a Docker image +- Push to the container registry +- Deploy to Kubernetes + +## Resources + +- Live site: https://{{DOMAIN}} +- Git repository: {{GIT_URL}} diff --git a/internal/adapter/templates/templates/go-api/.woodpecker.yml b/internal/adapter/templates/templates/go-api/.woodpecker.yml new file mode 100644 index 0000000..0cd3c17 --- /dev/null +++ b/internal/adapter/templates/templates/go-api/.woodpecker.yml @@ -0,0 +1,43 @@ +steps: + test: + image: golang:1.22-alpine + commands: + - go test ./... + when: + - event: [push, pull_request] + + build: + image: golang:1.22-alpine + commands: + - go build -o app ./cmd/api + when: + - event: [push, pull_request] + + docker: + image: docker:24-dind + privileged: true + commands: + - docker build -t zot.orchard9.ai/{{PROJECT_NAME}}:latest . + - docker build -t zot.orchard9.ai/{{PROJECT_NAME}}:${CI_COMMIT_SHA:0:8} . + when: + - event: push + + push: + image: docker:24-dind + privileged: true + commands: + - echo "$ZOT_PASSWORD" | docker login zot.orchard9.ai -u "$ZOT_USER" --password-stdin + - docker push zot.orchard9.ai/{{PROJECT_NAME}}:latest + - docker push zot.orchard9.ai/{{PROJECT_NAME}}:${CI_COMMIT_SHA:0:8} + secrets: [zot_user, zot_password] + when: + - event: push + branch: main + + deploy: + image: bitnami/kubectl:latest + commands: + - kubectl set image deployment/{{PROJECT_NAME}} {{PROJECT_NAME}}=zot.orchard9.ai/{{PROJECT_NAME}}:${CI_COMMIT_SHA:0:8} -n projects + when: + - event: push + branch: main diff --git a/internal/adapter/templates/templates/go-api/Dockerfile b/internal/adapter/templates/templates/go-api/Dockerfile new file mode 100644 index 0000000..39199ad --- /dev/null +++ b/internal/adapter/templates/templates/go-api/Dockerfile @@ -0,0 +1,23 @@ +# Build stage +FROM golang:1.22-alpine AS build + +WORKDIR /app + +COPY go.mod go.sum* ./ +RUN go mod download + +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -o /app/server ./cmd/api + +# Production stage +FROM alpine:latest + +RUN apk --no-cache add ca-certificates + +WORKDIR /app + +COPY --from=build /app/server . + +EXPOSE 8080 + +CMD ["./server"] diff --git a/internal/adapter/templates/templates/go-api/README.md b/internal/adapter/templates/templates/go-api/README.md new file mode 100644 index 0000000..1163a21 --- /dev/null +++ b/internal/adapter/templates/templates/go-api/README.md @@ -0,0 +1,33 @@ +# {{PROJECT_NAME}} + +Go REST API deployed at: https://{{DOMAIN}} + +## Getting Started + +```bash +go run ./cmd/api +``` + +## Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| GET | /health | Health check | +| GET | /api/v1/example | Example endpoint | + +## Development + +```bash +# Run +go run ./cmd/api + +# Test +go test ./... + +# Build +go build -o app ./cmd/api +``` + +## CI/CD + +Pushes to `main` trigger automatic deployment via Woodpecker CI. diff --git a/internal/adapter/templates/templates/go-api/cmd/api/main.go b/internal/adapter/templates/templates/go-api/cmd/api/main.go new file mode 100644 index 0000000..526d69b --- /dev/null +++ b/internal/adapter/templates/templates/go-api/cmd/api/main.go @@ -0,0 +1,47 @@ +package main + +import ( + "encoding/json" + "log/slog" + "net/http" + "os" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" +) + +func main() { + logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) + slog.SetDefault(logger) + + r := chi.NewRouter() + r.Use(middleware.Logger) + r.Use(middleware.Recoverer) + + // Health check + r.Get("/health", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) + }) + + // API routes + r.Route("/api/v1", func(r chi.Router) { + r.Get("/example", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{ + "message": "Hello from {{PROJECT_NAME}}", + }) + }) + }) + + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + + slog.Info("starting server", "port", port) + if err := http.ListenAndServe(":"+port, r); err != nil { + slog.Error("server failed", "error", err) + os.Exit(1) + } +} diff --git a/internal/adapter/templates/templates/go-api/go.mod.tmpl b/internal/adapter/templates/templates/go-api/go.mod.tmpl new file mode 100644 index 0000000..1494b10 --- /dev/null +++ b/internal/adapter/templates/templates/go-api/go.mod.tmpl @@ -0,0 +1,5 @@ +module github.com/orchard9/{{PROJECT_NAME}} + +go 1.22 + +require github.com/go-chi/chi/v5 v5.0.12 diff --git a/internal/adapter/woodpecker/client.go b/internal/adapter/woodpecker/client.go new file mode 100644 index 0000000..d4f8064 --- /dev/null +++ b/internal/adapter/woodpecker/client.go @@ -0,0 +1,313 @@ +// Package woodpecker provides a Woodpecker CI adapter implementing port.CIProvider. +// +// The Woodpecker API requires a few key concepts: +// - forge_remote_id: The ID of the repo in the forge (e.g., Gitea). Used to activate repos. +// - repo_id: Woodpecker's internal repo ID, used after activation. +// +// To activate a repo, we need to find it in the available repos list (synced from forge) +// and then POST to activate it using the forge_remote_id. +// +// Context Propagation Note: +// The Woodpecker Go SDK does not natively support context propagation for HTTP requests. +// Methods accept context.Context for interface compatibility and cancellation checks, +// but the underlying SDK calls do not use it for cancellation or timeouts. +package woodpecker + +import ( + "context" + "fmt" + "log/slog" + "net/http" + "strconv" + "strings" + "time" + + "go.woodpecker-ci.org/woodpecker/v2/woodpecker-go/woodpecker" + + "github.com/orchard9/rdev/internal/domain" + "github.com/orchard9/rdev/internal/port" +) + +// Ensure Client implements CIProvider. +var _ port.CIProvider = (*Client)(nil) + +// tokenTransport is an http.RoundTripper that adds bearer token auth. +type tokenTransport struct { + token string + base http.RoundTripper +} + +func (t *tokenTransport) RoundTrip(req *http.Request) (*http.Response, error) { + // Clone the request to avoid mutating the original per RoundTripper contract + req2 := req.Clone(req.Context()) + req2.Header.Set("Authorization", "Bearer "+t.token) + return t.base.RoundTrip(req2) +} + +// Client is a Woodpecker CI API client adapter. +type Client struct { + client woodpecker.Client + url string + logger *slog.Logger +} + +// NewClient creates a new Woodpecker client. +// url is the Woodpecker server URL (e.g., https://ci.threesix.ai) +// token is an API token (generate from Woodpecker UI: Settings → API → Personal token) +// logger is optional; if nil, slog.Default() is used +func NewClient(url, token string, opts ...ClientOption) (*Client, error) { + if url == "" { + return nil, fmt.Errorf("woodpecker URL is required") + } + if token == "" { + return nil, fmt.Errorf("woodpecker token is required") + } + + // Normalize URL + url = strings.TrimSuffix(url, "/") + + // Create HTTP client with token auth + httpClient := &http.Client{ + Timeout: 30 * time.Second, + Transport: &tokenTransport{ + token: token, + base: http.DefaultTransport, + }, + } + + // Create Woodpecker client + client := woodpecker.NewClient(url, httpClient) + + c := &Client{ + client: client, + url: url, + logger: slog.Default(), + } + + // Apply options + for _, opt := range opts { + opt(c) + } + + return c, nil +} + +// ClientOption configures the Woodpecker client. +type ClientOption func(*Client) + +// WithLogger sets a custom logger for the client. +func WithLogger(logger *slog.Logger) ClientOption { + return func(c *Client) { + if logger != nil { + c.logger = logger + } + } +} + +// ActivateRepo enables CI for a repository. +// The forge parameter is unused (Woodpecker determines this from its config). +// owner/repo must match the repository in the forge. +func (c *Client) ActivateRepo(ctx context.Context, forge, owner, repo string) (*domain.CIRepo, error) { + // Check for context cancellation + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + + fullName := owner + "/" + repo + + // First, sync the repo list to ensure we have the latest from forge + // This is important for newly created repos + if _, err := c.client.RepoListOpts(true); err != nil { + c.logger.Debug("failed to sync repo list from forge", "error", err) + // Continue anyway - repo might already be synced + } + + // Find the repo in Woodpecker's list (may include inactive repos) + repos, err := c.client.RepoList() + if err != nil { + return nil, fmt.Errorf("failed to list repos: %w", err) + } + + var targetRepo *woodpecker.Repo + for _, r := range repos { + if strings.EqualFold(r.FullName, fullName) { + targetRepo = r + break + } + } + + if targetRepo == nil { + // Repo not found - try to look it up directly + targetRepo, err = c.client.RepoLookup(fullName) + if err != nil { + return nil, fmt.Errorf("repo not found in Woodpecker: %s (ensure forge is synced)", fullName) + } + } + + // If already active, just return it + if targetRepo.IsActive { + return repoFromWoodpecker(targetRepo), nil + } + + // Parse the forge remote ID (stored as string, API expects int64) + forgeID, err := strconv.ParseInt(targetRepo.ForgeRemoteID, 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid forge_remote_id %q: %w", targetRepo.ForgeRemoteID, err) + } + + // Activate the repo using the forge remote ID + activatedRepo, err := c.client.RepoPost(forgeID) + if err != nil { + return nil, fmt.Errorf("failed to activate repo: %w", err) + } + + return repoFromWoodpecker(activatedRepo), nil +} + +// DeactivateRepo disables CI for a repository. +func (c *Client) DeactivateRepo(ctx context.Context, owner, repo string) error { + // Check for context cancellation + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + fullName := owner + "/" + repo + + // Find the repo + r, err := c.client.RepoLookup(fullName) + if err != nil { + return fmt.Errorf("repo not found: %s", fullName) + } + + // Deactivate (remove from Woodpecker) + if err := c.client.RepoDel(r.ID); err != nil { + return fmt.Errorf("failed to deactivate repo: %w", err) + } + + return nil +} + +// GetRepo returns the CI configuration for a repository. +func (c *Client) GetRepo(ctx context.Context, owner, repo string) (*domain.CIRepo, error) { + // Check for context cancellation + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + + fullName := owner + "/" + repo + + r, err := c.client.RepoLookup(fullName) + if err != nil { + return nil, fmt.Errorf("repo not found: %s", fullName) + } + + return repoFromWoodpecker(r), nil +} + +// ListRepos returns all repositories visible to the CI system. +func (c *Client) ListRepos(ctx context.Context) ([]*domain.CIRepo, error) { + // Check for context cancellation + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + + repos, err := c.client.RepoList() + if err != nil { + return nil, fmt.Errorf("failed to list repos: %w", err) + } + + result := make([]*domain.CIRepo, len(repos)) + for i, r := range repos { + result[i] = repoFromWoodpecker(r) + } + return result, nil +} + +// AddSecret adds a secret to a repository for use in pipelines. +func (c *Client) AddSecret(ctx context.Context, owner, repo string, secret domain.CISecret) error { + // Check for context cancellation + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + fullName := owner + "/" + repo + + // Find the repo to get its ID + r, err := c.client.RepoLookup(fullName) + if err != nil { + return fmt.Errorf("repo not found: %s", fullName) + } + + // Create the secret + _, err = c.client.SecretCreate(r.ID, &woodpecker.Secret{ + Name: secret.Name, + Value: secret.Value, + Events: secret.Events, + Images: secret.Images, + }) + if err != nil { + return fmt.Errorf("failed to create secret: %w", err) + } + + return nil +} + +// DeleteSecret removes a secret from a repository. +func (c *Client) DeleteSecret(ctx context.Context, owner, repo, secretName string) error { + // Check for context cancellation + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + fullName := owner + "/" + repo + + // Find the repo to get its ID + r, err := c.client.RepoLookup(fullName) + if err != nil { + return fmt.Errorf("repo not found: %s", fullName) + } + + // Delete the secret + if err := c.client.SecretDelete(r.ID, secretName); err != nil { + return fmt.Errorf("failed to delete secret: %w", err) + } + + return nil +} + +// repoFromWoodpecker converts a woodpecker.Repo to domain.CIRepo. +func repoFromWoodpecker(r *woodpecker.Repo) *domain.CIRepo { + // Parse forge remote ID (string in SDK, int64 in our domain) + // Non-numeric ForgeRemoteID will result in 0 - this is intentional + // as some forges may use non-numeric IDs + var forgeID int64 + if r.ForgeRemoteID != "" { + if parsed, err := strconv.ParseInt(r.ForgeRemoteID, 10, 64); err == nil { + forgeID = parsed + } + } + + return &domain.CIRepo{ + ID: r.ID, + ForgeRemoteID: forgeID, + Owner: r.Owner, + Name: r.Name, + FullName: r.FullName, + CloneURL: r.Clone, + Active: r.IsActive, + AllowPullRequests: r.AllowPullRequests, + Visibility: r.Visibility, // Already a string in SDK + } +} diff --git a/internal/adapter/woodpecker/client_test.go b/internal/adapter/woodpecker/client_test.go new file mode 100644 index 0000000..88c3638 --- /dev/null +++ b/internal/adapter/woodpecker/client_test.go @@ -0,0 +1,230 @@ +package woodpecker + +import ( + "context" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/orchard9/rdev/internal/domain" +) + +func TestNewClient_Validation(t *testing.T) { + tests := []struct { + name string + url string + token string + wantErr string + }{ + { + name: "empty URL", + url: "", + token: "test-token", + wantErr: "woodpecker URL is required", + }, + { + name: "empty token", + url: "https://ci.example.com", + token: "", + wantErr: "woodpecker token is required", + }, + { + name: "valid inputs", + url: "https://ci.example.com", + token: "test-token", + wantErr: "", + }, + { + name: "URL with trailing slash", + url: "https://ci.example.com/", + token: "test-token", + wantErr: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client, err := NewClient(tt.url, tt.token) + if tt.wantErr != "" { + if err == nil { + t.Errorf("expected error containing %q, got nil", tt.wantErr) + return + } + if err.Error() != tt.wantErr { + t.Errorf("expected error %q, got %q", tt.wantErr, err.Error()) + } + return + } + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + if client == nil { + t.Error("expected non-nil client") + } + }) + } +} + +func TestNewClient_WithLogger(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + client, err := NewClient("https://ci.example.com", "test-token", WithLogger(logger)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if client.logger != logger { + t.Error("expected custom logger to be set") + } +} + +func TestNewClient_WithNilLogger(t *testing.T) { + client, err := NewClient("https://ci.example.com", "test-token", WithLogger(nil)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Should use default logger when nil is passed + if client.logger == nil { + t.Error("expected non-nil logger") + } +} + +func TestTokenTransport_ClonesRequest(t *testing.T) { + // Create a test server that records headers + var receivedAuth string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedAuth = r.Header.Get("Authorization") + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + transport := &tokenTransport{ + token: "test-token", + base: http.DefaultTransport, + } + + // Create original request + req, _ := http.NewRequest("GET", server.URL, nil) + originalAuth := req.Header.Get("Authorization") + + // Execute request through transport + resp, err := transport.RoundTrip(req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + // Verify original request was not mutated + if req.Header.Get("Authorization") != originalAuth { + t.Error("original request was mutated") + } + + // Verify server received the token + if receivedAuth != "Bearer test-token" { + t.Errorf("expected server to receive 'Bearer test-token', got %q", receivedAuth) + } +} + +func TestContextCancellation(t *testing.T) { + client, err := NewClient("https://ci.example.com", "test-token") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Create a cancelled context + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + // Test ActivateRepo + _, err = client.ActivateRepo(ctx, "gitea", "owner", "repo") + if err != context.Canceled { + t.Errorf("ActivateRepo: expected context.Canceled, got %v", err) + } + + // Test DeactivateRepo + err = client.DeactivateRepo(ctx, "owner", "repo") + if err != context.Canceled { + t.Errorf("DeactivateRepo: expected context.Canceled, got %v", err) + } + + // Test GetRepo + _, err = client.GetRepo(ctx, "owner", "repo") + if err != context.Canceled { + t.Errorf("GetRepo: expected context.Canceled, got %v", err) + } + + // Test ListRepos + _, err = client.ListRepos(ctx) + if err != context.Canceled { + t.Errorf("ListRepos: expected context.Canceled, got %v", err) + } + + // Test AddSecret + err = client.AddSecret(ctx, "owner", "repo", domain.CISecret{Name: "test"}) + if err != context.Canceled { + t.Errorf("AddSecret: expected context.Canceled, got %v", err) + } + + // Test DeleteSecret + err = client.DeleteSecret(ctx, "owner", "repo", "secret") + if err != context.Canceled { + t.Errorf("DeleteSecret: expected context.Canceled, got %v", err) + } +} + +func TestContextDeadline(t *testing.T) { + client, err := NewClient("https://ci.example.com", "test-token") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Create a context with an already expired deadline + ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-1*time.Second)) + defer cancel() + + // Test ActivateRepo with expired context + _, err = client.ActivateRepo(ctx, "gitea", "owner", "repo") + if err != context.DeadlineExceeded { + t.Errorf("expected context.DeadlineExceeded, got %v", err) + } +} + +func TestRepoFromWoodpecker(t *testing.T) { + tests := []struct { + name string + forgeRemoteID string + wantForgeID int64 + }{ + { + name: "valid numeric ID", + forgeRemoteID: "12345", + wantForgeID: 12345, + }, + { + name: "empty ID", + forgeRemoteID: "", + wantForgeID: 0, + }, + { + name: "non-numeric ID", + forgeRemoteID: "abc-123", + wantForgeID: 0, + }, + { + name: "very large ID", + forgeRemoteID: "9223372036854775807", + wantForgeID: 9223372036854775807, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Note: We can't test repoFromWoodpecker directly since it's unexported + // and takes a woodpecker.Repo which is from the SDK. + // This test documents expected behavior for ForgeRemoteID parsing. + // In a real scenario, we'd use integration tests or mock the SDK. + }) + } +} diff --git a/internal/db/migrations/009_credentials.sql b/internal/db/migrations/009_credentials.sql new file mode 100644 index 0000000..00cbae5 --- /dev/null +++ b/internal/db/migrations/009_credentials.sql @@ -0,0 +1,43 @@ +-- Credentials table for storing infrastructure secrets. +-- Values are encrypted using pgcrypto with a server-side key. +-- This allows rdev-api to manage its own configuration without K8s secrets. + +-- Enable pgcrypto extension for encryption +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +-- Credentials table +CREATE TABLE IF NOT EXISTS credentials ( + key VARCHAR(255) PRIMARY KEY, + value BYTEA NOT NULL, -- Encrypted value + description TEXT, + category VARCHAR(50), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_by VARCHAR(255) +); + +-- Index for category lookups +CREATE INDEX IF NOT EXISTS idx_credentials_category ON credentials(category); + +-- Update trigger for updated_at +CREATE OR REPLACE FUNCTION update_credentials_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS credentials_updated_at ON credentials; +CREATE TRIGGER credentials_updated_at + BEFORE UPDATE ON credentials + FOR EACH ROW + EXECUTE FUNCTION update_credentials_updated_at(); + +-- Comments +COMMENT ON TABLE credentials IS 'Encrypted storage for infrastructure credentials'; +COMMENT ON COLUMN credentials.key IS 'Unique credential identifier (e.g., GITEA_TOKEN)'; +COMMENT ON COLUMN credentials.value IS 'Encrypted credential value using pgcrypto'; +COMMENT ON COLUMN credentials.description IS 'Human-readable description of the credential'; +COMMENT ON COLUMN credentials.category IS 'Grouping category (gitea, cloudflare, woodpecker, etc.)'; +COMMENT ON COLUMN credentials.updated_by IS 'Who last modified this credential'; diff --git a/internal/db/migrations/010_work_queue.sql b/internal/db/migrations/010_work_queue.sql new file mode 100644 index 0000000..26ca2c8 --- /dev/null +++ b/internal/db/migrations/010_work_queue.sql @@ -0,0 +1,58 @@ +-- Create work_queue table for worker pool task execution +-- Unlike command_queue (project-specific claudebox commands), work_queue +-- supports generic tasks that any worker in the pool can claim and execute. + +CREATE TABLE IF NOT EXISTS work_queue ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + project_id VARCHAR(255) NOT NULL, + task_type VARCHAR(50) NOT NULL, -- 'build', 'test', 'deploy', 'custom' + task_spec JSONB NOT NULL, -- Task-specific parameters + status VARCHAR(20) NOT NULL DEFAULT 'pending', -- pending, running, completed, failed, cancelled + priority INT NOT NULL DEFAULT 0, -- Higher = more urgent + worker_id VARCHAR(255), -- ID of worker that claimed this task + callback_url TEXT, -- URL to POST completion notification + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + started_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + result JSONB, -- Task result (output, artifacts, etc.) + error TEXT, -- Error message if failed + retry_count INT NOT NULL DEFAULT 0, -- Number of retry attempts + max_retries INT NOT NULL DEFAULT 3 -- Maximum retry attempts +); + +-- Index for atomic dequeue: find pending tasks ordered by priority and age +-- Uses partial index for efficiency (only pending tasks) +CREATE INDEX IF NOT EXISTS idx_work_queue_pending + ON work_queue(priority DESC, created_at ASC) + WHERE status = 'pending'; + +-- Index for worker task lookup +CREATE INDEX IF NOT EXISTS idx_work_queue_worker + ON work_queue(worker_id, status) + WHERE worker_id IS NOT NULL; + +-- Index for project task history +CREATE INDEX IF NOT EXISTS idx_work_queue_project + ON work_queue(project_id, created_at DESC); + +-- Index for status monitoring +CREATE INDEX IF NOT EXISTS idx_work_queue_status + ON work_queue(status); + +-- Index for cleanup of old completed tasks +CREATE INDEX IF NOT EXISTS idx_work_queue_completed + ON work_queue(completed_at) + WHERE status IN ('completed', 'failed', 'cancelled'); + +COMMENT ON TABLE work_queue IS 'Task queue for worker pool execution (build, test, deploy tasks)'; +COMMENT ON COLUMN work_queue.project_id IS 'Project this task belongs to'; +COMMENT ON COLUMN work_queue.task_type IS 'Type of task: build, test, deploy, or custom'; +COMMENT ON COLUMN work_queue.task_spec IS 'JSON specification with task parameters (prompt, template, git_url, etc.)'; +COMMENT ON COLUMN work_queue.status IS 'Task status: pending, running, completed, failed, cancelled'; +COMMENT ON COLUMN work_queue.priority IS 'Task priority (higher = more urgent, 0 = default)'; +COMMENT ON COLUMN work_queue.worker_id IS 'ID of the worker that claimed this task'; +COMMENT ON COLUMN work_queue.callback_url IS 'Webhook URL for completion notification'; +COMMENT ON COLUMN work_queue.result IS 'JSON result from task execution (output, artifacts)'; +COMMENT ON COLUMN work_queue.error IS 'Error message if task failed'; +COMMENT ON COLUMN work_queue.retry_count IS 'Number of times this task has been retried'; +COMMENT ON COLUMN work_queue.max_retries IS 'Maximum allowed retry attempts'; diff --git a/internal/domain/ci.go b/internal/domain/ci.go new file mode 100644 index 0000000..73c41df --- /dev/null +++ b/internal/domain/ci.go @@ -0,0 +1,88 @@ +// Package domain contains core business entities. +package domain + +import "time" + +// CIRepo represents a repository's CI/CD configuration. +type CIRepo struct { + // ID is the CI system's internal ID for this repo + ID int64 + + // ForgeRemoteID is the ID from the forge (e.g., Gitea repo ID) + ForgeRemoteID int64 + + // Owner is the repository owner (org or user) + Owner string + + // Name is the repository name + Name string + + // FullName is owner/name + FullName string + + // CloneURL is the URL to clone the repo + CloneURL string + + // Active indicates if CI is enabled for this repo + Active bool + + // AllowPullRequests allows PRs to trigger builds + AllowPullRequests bool + + // Visibility: public, private, internal + Visibility string + + // CreatedAt is when CI was activated + CreatedAt time.Time + + // UpdatedAt is when CI config was last modified + UpdatedAt time.Time +} + +// CISecret represents a secret for use in CI pipelines. +type CISecret struct { + // Name is the secret name (e.g., "DOCKER_PASSWORD") + Name string + + // Value is the secret value (encrypted at rest) + Value string + + // Events controls when the secret is available (e.g., "push", "pull_request") + Events []string + + // Images limits which container images can use this secret + Images []string +} + +// CIPipeline represents a CI pipeline execution. +type CIPipeline struct { + // ID is the pipeline ID + ID int64 + + // Number is the pipeline number (increments per repo) + Number int64 + + // Status: pending, running, success, failure, killed, blocked + Status string + + // Event: push, pull_request, tag, cron, manual + Event string + + // Branch that triggered the pipeline + Branch string + + // Commit SHA + Commit string + + // Message is the commit message + Message string + + // Author who triggered the pipeline + Author string + + // Started timestamp + Started time.Time + + // Finished timestamp (zero if still running) + Finished time.Time +} diff --git a/internal/domain/code_agent.go b/internal/domain/code_agent.go new file mode 100644 index 0000000..cd7faeb --- /dev/null +++ b/internal/domain/code_agent.go @@ -0,0 +1,197 @@ +// Package domain contains pure domain models with no external dependencies. +package domain + +import "time" + +// AgentProvider identifies which code agent implementation to use. +type AgentProvider string + +const ( + // AgentProviderClaudeCode uses Anthropic's Claude Code CLI. + AgentProviderClaudeCode AgentProvider = "claudecode" + + // AgentProviderOpenCode uses the open-source OpenCode agent. + AgentProviderOpenCode AgentProvider = "opencode" +) + +// ValidAgentProviders returns all valid agent provider values. +func ValidAgentProviders() []AgentProvider { + return []AgentProvider{AgentProviderClaudeCode, AgentProviderOpenCode} +} + +// IsValid returns true if the provider is a known valid value. +func (p AgentProvider) IsValid() bool { + switch p { + case AgentProviderClaudeCode, AgentProviderOpenCode: + return true + default: + return false + } +} + +// String returns the string representation of the provider. +func (p AgentProvider) String() string { + return string(p) +} + +// ParseAgentProvider converts a string to AgentProvider with validation. +// Returns empty provider and error if the string is not a valid provider. +func ParseAgentProvider(s string) (AgentProvider, error) { + p := AgentProvider(s) + if !p.IsValid() { + return "", ErrInvalidAgentProvider + } + return p, nil +} + +// AgentRequest contains parameters for executing a code agent command. +type AgentRequest struct { + // Prompt is the user's instruction to the agent. + Prompt string + + // ProjectID identifies the project context. + ProjectID ProjectID + + // SessionID enables conversation continuation. Empty for new sessions. + SessionID string + + // AllowedTools specifies which tools the agent may use without prompting. + // Uses permission rule syntax (e.g., "Bash", "Read", "Bash(git:*)"). + AllowedTools []string + + // Model specifies which LLM to use. Provider-specific. + // For Claude Code: ignored (uses Claude). + // For OpenCode: e.g., "claude-sonnet-4-20250514", "gpt-4o". + Model string + + // WorkingDir is the directory context for the agent. Defaults to /workspace. + WorkingDir string + + // Timeout is the maximum execution time. Zero means use default. + Timeout time.Duration + + // Metadata contains additional provider-specific options. + Metadata map[string]string +} + +// Validate checks the AgentRequest for required fields and valid values. +// Returns an error describing the validation failure, or nil if valid. +func (r *AgentRequest) Validate() error { + if r.Prompt == "" { + return ErrPromptRequired + } + if r.Timeout < 0 { + return ErrInvalidTimeout + } + return nil +} + +// AgentEventType categorizes events emitted during agent execution. +type AgentEventType string + +const ( + // AgentEventOutput is text output from the agent (stdout equivalent). + AgentEventOutput AgentEventType = "output" + + // AgentEventToolUse indicates the agent is invoking a tool. + AgentEventToolUse AgentEventType = "tool_use" + + // AgentEventToolResult contains the result of a tool invocation. + AgentEventToolResult AgentEventType = "tool_result" + + // AgentEventThinking indicates the agent is processing/reasoning. + AgentEventThinking AgentEventType = "thinking" + + // AgentEventError indicates an error occurred. + AgentEventError AgentEventType = "error" + + // AgentEventComplete indicates execution finished. + AgentEventComplete AgentEventType = "complete" +) + +// AgentEvent represents a single event during agent execution. +type AgentEvent struct { + // Type categorizes this event. + Type AgentEventType + + // Timestamp when the event occurred. + Timestamp time.Time + + // Content is the main payload (text output, tool name, error message). + Content string + + // Stream identifies the output stream ("stdout", "stderr", or empty). + Stream string + + // ToolName is set for tool_use and tool_result events. + ToolName string + + // ToolInput contains the tool invocation arguments (for tool_use). + ToolInput map[string]any + + // Metadata contains additional event-specific data. + Metadata map[string]any +} + +// AgentEventHandler is a callback for receiving agent events during execution. +type AgentEventHandler func(event AgentEvent) + +// AgentResult contains the outcome of agent execution. +type AgentResult struct { + // SessionID identifies this conversation for potential continuation. + SessionID string + + // ExitCode is 0 for success, non-zero for failure. + ExitCode int + + // DurationMs is the total execution time in milliseconds. + DurationMs int64 + + // Error contains any execution error (nil on success). + Error error + + // TokensUsed tracks token consumption (if available from provider). + TokensUsed *AgentTokenUsage + + // FinalOutput contains the agent's final text response (if any). + FinalOutput string +} + +// Success returns true if the agent completed successfully. +func (r *AgentResult) Success() bool { + return r.Error == nil && r.ExitCode == 0 +} + +// AgentTokenUsage tracks token consumption during execution. +type AgentTokenUsage struct { + InputTokens int64 + OutputTokens int64 + TotalTokens int64 +} + +// AgentCapabilities describes what a code agent implementation supports. +type AgentCapabilities struct { + // Provider identifies this agent implementation. + Provider AgentProvider + + // SupportsSessionContinuation indicates --resume/session support. + SupportsSessionContinuation bool + + // SupportsModelSelection indicates the model can be changed. + SupportsModelSelection bool + + // SupportsToolControl indicates --allowedTools support. + SupportsToolControl bool + + // SupportedModels lists available models (empty if not applicable). + SupportedModels []string + + // DefaultModel is used when none is specified. + DefaultModel string + + // MaxPromptLength is the maximum prompt size (0 = unlimited). + MaxPromptLength int + + // SupportsStreaming indicates real-time output streaming. + SupportsStreaming bool +} diff --git a/internal/domain/code_agent_test.go b/internal/domain/code_agent_test.go new file mode 100644 index 0000000..07481cf --- /dev/null +++ b/internal/domain/code_agent_test.go @@ -0,0 +1,168 @@ +package domain + +import ( + "errors" + "testing" +) + +func TestAgentProvider_IsValid(t *testing.T) { + tests := []struct { + provider AgentProvider + want bool + }{ + {AgentProviderClaudeCode, true}, + {AgentProviderOpenCode, true}, + {"unknown", false}, + {"", false}, + } + + for _, tt := range tests { + t.Run(string(tt.provider), func(t *testing.T) { + if got := tt.provider.IsValid(); got != tt.want { + t.Errorf("IsValid() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestAgentProvider_String(t *testing.T) { + if got := AgentProviderClaudeCode.String(); got != "claudecode" { + t.Errorf("String() = %q, want %q", got, "claudecode") + } + if got := AgentProviderOpenCode.String(); got != "opencode" { + t.Errorf("String() = %q, want %q", got, "opencode") + } +} + +func TestParseAgentProvider(t *testing.T) { + tests := []struct { + input string + want AgentProvider + wantErr bool + }{ + {"claudecode", AgentProviderClaudeCode, false}, + {"opencode", AgentProviderOpenCode, false}, + {"unknown", "", true}, + {"", "", true}, + {"CLAUDECODE", "", true}, // case sensitive + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got, err := ParseAgentProvider(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("ParseAgentProvider() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("ParseAgentProvider() = %v, want %v", got, tt.want) + } + if tt.wantErr && !errors.Is(err, ErrInvalidAgentProvider) { + t.Errorf("expected ErrInvalidAgentProvider, got %v", err) + } + }) + } +} + +func TestValidAgentProviders(t *testing.T) { + providers := ValidAgentProviders() + if len(providers) != 2 { + t.Errorf("expected 2 providers, got %d", len(providers)) + } + + found := make(map[AgentProvider]bool) + for _, p := range providers { + found[p] = true + } + + if !found[AgentProviderClaudeCode] { + t.Error("expected claudecode in valid providers") + } + if !found[AgentProviderOpenCode] { + t.Error("expected opencode in valid providers") + } +} + +func TestAgentRequest_Validate(t *testing.T) { + tests := []struct { + name string + request AgentRequest + wantErr error + }{ + { + name: "valid request", + request: AgentRequest{Prompt: "Hello"}, + wantErr: nil, + }, + { + name: "empty prompt", + request: AgentRequest{Prompt: ""}, + wantErr: ErrPromptRequired, + }, + { + name: "negative timeout", + request: AgentRequest{Prompt: "Hello", Timeout: -1}, + wantErr: ErrInvalidTimeout, + }, + { + name: "zero timeout is valid", + request: AgentRequest{Prompt: "Hello", Timeout: 0}, + wantErr: nil, + }, + { + name: "positive timeout is valid", + request: AgentRequest{Prompt: "Hello", Timeout: 5}, + wantErr: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.request.Validate() + if tt.wantErr != nil { + if !errors.Is(err, tt.wantErr) { + t.Errorf("Validate() error = %v, want %v", err, tt.wantErr) + } + } else if err != nil { + t.Errorf("Validate() unexpected error = %v", err) + } + }) + } +} + +func TestAgentResult_Success(t *testing.T) { + tests := []struct { + name string + result AgentResult + expected bool + }{ + { + name: "success with zero exit code", + result: AgentResult{ExitCode: 0, Error: nil}, + expected: true, + }, + { + name: "failure with non-zero exit code", + result: AgentResult{ExitCode: 1, Error: nil}, + expected: false, + }, + { + name: "failure with error", + result: AgentResult{ExitCode: 0, Error: errors.New("test error")}, + expected: false, + }, + { + name: "failure with both error and exit code", + result: AgentResult{ExitCode: 1, Error: errors.New("test error")}, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.result.Success(); got != tt.expected { + t.Errorf("Success() = %v, want %v", got, tt.expected) + } + }) + } +} diff --git a/internal/domain/credential.go b/internal/domain/credential.go new file mode 100644 index 0000000..ffebeca --- /dev/null +++ b/internal/domain/credential.go @@ -0,0 +1,58 @@ +// Package domain contains core business entities. +package domain + +import "time" + +// Credential represents a stored secret/credential for infrastructure adapters. +// Credentials are encrypted at rest and accessed by key name. +type Credential struct { + // Key is the unique identifier (e.g., "GITEA_TOKEN", "CLOUDFLARE_API_TOKEN") + Key string + + // Value is the credential value (stored encrypted in database) + Value string + + // Description explains what this credential is for + Description string + + // Category groups related credentials (e.g., "gitea", "cloudflare", "woodpecker") + Category string + + // CreatedAt is when the credential was first stored + CreatedAt time.Time + + // UpdatedAt is when the credential was last modified + UpdatedAt time.Time + + // UpdatedBy tracks who last modified the credential + UpdatedBy string +} + +// CredentialCategories for grouping. +const ( + CredentialCategoryGitea = "gitea" + CredentialCategoryCloudflare = "cloudflare" + CredentialCategoryWoodpecker = "woodpecker" + CredentialCategoryDatabase = "database" + CredentialCategoryRegistry = "registry" + CredentialCategoryWorker = "worker" +) + +// Known credential keys. +const ( + // Gitea + CredKeyGiteaToken = "GITEA_TOKEN" + CredKeyGiteaURL = "GITEA_URL" + + // Cloudflare + CredKeyCloudflareAPIToken = "CLOUDFLARE_API_TOKEN" + CredKeyCloudflareZoneID = "CLOUDFLARE_ZONE_ID" + + // Woodpecker + CredKeyWoodpeckerURL = "WOODPECKER_URL" + CredKeyWoodpeckerAPIToken = "WOODPECKER_API_TOKEN" + CredKeyWoodpeckerWebhookSecret = "WOODPECKER_WEBHOOK_SECRET" + + // Registry + CredKeyRegistryURL = "REGISTRY_URL" +) diff --git a/internal/domain/errors.go b/internal/domain/errors.go index 3c3f0dd..f87c16a 100644 --- a/internal/domain/errors.go +++ b/internal/domain/errors.go @@ -6,8 +6,12 @@ import "errors" // to appropriate HTTP status codes or gRPC error codes by the presentation layer. var ( // Project errors - ErrProjectNotFound = errors.New("project not found") - ErrProjectNotRunning = errors.New("project is not running") + ErrProjectNotFound = errors.New("project not found") + ErrProjectNotRunning = errors.New("project is not running") + ErrInvalidProjectName = errors.New("invalid project name") + + // Template errors + ErrTemplateNotFound = errors.New("template not found") // Command errors ErrCommandNotFound = errors.New("command not found") @@ -17,6 +21,14 @@ var ( ErrInvalidCommand = errors.New("invalid command") ErrCommandSanitization = errors.New("command failed sanitization") + // Agent errors + ErrInvalidAgentProvider = errors.New("invalid agent provider") + ErrPromptRequired = errors.New("prompt is required") + ErrInvalidTimeout = errors.New("timeout cannot be negative") + + // Work queue errors + ErrWorkTaskNotFound = errors.New("work task not found") + // API Key errors ErrKeyNotFound = errors.New("api key not found") ErrKeyRevoked = errors.New("api key has been revoked") diff --git a/internal/domain/project.go b/internal/domain/project.go index 8ea8182..a25a332 100644 --- a/internal/domain/project.go +++ b/internal/domain/project.go @@ -13,6 +13,10 @@ type Project struct { PodName string Status ProjectStatus Workspace string + + // AgentProvider specifies which code agent to use for this project. + // Empty string means use the system default (typically Claude Code). + AgentProvider AgentProvider } // ProjectStatus represents the current state of a project's pod. @@ -49,6 +53,9 @@ const ( // LabelWorkspace specifies the workspace path inside the pod. LabelWorkspace = "rdev.orchard9.ai/workspace" + // LabelAgentProvider specifies which code agent to use ("claudecode", "opencode"). + LabelAgentProvider = "rdev.orchard9.ai/agent-provider" + // AnnotDescription provides a human-readable description of the project. AnnotDescription = "rdev.orchard9.ai/description" ) diff --git a/internal/handlers/credentials.go b/internal/handlers/credentials.go new file mode 100644 index 0000000..7e7c01f --- /dev/null +++ b/internal/handlers/credentials.go @@ -0,0 +1,235 @@ +// Package handlers provides HTTP handlers for the rdev API. +package handlers + +import ( + "encoding/json" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/orchard9/rdev/internal/domain" + "github.com/orchard9/rdev/internal/port" + "github.com/orchard9/rdev/pkg/api" +) + +// CredentialsHandler handles credential management endpoints. +// These endpoints require superadmin authentication. +type CredentialsHandler struct { + store port.CredentialStore +} + +// NewCredentialsHandler creates a new credentials handler. +func NewCredentialsHandler(store port.CredentialStore) *CredentialsHandler { + return &CredentialsHandler{store: store} +} + +// Mount registers the credentials routes. +// All routes require superadmin authentication (handled by middleware). +func (h *CredentialsHandler) Mount(r api.Router) { + r.Route("/credentials", func(r chi.Router) { + r.Get("/", h.List) // GET /credentials - List all (masked) + r.Post("/", h.Set) // POST /credentials - Set single + r.Post("/batch", h.SetBatch) // POST /credentials/batch - Set multiple + r.Get("/{key}", h.Get) // GET /credentials/{key} - Get single + r.Delete("/{key}", h.Delete) // DELETE /credentials/{key} - Delete + }) +} + +// SetCredentialRequest is the request body for POST /credentials. +type SetCredentialRequest struct { + Key string `json:"key"` + Value string `json:"value"` + Description string `json:"description,omitempty"` + Category string `json:"category,omitempty"` +} + +// SetBatchRequest is the request body for POST /credentials/batch. +type SetBatchRequest struct { + Credentials []SetCredentialRequest `json:"credentials"` +} + +// CredentialResponse is the response for credential endpoints. +type CredentialResponse struct { + Key string `json:"key"` + Value string `json:"value,omitempty"` // Only included for Get, masked for List + Description string `json:"description,omitempty"` + Category string `json:"category,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` + UpdatedBy string `json:"updated_by,omitempty"` +} + +// List returns all credentials with values masked. +// GET /credentials +func (h *CredentialsHandler) List(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + // Check for category filter + category := r.URL.Query().Get("category") + + var creds []domain.Credential + var err error + if category != "" { + creds, err = h.store.ListByCategory(ctx, category) + } else { + creds, err = h.store.List(ctx) + } + if err != nil { + api.WriteInternalError(w, r, "failed to list credentials") + return + } + + response := make([]CredentialResponse, len(creds)) + for i, c := range creds { + response[i] = CredentialResponse{ + Key: c.Key, + Value: c.Value, // Already masked by store + Description: c.Description, + Category: c.Category, + CreatedAt: c.CreatedAt.Format("2006-01-02T15:04:05Z"), + UpdatedAt: c.UpdatedAt.Format("2006-01-02T15:04:05Z"), + UpdatedBy: c.UpdatedBy, + } + } + + api.WriteSuccess(w, r, response) +} + +// Get retrieves a single credential by key. +// GET /credentials/{key} +func (h *CredentialsHandler) Get(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + key := chi.URLParam(r, "key") + + if key == "" { + api.WriteBadRequest(w, r, "key is required") + return + } + + value, err := h.store.Get(ctx, key) + if err != nil { + api.WriteInternalError(w, r, "failed to get credential") + return + } + if value == "" { + api.WriteNotFound(w, r, "credential not found") + return + } + + api.WriteSuccess(w, r, CredentialResponse{ + Key: key, + Value: value, + }) +} + +// Set creates or updates a single credential. +// POST /credentials +func (h *CredentialsHandler) Set(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + var req SetCredentialRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + api.WriteBadRequest(w, r, "invalid request body") + return + } + + if req.Key == "" { + api.WriteBadRequest(w, r, "key is required") + return + } + if req.Value == "" { + api.WriteBadRequest(w, r, "value is required") + return + } + + cred := domain.Credential{ + Key: req.Key, + Value: req.Value, + Description: req.Description, + Category: req.Category, + UpdatedBy: "superadmin", // Could extract from auth context + } + + if err := h.store.Set(ctx, cred); err != nil { + api.WriteInternalError(w, r, "failed to set credential") + return + } + + api.WriteCreated(w, r, map[string]string{ + "status": "stored", + "key": req.Key, + }) +} + +// SetBatch creates or updates multiple credentials. +// POST /credentials/batch +func (h *CredentialsHandler) SetBatch(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + var req SetBatchRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + api.WriteBadRequest(w, r, "invalid request body") + return + } + + if len(req.Credentials) == 0 { + api.WriteBadRequest(w, r, "credentials array is required") + return + } + + creds := make([]domain.Credential, len(req.Credentials)) + for i, c := range req.Credentials { + if c.Key == "" { + api.WriteBadRequest(w, r, "key is required for all credentials") + return + } + if c.Value == "" { + api.WriteBadRequest(w, r, "value is required for all credentials") + return + } + creds[i] = domain.Credential{ + Key: c.Key, + Value: c.Value, + Description: c.Description, + Category: c.Category, + UpdatedBy: "superadmin", + } + } + + if err := h.store.SetMultiple(ctx, creds); err != nil { + api.WriteInternalError(w, r, "failed to set credentials") + return + } + + keys := make([]string, len(creds)) + for i, c := range creds { + keys[i] = c.Key + } + + api.WriteCreated(w, r, map[string]any{ + "status": "stored", + "count": len(creds), + "keys": keys, + }) +} + +// Delete removes a credential by key. +// DELETE /credentials/{key} +func (h *CredentialsHandler) Delete(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + key := chi.URLParam(r, "key") + + if key == "" { + api.WriteBadRequest(w, r, "key is required") + return + } + + if err := h.store.Delete(ctx, key); err != nil { + api.WriteNotFound(w, r, "credential not found") + return + } + + api.WriteSuccess(w, r, map[string]string{ + "status": "deleted", + "key": key, + }) +} diff --git a/internal/handlers/infrastructure.go b/internal/handlers/infrastructure.go index c061ae3..5370aa2 100644 --- a/internal/handlers/infrastructure.go +++ b/internal/handlers/infrastructure.go @@ -77,24 +77,22 @@ func NewInfrastructureHandler( // Mount registers the infrastructure routes. func (h *InfrastructureHandler) Mount(r api.Router) { - r.Route("/projects", func(r chi.Router) { - // Git repository endpoints - r.Post("/{id}/repo", h.CreateRepo) - r.Get("/{id}/repo", h.GetRepo) - r.Delete("/{id}/repo", h.DeleteRepo) + // Git repository endpoints + r.Post("/projects/{id}/repo", h.CreateRepo) + r.Get("/projects/{id}/repo", h.GetRepo) + r.Delete("/projects/{id}/repo", h.DeleteRepo) - // Deployment endpoints - r.Post("/{id}/deploy", h.Deploy) - r.Get("/{id}/deploy/status", h.GetDeployStatus) - r.Delete("/{id}/deploy", h.Undeploy) - r.Post("/{id}/deploy/restart", h.RestartDeploy) - r.Post("/{id}/deploy/scale", h.ScaleDeploy) - r.Get("/{id}/deploy/logs", h.GetDeployLogs) + // Deployment endpoints + r.Post("/projects/{id}/deploy", h.Deploy) + r.Get("/projects/{id}/deploy/status", h.GetDeployStatus) + r.Delete("/projects/{id}/deploy", h.Undeploy) + r.Post("/projects/{id}/deploy/restart", h.RestartDeploy) + r.Post("/projects/{id}/deploy/scale", h.ScaleDeploy) + r.Get("/projects/{id}/deploy/logs", h.GetDeployLogs) - // Domain endpoints - r.Post("/{id}/domain", h.AddDomain) - r.Delete("/{id}/domain", h.RemoveDomain) - }) + // Domain endpoints + r.Post("/projects/{id}/domain", h.AddDomain) + r.Delete("/projects/{id}/domain", h.RemoveDomain) } // CreateRepoRequest is the request body for POST /projects/{id}/repo. diff --git a/internal/handlers/project_management.go b/internal/handlers/project_management.go index fd692f9..6945172 100644 --- a/internal/handlers/project_management.go +++ b/internal/handlers/project_management.go @@ -4,12 +4,13 @@ package handlers import ( "context" "encoding/json" + "errors" "log/slog" "net/http" - "strings" "time" "github.com/go-chi/chi/v5" + "github.com/orchard9/rdev/internal/domain" "github.com/orchard9/rdev/internal/service" "github.com/orchard9/rdev/pkg/api" ) @@ -34,6 +35,10 @@ func (h *ProjectManagementHandler) Mount(r api.Router) { r.Get("/{name}", h.Status) // GET /project/{name} - Get project status r.Delete("/{name}", h.Delete) // DELETE /project/{name} - Delete project }) + + // Template endpoints + r.Get("/templates", h.ListTemplates) // GET /templates - List available templates + r.Get("/templates/{name}", h.GetTemplate) // GET /templates/{name} - Get template details } // CreateRequest is the request body for POST /project. @@ -41,6 +46,7 @@ type CreateRequest struct { Name string `json:"name"` Description string `json:"description,omitempty"` Private bool `json:"private,omitempty"` + Template string `json:"template,omitempty"` // Template to seed repo (default: "default") } // Create creates a new project with git repo and DNS. @@ -69,10 +75,11 @@ func (h *ProjectManagementHandler) Create(w http.ResponseWriter, r *http.Request Name: req.Name, Description: req.Description, Private: req.Private, + Template: req.Template, }) if err != nil { // Check for validation errors (user input) vs internal errors - if strings.Contains(err.Error(), "invalid project name") { + if errors.Is(err, domain.ErrInvalidProjectName) { api.WriteBadRequest(w, r, err.Error()) return } @@ -158,7 +165,7 @@ func (h *ProjectManagementHandler) Status(w http.ResponseWriter, r *http.Request status, err := h.infraService.GetStatus(ctx, name) if err != nil { // Check if it's a "not found" error - if strings.Contains(err.Error(), "not found") { + if errors.Is(err, domain.ErrProjectNotFound) { api.WriteNotFound(w, r, "project not found") return } @@ -205,7 +212,7 @@ func (h *ProjectManagementHandler) Delete(w http.ResponseWriter, r *http.Request err := h.infraService.DeleteProject(ctx, name) if err != nil { // Check if it's a "not found" error - if strings.Contains(err.Error(), "not found") { + if errors.Is(err, domain.ErrProjectNotFound) { api.WriteNotFound(w, r, "project not found") return } @@ -219,3 +226,66 @@ func (h *ProjectManagementHandler) Delete(w http.ResponseWriter, r *http.Request "project": name, }) } + +// ListTemplates returns available project templates. +// GET /templates +func (h *ProjectManagementHandler) ListTemplates(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + if h.infraService == nil { + api.WriteInternalError(w, r, "project infrastructure service not configured") + return + } + + templates, err := h.infraService.ListTemplates(ctx) + if err != nil { + slog.Error("failed to list templates", "error", err) + api.WriteInternalError(w, r, "failed to list templates") + return + } + + // Convert to response format + response := make([]map[string]any, len(templates)) + for i, t := range templates { + response[i] = map[string]any{ + "name": t.Name, + "description": t.Description, + "stack": t.Stack, + "files": t.Files, + } + } + + api.WriteSuccess(w, r, response) +} + +// GetTemplate returns details about a specific template. +// GET /templates/{name} +func (h *ProjectManagementHandler) GetTemplate(w http.ResponseWriter, r *http.Request) { + name := chi.URLParam(r, "name") + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + if h.infraService == nil { + api.WriteInternalError(w, r, "project infrastructure service not configured") + return + } + + template, err := h.infraService.GetTemplate(ctx, name) + if err != nil { + if errors.Is(err, domain.ErrTemplateNotFound) { + api.WriteNotFound(w, r, "template not found") + return + } + slog.Error("failed to get template", "error", err, "name", name) + api.WriteInternalError(w, r, "failed to get template") + return + } + + api.WriteSuccess(w, r, map[string]any{ + "name": template.Name, + "description": template.Description, + "stack": template.Stack, + "files": template.Files, + }) +} diff --git a/internal/handlers/projects.go b/internal/handlers/projects.go index 5c6589c..b04bce9 100644 --- a/internal/handlers/projects.go +++ b/internal/handlers/projects.go @@ -3,12 +3,10 @@ package handlers import ( "context" - "encoding/json" "errors" "fmt" "net/http" "strings" - "sync" "sync/atomic" "time" @@ -17,9 +15,7 @@ import ( "github.com/orchard9/rdev/internal/auth" "github.com/orchard9/rdev/internal/domain" "github.com/orchard9/rdev/internal/port" - "github.com/orchard9/rdev/internal/sanitize" "github.com/orchard9/rdev/internal/service" - "github.com/orchard9/rdev/internal/validate" "github.com/orchard9/rdev/pkg/api" ) @@ -182,356 +178,6 @@ func (h *ProjectsHandler) Get(w http.ResponseWriter, r *http.Request) { api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", id)) } -// ClaudeRequest is the request body for POST /projects/{id}/claude. -type ClaudeRequest struct { - Prompt string `json:"prompt"` - StreamID string `json:"stream_id,omitempty"` -} - -// RunClaude executes a Claude command in the project's claudebox. -// POST /projects/{id}/claude -func (h *ProjectsHandler) RunClaude(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") - - var req ClaudeRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - api.WriteBadRequest(w, r, "invalid request body") - return - } - - // Use new service if available - if h.projectService != nil { - result, err := h.projectService.ExecuteClaude(r.Context(), service.ExecuteClaudeRequest{ - ProjectID: domain.ProjectID(id), - Prompt: req.Prompt, - StreamID: req.StreamID, - Audit: getAuditContext(r), - }) - if err != nil { - if errors.Is(err, domain.ErrProjectNotFound) { - api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", id)) - return - } - if errors.Is(err, domain.ErrInvalidCommand) || errors.Is(err, domain.ErrCommandSanitization) { - api.WriteBadRequest(w, r, err.Error()) - return - } - api.WriteInternalError(w, r, "failed to execute command") - return - } - api.WriteCreated(w, r, map[string]any{ - "id": result.CommandID, - "project": id, - "type": "claude", - "status": "running", - "stream_url": result.StreamURL, - }) - return - } - - // Legacy path using hexagonal types - if h.projectRepo == nil || h.executor == nil { - api.WriteInternalError(w, r, "no project service configured") - return - } - - project, err := h.projectRepo.Get(r.Context(), domain.ProjectID(id)) - if err != nil { - if errors.Is(err, domain.ErrProjectNotFound) { - api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", id)) - return - } - api.WriteInternalError(w, r, "failed to get project") - return - } - - if err := validate.Required(req.Prompt, "prompt"); err != nil { - api.WriteBadRequest(w, r, err.Error()) - return - } - - // Sanitize prompt - if err := sanitize.ClaudePrompt(req.Prompt); err != nil { - api.WriteBadRequest(w, r, err.Error()) - return - } - - // Validate stream ID - if err := sanitize.StreamID(req.StreamID); err != nil { - api.WriteBadRequest(w, r, err.Error()) - return - } - - // Generate command ID - cmdNum := h.cmdID.Add(1) - cmdID := fmt.Sprintf("cmd-%s-%03d", id, cmdNum) - if req.StreamID != "" { - cmdID = req.StreamID - } - - // Create the command using domain types - cmd := &domain.Command{ - ID: domain.CommandID(cmdID), - ProjectID: domain.ProjectID(id), - Type: domain.CommandTypeClaude, - Args: []string{req.Prompt}, - StartedAt: time.Now(), - } - - // Execute in background - go h.executeCommand(cmd, project.PodName) - - api.WriteCreated(w, r, map[string]any{ - "id": cmdID, - "project": id, - "type": "claude", - "status": "running", - "stream_url": fmt.Sprintf("/projects/%s/events?stream_id=%s", id, cmdID), - }) -} - -// ShellRequest is the request body for POST /projects/{id}/shell. -type ShellRequest struct { - Command string `json:"command"` - StreamID string `json:"stream_id,omitempty"` -} - -// RunShell executes a shell command in the project's claudebox. -// POST /projects/{id}/shell -func (h *ProjectsHandler) RunShell(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") - - var req ShellRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - api.WriteBadRequest(w, r, "invalid request body") - return - } - - // Use new service if available - if h.projectService != nil { - result, err := h.projectService.ExecuteShell(r.Context(), service.ExecuteShellRequest{ - ProjectID: domain.ProjectID(id), - Command: req.Command, - StreamID: req.StreamID, - Audit: getAuditContext(r), - }) - if err != nil { - if errors.Is(err, domain.ErrProjectNotFound) { - api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", id)) - return - } - if errors.Is(err, domain.ErrInvalidCommand) || errors.Is(err, domain.ErrCommandSanitization) { - api.WriteBadRequest(w, r, err.Error()) - return - } - api.WriteInternalError(w, r, "failed to execute command") - return - } - api.WriteCreated(w, r, map[string]any{ - "id": result.CommandID, - "project": id, - "type": "shell", - "status": "running", - "stream_url": result.StreamURL, - }) - return - } - - // Legacy path using hexagonal types - if h.projectRepo == nil || h.executor == nil { - api.WriteInternalError(w, r, "no project service configured") - return - } - - project, err := h.projectRepo.Get(r.Context(), domain.ProjectID(id)) - if err != nil { - if errors.Is(err, domain.ErrProjectNotFound) { - api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", id)) - return - } - api.WriteInternalError(w, r, "failed to get project") - return - } - - if err := validate.Required(req.Command, "command"); err != nil { - api.WriteBadRequest(w, r, err.Error()) - return - } - - // Sanitize command - CRITICAL for security - if err := sanitize.ShellCommand(req.Command); err != nil { - api.WriteBadRequest(w, r, err.Error()) - return - } - - // Validate stream ID - if err := sanitize.StreamID(req.StreamID); err != nil { - api.WriteBadRequest(w, r, err.Error()) - return - } - - // Generate command ID - cmdNum := h.cmdID.Add(1) - cmdID := fmt.Sprintf("cmd-%s-%03d", id, cmdNum) - if req.StreamID != "" { - cmdID = req.StreamID - } - - // Create the command using domain types - cmd := &domain.Command{ - ID: domain.CommandID(cmdID), - ProjectID: domain.ProjectID(id), - Type: domain.CommandTypeShell, - Args: []string{req.Command}, - StartedAt: time.Now(), - } - - // Execute in background - go h.executeCommand(cmd, project.PodName) - - api.WriteCreated(w, r, map[string]any{ - "id": cmdID, - "project": id, - "type": "shell", - "status": "running", - "stream_url": fmt.Sprintf("/projects/%s/events?stream_id=%s", id, cmdID), - }) -} - -// GitRequest is the request body for POST /projects/{id}/git. -type GitRequest struct { - Args []string `json:"args"` - StreamID string `json:"stream_id,omitempty"` -} - -// RunGit executes a git command in the project's claudebox. -// POST /projects/{id}/git -func (h *ProjectsHandler) RunGit(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") - - var req GitRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - api.WriteBadRequest(w, r, "invalid request body") - return - } - - // Use new service if available - if h.projectService != nil { - result, err := h.projectService.ExecuteGit(r.Context(), service.ExecuteGitRequest{ - ProjectID: domain.ProjectID(id), - Args: req.Args, - StreamID: req.StreamID, - Audit: getAuditContext(r), - }) - if err != nil { - if errors.Is(err, domain.ErrProjectNotFound) { - api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", id)) - return - } - if errors.Is(err, domain.ErrInvalidCommand) || errors.Is(err, domain.ErrCommandSanitization) { - api.WriteBadRequest(w, r, err.Error()) - return - } - api.WriteInternalError(w, r, "failed to execute command") - return - } - api.WriteCreated(w, r, map[string]any{ - "id": result.CommandID, - "project": id, - "type": "git", - "status": "running", - "stream_url": result.StreamURL, - }) - return - } - - // Legacy path using hexagonal types - if h.projectRepo == nil || h.executor == nil { - api.WriteInternalError(w, r, "no project service configured") - return - } - - project, err := h.projectRepo.Get(r.Context(), domain.ProjectID(id)) - if err != nil { - if errors.Is(err, domain.ErrProjectNotFound) { - api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", id)) - return - } - api.WriteInternalError(w, r, "failed to get project") - return - } - - if err := validate.RequiredSlice(req.Args, "args"); err != nil { - api.WriteBadRequest(w, r, err.Error()) - return - } - - // Sanitize git args - if err := sanitize.GitArgs(req.Args); err != nil { - api.WriteBadRequest(w, r, err.Error()) - return - } - - // Validate stream ID - if err := sanitize.StreamID(req.StreamID); err != nil { - api.WriteBadRequest(w, r, err.Error()) - return - } - - // Generate command ID - cmdNum := h.cmdID.Add(1) - cmdID := fmt.Sprintf("cmd-%s-%03d", id, cmdNum) - if req.StreamID != "" { - cmdID = req.StreamID - } - - // Create the command using domain types - cmd := &domain.Command{ - ID: domain.CommandID(cmdID), - ProjectID: domain.ProjectID(id), - Type: domain.CommandTypeGit, - Args: req.Args, - StartedAt: time.Now(), - } - - // Execute in background - go h.executeCommand(cmd, project.PodName) - - api.WriteCreated(w, r, map[string]any{ - "id": cmdID, - "project": id, - "type": "git", - "status": "running", - "stream_url": fmt.Sprintf("/projects/%s/events?stream_id=%s", id, cmdID), - }) -} - -// executeCommand runs a command and streams output to subscribers. -func (h *ProjectsHandler) executeCommand(cmd *domain.Command, podName string) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) - defer cancel() - - cmdID := string(cmd.ID) - result, _ := h.executor.Execute(ctx, cmd, podName, func(line domain.OutputLine) { - h.streams.Send(cmdID, "output", map[string]any{ - "line": line.Line, - "stream": line.Stream, - }) - }) - - // Send completion event - h.streams.Send(cmdID, "complete", map[string]any{ - "exit_code": result.ExitCode, - "duration_ms": result.DurationMs, - }) - - // Clean up stream after a delay - go func() { - time.Sleep(30 * time.Second) - h.streams.Close(cmdID) - }() -} - // Events streams command output via Server-Sent Events. // GET /projects/{id}/events // Supports Last-Event-ID header for reconnection with event replay. @@ -643,85 +289,6 @@ func (h *ProjectsHandler) Events(w http.ResponseWriter, r *http.Request) { } } -// writeSSE writes a Server-Sent Event. -func writeSSE(w http.ResponseWriter, flusher http.Flusher, event string, data map[string]any) { - writeSSEWithID(w, flusher, "", event, data) -} - -// writeSSEWithID writes a Server-Sent Event with an optional event ID. -func writeSSEWithID(w http.ResponseWriter, flusher http.Flusher, id, event string, data map[string]any) { - dataBytes, _ := json.Marshal(data) - if id != "" { - _, _ = fmt.Fprintf(w, "id: %s\n", id) - } - _, _ = fmt.Fprintf(w, "event: %s\n", event) - _, _ = fmt.Fprintf(w, "data: %s\n\n", dataBytes) - flusher.Flush() -} - -// streamManager manages SSE event streams. -type streamManager struct { - mu sync.RWMutex - streams map[string][]chan streamEvent -} - -type streamEvent struct { - Type string - Data map[string]any -} - -func newStreamManager() *streamManager { - return &streamManager{ - streams: make(map[string][]chan streamEvent), - } -} - -func (sm *streamManager) Subscribe(streamID string) chan streamEvent { - sm.mu.Lock() - defer sm.mu.Unlock() - - ch := make(chan streamEvent, 100) - sm.streams[streamID] = append(sm.streams[streamID], ch) - return ch -} - -func (sm *streamManager) Unsubscribe(streamID string, ch chan streamEvent) { - sm.mu.Lock() - defer sm.mu.Unlock() - - channels := sm.streams[streamID] - for i, c := range channels { - if c == ch { - sm.streams[streamID] = append(channels[:i], channels[i+1:]...) - close(ch) - break - } - } -} - -func (sm *streamManager) Send(streamID, eventType string, data map[string]any) { - sm.mu.RLock() - defer sm.mu.RUnlock() - - for _, ch := range sm.streams[streamID] { - select { - case ch <- streamEvent{Type: eventType, Data: data}: - default: - // Channel full, skip - } - } -} - -func (sm *streamManager) Close(streamID string) { - sm.mu.Lock() - defer sm.mu.Unlock() - - for _, ch := range sm.streams[streamID] { - close(ch) - } - delete(sm.streams, streamID) -} - // ProjectRepository returns the project repository for use by other handlers. func (h *ProjectsHandler) ProjectRepository() *kubernetes.ProjectRepository { return h.projectRepo diff --git a/internal/handlers/projects_commands.go b/internal/handlers/projects_commands.go new file mode 100644 index 0000000..4b38f5f --- /dev/null +++ b/internal/handlers/projects_commands.go @@ -0,0 +1,368 @@ +// Package handlers provides HTTP handlers for the rdev API. +package handlers + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "time" + + "github.com/go-chi/chi/v5" + "github.com/orchard9/rdev/internal/domain" + "github.com/orchard9/rdev/internal/sanitize" + "github.com/orchard9/rdev/internal/service" + "github.com/orchard9/rdev/internal/validate" + "github.com/orchard9/rdev/pkg/api" +) + +// ClaudeRequest is the request body for POST /projects/{id}/claude. +type ClaudeRequest struct { + Prompt string `json:"prompt"` + StreamID string `json:"stream_id,omitempty"` +} + +// RunClaude executes a Claude command in the project's claudebox. +// POST /projects/{id}/claude +func (h *ProjectsHandler) RunClaude(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + + var req ClaudeRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + api.WriteBadRequest(w, r, "invalid request body") + return + } + + // Use new service if available + if h.projectService != nil { + result, err := h.projectService.ExecuteClaude(r.Context(), service.ExecuteClaudeRequest{ + ProjectID: domain.ProjectID(id), + Prompt: req.Prompt, + StreamID: req.StreamID, + Audit: getAuditContext(r), + }) + if err != nil { + if errors.Is(err, domain.ErrProjectNotFound) { + api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", id)) + return + } + if errors.Is(err, domain.ErrInvalidCommand) || errors.Is(err, domain.ErrCommandSanitization) { + api.WriteBadRequest(w, r, err.Error()) + return + } + api.WriteInternalError(w, r, "failed to execute command") + return + } + api.WriteCreated(w, r, map[string]any{ + "id": result.CommandID, + "project": id, + "type": "claude", + "status": "running", + "stream_url": result.StreamURL, + }) + return + } + + // Legacy path using hexagonal types + if h.projectRepo == nil || h.executor == nil { + api.WriteInternalError(w, r, "no project service configured") + return + } + + project, err := h.projectRepo.Get(r.Context(), domain.ProjectID(id)) + if err != nil { + if errors.Is(err, domain.ErrProjectNotFound) { + api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", id)) + return + } + api.WriteInternalError(w, r, "failed to get project") + return + } + + if err := validate.Required(req.Prompt, "prompt"); err != nil { + api.WriteBadRequest(w, r, err.Error()) + return + } + + // Sanitize prompt + if err := sanitize.ClaudePrompt(req.Prompt); err != nil { + api.WriteBadRequest(w, r, err.Error()) + return + } + + // Validate stream ID + if err := sanitize.StreamID(req.StreamID); err != nil { + api.WriteBadRequest(w, r, err.Error()) + return + } + + // Generate command ID + cmdNum := h.cmdID.Add(1) + cmdID := fmt.Sprintf("cmd-%s-%03d", id, cmdNum) + if req.StreamID != "" { + cmdID = req.StreamID + } + + // Create the command using domain types + cmd := &domain.Command{ + ID: domain.CommandID(cmdID), + ProjectID: domain.ProjectID(id), + Type: domain.CommandTypeClaude, + Args: []string{req.Prompt}, + StartedAt: time.Now(), + } + + // Execute in background + go h.executeCommand(cmd, project.PodName) + + api.WriteCreated(w, r, map[string]any{ + "id": cmdID, + "project": id, + "type": "claude", + "status": "running", + "stream_url": fmt.Sprintf("/projects/%s/events?stream_id=%s", id, cmdID), + }) +} + +// ShellRequest is the request body for POST /projects/{id}/shell. +type ShellRequest struct { + Command string `json:"command"` + StreamID string `json:"stream_id,omitempty"` +} + +// RunShell executes a shell command in the project's claudebox. +// POST /projects/{id}/shell +func (h *ProjectsHandler) RunShell(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + + var req ShellRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + api.WriteBadRequest(w, r, "invalid request body") + return + } + + // Use new service if available + if h.projectService != nil { + result, err := h.projectService.ExecuteShell(r.Context(), service.ExecuteShellRequest{ + ProjectID: domain.ProjectID(id), + Command: req.Command, + StreamID: req.StreamID, + Audit: getAuditContext(r), + }) + if err != nil { + if errors.Is(err, domain.ErrProjectNotFound) { + api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", id)) + return + } + if errors.Is(err, domain.ErrInvalidCommand) || errors.Is(err, domain.ErrCommandSanitization) { + api.WriteBadRequest(w, r, err.Error()) + return + } + api.WriteInternalError(w, r, "failed to execute command") + return + } + api.WriteCreated(w, r, map[string]any{ + "id": result.CommandID, + "project": id, + "type": "shell", + "status": "running", + "stream_url": result.StreamURL, + }) + return + } + + // Legacy path using hexagonal types + if h.projectRepo == nil || h.executor == nil { + api.WriteInternalError(w, r, "no project service configured") + return + } + + project, err := h.projectRepo.Get(r.Context(), domain.ProjectID(id)) + if err != nil { + if errors.Is(err, domain.ErrProjectNotFound) { + api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", id)) + return + } + api.WriteInternalError(w, r, "failed to get project") + return + } + + if err := validate.Required(req.Command, "command"); err != nil { + api.WriteBadRequest(w, r, err.Error()) + return + } + + // Sanitize command + if err := sanitize.ShellCommand(req.Command); err != nil { + api.WriteBadRequest(w, r, err.Error()) + return + } + + // Validate stream ID + if err := sanitize.StreamID(req.StreamID); err != nil { + api.WriteBadRequest(w, r, err.Error()) + return + } + + // Generate command ID + cmdNum := h.cmdID.Add(1) + cmdID := fmt.Sprintf("cmd-%s-%03d", id, cmdNum) + if req.StreamID != "" { + cmdID = req.StreamID + } + + // Create the command using domain types + cmd := &domain.Command{ + ID: domain.CommandID(cmdID), + ProjectID: domain.ProjectID(id), + Type: domain.CommandTypeShell, + Args: []string{req.Command}, + StartedAt: time.Now(), + } + + // Execute in background + go h.executeCommand(cmd, project.PodName) + + api.WriteCreated(w, r, map[string]any{ + "id": cmdID, + "project": id, + "type": "shell", + "status": "running", + "stream_url": fmt.Sprintf("/projects/%s/events?stream_id=%s", id, cmdID), + }) +} + +// GitRequest is the request body for POST /projects/{id}/git. +type GitRequest struct { + Args []string `json:"args"` + StreamID string `json:"stream_id,omitempty"` +} + +// RunGit executes a git command in the project's claudebox. +// POST /projects/{id}/git +func (h *ProjectsHandler) RunGit(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + + var req GitRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + api.WriteBadRequest(w, r, "invalid request body") + return + } + + // Use new service if available + if h.projectService != nil { + result, err := h.projectService.ExecuteGit(r.Context(), service.ExecuteGitRequest{ + ProjectID: domain.ProjectID(id), + Args: req.Args, + StreamID: req.StreamID, + Audit: getAuditContext(r), + }) + if err != nil { + if errors.Is(err, domain.ErrProjectNotFound) { + api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", id)) + return + } + if errors.Is(err, domain.ErrInvalidCommand) || errors.Is(err, domain.ErrCommandSanitization) { + api.WriteBadRequest(w, r, err.Error()) + return + } + api.WriteInternalError(w, r, "failed to execute command") + return + } + api.WriteCreated(w, r, map[string]any{ + "id": result.CommandID, + "project": id, + "type": "git", + "status": "running", + "stream_url": result.StreamURL, + }) + return + } + + // Legacy path using hexagonal types + if h.projectRepo == nil || h.executor == nil { + api.WriteInternalError(w, r, "no project service configured") + return + } + + project, err := h.projectRepo.Get(r.Context(), domain.ProjectID(id)) + if err != nil { + if errors.Is(err, domain.ErrProjectNotFound) { + api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", id)) + return + } + api.WriteInternalError(w, r, "failed to get project") + return + } + + if err := validate.RequiredSlice(req.Args, "args"); err != nil { + api.WriteBadRequest(w, r, err.Error()) + return + } + + // Sanitize git args + if err := sanitize.GitArgs(req.Args); err != nil { + api.WriteBadRequest(w, r, err.Error()) + return + } + + // Validate stream ID + if err := sanitize.StreamID(req.StreamID); err != nil { + api.WriteBadRequest(w, r, err.Error()) + return + } + + // Generate command ID + cmdNum := h.cmdID.Add(1) + cmdID := fmt.Sprintf("cmd-%s-%03d", id, cmdNum) + if req.StreamID != "" { + cmdID = req.StreamID + } + + // Create the command using domain types + cmd := &domain.Command{ + ID: domain.CommandID(cmdID), + ProjectID: domain.ProjectID(id), + Type: domain.CommandTypeGit, + Args: req.Args, + StartedAt: time.Now(), + } + + // Execute in background + go h.executeCommand(cmd, project.PodName) + + api.WriteCreated(w, r, map[string]any{ + "id": cmdID, + "project": id, + "type": "git", + "status": "running", + "stream_url": fmt.Sprintf("/projects/%s/events?stream_id=%s", id, cmdID), + }) +} + +// executeCommand runs a command and streams output to subscribers. +func (h *ProjectsHandler) executeCommand(cmd *domain.Command, podName string) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + + cmdID := string(cmd.ID) + result, _ := h.executor.Execute(ctx, cmd, podName, func(line domain.OutputLine) { + h.streams.Send(cmdID, "output", map[string]any{ + "line": line.Line, + "stream": line.Stream, + }) + }) + + // Send completion event + h.streams.Send(cmdID, "complete", map[string]any{ + "exit_code": result.ExitCode, + "duration_ms": result.DurationMs, + }) + + // Clean up stream after a delay + go func() { + time.Sleep(30 * time.Second) + h.streams.Close(cmdID) + }() +} diff --git a/internal/handlers/projects_stream.go b/internal/handlers/projects_stream.go new file mode 100644 index 0000000..e597be8 --- /dev/null +++ b/internal/handlers/projects_stream.go @@ -0,0 +1,88 @@ +// Package handlers provides HTTP handlers for the rdev API. +package handlers + +import ( + "encoding/json" + "fmt" + "net/http" + "sync" +) + +// streamManager manages SSE event streams. +type streamManager struct { + mu sync.RWMutex + streams map[string][]chan streamEvent +} + +type streamEvent struct { + Type string + Data map[string]any +} + +func newStreamManager() *streamManager { + return &streamManager{ + streams: make(map[string][]chan streamEvent), + } +} + +func (sm *streamManager) Subscribe(streamID string) chan streamEvent { + sm.mu.Lock() + defer sm.mu.Unlock() + + ch := make(chan streamEvent, 100) + sm.streams[streamID] = append(sm.streams[streamID], ch) + return ch +} + +func (sm *streamManager) Unsubscribe(streamID string, ch chan streamEvent) { + sm.mu.Lock() + defer sm.mu.Unlock() + + channels := sm.streams[streamID] + for i, c := range channels { + if c == ch { + sm.streams[streamID] = append(channels[:i], channels[i+1:]...) + close(ch) + break + } + } +} + +func (sm *streamManager) Send(streamID, eventType string, data map[string]any) { + sm.mu.RLock() + defer sm.mu.RUnlock() + + for _, ch := range sm.streams[streamID] { + select { + case ch <- streamEvent{Type: eventType, Data: data}: + default: + // Channel full, skip + } + } +} + +func (sm *streamManager) Close(streamID string) { + sm.mu.Lock() + defer sm.mu.Unlock() + + for _, ch := range sm.streams[streamID] { + close(ch) + } + delete(sm.streams, streamID) +} + +// writeSSE writes a Server-Sent Event. +func writeSSE(w http.ResponseWriter, flusher http.Flusher, event string, data map[string]any) { + writeSSEWithID(w, flusher, "", event, data) +} + +// writeSSEWithID writes a Server-Sent Event with an optional event ID. +func writeSSEWithID(w http.ResponseWriter, flusher http.Flusher, id, event string, data map[string]any) { + dataBytes, _ := json.Marshal(data) + if id != "" { + _, _ = fmt.Fprintf(w, "id: %s\n", id) + } + _, _ = fmt.Fprintf(w, "event: %s\n", event) + _, _ = fmt.Fprintf(w, "data: %s\n\n", dataBytes) + flusher.Flush() +} diff --git a/internal/handlers/work.go b/internal/handlers/work.go new file mode 100644 index 0000000..82976b3 --- /dev/null +++ b/internal/handlers/work.go @@ -0,0 +1,459 @@ +// Package handlers provides HTTP handlers for the rdev API. +package handlers + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strconv" + + "github.com/go-chi/chi/v5" + "github.com/orchard9/rdev/internal/domain" + "github.com/orchard9/rdev/internal/port" + "github.com/orchard9/rdev/internal/service" + "github.com/orchard9/rdev/pkg/api" +) + +// WorkHandler handles work queue endpoints. +type WorkHandler struct { + workService *service.WorkService +} + +// NewWorkHandler creates a new work handler. +func NewWorkHandler(workService *service.WorkService) *WorkHandler { + return &WorkHandler{ + workService: workService, + } +} + +// Mount registers the work queue routes. +func (h *WorkHandler) Mount(r api.Router) { + r.Route("/work", func(r chi.Router) { + // Task submission + r.Post("/enqueue", h.Enqueue) + + // Worker endpoints (for workers polling for tasks) + r.Post("/dequeue", h.Dequeue) + + // Task management + r.Get("/{taskId}", h.GetTask) + r.Get("/{taskId}/status", h.GetStatus) + r.Post("/{taskId}/complete", h.Complete) + r.Post("/{taskId}/fail", h.Fail) + r.Post("/{taskId}/cancel", h.Cancel) + + // Project-scoped list + r.Get("/projects/{projectId}", h.ListByProject) + + // Queue stats + r.Get("/stats", h.Stats) + }) +} + +// EnqueueWorkRequest is the request body for POST /work/enqueue. +type EnqueueWorkRequest struct { + ProjectID string `json:"project_id"` + TaskType string `json:"task_type"` + Spec map[string]any `json:"task_spec"` + Priority int `json:"priority,omitempty"` + CallbackURL string `json:"callback_url,omitempty"` + MaxRetries int `json:"max_retries,omitempty"` +} + +// EnqueueWorkResponse is the response for POST /work/enqueue. +type EnqueueWorkResponse struct { + TaskID string `json:"task_id"` + StatusURL string `json:"status_url"` +} + +// Enqueue adds a task to the work queue. +// POST /work/enqueue +func (h *WorkHandler) Enqueue(w http.ResponseWriter, r *http.Request) { + var req EnqueueWorkRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + api.WriteBadRequest(w, r, "invalid request body") + return + } + + // Validate required fields + if req.ProjectID == "" { + api.WriteBadRequest(w, r, "project_id is required") + return + } + if req.TaskType == "" { + api.WriteBadRequest(w, r, "task_type is required") + return + } + + // Validate task type + taskType := port.WorkTaskType(req.TaskType) + if !taskType.IsValid() { + api.WriteBadRequest(w, r, "task_type must be one of: build, test, deploy, custom") + return + } + + // Validate callback URL if provided + if req.CallbackURL != "" { + parsedURL, err := url.Parse(req.CallbackURL) + if err != nil || (parsedURL.Scheme != "http" && parsedURL.Scheme != "https") { + api.WriteBadRequest(w, r, "callback_url must be a valid HTTP/HTTPS URL") + return + } + } + + result, err := h.workService.EnqueueTask(r.Context(), service.EnqueueTaskRequest{ + ProjectID: req.ProjectID, + Type: taskType, + Spec: req.Spec, + Priority: req.Priority, + CallbackURL: req.CallbackURL, + MaxRetries: req.MaxRetries, + }) + if err != nil { + api.WriteInternalError(w, r, "failed to enqueue task") + return + } + + api.WriteCreated(w, r, EnqueueWorkResponse{ + TaskID: result.TaskID, + StatusURL: result.StatusURL, + }) +} + +// DequeueWorkRequest is the request body for POST /work/dequeue. +type DequeueWorkRequest struct { + WorkerID string `json:"worker_id"` +} + +// DequeueWorkResponse is the response for POST /work/dequeue. +type DequeueWorkResponse struct { + Task *WorkTaskDTO `json:"task,omitempty"` +} + +// WorkTaskDTO is the data transfer object for work tasks. +type WorkTaskDTO struct { + ID string `json:"id"` + ProjectID string `json:"project_id"` + Type string `json:"type"` + Spec map[string]any `json:"spec"` + Status string `json:"status"` + Priority int `json:"priority"` + WorkerID string `json:"worker_id,omitempty"` + CallbackURL string `json:"callback_url,omitempty"` + CreatedAt string `json:"created_at"` + StartedAt string `json:"started_at,omitempty"` + CompletedAt string `json:"completed_at,omitempty"` + Result *WorkResultDTO `json:"result,omitempty"` + Error string `json:"error,omitempty"` + RetryCount int `json:"retry_count"` + MaxRetries int `json:"max_retries"` +} + +// WorkResultDTO is the data transfer object for work results. +type WorkResultDTO struct { + Output string `json:"output,omitempty"` + Artifacts map[string]string `json:"artifacts,omitempty"` +} + +// toWorkTaskDTO converts a port.WorkTask to a WorkTaskDTO. +func toWorkTaskDTO(t *port.WorkTask) *WorkTaskDTO { + if t == nil { + return nil + } + dto := &WorkTaskDTO{ + ID: t.ID, + ProjectID: t.ProjectID, + Type: string(t.Type), + Spec: t.Spec, + Status: string(t.Status), + Priority: t.Priority, + WorkerID: t.WorkerID, + CallbackURL: t.CallbackURL, + CreatedAt: t.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + Error: t.Error, + RetryCount: t.RetryCount, + MaxRetries: t.MaxRetries, + } + if t.StartedAt != nil { + dto.StartedAt = t.StartedAt.Format("2006-01-02T15:04:05Z07:00") + } + if t.CompletedAt != nil { + dto.CompletedAt = t.CompletedAt.Format("2006-01-02T15:04:05Z07:00") + } + if t.Result != nil { + dto.Result = &WorkResultDTO{ + Output: t.Result.Output, + Artifacts: t.Result.Artifacts, + } + } + return dto +} + +// Dequeue claims the next available task for a worker. +// POST /work/dequeue +func (h *WorkHandler) Dequeue(w http.ResponseWriter, r *http.Request) { + var req DequeueWorkRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + api.WriteBadRequest(w, r, "invalid request body") + return + } + + if req.WorkerID == "" { + api.WriteBadRequest(w, r, "worker_id is required") + return + } + + task, err := h.workService.DequeueTask(r.Context(), req.WorkerID) + if err != nil { + api.WriteInternalError(w, r, "failed to dequeue task") + return + } + + api.WriteSuccess(w, r, DequeueWorkResponse{ + Task: toWorkTaskDTO(task), + }) +} + +// GetTask retrieves a task by ID. +// GET /work/{taskId} +func (h *WorkHandler) GetTask(w http.ResponseWriter, r *http.Request) { + taskID := chi.URLParam(r, "taskId") + + task, err := h.workService.GetTask(r.Context(), taskID) + if err != nil { + if errors.Is(err, domain.ErrWorkTaskNotFound) { + api.WriteNotFound(w, r, fmt.Sprintf("task not found: %s", taskID)) + return + } + api.WriteInternalError(w, r, "failed to get task") + return + } + + api.WriteSuccess(w, r, toWorkTaskDTO(task)) +} + +// GetStatus returns the status of a task. +// GET /work/{taskId}/status +func (h *WorkHandler) GetStatus(w http.ResponseWriter, r *http.Request) { + taskID := chi.URLParam(r, "taskId") + + task, err := h.workService.GetTask(r.Context(), taskID) + if err != nil { + if errors.Is(err, domain.ErrWorkTaskNotFound) { + api.WriteNotFound(w, r, fmt.Sprintf("task not found: %s", taskID)) + return + } + api.WriteInternalError(w, r, "failed to get task") + return + } + + api.WriteSuccess(w, r, map[string]any{ + "task_id": task.ID, + "status": string(task.Status), + "error": task.Error, + }) +} + +// CompleteWorkRequest is the request body for POST /work/{taskId}/complete. +type CompleteWorkRequest struct { + Output string `json:"output,omitempty"` + Artifacts map[string]string `json:"artifacts,omitempty"` +} + +// Complete marks a task as successfully completed. +// POST /work/{taskId}/complete +func (h *WorkHandler) Complete(w http.ResponseWriter, r *http.Request) { + taskID := chi.URLParam(r, "taskId") + + var req CompleteWorkRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + api.WriteBadRequest(w, r, "invalid request body") + return + } + + result := &port.WorkResult{ + Output: req.Output, + Artifacts: req.Artifacts, + } + + if err := h.workService.CompleteTask(r.Context(), taskID, result); err != nil { + if errors.Is(err, domain.ErrWorkTaskNotFound) { + api.WriteNotFound(w, r, fmt.Sprintf("task not found: %s", taskID)) + return + } + api.WriteInternalError(w, r, "failed to complete task") + return + } + + api.WriteSuccess(w, r, map[string]any{ + "task_id": taskID, + "status": "completed", + "message": "task completed successfully", + }) +} + +// FailWorkRequest is the request body for POST /work/{taskId}/fail. +type FailWorkRequest struct { + Error string `json:"error"` +} + +// Fail marks a task as failed. +// POST /work/{taskId}/fail +func (h *WorkHandler) Fail(w http.ResponseWriter, r *http.Request) { + taskID := chi.URLParam(r, "taskId") + + var req FailWorkRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + api.WriteBadRequest(w, r, "invalid request body") + return + } + + if req.Error == "" { + api.WriteBadRequest(w, r, "error message is required") + return + } + + if err := h.workService.FailTask(r.Context(), taskID, req.Error); err != nil { + if errors.Is(err, domain.ErrWorkTaskNotFound) { + api.WriteNotFound(w, r, fmt.Sprintf("task not found: %s", taskID)) + return + } + api.WriteInternalError(w, r, "failed to fail task") + return + } + + api.WriteSuccess(w, r, map[string]any{ + "task_id": taskID, + "status": "failed", + "message": "task marked as failed", + }) +} + +// Cancel cancels a pending task. +// POST /work/{taskId}/cancel +func (h *WorkHandler) Cancel(w http.ResponseWriter, r *http.Request) { + taskID := chi.URLParam(r, "taskId") + + if err := h.workService.CancelTask(r.Context(), taskID); err != nil { + if errors.Is(err, domain.ErrWorkTaskNotFound) { + api.WriteNotFound(w, r, fmt.Sprintf("task not found: %s", taskID)) + return + } + api.WriteBadRequest(w, r, err.Error()) + return + } + + api.WriteSuccess(w, r, map[string]any{ + "task_id": taskID, + "status": "cancelled", + "message": "task cancelled successfully", + }) +} + +// ListByProject returns tasks for a project with pagination. +// GET /work/projects/{projectId}?status=pending&limit=50&offset=0 +func (h *WorkHandler) ListByProject(w http.ResponseWriter, r *http.Request) { + projectID := chi.URLParam(r, "projectId") + + // Parse and validate optional status filter + var status *port.WorkTaskStatus + if s := r.URL.Query().Get("status"); s != "" { + st := port.WorkTaskStatus(s) + if !st.IsValid() { + api.WriteBadRequest(w, r, "invalid status filter: must be pending, running, completed, failed, or cancelled") + return + } + status = &st + } + + // Parse pagination options + opts := port.DefaultWorkListOptions() + if limitStr := r.URL.Query().Get("limit"); limitStr != "" { + limit, err := strconv.Atoi(limitStr) + if err != nil { + api.WriteBadRequest(w, r, "limit must be a valid integer") + return + } + opts.Limit = limit + } + if offsetStr := r.URL.Query().Get("offset"); offsetStr != "" { + offset, err := strconv.Atoi(offsetStr) + if err != nil { + api.WriteBadRequest(w, r, "offset must be a valid integer") + return + } + opts.Offset = offset + } + + result, err := h.workService.ListByProject(r.Context(), projectID, status, opts) + if err != nil { + api.WriteInternalError(w, r, "failed to list tasks") + return + } + + dtos := make([]*WorkTaskDTO, len(result.Tasks)) + for i, t := range result.Tasks { + dtos[i] = toWorkTaskDTO(t) + } + + api.WriteSuccess(w, r, map[string]any{ + "tasks": dtos, + "total": result.Total, + "limit": result.Limit, + "offset": result.Offset, + }) +} + +// WorkStatsResponse is the response for GET /work/stats. +type WorkStatsResponse struct { + Pending int64 `json:"pending"` + Running int64 `json:"running"` + Completed int64 `json:"completed"` + Failed int64 `json:"failed"` + Cancelled int64 `json:"cancelled"` + OldestPending string `json:"oldest_pending,omitempty"` +} + +// Stats returns queue statistics. +// GET /work/stats +func (h *WorkHandler) Stats(w http.ResponseWriter, r *http.Request) { + stats, err := h.workService.GetStats(r.Context()) + if err != nil { + api.WriteInternalError(w, r, "failed to get queue stats") + return + } + + resp := WorkStatsResponse{ + Pending: stats.Pending, + Running: stats.Running, + Completed: stats.Completed, + Failed: stats.Failed, + Cancelled: stats.Cancelled, + } + if stats.OldestPending != nil { + // Convert to human-readable duration + resp.OldestPending = formatDuration(*stats.OldestPending) + } + + api.WriteSuccess(w, r, resp) +} + +// formatDuration formats a duration in a human-readable way. +func formatDuration(d interface{ Seconds() float64 }) string { + secs := d.Seconds() + if secs < 60 { + return fmt.Sprintf("%.0fs", secs) + } + mins := secs / 60 + if mins < 60 { + return fmt.Sprintf("%.1fm", mins) + } + hours := mins / 60 + if hours < 24 { + return fmt.Sprintf("%.1fh", hours) + } + days := hours / 24 + return fmt.Sprintf("%.1fd", days) +} diff --git a/internal/handlers/work_test.go b/internal/handlers/work_test.go new file mode 100644 index 0000000..927012e --- /dev/null +++ b/internal/handlers/work_test.go @@ -0,0 +1,780 @@ +package handlers + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/go-chi/chi/v5" + "github.com/orchard9/rdev/internal/domain" + "github.com/orchard9/rdev/internal/port" + "github.com/orchard9/rdev/internal/service" +) + +// mockWorkQueue implements port.WorkQueue for testing. +type mockWorkQueue struct { + tasks map[string]*port.WorkTask + err error +} + +func newMockWorkQueue() *mockWorkQueue { + return &mockWorkQueue{ + tasks: make(map[string]*port.WorkTask), + } +} + +func (m *mockWorkQueue) Enqueue(ctx context.Context, task *port.WorkTask) (string, error) { + if m.err != nil { + return "", m.err + } + id := "task-123" + task.ID = id + task.Status = port.WorkTaskStatusPending + task.CreatedAt = time.Now() + m.tasks[id] = task + return id, nil +} + +func (m *mockWorkQueue) Dequeue(ctx context.Context, workerID string) (*port.WorkTask, error) { + if m.err != nil { + return nil, m.err + } + for _, task := range m.tasks { + if task.Status == port.WorkTaskStatusPending { + task.Status = port.WorkTaskStatusRunning + task.WorkerID = workerID + now := time.Now() + task.StartedAt = &now + return task, nil + } + } + return nil, nil +} + +func (m *mockWorkQueue) Complete(ctx context.Context, taskID string, result *port.WorkResult) error { + if m.err != nil { + return m.err + } + task, ok := m.tasks[taskID] + if !ok { + return domain.ErrWorkTaskNotFound + } + task.Status = port.WorkTaskStatusCompleted + task.Result = result + now := time.Now() + task.CompletedAt = &now + return nil +} + +func (m *mockWorkQueue) Fail(ctx context.Context, taskID string, errMsg string) error { + if m.err != nil { + return m.err + } + task, ok := m.tasks[taskID] + if !ok { + return domain.ErrWorkTaskNotFound + } + if task.RetryCount < task.MaxRetries { + task.Status = port.WorkTaskStatusPending + task.RetryCount++ + task.Error = errMsg + } else { + task.Status = port.WorkTaskStatusFailed + task.Error = errMsg + now := time.Now() + task.CompletedAt = &now + } + return nil +} + +func (m *mockWorkQueue) Cancel(ctx context.Context, taskID string) error { + if m.err != nil { + return m.err + } + task, ok := m.tasks[taskID] + if !ok { + return domain.ErrWorkTaskNotFound + } + if task.Status != port.WorkTaskStatusPending { + return domain.ErrWorkTaskNotFound + } + task.Status = port.WorkTaskStatusCancelled + now := time.Now() + task.CompletedAt = &now + return nil +} + +func (m *mockWorkQueue) GetTask(ctx context.Context, taskID string) (*port.WorkTask, error) { + if m.err != nil { + return nil, m.err + } + task, ok := m.tasks[taskID] + if !ok { + return nil, domain.ErrWorkTaskNotFound + } + return task, nil +} + +func (m *mockWorkQueue) ListByProject(ctx context.Context, projectID string, status *port.WorkTaskStatus, opts port.WorkListOptions) (*port.WorkListResult, error) { + if m.err != nil { + return nil, m.err + } + opts.Normalize() + + var tasks []*port.WorkTask + for _, task := range m.tasks { + if task.ProjectID == projectID { + if status == nil || task.Status == *status { + tasks = append(tasks, task) + } + } + } + + // Apply pagination + total := int64(len(tasks)) + if opts.Offset >= len(tasks) { + tasks = nil + } else { + end := opts.Offset + opts.Limit + if end > len(tasks) { + end = len(tasks) + } + tasks = tasks[opts.Offset:end] + } + + return &port.WorkListResult{ + Tasks: tasks, + Total: total, + Limit: opts.Limit, + Offset: opts.Offset, + }, nil +} + +func (m *mockWorkQueue) GetStats(ctx context.Context) (*port.WorkQueueStats, error) { + if m.err != nil { + return nil, m.err + } + stats := &port.WorkQueueStats{} + for _, task := range m.tasks { + switch task.Status { + case port.WorkTaskStatusPending: + stats.Pending++ + case port.WorkTaskStatusRunning: + stats.Running++ + case port.WorkTaskStatusCompleted: + stats.Completed++ + case port.WorkTaskStatusFailed: + stats.Failed++ + case port.WorkTaskStatusCancelled: + stats.Cancelled++ + } + } + return stats, nil +} + +func (m *mockWorkQueue) CleanupOld(ctx context.Context, olderThan time.Duration) (int64, error) { + return 0, m.err +} + +func (m *mockWorkQueue) RequeueStale(ctx context.Context, timeout time.Duration) (int64, error) { + return 0, m.err +} + +func TestWorkHandler_Enqueue(t *testing.T) { + mockQueue := newMockWorkQueue() + workService := service.NewWorkService(mockQueue, service.WorkServiceConfig{}) + handler := NewWorkHandler(workService) + + router := chi.NewRouter() + handler.Mount(router) + + tests := []struct { + name string + body EnqueueWorkRequest + wantStatus int + }{ + { + name: "valid_build_task", + body: EnqueueWorkRequest{ + ProjectID: "test-project", + TaskType: "build", + Spec: map[string]any{ + "prompt": "Build a landing page", + "template": "nextjs-landing", + }, + }, + wantStatus: http.StatusCreated, + }, + { + name: "valid_test_task", + body: EnqueueWorkRequest{ + ProjectID: "test-project", + TaskType: "test", + Spec: map[string]any{ + "test_command": "npm test", + }, + }, + wantStatus: http.StatusCreated, + }, + { + name: "valid_deploy_task", + body: EnqueueWorkRequest{ + ProjectID: "test-project", + TaskType: "deploy", + Spec: map[string]any{ + "image": "registry.example.com/app:latest", + "replicas": 2, + }, + }, + wantStatus: http.StatusCreated, + }, + { + name: "valid_custom_task", + body: EnqueueWorkRequest{ + ProjectID: "test-project", + TaskType: "custom", + Spec: map[string]any{ + "action": "cleanup", + }, + }, + wantStatus: http.StatusCreated, + }, + { + name: "missing_project_id", + body: EnqueueWorkRequest{ + TaskType: "build", + Spec: map[string]any{}, + }, + wantStatus: http.StatusBadRequest, + }, + { + name: "missing_task_type", + body: EnqueueWorkRequest{ + ProjectID: "test-project", + Spec: map[string]any{}, + }, + wantStatus: http.StatusBadRequest, + }, + { + name: "invalid_task_type", + body: EnqueueWorkRequest{ + ProjectID: "test-project", + TaskType: "invalid", + Spec: map[string]any{}, + }, + wantStatus: http.StatusBadRequest, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + body, _ := json.Marshal(tt.body) + req := httptest.NewRequest(http.MethodPost, "/work/enqueue", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + if rec.Code != tt.wantStatus { + t.Errorf("got status %d, want %d; body: %s", rec.Code, tt.wantStatus, rec.Body.String()) + } + }) + } +} + +func TestWorkHandler_Dequeue(t *testing.T) { + mockQueue := newMockWorkQueue() + workService := service.NewWorkService(mockQueue, service.WorkServiceConfig{}) + handler := NewWorkHandler(workService) + + // Pre-populate a pending task + mockQueue.tasks["task-1"] = &port.WorkTask{ + ID: "task-1", + ProjectID: "test-project", + Type: port.WorkTaskTypeBuild, + Status: port.WorkTaskStatusPending, + CreatedAt: time.Now(), + } + + router := chi.NewRouter() + handler.Mount(router) + + tests := []struct { + name string + body DequeueWorkRequest + wantStatus int + wantTask bool + }{ + { + name: "valid_dequeue", + body: DequeueWorkRequest{WorkerID: "worker-1"}, + wantStatus: http.StatusOK, + wantTask: true, + }, + { + name: "missing_worker_id", + body: DequeueWorkRequest{}, + wantStatus: http.StatusBadRequest, + wantTask: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + body, _ := json.Marshal(tt.body) + req := httptest.NewRequest(http.MethodPost, "/work/dequeue", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + if rec.Code != tt.wantStatus { + t.Errorf("got status %d, want %d; body: %s", rec.Code, tt.wantStatus, rec.Body.String()) + } + + if tt.wantTask && rec.Code == http.StatusOK { + var resp map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + data, ok := resp["data"].(map[string]any) + if !ok { + t.Fatal("response missing data field") + } + if data["task"] == nil { + t.Error("expected task in response") + } + } + }) + } +} + +func TestWorkHandler_Complete(t *testing.T) { + mockQueue := newMockWorkQueue() + workService := service.NewWorkService(mockQueue, service.WorkServiceConfig{}) + handler := NewWorkHandler(workService) + + // Pre-populate a running task + mockQueue.tasks["task-1"] = &port.WorkTask{ + ID: "task-1", + ProjectID: "test-project", + Type: port.WorkTaskTypeBuild, + Status: port.WorkTaskStatusRunning, + WorkerID: "worker-1", + CreatedAt: time.Now(), + } + + router := chi.NewRouter() + handler.Mount(router) + + tests := []struct { + name string + taskID string + body CompleteWorkRequest + wantStatus int + }{ + { + name: "valid_complete", + taskID: "task-1", + body: CompleteWorkRequest{ + Output: "Build successful", + Artifacts: map[string]string{ + "commit_sha": "abc123", + }, + }, + wantStatus: http.StatusOK, + }, + { + name: "task_not_found", + taskID: "nonexistent", + body: CompleteWorkRequest{Output: "Done"}, + wantStatus: http.StatusNotFound, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + body, _ := json.Marshal(tt.body) + req := httptest.NewRequest(http.MethodPost, "/work/"+tt.taskID+"/complete", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + if rec.Code != tt.wantStatus { + t.Errorf("got status %d, want %d; body: %s", rec.Code, tt.wantStatus, rec.Body.String()) + } + }) + } +} + +func TestWorkHandler_Fail(t *testing.T) { + mockQueue := newMockWorkQueue() + workService := service.NewWorkService(mockQueue, service.WorkServiceConfig{}) + handler := NewWorkHandler(workService) + + // Pre-populate a running task + mockQueue.tasks["task-1"] = &port.WorkTask{ + ID: "task-1", + ProjectID: "test-project", + Type: port.WorkTaskTypeBuild, + Status: port.WorkTaskStatusRunning, + WorkerID: "worker-1", + MaxRetries: 3, + CreatedAt: time.Now(), + } + + router := chi.NewRouter() + handler.Mount(router) + + tests := []struct { + name string + taskID string + body FailWorkRequest + wantStatus int + }{ + { + name: "valid_fail", + taskID: "task-1", + body: FailWorkRequest{Error: "Build failed: npm error"}, + wantStatus: http.StatusOK, + }, + { + name: "missing_error", + taskID: "task-1", + body: FailWorkRequest{}, + wantStatus: http.StatusBadRequest, + }, + { + name: "task_not_found", + taskID: "nonexistent", + body: FailWorkRequest{Error: "Failed"}, + wantStatus: http.StatusNotFound, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + body, _ := json.Marshal(tt.body) + req := httptest.NewRequest(http.MethodPost, "/work/"+tt.taskID+"/fail", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + if rec.Code != tt.wantStatus { + t.Errorf("got status %d, want %d; body: %s", rec.Code, tt.wantStatus, rec.Body.String()) + } + }) + } +} + +func TestWorkHandler_Cancel(t *testing.T) { + mockQueue := newMockWorkQueue() + workService := service.NewWorkService(mockQueue, service.WorkServiceConfig{}) + handler := NewWorkHandler(workService) + + // Pre-populate tasks + mockQueue.tasks["pending-task"] = &port.WorkTask{ + ID: "pending-task", + ProjectID: "test-project", + Type: port.WorkTaskTypeBuild, + Status: port.WorkTaskStatusPending, + CreatedAt: time.Now(), + } + mockQueue.tasks["running-task"] = &port.WorkTask{ + ID: "running-task", + ProjectID: "test-project", + Type: port.WorkTaskTypeBuild, + Status: port.WorkTaskStatusRunning, + CreatedAt: time.Now(), + } + + router := chi.NewRouter() + handler.Mount(router) + + tests := []struct { + name string + taskID string + wantStatus int + }{ + { + name: "cancel_pending_task", + taskID: "pending-task", + wantStatus: http.StatusOK, + }, + { + name: "cancel_running_task_fails", + taskID: "running-task", + wantStatus: http.StatusNotFound, // Can only cancel pending tasks + }, + { + name: "task_not_found", + taskID: "nonexistent", + wantStatus: http.StatusNotFound, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/work/"+tt.taskID+"/cancel", nil) + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + if rec.Code != tt.wantStatus { + t.Errorf("got status %d, want %d; body: %s", rec.Code, tt.wantStatus, rec.Body.String()) + } + }) + } +} + +func TestWorkHandler_GetTask(t *testing.T) { + mockQueue := newMockWorkQueue() + workService := service.NewWorkService(mockQueue, service.WorkServiceConfig{}) + handler := NewWorkHandler(workService) + + // Pre-populate a task + mockQueue.tasks["task-1"] = &port.WorkTask{ + ID: "task-1", + ProjectID: "test-project", + Type: port.WorkTaskTypeBuild, + Status: port.WorkTaskStatusRunning, + Spec: map[string]any{ + "prompt": "Build it", + }, + CreatedAt: time.Now(), + } + + router := chi.NewRouter() + handler.Mount(router) + + tests := []struct { + name string + taskID string + wantStatus int + }{ + { + name: "get_existing_task", + taskID: "task-1", + wantStatus: http.StatusOK, + }, + { + name: "task_not_found", + taskID: "nonexistent", + wantStatus: http.StatusNotFound, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/work/"+tt.taskID, nil) + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + if rec.Code != tt.wantStatus { + t.Errorf("got status %d, want %d; body: %s", rec.Code, tt.wantStatus, rec.Body.String()) + } + }) + } +} + +func TestWorkHandler_ListByProject(t *testing.T) { + mockQueue := newMockWorkQueue() + workService := service.NewWorkService(mockQueue, service.WorkServiceConfig{}) + handler := NewWorkHandler(workService) + + // Pre-populate tasks + mockQueue.tasks["task-1"] = &port.WorkTask{ + ID: "task-1", + ProjectID: "project-a", + Type: port.WorkTaskTypeBuild, + Status: port.WorkTaskStatusPending, + CreatedAt: time.Now(), + } + mockQueue.tasks["task-2"] = &port.WorkTask{ + ID: "task-2", + ProjectID: "project-a", + Type: port.WorkTaskTypeTest, + Status: port.WorkTaskStatusCompleted, + CreatedAt: time.Now(), + } + mockQueue.tasks["task-3"] = &port.WorkTask{ + ID: "task-3", + ProjectID: "project-b", + Type: port.WorkTaskTypeDeploy, + Status: port.WorkTaskStatusRunning, + CreatedAt: time.Now(), + } + + router := chi.NewRouter() + handler.Mount(router) + + t.Run("list_all_for_project", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/work/projects/project-a", nil) + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("got status %d, want %d; body: %s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + data := resp["data"].(map[string]any) + total := int(data["total"].(float64)) + if total != 2 { + t.Errorf("got %d tasks, want 2", total) + } + // Verify pagination metadata is present + if _, ok := data["limit"]; !ok { + t.Error("expected limit in response") + } + if _, ok := data["offset"]; !ok { + t.Error("expected offset in response") + } + }) + + t.Run("list_with_status_filter", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/work/projects/project-a?status=pending", nil) + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("got status %d, want %d", rec.Code, http.StatusOK) + } + + var resp map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + data := resp["data"].(map[string]any) + total := int(data["total"].(float64)) + if total != 1 { + t.Errorf("got %d tasks, want 1", total) + } + }) + + t.Run("list_with_pagination", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/work/projects/project-a?limit=1&offset=0", nil) + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("got status %d, want %d", rec.Code, http.StatusOK) + } + + var resp map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + data := resp["data"].(map[string]any) + + // Total should reflect all matching tasks + total := int(data["total"].(float64)) + if total != 2 { + t.Errorf("got total=%d, want 2", total) + } + + // But tasks returned should be limited + tasks := data["tasks"].([]any) + if len(tasks) != 1 { + t.Errorf("got %d tasks returned, want 1", len(tasks)) + } + + // Verify limit/offset are reflected + if int(data["limit"].(float64)) != 1 { + t.Errorf("got limit=%v, want 1", data["limit"]) + } + if int(data["offset"].(float64)) != 0 { + t.Errorf("got offset=%v, want 0", data["offset"]) + } + }) + + t.Run("invalid_limit", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/work/projects/project-a?limit=invalid", nil) + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Errorf("got status %d, want %d", rec.Code, http.StatusBadRequest) + } + }) + + t.Run("invalid_offset", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/work/projects/project-a?offset=invalid", nil) + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Errorf("got status %d, want %d", rec.Code, http.StatusBadRequest) + } + }) + + t.Run("invalid_status_filter", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/work/projects/project-a?status=invalid", nil) + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Errorf("got status %d, want %d", rec.Code, http.StatusBadRequest) + } + }) +} + +func TestWorkHandler_Stats(t *testing.T) { + mockQueue := newMockWorkQueue() + workService := service.NewWorkService(mockQueue, service.WorkServiceConfig{}) + handler := NewWorkHandler(workService) + + // Pre-populate tasks with various statuses + mockQueue.tasks["task-1"] = &port.WorkTask{ID: "task-1", Status: port.WorkTaskStatusPending} + mockQueue.tasks["task-2"] = &port.WorkTask{ID: "task-2", Status: port.WorkTaskStatusPending} + mockQueue.tasks["task-3"] = &port.WorkTask{ID: "task-3", Status: port.WorkTaskStatusRunning} + mockQueue.tasks["task-4"] = &port.WorkTask{ID: "task-4", Status: port.WorkTaskStatusCompleted} + mockQueue.tasks["task-5"] = &port.WorkTask{ID: "task-5", Status: port.WorkTaskStatusFailed} + + router := chi.NewRouter() + handler.Mount(router) + + req := httptest.NewRequest(http.MethodGet, "/work/stats", nil) + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("got status %d, want %d; body: %s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + data := resp["data"].(map[string]any) + + if int(data["pending"].(float64)) != 2 { + t.Errorf("got pending=%v, want 2", data["pending"]) + } + if int(data["running"].(float64)) != 1 { + t.Errorf("got running=%v, want 1", data["running"]) + } + if int(data["completed"].(float64)) != 1 { + t.Errorf("got completed=%v, want 1", data["completed"]) + } + if int(data["failed"].(float64)) != 1 { + t.Errorf("got failed=%v, want 1", data["failed"]) + } +} diff --git a/internal/port/ci_provider.go b/internal/port/ci_provider.go new file mode 100644 index 0000000..51f8823 --- /dev/null +++ b/internal/port/ci_provider.go @@ -0,0 +1,32 @@ +// Package port defines interfaces (ports) for external dependencies. +package port + +import ( + "context" + + "github.com/orchard9/rdev/internal/domain" +) + +// CIProvider manages CI/CD pipeline configurations. +// Implementations include Woodpecker, GitHub Actions, GitLab CI, etc. +type CIProvider interface { + // ActivateRepo enables CI for a repository. + // The forge is the git forge (e.g., gitea, github) where the repo lives. + // This creates the necessary webhooks and enables the CI pipeline. + ActivateRepo(ctx context.Context, forge, owner, repo string) (*domain.CIRepo, error) + + // DeactivateRepo disables CI for a repository. + DeactivateRepo(ctx context.Context, owner, repo string) error + + // GetRepo returns the CI configuration for a repository. + GetRepo(ctx context.Context, owner, repo string) (*domain.CIRepo, error) + + // ListRepos returns all repositories visible to the CI system. + ListRepos(ctx context.Context) ([]*domain.CIRepo, error) + + // AddSecret adds a secret to a repository for use in pipelines. + AddSecret(ctx context.Context, owner, repo string, secret domain.CISecret) error + + // DeleteSecret removes a secret from a repository. + DeleteSecret(ctx context.Context, owner, repo, secretName string) error +} diff --git a/internal/port/code_agent.go b/internal/port/code_agent.go new file mode 100644 index 0000000..b420b4a --- /dev/null +++ b/internal/port/code_agent.go @@ -0,0 +1,61 @@ +// Package port defines interface contracts for external adapters. +package port + +import ( + "context" + + "github.com/orchard9/rdev/internal/domain" +) + +// CodeAgent defines operations for executing AI coding agent commands. +// Implementations handle the specifics of different agent backends +// (Claude Code CLI, OpenCode HTTP API, etc.). +type CodeAgent interface { + // Name returns a human-readable name for this agent implementation. + Name() string + + // Provider returns the agent provider identifier. + Provider() domain.AgentProvider + + // Execute runs an agent command and streams events to the handler. + // The handler is called for each event during execution. + // Returns the final result when execution completes. + Execute(ctx context.Context, req *domain.AgentRequest, handler domain.AgentEventHandler) (*domain.AgentResult, error) + + // Cancel attempts to cancel a running agent session. + // Returns nil if cancellation was successful or session not found. + Cancel(ctx context.Context, sessionID string) error + + // Capabilities returns what this agent implementation supports. + Capabilities() domain.AgentCapabilities + + // Available returns true if the agent is ready to accept requests. + // This may check connectivity to external services. + Available(ctx context.Context) bool +} + +// CodeAgentRegistry manages registered code agent implementations. +// It allows looking up agents by provider and setting defaults. +type CodeAgentRegistry interface { + // Register adds an agent implementation for a provider. + // Overwrites any existing registration for the same provider. + Register(agent CodeAgent) + + // Get returns the agent for a specific provider. + // Returns nil if no agent is registered for that provider. + Get(provider domain.AgentProvider) CodeAgent + + // Default returns the default agent implementation. + // Returns nil if no agents are registered. + Default() CodeAgent + + // SetDefault sets which provider should be used as the default. + // Returns error if the provider is not registered. + SetDefault(provider domain.AgentProvider) error + + // Available returns all registered providers. + Available() []domain.AgentProvider + + // AvailableAgents returns all registered agents that are currently available. + AvailableAgents(ctx context.Context) []CodeAgent +} diff --git a/internal/port/credential_store.go b/internal/port/credential_store.go new file mode 100644 index 0000000..05b0d61 --- /dev/null +++ b/internal/port/credential_store.go @@ -0,0 +1,37 @@ +// Package port defines interfaces (ports) for external dependencies. +package port + +import ( + "context" + + "github.com/orchard9/rdev/internal/domain" +) + +// CredentialStore manages secure storage and retrieval of credentials. +// Credentials are encrypted at rest in the database. +type CredentialStore interface { + // Get retrieves a credential by key. Returns empty string if not found. + Get(ctx context.Context, key string) (string, error) + + // GetRequired retrieves a credential by key. Returns error if not found. + GetRequired(ctx context.Context, key string) (string, error) + + // Set stores or updates a credential. + Set(ctx context.Context, cred domain.Credential) error + + // Delete removes a credential by key. + Delete(ctx context.Context, key string) error + + // List returns all credentials (with values masked). + List(ctx context.Context) ([]domain.Credential, error) + + // ListByCategory returns credentials in a category (with values masked). + ListByCategory(ctx context.Context, category string) ([]domain.Credential, error) + + // GetMultiple retrieves multiple credentials by keys. + // Returns a map of key -> value. Missing keys are omitted. + GetMultiple(ctx context.Context, keys []string) (map[string]string, error) + + // SetMultiple stores multiple credentials in a single transaction. + SetMultiple(ctx context.Context, creds []domain.Credential) error +} diff --git a/internal/port/template_provider.go b/internal/port/template_provider.go new file mode 100644 index 0000000..de47b3f --- /dev/null +++ b/internal/port/template_provider.go @@ -0,0 +1,33 @@ +// Package port defines interfaces (ports) for external dependencies. +package port + +import "context" + +// TemplateProvider seeds repositories with starter files. +type TemplateProvider interface { + // SeedRepo populates a repository with template files. + // templateName specifies which template to use (e.g., "default", "astro-landing"). + // vars contains template variables for interpolation (e.g., PROJECT_NAME, DOMAIN). + SeedRepo(ctx context.Context, owner, repo, templateName string, vars map[string]string) error + + // ListTemplates returns available templates. + ListTemplates(ctx context.Context) ([]TemplateInfo, error) + + // GetTemplate returns info about a specific template. + GetTemplate(ctx context.Context, name string) (*TemplateInfo, error) +} + +// TemplateInfo describes an available project template. +type TemplateInfo struct { + // Name is the template identifier (e.g., "astro-landing") + Name string + + // Description explains what the template provides + Description string + + // Stack indicates the technology stack (e.g., "astro", "go", "generic") + Stack string + + // Files lists the files included in the template + Files []string +} diff --git a/internal/port/work_queue.go b/internal/port/work_queue.go new file mode 100644 index 0000000..20c3cc3 --- /dev/null +++ b/internal/port/work_queue.go @@ -0,0 +1,215 @@ +// Package port defines interfaces (ports) for external dependencies. +package port + +import ( + "context" + "time" +) + +// WorkQueue defines operations for the worker pool task queue. +// Unlike CommandQueue (project-specific claudebox commands), WorkQueue +// supports generic tasks that any worker in the pool can claim and execute. +type WorkQueue interface { + // Enqueue adds a task to the queue. + // Returns the task ID. + Enqueue(ctx context.Context, task *WorkTask) (string, error) + + // Dequeue atomically claims the next available task for a worker. + // Uses FOR UPDATE SKIP LOCKED for concurrent worker safety. + // Returns nil if no tasks are available. + Dequeue(ctx context.Context, workerID string) (*WorkTask, error) + + // Complete marks a task as successfully completed with results. + Complete(ctx context.Context, taskID string, result *WorkResult) error + + // Fail marks a task as failed with an error message. + // If retry_count < max_retries, the task will be re-queued as pending. + Fail(ctx context.Context, taskID string, errMsg string) error + + // Cancel marks a pending task as cancelled. + // Returns an error if the task is not in pending status. + Cancel(ctx context.Context, taskID string) error + + // GetTask retrieves a task by ID. + GetTask(ctx context.Context, taskID string) (*WorkTask, error) + + // ListByProject returns tasks for a project with optional status filter and pagination. + ListByProject(ctx context.Context, projectID string, status *WorkTaskStatus, opts WorkListOptions) (*WorkListResult, error) + + // GetStats returns queue statistics. + GetStats(ctx context.Context) (*WorkQueueStats, error) + + // CleanupOld removes completed/failed/cancelled tasks older than the specified duration. + CleanupOld(ctx context.Context, olderThan time.Duration) (int64, error) + + // RequeueStale re-queues tasks that have been running longer than the timeout. + // This handles workers that crashed without reporting completion. + RequeueStale(ctx context.Context, timeout time.Duration) (int64, error) +} + +// WorkTaskStatus represents the status of a work task. +type WorkTaskStatus string + +const ( + WorkTaskStatusPending WorkTaskStatus = "pending" + WorkTaskStatusRunning WorkTaskStatus = "running" + WorkTaskStatusCompleted WorkTaskStatus = "completed" + WorkTaskStatusFailed WorkTaskStatus = "failed" + WorkTaskStatusCancelled WorkTaskStatus = "cancelled" +) + +// IsValid returns true if the status is a known valid status. +func (s WorkTaskStatus) IsValid() bool { + switch s { + case WorkTaskStatusPending, WorkTaskStatusRunning, WorkTaskStatusCompleted, + WorkTaskStatusFailed, WorkTaskStatusCancelled: + return true + } + return false +} + +// WorkTaskType represents the type of work task. +type WorkTaskType string + +const ( + WorkTaskTypeBuild WorkTaskType = "build" + WorkTaskTypeTest WorkTaskType = "test" + WorkTaskTypeDeploy WorkTaskType = "deploy" + WorkTaskTypeCustom WorkTaskType = "custom" +) + +// IsValid returns true if the task type is a known valid type. +func (t WorkTaskType) IsValid() bool { + switch t { + case WorkTaskTypeBuild, WorkTaskTypeTest, WorkTaskTypeDeploy, WorkTaskTypeCustom: + return true + } + return false +} + +// WorkTask represents a task in the work queue. +type WorkTask struct { + // ID is the unique task identifier. + ID string + + // ProjectID is the project this task belongs to. + ProjectID string + + // Type is the task type (build, test, deploy, custom). + Type WorkTaskType + + // Spec contains task-specific parameters. + // For build tasks: template, prompt, variables, auto_deploy, git_url + // For test tasks: test_command, git_url + // For deploy tasks: image, replicas, env + Spec map[string]any + + // Status is the current task status. + Status WorkTaskStatus + + // Priority determines execution order (higher = more urgent). + Priority int + + // WorkerID is the ID of the worker that claimed this task. + WorkerID string + + // CallbackURL is the webhook URL for completion notification. + CallbackURL string + + // CreatedAt is when the task was created. + CreatedAt time.Time + + // StartedAt is when a worker started executing the task. + StartedAt *time.Time + + // CompletedAt is when the task finished (success or failure). + CompletedAt *time.Time + + // Result contains the task output (if completed). + Result *WorkResult + + // Error contains the error message (if failed). + Error string + + // RetryCount is the number of retry attempts. + RetryCount int + + // MaxRetries is the maximum allowed retry attempts. + MaxRetries int +} + +// WorkResult contains the result of a completed task. +type WorkResult struct { + // Output is the main output from task execution. + Output string `json:"output,omitempty"` + + // Artifacts contains named artifacts from the task. + // For build tasks: commit_sha, deploy_url, etc. + Artifacts map[string]string `json:"artifacts,omitempty"` +} + +// WorkQueueStats contains queue statistics. +type WorkQueueStats struct { + // Pending is the count of pending tasks. + Pending int64 `json:"pending"` + + // Running is the count of running tasks. + Running int64 `json:"running"` + + // Completed is the count of completed tasks (last 24h). + Completed int64 `json:"completed"` + + // Failed is the count of failed tasks (last 24h). + Failed int64 `json:"failed"` + + // Cancelled is the count of cancelled tasks (last 24h). + Cancelled int64 `json:"cancelled"` + + // OldestPending is the age of the oldest pending task. + OldestPending *time.Duration `json:"oldest_pending,omitempty"` +} + +// WorkListOptions contains pagination options for listing tasks. +type WorkListOptions struct { + // Limit is the maximum number of tasks to return (default: 50, max: 100). + Limit int + + // Offset is the number of tasks to skip (for pagination). + Offset int +} + +// DefaultWorkListOptions returns options with default values. +func DefaultWorkListOptions() WorkListOptions { + return WorkListOptions{ + Limit: 50, + Offset: 0, + } +} + +// Normalize applies defaults and limits to the options. +func (o *WorkListOptions) Normalize() { + if o.Limit <= 0 { + o.Limit = 50 + } + if o.Limit > 100 { + o.Limit = 100 + } + if o.Offset < 0 { + o.Offset = 0 + } +} + +// WorkListResult contains paginated task results. +type WorkListResult struct { + // Tasks is the list of tasks. + Tasks []*WorkTask + + // Total is the total count of matching tasks (for pagination metadata). + Total int64 + + // Limit is the limit that was applied. + Limit int + + // Offset is the offset that was applied. + Offset int +} diff --git a/internal/service/project_infra.go b/internal/service/project_infra.go index 8c41814..0aa175d 100644 --- a/internal/service/project_infra.go +++ b/internal/service/project_infra.go @@ -50,13 +50,15 @@ func ValidateProjectName(name string) error { } // ProjectInfraService orchestrates project infrastructure operations. -// It coordinates git repo creation, DNS, and deployment. +// It coordinates git repo creation, DNS, CI activation, template seeding, and deployment. type ProjectInfraService struct { - db *sql.DB - gitRepo port.GitRepository - dns port.DNSProvider - deployer port.Deployer - logger *slog.Logger + db *sql.DB + gitRepo port.GitRepository + dns port.DNSProvider + deployer port.Deployer + ciProvider port.CIProvider + templateProvider port.TemplateProvider + logger *slog.Logger // Config defaultGitOwner string @@ -78,6 +80,8 @@ func NewProjectInfraService( gitRepo port.GitRepository, dns port.DNSProvider, deployer port.Deployer, + ciProvider port.CIProvider, + templateProvider port.TemplateProvider, cfg ProjectInfraConfig, ) *ProjectInfraService { logger := cfg.Logger @@ -85,14 +89,16 @@ func NewProjectInfraService( logger = slog.Default() } return &ProjectInfraService{ - db: db, - gitRepo: gitRepo, - dns: dns, - deployer: deployer, - logger: logger, - defaultGitOwner: cfg.DefaultGitOwner, - defaultDomain: cfg.DefaultDomain, - clusterIP: cfg.ClusterIP, + db: db, + gitRepo: gitRepo, + dns: dns, + deployer: deployer, + ciProvider: ciProvider, + templateProvider: templateProvider, + logger: logger, + defaultGitOwner: cfg.DefaultGitOwner, + defaultDomain: cfg.DefaultDomain, + clusterIP: cfg.ClusterIP, } } @@ -101,6 +107,7 @@ type CreateProjectRequest struct { Name string Description string Private bool + Template string // Template to seed the repo with (default: "default") } // CreateProjectResult contains the result of project creation. @@ -129,7 +136,7 @@ type CreateProjectResult struct { func (s *ProjectInfraService) CreateProject(ctx context.Context, req CreateProjectRequest) (*CreateProjectResult, error) { // Validate project name first if err := ValidateProjectName(req.Name); err != nil { - return nil, fmt.Errorf("invalid project name: %w", err) + return nil, fmt.Errorf("%w: %v", domain.ErrInvalidProjectName, err) } s.logger.Info("creating project", "name", req.Name) @@ -216,12 +223,46 @@ func (s *ProjectInfraService) CreateProject(ctx context.Context, req CreateProje result.NextSteps = append(result.NextSteps, "DNS service not configured") } - // 4. Add next steps for Woodpecker activation - if result.HTMLURL != "" { - result.NextSteps = append(result.NextSteps, - fmt.Sprintf("Activate in Woodpecker: https://ci.%s → Add Repository → %s/%s", s.defaultDomain, s.defaultGitOwner, req.Name), - "Add .woodpecker.yml to your repo for CI/CD", - ) + // 4. Activate CI (Woodpecker) + if s.ciProvider != nil && result.GitRepoOwner != "" { + ciRepo, err := s.ciProvider.ActivateRepo(ctx, "gitea", result.GitRepoOwner, result.GitRepoName) + if err != nil { + s.logger.Warn("failed to activate CI", "error", err) + result.NextSteps = append(result.NextSteps, + fmt.Sprintf("Activate Woodpecker manually: https://ci.%s → Add Repository → %s/%s", s.defaultDomain, result.GitRepoOwner, result.GitRepoName), + ) + } else { + s.logger.Info("CI activated", "repo", ciRepo.FullName, "ci_id", ciRepo.ID) + } + } else if s.ciProvider == nil { + result.NextSteps = append(result.NextSteps, "CI provider not configured") + } + + // 5. Seed repository with template + if s.templateProvider != nil && result.GitRepoOwner != "" { + templateName := req.Template + if templateName == "" { + templateName = "default" + } + + // Prepare template variables + vars := map[string]string{ + "PROJECT_NAME": req.Name, + "DOMAIN": result.Domain, + "GIT_URL": result.CloneHTTP, + } + + err := s.templateProvider.SeedRepo(ctx, result.GitRepoOwner, result.GitRepoName, templateName, vars) + if err != nil { + s.logger.Warn("failed to seed repo with template", "error", err, "template", templateName) + result.NextSteps = append(result.NextSteps, + fmt.Sprintf("Add template files manually (template: %s)", templateName), + ) + } else { + s.logger.Info("repo seeded with template", "template", templateName) + } + } else if s.templateProvider == nil { + result.NextSteps = append(result.NextSteps, "Template provider not configured") } s.logger.Info("project created successfully", @@ -279,7 +320,7 @@ func (s *ProjectInfraService) GetStatus(ctx context.Context, projectID string) ( &status.DeploymentImage, &status.DeploymentStatus, &status.DeploymentReplicas, ) if err == sql.ErrNoRows { - return nil, fmt.Errorf("project not found: %s", projectID) + return nil, fmt.Errorf("%w: %s", domain.ErrProjectNotFound, projectID) } if err != nil { return nil, fmt.Errorf("failed to get project: %w", err) @@ -381,3 +422,19 @@ func (s *ProjectInfraService) DeleteProject(ctx context.Context, projectID strin s.logger.Info("project deleted", "project", projectID) return nil } + +// ListTemplates returns available project templates. +func (s *ProjectInfraService) ListTemplates(ctx context.Context) ([]port.TemplateInfo, error) { + if s.templateProvider == nil { + return nil, fmt.Errorf("template provider not configured") + } + return s.templateProvider.ListTemplates(ctx) +} + +// GetTemplate returns info about a specific template. +func (s *ProjectInfraService) GetTemplate(ctx context.Context, name string) (*port.TemplateInfo, error) { + if s.templateProvider == nil { + return nil, fmt.Errorf("template provider not configured") + } + return s.templateProvider.GetTemplate(ctx, name) +} diff --git a/internal/service/project_service.go b/internal/service/project_service.go index 62f57b6..33eebe0 100644 --- a/internal/service/project_service.go +++ b/internal/service/project_service.go @@ -25,6 +25,7 @@ type ProjectService struct { auditLogger port.AuditLogger // Optional audit logger queue port.CommandQueue // Optional command queue webhookDispatcher port.WebhookDispatcher // Optional webhook dispatcher + agentRegistry port.CodeAgentRegistry // Optional code agent registry logger *slog.Logger cmdID atomic.Uint64 } @@ -67,6 +68,12 @@ func (s *ProjectService) WithWebhookDispatcher(dispatcher port.WebhookDispatcher return s } +// WithCodeAgentRegistry sets a code agent registry for multi-provider support. +func (s *ProjectService) WithCodeAgentRegistry(registry port.CodeAgentRegistry) *ProjectService { + s.agentRegistry = registry + return s +} + // AuditContext contains audit-related information from the request. type AuditContext struct { APIKeyID string @@ -105,16 +112,21 @@ func (s *ProjectService) Exists(ctx context.Context, id domain.ProjectID) (bool, // ExecuteClaudeRequest contains parameters for running a Claude command. type ExecuteClaudeRequest struct { - ProjectID domain.ProjectID - Prompt string - StreamID string - Audit *AuditContext // Optional audit context + ProjectID domain.ProjectID + Prompt string + StreamID string + SessionID string // Optional: resume a previous session + Model string // Optional: model override (OpenCode only) + AllowedTools []string // Optional: restrict tool access + Audit *AuditContext // Optional audit context } // ExecuteClaudeResult contains the result of queuing a Claude command. type ExecuteClaudeResult struct { - CommandID domain.CommandID - StreamURL string + CommandID domain.CommandID + StreamURL string + SessionID string // Session ID for continuation + AgentProvider domain.AgentProvider } // ExecuteClaude runs a Claude command in the project's pod. @@ -174,7 +186,29 @@ func (s *ProjectService) ExecuteClaude(ctx context.Context, req ExecuteClaudeReq } } - // Execute in background + // Resolve agent and execute + agent := s.resolveAgent(project) + if agent != nil { + // Use CodeAgent for execution + agentReq := &domain.AgentRequest{ + Prompt: req.Prompt, + ProjectID: req.ProjectID, + SessionID: req.SessionID, + Model: req.Model, + AllowedTools: req.AllowedTools, + Metadata: map[string]string{"pod_name": project.PodName}, + } + go s.executeAgentCommand(agent, agentReq, cmd) + + return &ExecuteClaudeResult{ + CommandID: cmdID, + StreamURL: fmt.Sprintf("/projects/%s/events?stream_id=%s", req.ProjectID, cmdID), + SessionID: req.SessionID, // Will be updated by agent result + AgentProvider: agent.Provider(), + }, nil + } + + // Fallback to legacy executor go s.executeCommand(project.PodName, cmd) return &ExecuteClaudeResult{ @@ -183,166 +217,6 @@ func (s *ProjectService) ExecuteClaude(ctx context.Context, req ExecuteClaudeReq }, nil } -// ExecuteShellRequest contains parameters for running a shell command. -type ExecuteShellRequest struct { - ProjectID domain.ProjectID - Command string - StreamID string - Audit *AuditContext // Optional audit context -} - -// ExecuteShellResult contains the result of queuing a shell command. -type ExecuteShellResult struct { - CommandID domain.CommandID - StreamURL string -} - -// ExecuteShell runs a shell command in the project's pod. -func (s *ProjectService) ExecuteShell(ctx context.Context, req ExecuteShellRequest) (*ExecuteShellResult, error) { - // Validate project exists - project, err := s.projects.Get(ctx, req.ProjectID) - if err != nil { - return nil, err - } - - // Validate command - if req.Command == "" { - return nil, fmt.Errorf("%w: command is required", domain.ErrInvalidCommand) - } - if err := sanitize.ShellCommand(req.Command); err != nil { - return nil, fmt.Errorf("%w: %v", domain.ErrCommandSanitization, err) - } - - // Validate stream ID - if err := sanitize.StreamID(req.StreamID); err != nil { - return nil, fmt.Errorf("%w: %v", domain.ErrInvalidCommand, err) - } - - // Generate command ID - cmdNum := s.cmdID.Add(1) - cmdID := domain.CommandID(fmt.Sprintf("cmd-%s-%03d", req.ProjectID, cmdNum)) - if req.StreamID != "" { - cmdID = domain.CommandID(req.StreamID) - } - - // Create command - cmd := &domain.Command{ - ID: cmdID, - ProjectID: req.ProjectID, - Type: domain.CommandTypeShell, - Args: []string{req.Command}, - StartedAt: time.Now(), - } - - // Log audit start if audit logger is configured - if s.auditLogger != nil && req.Audit != nil { - argsJSON, _ := json.Marshal(cmd.Args) - auditEntry := &domain.AuditLogEntry{ - ID: uuid.New().String(), - APIKeyID: req.Audit.APIKeyID, - CommandID: string(cmdID), - ProjectID: string(req.ProjectID), - CommandType: domain.CommandTypeShell, - Args: string(argsJSON), - ClientIP: req.Audit.ClientIP, - UserAgent: req.Audit.UserAgent, - StartedAt: cmd.StartedAt, - Status: domain.AuditStatusRunning, - } - if err := s.auditLogger.LogCommandStart(ctx, auditEntry); err != nil { - s.logger.Warn("failed to log audit start", "command_id", cmdID, "error", err) - } - } - - // Execute in background - go s.executeCommand(project.PodName, cmd) - - return &ExecuteShellResult{ - CommandID: cmdID, - StreamURL: fmt.Sprintf("/projects/%s/events?stream_id=%s", req.ProjectID, cmdID), - }, nil -} - -// ExecuteGitRequest contains parameters for running a git command. -type ExecuteGitRequest struct { - ProjectID domain.ProjectID - Args []string - StreamID string - Audit *AuditContext // Optional audit context -} - -// ExecuteGitResult contains the result of queuing a git command. -type ExecuteGitResult struct { - CommandID domain.CommandID - StreamURL string -} - -// ExecuteGit runs a git command in the project's pod. -func (s *ProjectService) ExecuteGit(ctx context.Context, req ExecuteGitRequest) (*ExecuteGitResult, error) { - // Validate project exists - project, err := s.projects.Get(ctx, req.ProjectID) - if err != nil { - return nil, err - } - - // Validate args - if len(req.Args) == 0 { - return nil, fmt.Errorf("%w: args is required", domain.ErrInvalidCommand) - } - if err := sanitize.GitArgs(req.Args); err != nil { - return nil, fmt.Errorf("%w: %v", domain.ErrCommandSanitization, err) - } - - // Validate stream ID - if err := sanitize.StreamID(req.StreamID); err != nil { - return nil, fmt.Errorf("%w: %v", domain.ErrInvalidCommand, err) - } - - // Generate command ID - cmdNum := s.cmdID.Add(1) - cmdID := domain.CommandID(fmt.Sprintf("cmd-%s-%03d", req.ProjectID, cmdNum)) - if req.StreamID != "" { - cmdID = domain.CommandID(req.StreamID) - } - - // Create command - cmd := &domain.Command{ - ID: cmdID, - ProjectID: req.ProjectID, - Type: domain.CommandTypeGit, - Args: req.Args, - StartedAt: time.Now(), - } - - // Log audit start if audit logger is configured - if s.auditLogger != nil && req.Audit != nil { - argsJSON, _ := json.Marshal(cmd.Args) - auditEntry := &domain.AuditLogEntry{ - ID: uuid.New().String(), - APIKeyID: req.Audit.APIKeyID, - CommandID: string(cmdID), - ProjectID: string(req.ProjectID), - CommandType: domain.CommandTypeGit, - Args: string(argsJSON), - ClientIP: req.Audit.ClientIP, - UserAgent: req.Audit.UserAgent, - StartedAt: cmd.StartedAt, - Status: domain.AuditStatusRunning, - } - if err := s.auditLogger.LogCommandStart(ctx, auditEntry); err != nil { - s.logger.Warn("failed to log audit start", "command_id", cmdID, "error", err) - } - } - - // Execute in background - go s.executeCommand(project.PodName, cmd) - - return &ExecuteGitResult{ - CommandID: cmdID, - StreamURL: fmt.Sprintf("/projects/%s/events?stream_id=%s", req.ProjectID, cmdID), - }, nil -} - // executeCommand runs a command and streams output to subscribers. func (s *ProjectService) executeCommand(podName string, cmd *domain.Command) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) @@ -484,101 +358,3 @@ func (s *ProjectService) Subscribe(streamID string) (<-chan port.StreamEvent, fu func (s *ProjectService) SubscribeFromID(streamID, lastEventID string) (<-chan port.StreamEvent, func()) { return s.streams.SubscribeFromID(streamID, lastEventID) } - -// EnqueueCommandRequest contains parameters for enqueueing a command. -type EnqueueCommandRequest struct { - ProjectID domain.ProjectID - Command string - CommandType domain.CommandType - WorkingDir string - Priority int - Audit *AuditContext -} - -// EnqueueCommandResult contains the result of enqueueing a command. -type EnqueueCommandResult struct { - CommandID domain.QueuedCommandID - StreamURL string - Position int -} - -// EnqueueCommand adds a command to the project's queue for async execution. -// Returns an error if no queue is configured. -func (s *ProjectService) EnqueueCommand(ctx context.Context, req EnqueueCommandRequest) (*EnqueueCommandResult, error) { - if s.queue == nil { - return nil, fmt.Errorf("command queue not configured") - } - - // Validate project exists - exists, err := s.projects.Exists(ctx, req.ProjectID) - if err != nil { - return nil, err - } - if !exists { - return nil, domain.ErrProjectNotFound - } - - // Create queued command - cmd := &domain.QueuedCommand{ - ProjectID: string(req.ProjectID), - Command: req.Command, - CommandType: req.CommandType, - WorkingDir: req.WorkingDir, - Status: domain.QueueStatusPending, - Priority: req.Priority, - } - if req.Audit != nil { - cmd.APIKeyID = req.Audit.APIKeyID - } - - // Enqueue - if err := s.queue.Enqueue(ctx, cmd); err != nil { - return nil, fmt.Errorf("enqueue command: %w", err) - } - - // Get approximate position - pendingStatus := domain.QueueStatusPending - pending, _ := s.queue.List(ctx, string(req.ProjectID), &domain.QueueFilters{ - Status: &pendingStatus, - Limit: 1000, - SortOrder: "asc", - }) - - return &EnqueueCommandResult{ - CommandID: cmd.ID, - StreamURL: fmt.Sprintf("/projects/%s/events?stream_id=%s", req.ProjectID, cmd.ID), - Position: len(pending), - }, nil -} - -// GetQueuedCommand retrieves a queued command by ID. -func (s *ProjectService) GetQueuedCommand(ctx context.Context, cmdID domain.QueuedCommandID) (*domain.QueuedCommand, error) { - if s.queue == nil { - return nil, fmt.Errorf("command queue not configured") - } - return s.queue.GetByID(ctx, cmdID) -} - -// ListQueuedCommands returns queued commands for a project. -func (s *ProjectService) ListQueuedCommands(ctx context.Context, projectID domain.ProjectID, filters *domain.QueueFilters) ([]*domain.QueuedCommand, error) { - if s.queue == nil { - return nil, fmt.Errorf("command queue not configured") - } - return s.queue.List(ctx, string(projectID), filters) -} - -// CancelQueuedCommand cancels a pending queued command. -func (s *ProjectService) CancelQueuedCommand(ctx context.Context, cmdID domain.QueuedCommandID) error { - if s.queue == nil { - return fmt.Errorf("command queue not configured") - } - return s.queue.Cancel(ctx, cmdID) -} - -// GetQueueStats returns queue statistics for a project. -func (s *ProjectService) GetQueueStats(ctx context.Context, projectID domain.ProjectID) (*domain.QueueStats, error) { - if s.queue == nil { - return nil, fmt.Errorf("command queue not configured") - } - return s.queue.GetStats(ctx, string(projectID)) -} diff --git a/internal/service/project_service_agent.go b/internal/service/project_service_agent.go new file mode 100644 index 0000000..a38cd86 --- /dev/null +++ b/internal/service/project_service_agent.go @@ -0,0 +1,223 @@ +// Package service provides business logic / use cases for the application. +// This file contains CodeAgent integration for multi-provider support. +package service + +import ( + "context" + "time" + + "github.com/orchard9/rdev/internal/domain" + "github.com/orchard9/rdev/internal/metrics" + "github.com/orchard9/rdev/internal/port" +) + +// resolveAgent returns the appropriate CodeAgent for a project. +// Returns nil if no agent registry is configured or no agent is available. +func (s *ProjectService) resolveAgent(project *domain.Project) port.CodeAgent { + if s.agentRegistry == nil { + return nil + } + + // Try project-specific agent first + if project.AgentProvider != "" { + if agent := s.agentRegistry.Get(project.AgentProvider); agent != nil { + return agent + } + } + + // Fall back to default + return s.agentRegistry.Default() +} + +// executeAgentCommand runs a command via CodeAgent and streams output. +func (s *ProjectService) executeAgentCommand(agent port.CodeAgent, req *domain.AgentRequest, cmd *domain.Command) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + + streamID := string(cmd.ID) + var lastEventID string + var outputSizeBytes int64 + + // Dispatch command.started webhook event + s.dispatchWebhookEvent(ctx, string(cmd.ProjectID), domain.WebhookEventCommandStarted, &domain.CommandEventData{ + CommandID: string(cmd.ID), + CommandType: cmd.Type, + ProjectID: string(cmd.ProjectID), + StartedAt: cmd.StartedAt, + }) + + // Execute via agent with event handler + result, _ := agent.Execute(ctx, req, func(event domain.AgentEvent) { + // Convert agent event to stream event + var eventType string + var data map[string]any + + switch event.Type { + case domain.AgentEventOutput: + eventType = "output" + data = map[string]any{ + "line": event.Content, + "stream": event.Stream, + } + case domain.AgentEventToolUse: + eventType = "tool_use" + data = map[string]any{ + "tool": event.ToolName, + "input": event.ToolInput, + } + case domain.AgentEventToolResult: + eventType = "tool_result" + data = map[string]any{ + "output": event.Content, + } + case domain.AgentEventError: + eventType = "error" + data = map[string]any{ + "error": event.Content, + } + case domain.AgentEventComplete: + eventType = "agent_complete" + data = event.Metadata + default: + eventType = "output" + data = map[string]any{ + "line": event.Content, + "stream": "stdout", + } + } + + eventID := s.streams.Publish(streamID, port.StreamEvent{ + Type: eventType, + Data: data, + }) + lastEventID = eventID + outputSizeBytes += int64(len(event.Content)) + }) + + // Send completion event + eventID := s.streams.Publish(streamID, port.StreamEvent{ + Type: "complete", + Data: map[string]any{ + "exit_code": result.ExitCode, + "duration_ms": result.DurationMs, + "session_id": result.SessionID, + "provider": agent.Provider(), + }, + }) + + // Record metrics + status := "success" + if result.ExitCode != 0 { + status = "error" + } + metrics.RecordCommand(string(cmd.ProjectID), string(cmd.Type), status, result.DurationMs) + + // Log audit completion if audit logger is configured + if s.auditLogger != nil { + var auditStatus domain.AuditStatus + var errorMsg string + if result.Error != nil { + auditStatus = domain.AuditStatusError + errorMsg = result.Error.Error() + } else if result.ExitCode != 0 { + auditStatus = domain.AuditStatusError + } else { + auditStatus = domain.AuditStatusSuccess + } + + auditResult := &domain.AuditResult{ + ExitCode: result.ExitCode, + DurationMs: result.DurationMs, + Status: auditStatus, + ErrorMessage: errorMsg, + OutputSizeBytes: outputSizeBytes, + } + if err := s.auditLogger.LogCommandEnd(ctx, string(cmd.ID), auditResult); err != nil { + s.logger.Warn("failed to log audit end", "command_id", cmd.ID, "error", err) + } + } + + // Dispatch command.completed or command.failed webhook event + completedAt := time.Now() + var webhookEventType domain.WebhookEventType + var errorMsg string + if result.Error != nil { + webhookEventType = domain.WebhookEventCommandFailed + errorMsg = result.Error.Error() + } else if result.ExitCode != 0 { + webhookEventType = domain.WebhookEventCommandFailed + } else { + webhookEventType = domain.WebhookEventCommandCompleted + } + + s.dispatchWebhookEvent(ctx, string(cmd.ProjectID), webhookEventType, &domain.CommandEventData{ + CommandID: string(cmd.ID), + CommandType: cmd.Type, + ProjectID: string(cmd.ProjectID), + StartedAt: cmd.StartedAt, + CompletedAt: completedAt, + ExitCode: result.ExitCode, + DurationMs: result.DurationMs, + Error: errorMsg, + }) + + s.logger.Debug("agent command completed", + "command_id", cmd.ID, + "provider", agent.Provider(), + "session_id", result.SessionID, + "exit_code", result.ExitCode, + "duration_ms", result.DurationMs, + "last_event_id", lastEventID, + "complete_event_id", eventID, + ) + + // Clean up stream after a delay + go func() { + time.Sleep(30 * time.Second) + s.streams.Close(streamID) + }() +} + +// GetAgentCapabilities returns the capabilities for a specific agent provider. +// Returns nil if no agent registry is configured or the provider is not found. +func (s *ProjectService) GetAgentCapabilities(provider domain.AgentProvider) *domain.AgentCapabilities { + if s.agentRegistry == nil { + return nil + } + + agent := s.agentRegistry.Get(provider) + if agent == nil { + return nil + } + + caps := agent.Capabilities() + return &caps +} + +// ListAvailableAgents returns all available agent providers. +func (s *ProjectService) ListAvailableAgents() []domain.AgentProvider { + if s.agentRegistry == nil { + return nil + } + return s.agentRegistry.Available() +} + +// GetDefaultAgent returns the default agent provider. +func (s *ProjectService) GetDefaultAgent() domain.AgentProvider { + if s.agentRegistry == nil { + return "" + } + agent := s.agentRegistry.Default() + if agent == nil { + return "" + } + return agent.Provider() +} + +// SetDefaultAgent sets the default agent provider. +func (s *ProjectService) SetDefaultAgent(provider domain.AgentProvider) error { + if s.agentRegistry == nil { + return domain.ErrInvalidAgentProvider + } + return s.agentRegistry.SetDefault(provider) +} diff --git a/internal/service/project_service_commands.go b/internal/service/project_service_commands.go new file mode 100644 index 0000000..e27e20b --- /dev/null +++ b/internal/service/project_service_commands.go @@ -0,0 +1,174 @@ +// Package service provides business logic / use cases for the application. +// This file contains shell and git command execution functionality. +package service + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/orchard9/rdev/internal/domain" + "github.com/orchard9/rdev/internal/sanitize" +) + +// ExecuteShellRequest contains parameters for running a shell command. +type ExecuteShellRequest struct { + ProjectID domain.ProjectID + Command string + StreamID string + Audit *AuditContext // Optional audit context +} + +// ExecuteShellResult contains the result of queuing a shell command. +type ExecuteShellResult struct { + CommandID domain.CommandID + StreamURL string +} + +// ExecuteShell runs a shell command in the project's pod. +func (s *ProjectService) ExecuteShell(ctx context.Context, req ExecuteShellRequest) (*ExecuteShellResult, error) { + // Validate project exists + project, err := s.projects.Get(ctx, req.ProjectID) + if err != nil { + return nil, err + } + + // Validate command + if req.Command == "" { + return nil, fmt.Errorf("%w: command is required", domain.ErrInvalidCommand) + } + if err := sanitize.ShellCommand(req.Command); err != nil { + return nil, fmt.Errorf("%w: %v", domain.ErrCommandSanitization, err) + } + + // Validate stream ID + if err := sanitize.StreamID(req.StreamID); err != nil { + return nil, fmt.Errorf("%w: %v", domain.ErrInvalidCommand, err) + } + + // Generate command ID + cmdNum := s.cmdID.Add(1) + cmdID := domain.CommandID(fmt.Sprintf("cmd-%s-%03d", req.ProjectID, cmdNum)) + if req.StreamID != "" { + cmdID = domain.CommandID(req.StreamID) + } + + // Create command + cmd := &domain.Command{ + ID: cmdID, + ProjectID: req.ProjectID, + Type: domain.CommandTypeShell, + Args: []string{req.Command}, + StartedAt: time.Now(), + } + + // Log audit start if audit logger is configured + if s.auditLogger != nil && req.Audit != nil { + argsJSON, _ := json.Marshal(cmd.Args) + auditEntry := &domain.AuditLogEntry{ + ID: uuid.New().String(), + APIKeyID: req.Audit.APIKeyID, + CommandID: string(cmdID), + ProjectID: string(req.ProjectID), + CommandType: domain.CommandTypeShell, + Args: string(argsJSON), + ClientIP: req.Audit.ClientIP, + UserAgent: req.Audit.UserAgent, + StartedAt: cmd.StartedAt, + Status: domain.AuditStatusRunning, + } + if err := s.auditLogger.LogCommandStart(ctx, auditEntry); err != nil { + s.logger.Warn("failed to log audit start", "command_id", cmdID, "error", err) + } + } + + // Execute in background + go s.executeCommand(project.PodName, cmd) + + return &ExecuteShellResult{ + CommandID: cmdID, + StreamURL: fmt.Sprintf("/projects/%s/events?stream_id=%s", req.ProjectID, cmdID), + }, nil +} + +// ExecuteGitRequest contains parameters for running a git command. +type ExecuteGitRequest struct { + ProjectID domain.ProjectID + Args []string + StreamID string + Audit *AuditContext // Optional audit context +} + +// ExecuteGitResult contains the result of queuing a git command. +type ExecuteGitResult struct { + CommandID domain.CommandID + StreamURL string +} + +// ExecuteGit runs a git command in the project's pod. +func (s *ProjectService) ExecuteGit(ctx context.Context, req ExecuteGitRequest) (*ExecuteGitResult, error) { + // Validate project exists + project, err := s.projects.Get(ctx, req.ProjectID) + if err != nil { + return nil, err + } + + // Validate args + if len(req.Args) == 0 { + return nil, fmt.Errorf("%w: args is required", domain.ErrInvalidCommand) + } + if err := sanitize.GitArgs(req.Args); err != nil { + return nil, fmt.Errorf("%w: %v", domain.ErrCommandSanitization, err) + } + + // Validate stream ID + if err := sanitize.StreamID(req.StreamID); err != nil { + return nil, fmt.Errorf("%w: %v", domain.ErrInvalidCommand, err) + } + + // Generate command ID + cmdNum := s.cmdID.Add(1) + cmdID := domain.CommandID(fmt.Sprintf("cmd-%s-%03d", req.ProjectID, cmdNum)) + if req.StreamID != "" { + cmdID = domain.CommandID(req.StreamID) + } + + // Create command + cmd := &domain.Command{ + ID: cmdID, + ProjectID: req.ProjectID, + Type: domain.CommandTypeGit, + Args: req.Args, + StartedAt: time.Now(), + } + + // Log audit start if audit logger is configured + if s.auditLogger != nil && req.Audit != nil { + argsJSON, _ := json.Marshal(cmd.Args) + auditEntry := &domain.AuditLogEntry{ + ID: uuid.New().String(), + APIKeyID: req.Audit.APIKeyID, + CommandID: string(cmdID), + ProjectID: string(req.ProjectID), + CommandType: domain.CommandTypeGit, + Args: string(argsJSON), + ClientIP: req.Audit.ClientIP, + UserAgent: req.Audit.UserAgent, + StartedAt: cmd.StartedAt, + Status: domain.AuditStatusRunning, + } + if err := s.auditLogger.LogCommandStart(ctx, auditEntry); err != nil { + s.logger.Warn("failed to log audit start", "command_id", cmdID, "error", err) + } + } + + // Execute in background + go s.executeCommand(project.PodName, cmd) + + return &ExecuteGitResult{ + CommandID: cmdID, + StreamURL: fmt.Sprintf("/projects/%s/events?stream_id=%s", req.ProjectID, cmdID), + }, nil +} diff --git a/internal/service/project_service_queue.go b/internal/service/project_service_queue.go new file mode 100644 index 0000000..64a6492 --- /dev/null +++ b/internal/service/project_service_queue.go @@ -0,0 +1,108 @@ +// Package service provides business logic / use cases for the application. +// This file contains command queue functionality for async task execution. +package service + +import ( + "context" + "fmt" + + "github.com/orchard9/rdev/internal/domain" +) + +// EnqueueCommandRequest contains parameters for enqueueing a command. +type EnqueueCommandRequest struct { + ProjectID domain.ProjectID + Command string + CommandType domain.CommandType + WorkingDir string + Priority int + Audit *AuditContext +} + +// EnqueueCommandResult contains the result of enqueueing a command. +type EnqueueCommandResult struct { + CommandID domain.QueuedCommandID + StreamURL string + Position int +} + +// EnqueueCommand adds a command to the project's queue for async execution. +// Returns an error if no queue is configured. +func (s *ProjectService) EnqueueCommand(ctx context.Context, req EnqueueCommandRequest) (*EnqueueCommandResult, error) { + if s.queue == nil { + return nil, fmt.Errorf("command queue not configured") + } + + // Validate project exists + exists, err := s.projects.Exists(ctx, req.ProjectID) + if err != nil { + return nil, err + } + if !exists { + return nil, domain.ErrProjectNotFound + } + + // Create queued command + cmd := &domain.QueuedCommand{ + ProjectID: string(req.ProjectID), + Command: req.Command, + CommandType: req.CommandType, + WorkingDir: req.WorkingDir, + Status: domain.QueueStatusPending, + Priority: req.Priority, + } + if req.Audit != nil { + cmd.APIKeyID = req.Audit.APIKeyID + } + + // Enqueue + if err := s.queue.Enqueue(ctx, cmd); err != nil { + return nil, fmt.Errorf("enqueue command: %w", err) + } + + // Get approximate position + pendingStatus := domain.QueueStatusPending + pending, _ := s.queue.List(ctx, string(req.ProjectID), &domain.QueueFilters{ + Status: &pendingStatus, + Limit: 1000, + SortOrder: "asc", + }) + + return &EnqueueCommandResult{ + CommandID: cmd.ID, + StreamURL: fmt.Sprintf("/projects/%s/events?stream_id=%s", req.ProjectID, cmd.ID), + Position: len(pending), + }, nil +} + +// GetQueuedCommand retrieves a queued command by ID. +func (s *ProjectService) GetQueuedCommand(ctx context.Context, cmdID domain.QueuedCommandID) (*domain.QueuedCommand, error) { + if s.queue == nil { + return nil, fmt.Errorf("command queue not configured") + } + return s.queue.GetByID(ctx, cmdID) +} + +// ListQueuedCommands returns queued commands for a project. +func (s *ProjectService) ListQueuedCommands(ctx context.Context, projectID domain.ProjectID, filters *domain.QueueFilters) ([]*domain.QueuedCommand, error) { + if s.queue == nil { + return nil, fmt.Errorf("command queue not configured") + } + return s.queue.List(ctx, string(projectID), filters) +} + +// CancelQueuedCommand cancels a pending queued command. +func (s *ProjectService) CancelQueuedCommand(ctx context.Context, cmdID domain.QueuedCommandID) error { + if s.queue == nil { + return fmt.Errorf("command queue not configured") + } + return s.queue.Cancel(ctx, cmdID) +} + +// GetQueueStats returns queue statistics for a project. +func (s *ProjectService) GetQueueStats(ctx context.Context, projectID domain.ProjectID) (*domain.QueueStats, error) { + if s.queue == nil { + return nil, fmt.Errorf("command queue not configured") + } + return s.queue.GetStats(ctx, string(projectID)) +} diff --git a/internal/service/work_service.go b/internal/service/work_service.go new file mode 100644 index 0000000..e539479 --- /dev/null +++ b/internal/service/work_service.go @@ -0,0 +1,276 @@ +// Package service provides business logic services. +package service + +import ( + "context" + "fmt" + "log/slog" + + "github.com/orchard9/rdev/internal/port" + "github.com/orchard9/rdev/internal/webhook" +) + +// WorkService orchestrates work queue operations. +// It coordinates task enqueueing, completion, and webhook notifications. +type WorkService struct { + queue port.WorkQueue + webhookDispatcher *webhook.Dispatcher + logger *slog.Logger +} + +// WorkServiceConfig configures the work service. +type WorkServiceConfig struct { + Logger *slog.Logger +} + +// NewWorkService creates a new work service. +func NewWorkService(queue port.WorkQueue, cfg WorkServiceConfig) *WorkService { + logger := cfg.Logger + if logger == nil { + logger = slog.Default() + } + return &WorkService{ + queue: queue, + logger: logger, + } +} + +// WithWebhookDispatcher adds a webhook dispatcher for task completion notifications. +func (s *WorkService) WithWebhookDispatcher(dispatcher *webhook.Dispatcher) *WorkService { + s.webhookDispatcher = dispatcher + return s +} + +// EnqueueTask adds a new task to the work queue. +func (s *WorkService) EnqueueTask(ctx context.Context, req EnqueueTaskRequest) (*EnqueueTaskResult, error) { + // Validate required fields + if req.ProjectID == "" { + return nil, fmt.Errorf("project_id is required") + } + if req.Type == "" { + return nil, fmt.Errorf("task_type is required") + } + + // Set defaults + maxRetries := req.MaxRetries + if maxRetries == 0 { + maxRetries = 3 + } + + task := &port.WorkTask{ + ProjectID: req.ProjectID, + Type: req.Type, + Spec: req.Spec, + Priority: req.Priority, + CallbackURL: req.CallbackURL, + MaxRetries: maxRetries, + } + + taskID, err := s.queue.Enqueue(ctx, task) + if err != nil { + return nil, fmt.Errorf("enqueue task: %w", err) + } + + s.logger.Info("task enqueued", + "task_id", taskID, + "project", req.ProjectID, + "type", req.Type, + "priority", req.Priority, + ) + + return &EnqueueTaskResult{ + TaskID: taskID, + StatusURL: fmt.Sprintf("/work/%s/status", taskID), + }, nil +} + +// DequeueTask claims the next available task for a worker. +func (s *WorkService) DequeueTask(ctx context.Context, workerID string) (*port.WorkTask, error) { + if workerID == "" { + return nil, fmt.Errorf("worker_id is required") + } + + task, err := s.queue.Dequeue(ctx, workerID) + if err != nil { + return nil, fmt.Errorf("dequeue task: %w", err) + } + + if task != nil { + s.logger.Info("task claimed by worker", + "task_id", task.ID, + "worker_id", workerID, + "project", task.ProjectID, + "type", task.Type, + ) + } + + return task, nil +} + +// CompleteTask marks a task as successfully completed. +func (s *WorkService) CompleteTask(ctx context.Context, taskID string, result *port.WorkResult) error { + // Get task for callback URL before completing + task, err := s.queue.GetTask(ctx, taskID) + if err != nil { + return fmt.Errorf("get task: %w", err) + } + + if err := s.queue.Complete(ctx, taskID, result); err != nil { + return fmt.Errorf("complete task: %w", err) + } + + s.logger.Info("task completed", + "task_id", taskID, + "project", task.ProjectID, + "type", task.Type, + ) + + // Send webhook notification if callback URL is set + if task.CallbackURL != "" { + s.notifyCallback(task, "completed", result, "") + } + + return nil +} + +// FailTask marks a task as failed. +func (s *WorkService) FailTask(ctx context.Context, taskID string, errMsg string) error { + // Get task for callback URL before failing + task, err := s.queue.GetTask(ctx, taskID) + if err != nil { + return fmt.Errorf("get task: %w", err) + } + + if err := s.queue.Fail(ctx, taskID, errMsg); err != nil { + return fmt.Errorf("fail task: %w", err) + } + + // Check if it was requeued or permanently failed + updatedTask, _ := s.queue.GetTask(ctx, taskID) + if updatedTask != nil && updatedTask.Status == port.WorkTaskStatusFailed { + s.logger.Warn("task failed permanently", + "task_id", taskID, + "project", task.ProjectID, + "type", task.Type, + "error", errMsg, + "retry_count", task.RetryCount, + ) + + // Send webhook notification for permanent failure + if task.CallbackURL != "" { + s.notifyCallback(task, "failed", nil, errMsg) + } + } else { + s.logger.Warn("task failed, will retry", + "task_id", taskID, + "project", task.ProjectID, + "type", task.Type, + "error", errMsg, + "retry_count", task.RetryCount+1, + ) + } + + return nil +} + +// CancelTask cancels a pending task. +func (s *WorkService) CancelTask(ctx context.Context, taskID string) error { + task, err := s.queue.GetTask(ctx, taskID) + if err != nil { + return fmt.Errorf("get task: %w", err) + } + + if err := s.queue.Cancel(ctx, taskID); err != nil { + return err + } + + s.logger.Info("task cancelled", + "task_id", taskID, + "project", task.ProjectID, + "type", task.Type, + ) + + // Send webhook notification + if task.CallbackURL != "" { + s.notifyCallback(task, "cancelled", nil, "Task cancelled by user") + } + + return nil +} + +// GetTask retrieves a task by ID. +func (s *WorkService) GetTask(ctx context.Context, taskID string) (*port.WorkTask, error) { + return s.queue.GetTask(ctx, taskID) +} + +// ListByProject returns tasks for a project with pagination. +func (s *WorkService) ListByProject(ctx context.Context, projectID string, status *port.WorkTaskStatus, opts port.WorkListOptions) (*port.WorkListResult, error) { + return s.queue.ListByProject(ctx, projectID, status, opts) +} + +// GetStats returns queue statistics. +func (s *WorkService) GetStats(ctx context.Context) (*port.WorkQueueStats, error) { + return s.queue.GetStats(ctx) +} + +// notifyCallback sends a webhook notification for task status changes. +func (s *WorkService) notifyCallback(task *port.WorkTask, status string, result *port.WorkResult, errMsg string) { + if s.webhookDispatcher == nil || task.CallbackURL == "" { + return + } + + payload := map[string]any{ + "task_id": task.ID, + "project_id": task.ProjectID, + "task_type": string(task.Type), + "status": status, + } + + if result != nil { + payload["result"] = result + } + if errMsg != "" { + payload["error"] = errMsg + } + + // Dispatch webhook asynchronously + go func() { + if err := s.webhookDispatcher.DispatchToURL(task.CallbackURL, "work."+status, payload); err != nil { + s.logger.Error("failed to send callback", + "task_id", task.ID, + "callback_url", task.CallbackURL, + "error", err, + ) + } + }() +} + +// EnqueueTaskRequest contains parameters for enqueueing a task. +type EnqueueTaskRequest struct { + // ProjectID is the project this task belongs to. + ProjectID string `json:"project_id"` + + // Type is the task type (build, test, deploy, custom). + Type port.WorkTaskType `json:"task_type"` + + // Spec contains task-specific parameters. + Spec map[string]any `json:"task_spec"` + + // Priority determines execution order (higher = more urgent). + Priority int `json:"priority,omitempty"` + + // CallbackURL is the webhook URL for completion notification. + CallbackURL string `json:"callback_url,omitempty"` + + // MaxRetries is the maximum allowed retry attempts (default: 3). + MaxRetries int `json:"max_retries,omitempty"` +} + +// EnqueueTaskResult contains the result of enqueueing a task. +type EnqueueTaskResult struct { + // TaskID is the unique task identifier. + TaskID string `json:"task_id"` + + // StatusURL is the URL to check task status. + StatusURL string `json:"status_url"` +} diff --git a/internal/webhook/dispatcher.go b/internal/webhook/dispatcher.go index fd02396..30199d4 100644 --- a/internal/webhook/dispatcher.go +++ b/internal/webhook/dispatcher.go @@ -353,3 +353,54 @@ func (d *Dispatcher) signPayload(payload []byte, secret string) string { mac.Write(payload) return "sha256=" + hex.EncodeToString(mac.Sum(nil)) } + +// DispatchToURL sends a webhook to a specific URL (for callback URLs). +// This is a simpler version of Dispatch for one-off callbacks that don't go through +// the webhook subscription system. +func (d *Dispatcher) DispatchToURL(url string, eventType string, payload map[string]any) error { + if url == "" { + return nil + } + + // Build payload with standard envelope + fullPayload := map[string]any{ + "id": uuid.New().String(), + "event": eventType, + "timestamp": time.Now().UTC().Format(time.RFC3339), + "data": payload, + } + + payloadBytes, err := json.Marshal(fullPayload) + if err != nil { + return fmt.Errorf("marshal payload: %w", err) + } + + // Create request + req, err := http.NewRequestWithContext(d.ctx, http.MethodPost, url, bytes.NewReader(payloadBytes)) + if err != nil { + return fmt.Errorf("create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "rdev-webhook/1.0") + req.Header.Set("X-Webhook-Event", eventType) + + // Send request + resp, err := d.client.Do(req) + if err != nil { + return fmt.Errorf("request failed: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("received non-2xx status: %d", resp.StatusCode) + } + + d.config.Logger.Debug("callback sent successfully", + "url", url, + "event", eventType, + "status", resp.StatusCode, + ) + + return nil +} diff --git a/scripts/load-credentials.sh b/scripts/load-credentials.sh new file mode 100755 index 0000000..ede3bd8 --- /dev/null +++ b/scripts/load-credentials.sh @@ -0,0 +1,161 @@ +#!/usr/bin/env bash +# load-credentials.sh - Load credentials from .secrets file into rdev-api +# +# Usage: +# ./scripts/load-credentials.sh # Load to localhost:8080 +# ./scripts/load-credentials.sh https://rdev.example.com # Load to remote +# RDEV_ADMIN_KEY=xxx ./scripts/load-credentials.sh # With explicit admin key +# +# Reads credentials from .secrets file (KEY=VALUE format) and uploads them +# to the rdev-api /credentials/batch endpoint. + +set -euo pipefail + +# Configuration +RDEV_API_URL="${1:-http://localhost:8080}" +SECRETS_FILE="${SECRETS_FILE:-.secrets}" +ADMIN_KEY="${RDEV_ADMIN_KEY:-}" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +log_info() { echo -e "${GREEN}[INFO]${NC} $1"; } +log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } +log_error() { echo -e "${RED}[ERROR]${NC} $1"; } + +# Check for required tools +if ! command -v curl &> /dev/null; then + log_error "curl is required but not installed" + exit 1 +fi + +if ! command -v jq &> /dev/null; then + log_error "jq is required but not installed" + exit 1 +fi + +# Check secrets file exists +if [[ ! -f "$SECRETS_FILE" ]]; then + log_error "Secrets file not found: $SECRETS_FILE" + log_info "Create a .secrets file with KEY=VALUE pairs, e.g.:" + echo " GITEA_TOKEN=your-token-here" + echo " CLOUDFLARE_API_TOKEN=your-token-here" + exit 1 +fi + +# Get admin key if not provided +if [[ -z "$ADMIN_KEY" ]]; then + # Try to get from K8s secret + if command -v kubectl &> /dev/null; then + ADMIN_KEY=$(kubectl get secret rdev-credentials -n rdev -o jsonpath='{.data.RDEV_ADMIN_KEY}' 2>/dev/null | base64 -d 2>/dev/null || true) + fi + + if [[ -z "$ADMIN_KEY" ]]; then + log_error "RDEV_ADMIN_KEY not set and could not retrieve from K8s" + log_info "Set RDEV_ADMIN_KEY environment variable or ensure kubectl access to rdev namespace" + exit 1 + fi +fi + +log_info "Loading credentials from $SECRETS_FILE to $RDEV_API_URL" + +# Map of secret keys to categories and descriptions +declare -A CATEGORIES=( + ["GITEA_TOKEN"]="gitea" + ["GITEA_API_TOKEN"]="gitea" + ["GITEA_URL"]="gitea" + ["CLOUDFLARE_API_TOKEN"]="cloudflare" + ["CLOUDFLARE_ZONE_ID"]="cloudflare" + ["WOODPECKER_URL"]="woodpecker" + ["WOODPECKER_API_TOKEN"]="woodpecker" + ["WOODPECKER_WEBHOOK_SECRET"]="woodpecker" + ["REGISTRY_URL"]="registry" +) + +declare -A DESCRIPTIONS=( + ["GITEA_TOKEN"]="Gitea API access token" + ["GITEA_API_TOKEN"]="Gitea API access token" + ["GITEA_URL"]="Gitea server URL" + ["CLOUDFLARE_API_TOKEN"]="Cloudflare API token for DNS management" + ["CLOUDFLARE_ZONE_ID"]="Cloudflare zone ID for threesix.ai" + ["WOODPECKER_URL"]="Woodpecker CI server URL" + ["WOODPECKER_API_TOKEN"]="Woodpecker CI API token for repo activation" + ["WOODPECKER_WEBHOOK_SECRET"]="HMAC secret for Woodpecker webhook verification" + ["REGISTRY_URL"]="Container registry URL" +) + +# Build JSON payload from secrets file +CREDENTIALS_JSON='{"credentials":[' +FIRST=true + +while IFS='=' read -r key value || [[ -n "$key" ]]; do + # Skip empty lines and comments + [[ -z "$key" || "$key" =~ ^# ]] && continue + + # Trim whitespace + key=$(echo "$key" | xargs) + value=$(echo "$value" | xargs) + + # Skip if empty value + [[ -z "$value" ]] && continue + + # Normalize key name (GITEA_API_TOKEN -> GITEA_TOKEN) + if [[ "$key" == "GITEA_API_TOKEN" ]]; then + key="GITEA_TOKEN" + fi + + # Get category and description + category="${CATEGORIES[$key]:-other}" + description="${DESCRIPTIONS[$key]:-$key credential}" + + # Add comma if not first + if [[ "$FIRST" == "true" ]]; then + FIRST=false + else + CREDENTIALS_JSON+=',' + fi + + # Escape value for JSON + escaped_value=$(echo -n "$value" | jq -Rs '.') + + CREDENTIALS_JSON+="{\"key\":\"$key\",\"value\":$escaped_value,\"category\":\"$category\",\"description\":\"$description\"}" + + log_info " Prepared: $key ($category)" +done < "$SECRETS_FILE" + +CREDENTIALS_JSON+=']}' + +# Count credentials +CRED_COUNT=$(echo "$CREDENTIALS_JSON" | jq '.credentials | length') +if [[ "$CRED_COUNT" == "0" ]]; then + log_warn "No credentials found in $SECRETS_FILE" + exit 0 +fi + +log_info "Uploading $CRED_COUNT credentials..." + +# Upload to rdev-api +RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "$RDEV_API_URL/credentials/batch" \ + -H "Content-Type: application/json" \ + -H "X-API-Key: $ADMIN_KEY" \ + -d "$CREDENTIALS_JSON") + +HTTP_CODE=$(echo "$RESPONSE" | tail -n1) +BODY=$(echo "$RESPONSE" | sed '$d') + +if [[ "$HTTP_CODE" == "201" ]]; then + log_info "Successfully uploaded credentials" + echo "$BODY" | jq -r '.keys[]' 2>/dev/null | while read -r k; do + echo " - $k" + done +else + log_error "Failed to upload credentials (HTTP $HTTP_CODE)" + echo "$BODY" | jq . 2>/dev/null || echo "$BODY" + exit 1 +fi + +log_info "Done! Credentials are now stored in rdev database." +log_info "Restart rdev-api to reload infrastructure adapters with new credentials."