From 0fd4e32073c5d6e927522e02427eb8d13787d148 Mon Sep 17 00:00:00 2001 From: jordan Date: Sun, 25 Jan 2026 22:49:58 -0700 Subject: [PATCH] feat: Add infrastructure adapters for threesix.ai Add Gitea, Cloudflare DNS, and Kubernetes deployer adapters following hexagonal architecture. These enable automated project provisioning: - Git repository creation/management via Gitea - DNS record management via Cloudflare - Container deployment to Kubernetes Includes domain models, ports, handlers, and Woodpecker CI webhook integration for automated deployments on push. Co-Authored-By: Claude Opus 4.5 --- .golangci.yml | 13 + cmd/rdev-api/main.go | 104 +++ docs/plans/THREESIX_INFRASTRUCTURE.md | 440 +++++++++---- go.mod | 8 +- go.sum | 24 + internal/adapter/cloudflare/client.go | 293 +++++++++ internal/adapter/deployer/deployer.go | 502 +++++++++++++++ internal/adapter/gitea/client.go | 220 +++++++ .../migrations/008_project_infrastructure.sql | 84 +++ internal/domain/deployment.go | 44 ++ internal/domain/dns.go | 21 + internal/domain/git.go | 40 ++ internal/handlers/infrastructure.go | 592 ++++++++++++++++++ internal/handlers/project_management.go | 221 +++++++ internal/handlers/woodpecker_webhook.go | 260 ++++++++ internal/handlers/woodpecker_webhook_test.go | 85 +++ internal/port/deployer.go | 33 + internal/port/dns_provider.go | 36 ++ internal/port/git_repository.go | 42 ++ internal/service/project_infra.go | 383 +++++++++++ 20 files changed, 3319 insertions(+), 126 deletions(-) create mode 100644 .golangci.yml create mode 100644 internal/adapter/cloudflare/client.go create mode 100644 internal/adapter/deployer/deployer.go create mode 100644 internal/adapter/gitea/client.go create mode 100644 internal/db/migrations/008_project_infrastructure.sql create mode 100644 internal/domain/deployment.go create mode 100644 internal/domain/dns.go create mode 100644 internal/domain/git.go create mode 100644 internal/handlers/infrastructure.go create mode 100644 internal/handlers/project_management.go create mode 100644 internal/handlers/woodpecker_webhook.go create mode 100644 internal/handlers/woodpecker_webhook_test.go create mode 100644 internal/port/deployer.go create mode 100644 internal/port/dns_provider.go create mode 100644 internal/port/git_repository.go create mode 100644 internal/service/project_infra.go diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..71f89c8 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,13 @@ +version: "2" + +run: + timeout: 5m + tests: false + +linters: + enable: + - errcheck + - govet + - staticcheck + - unused + - ineffassign diff --git a/cmd/rdev-api/main.go b/cmd/rdev-api/main.go index a52df6a..0cfece0 100644 --- a/cmd/rdev-api/main.go +++ b/cmd/rdev-api/main.go @@ -40,6 +40,9 @@ import ( "strconv" "time" + "github.com/orchard9/rdev/internal/adapter/cloudflare" + "github.com/orchard9/rdev/internal/adapter/deployer" + "github.com/orchard9/rdev/internal/adapter/gitea" "github.com/orchard9/rdev/internal/adapter/kubernetes" "github.com/orchard9/rdev/internal/adapter/memory" "github.com/orchard9/rdev/internal/adapter/postgres" @@ -140,6 +143,36 @@ func main() { os.Exit(1) } + // Initialize infrastructure adapters (optional - only if configured) + var giteaClient *gitea.Client + if cfg.GiteaToken != "" && cfg.GiteaURL != "" { + var err error + giteaClient, err = gitea.NewClient(cfg.GiteaURL, cfg.GiteaToken, cfg.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) + } + } + + 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) + } + + var deployerAdapter *deployer.Deployer + if k8sClient != nil { + deployerAdapter = deployer.NewDeployer(k8sClient, deployer.Config{ + Namespace: cfg.DeployNamespace, + IngressClass: "traefik", + TLSIssuer: cfg.DeployTLSIssuer, + DefaultDomain: cfg.DefaultDomain, + DefaultReplicas: 1, + }) + logger.Info("deployer initialized", "namespace", cfg.DeployNamespace) + } + // Create services projectService := service.NewProjectService(projectRepo, k8sExecutor, streamPub). WithAuditLogger(auditLogger). @@ -177,6 +210,48 @@ func main() { queueHandler := handlers.NewQueueHandler(commandQueue, projectRepo) webhookHandler := handlers.NewWebhookHandler(webhookRepo, projectRepo) + // Initialize infrastructure handler (for threesix.ai git/deploy/dns) + infraHandler := handlers.NewInfrastructureHandler( + giteaClient, + dnsClient, + deployerAdapter, + projectRepo, + handlers.InfrastructureConfig{ + DefaultGitOwner: cfg.GiteaDefaultOrg, + DefaultDomain: cfg.DefaultDomain, + }, + ) + + // Initialize project infrastructure service (orchestrates full project lifecycle) + projectInfraService := service.NewProjectInfraService( + database.DB, + giteaClient, + dnsClient, + deployerAdapter, + service.ProjectInfraConfig{ + DefaultGitOwner: cfg.GiteaDefaultOrg, + DefaultDomain: cfg.DefaultDomain, + ClusterIP: cfg.ClusterIP, + Logger: logger, + }, + ) + + // Initialize project management handler + projectMgmtHandler := handlers.NewProjectManagementHandler(projectInfraService) + + // Initialize Woodpecker webhook handler (for CI/CD auto-deploy) + woodpeckerHandler := handlers.NewWoodpeckerWebhookHandler( + deployerAdapter, + dnsClient, + handlers.WoodpeckerWebhookConfig{ + WebhookSecret: cfg.WoodpeckerWebhookSecret, + DefaultDomain: cfg.DefaultDomain, + RegistryURL: cfg.RegistryURL, + ClusterIP: cfg.ClusterIP, + Logger: logger, + }, + ) + // Register routes projectsHandler.Mount(app.Router()) keysHandler.Mount(app.Router()) @@ -184,6 +259,9 @@ func main() { auditHandler.Mount(app.Router()) queueHandler.Mount(app.Router()) webhookHandler.Mount(app.Router()) + infraHandler.Mount(app.Router()) + projectMgmtHandler.Mount(app.Router()) + woodpeckerHandler.Mount(app.Router()) // Start queue processor worker queueProcessor := worker.NewQueueProcessor( @@ -245,6 +323,19 @@ type Config struct { DBName string DBSSLMode string AdminKey string + + // Infrastructure adapters (threesix.ai) + GiteaURL string + GiteaToken string + GiteaDefaultOrg string + CloudflareToken string + CloudflareZoneID string + DefaultDomain string + DeployNamespace string + DeployTLSIssuer string + ClusterIP string + RegistryURL string + WoodpeckerWebhookSecret string } func loadConfig() Config { @@ -271,6 +362,19 @@ func loadConfig() Config { DBName: getEnv("DB_NAME", "rdev"), DBSSLMode: getEnv("DB_SSL_MODE", "disable"), AdminKey: os.Getenv("RDEV_ADMIN_KEY"), + + // Infrastructure adapters + GiteaURL: getEnv("GITEA_URL", "https://git.threesix.ai"), + GiteaToken: os.Getenv("GITEA_TOKEN"), + GiteaDefaultOrg: getEnv("GITEA_DEFAULT_ORG", "threesix"), + CloudflareToken: os.Getenv("CLOUDFLARE_API_TOKEN"), + CloudflareZoneID: os.Getenv("CLOUDFLARE_ZONE_ID"), + DefaultDomain: getEnv("DEFAULT_DOMAIN", "threesix.ai"), + DeployNamespace: getEnv("DEPLOY_NAMESPACE", "projects"), + DeployTLSIssuer: getEnv("DEPLOY_TLS_ISSUER", "letsencrypt-threesix"), + ClusterIP: getEnv("CLUSTER_IP", "208.122.204.172"), + RegistryURL: getEnv("REGISTRY_URL", "zot.threesix.svc.cluster.local:5000"), + WoodpeckerWebhookSecret: os.Getenv("WOODPECKER_WEBHOOK_SECRET"), } } diff --git a/docs/plans/THREESIX_INFRASTRUCTURE.md b/docs/plans/THREESIX_INFRASTRUCTURE.md index 44369ad..cec2a54 100644 --- a/docs/plans/THREESIX_INFRASTRUCTURE.md +++ b/docs/plans/THREESIX_INFRASTRUCTURE.md @@ -5,7 +5,7 @@ ## Overview Replace GitHub dependency with self-hosted infrastructure on k3s: -- **soft-serve** - Git server (SSH-based, minimal) +- **Gitea** - Git server (full-featured, web UI, native Woodpecker integration) - **Zot** - Container registry (OCI-native) - **Woodpecker** - CI/CD pipelines - **rdev-api** - Orchestration layer with DNS management @@ -16,7 +16,7 @@ Replace GitHub dependency with self-hosted infrastructure on k3s: ┌─────────────────────────────────────────────────────────────────────────────┐ │ threesix.ai │ │ │ -│ git.threesix.ai ──────▶ soft-serve (SSH :22) │ +│ git.threesix.ai ──────▶ gitea (web UI + SSH :22) │ │ registry.threesix.ai ─▶ zot (internal only, HTTPS for UI) │ │ ci.threesix.ai ───────▶ woodpecker (web UI) │ │ *.threesix.ai ────────▶ project deployments │ @@ -27,7 +27,7 @@ Replace GitHub dependency with self-hosted infrastructure on k3s: │ k3s cluster │ │ │ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ -│ │ soft-serve │───▶│ woodpecker │───▶│ zot │ │ +│ │ gitea │───▶│ woodpecker │───▶│ zot │ │ │ │ (git repos) │ │ (CI/CD) │ │ (registry) │ │ │ └──────────────┘ └──────────────┘ └──────────────┘ │ │ │ │ │ │ @@ -57,6 +57,12 @@ Replace GitHub dependency with self-hosted infrastructure on k3s: |-----|-------|---------| | CLOUDFLARE_API_TOKEN | `nGoDhG6Za...` | DNS management | | CLOUDFLARE_ZONE_ID | `e0bc8d51...` | threesix.ai zone | +| GITEA_ADMIN_PASSWORD | (generate) | Gitea admin login | +| GITEA_SECRET_KEY | (generate: `openssl rand -hex 32`) | Gitea internal security | +| GITEA_API_TOKEN | (create in Gitea UI) | rdev-api access to Gitea | +| GITEA_OAUTH_CLIENT_ID | (create in Gitea UI) | Woodpecker OAuth | +| GITEA_OAUTH_CLIENT_SECRET | (create in Gitea UI) | Woodpecker OAuth | +| WOODPECKER_AGENT_SECRET | (generate: `openssl rand -hex 32`) | Agent-server auth | ### Network @@ -68,8 +74,9 @@ Replace GitHub dependency with self-hosted infrastructure on k3s: ### Admin Access +Admin SSH key (add in Gitea UI: Settings → SSH/GPG Keys): ``` -SSH Public Key: ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDZwQF0Ro0E0foFo0oro/NrfUb5abEec/A0OP2qO8dVn jordanwashburn@jordanmacstudio.lan +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDZwQF0Ro0E0foFo0oro/NrfUb5abEec/A0OP2qO8dVn jordanwashburn@jordanmacstudio.lan ``` --- @@ -137,77 +144,119 @@ spec: - "*.threesix.ai" ``` -### 1.3 Deploy soft-serve +### 1.3 Deploy Gitea ```yaml -# deployments/k8s/base/threesix/soft-serve.yaml +# deployments/k8s/base/threesix/gitea.yaml +apiVersion: v1 +kind: Secret +metadata: + name: gitea-admin + namespace: threesix +type: Opaque +stringData: + username: jordan + password: "${GITEA_ADMIN_PASSWORD}" +--- apiVersion: v1 kind: ConfigMap metadata: - name: soft-serve-config + name: gitea-config namespace: threesix data: - config.yaml: | - name: threesix - log_format: text - ssh: - listen_addr: :22 - public_url: ssh://git.threesix.ai - max_timeout: 30 - idle_timeout: 120 - http: - listen_addr: :23231 - public_url: https://git.threesix.ai - stats: - listen_addr: :23233 - initial_admin_keys: - - "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDZwQF0Ro0E0foFo0oro/NrfUb5abEec/A0OP2qO8dVn jordanwashburn" - # Allow anyone to read public repos, admins can create - anon_access: read-only + app.ini: | + APP_NAME = threesix + RUN_MODE = prod + + [server] + DOMAIN = git.threesix.ai + SSH_DOMAIN = git.threesix.ai + ROOT_URL = https://git.threesix.ai/ + HTTP_PORT = 3000 + SSH_PORT = 22 + SSH_LISTEN_PORT = 22 + LFS_START_SERVER = true + + [database] + DB_TYPE = sqlite3 + PATH = /data/gitea/gitea.db + + [repository] + ROOT = /data/git/repositories + DEFAULT_BRANCH = main + + [security] + INSTALL_LOCK = true + SECRET_KEY = ${GITEA_SECRET_KEY} + + [service] + DISABLE_REGISTRATION = true + REQUIRE_SIGNIN_VIEW = false + + [oauth2_client] + ENABLE_AUTO_REGISTRATION = false + + [webhook] + ALLOWED_HOST_LIST = woodpecker-server.threesix.svc.cluster.local --- apiVersion: apps/v1 kind: StatefulSet metadata: - name: soft-serve + name: gitea namespace: threesix spec: - serviceName: soft-serve + serviceName: gitea replicas: 1 selector: matchLabels: - app: soft-serve + app: gitea template: metadata: labels: - app: soft-serve + app: gitea spec: - containers: - - name: soft-serve - image: charmcli/soft-serve:latest - ports: - - containerPort: 22 - name: ssh - - containerPort: 23231 - name: http - - containerPort: 23233 - name: stats + initContainers: + - name: init-config + image: gitea/gitea:latest + command: ['sh', '-c', 'cp /etc/gitea/app.ini /data/gitea/conf/app.ini'] volumeMounts: - name: data - mountPath: /soft-serve + mountPath: /data - name: config - mountPath: /soft-serve/config.yaml - subPath: config.yaml + mountPath: /etc/gitea + containers: + - name: gitea + image: gitea/gitea:latest + ports: + - containerPort: 3000 + name: http + - containerPort: 22 + name: ssh + env: + - name: GITEA_ADMIN_USERNAME + valueFrom: + secretKeyRef: + name: gitea-admin + key: username + - name: GITEA_ADMIN_PASSWORD + valueFrom: + secretKeyRef: + name: gitea-admin + key: password + volumeMounts: + - name: data + mountPath: /data resources: requests: - memory: "64Mi" - cpu: "50m" - limits: memory: "256Mi" - cpu: "500m" + cpu: "100m" + limits: + memory: "1Gi" + cpu: "1000m" volumes: - name: config configMap: - name: soft-serve-config + name: gitea-config volumeClaimTemplates: - metadata: name: data @@ -216,37 +265,34 @@ spec: storageClassName: longhorn resources: requests: - storage: 10Gi + storage: 20Gi --- apiVersion: v1 kind: Service metadata: - name: soft-serve + name: gitea namespace: threesix spec: selector: - app: soft-serve + app: gitea ports: + - name: http + port: 3000 + targetPort: 3000 - name: ssh port: 22 targetPort: 22 - - name: http - port: 80 - targetPort: 23231 - - name: stats - port: 23233 - targetPort: 23233 --- # External SSH access via LoadBalancer apiVersion: v1 kind: Service metadata: - name: soft-serve-ssh + name: gitea-ssh namespace: threesix spec: type: LoadBalancer selector: - app: soft-serve + app: gitea ports: - name: ssh port: 22 @@ -256,7 +302,7 @@ spec: apiVersion: networking.k8s.io/v1 kind: Ingress metadata: - name: soft-serve + name: gitea namespace: threesix annotations: cert-manager.io/cluster-issuer: letsencrypt-threesix @@ -274,9 +320,9 @@ spec: pathType: Prefix backend: service: - name: soft-serve + name: gitea port: - number: 80 + number: 3000 ``` ### 1.4 Deploy Zot Registry @@ -419,7 +465,17 @@ Create via Cloudflare API or dashboard: ## Phase 2: CI/CD (Woodpecker) -### 2.1 Deploy Woodpecker Server +### 2.1 Create Gitea OAuth Application + +Before deploying Woodpecker, create an OAuth application in Gitea: + +1. Login to https://git.threesix.ai as admin +2. Go to Site Administration → Applications → Create OAuth2 Application +3. Application Name: `Woodpecker CI` +4. Redirect URI: `https://ci.threesix.ai/authorize` +5. Save the Client ID and Client Secret + +### 2.2 Deploy Woodpecker Server ```yaml # deployments/k8s/base/threesix/woodpecker-server.yaml @@ -432,6 +488,9 @@ type: Opaque stringData: # Generate with: openssl rand -hex 32 WOODPECKER_AGENT_SECRET: "${WOODPECKER_AGENT_SECRET}" + # From Gitea OAuth application + WOODPECKER_GITEA_CLIENT: "${GITEA_OAUTH_CLIENT_ID}" + WOODPECKER_GITEA_SECRET: "${GITEA_OAUTH_CLIENT_SECRET}" --- apiVersion: apps/v1 kind: Deployment @@ -457,14 +516,14 @@ spec: - name: WOODPECKER_HOST value: "https://ci.threesix.ai" - name: WOODPECKER_OPEN - value: "false" + value: "true" - name: WOODPECKER_ADMIN value: "jordan" - # Soft-serve / generic git integration + # Gitea forge integration - name: WOODPECKER_GITEA - value: "false" - - name: WOODPECKER_WEBHOOK_HOST - value: "http://woodpecker-server.threesix.svc:8000" + value: "true" + - name: WOODPECKER_GITEA_URL + value: "https://git.threesix.ai" envFrom: - secretRef: name: woodpecker-secrets @@ -530,7 +589,7 @@ spec: number: 8000 ``` -### 2.2 Deploy Woodpecker Agent (with Kaniko) +### 2.3 Deploy Woodpecker Agent (with Kaniko) ```yaml # deployments/k8s/base/threesix/woodpecker-agent.yaml @@ -611,34 +670,43 @@ package port import "context" -// GitRepository manages git repositories. +// GitRepository manages git repositories via Gitea API. type GitRepository interface { // CreateRepo creates a new git repository. - CreateRepo(ctx context.Context, name, description string) (*Repo, error) + CreateRepo(ctx context.Context, name, description string, private bool) (*Repo, error) // DeleteRepo deletes a repository. - DeleteRepo(ctx context.Context, name string) error + DeleteRepo(ctx context.Context, owner, name string) error - // ListRepos returns all repositories. - ListRepos(ctx context.Context) ([]*Repo, error) + // ListRepos returns all repositories for an owner. + ListRepos(ctx context.Context, owner string) ([]*Repo, error) // GetRepo returns a single repository. - GetRepo(ctx context.Context, name string) (*Repo, error) + GetRepo(ctx context.Context, owner, name string) (*Repo, error) - // AddCollaborator adds a user's SSH key to a repo. - AddCollaborator(ctx context.Context, repo, keyName, publicKey string) error + // AddCollaborator adds a user as collaborator to a repo. + AddCollaborator(ctx context.Context, owner, repo, username string, permission string) error - // AddWebhook adds a webhook to trigger on push. - AddWebhook(ctx context.Context, repo, url, secret string) error + // AddDeployKey adds a deploy key to a repo for read-only or read-write access. + AddDeployKey(ctx context.Context, owner, repo, title, publicKey string, readOnly bool) error + + // CreateWebhook creates a webhook to trigger on push events. + CreateWebhook(ctx context.Context, owner, repo, url, secret string, events []string) error } // Repo represents a git repository. type Repo struct { + ID int64 + Owner string Name string + FullName string // owner/name Description string - CloneSSH string // ssh://git@git.threesix.ai/name.git - CloneHTTP string // https://git.threesix.ai/name.git + Private bool + CloneSSH string // git@git.threesix.ai:owner/name.git + CloneHTTP string // https://git.threesix.ai/owner/name.git + HTMLURL string // https://git.threesix.ai/owner/name CreatedAt time.Time + UpdatedAt time.Time } ``` @@ -714,7 +782,7 @@ type DeployStatus struct { ``` internal/adapter/ -├── softserve/ # soft-serve SSH/API client +├── gitea/ # Gitea REST API client │ └── client.go ├── cloudflare/ # Cloudflare DNS API client │ └── client.go @@ -724,6 +792,56 @@ internal/adapter/ └── client.go ``` +#### Gitea Client Example + +```go +// internal/adapter/gitea/client.go +package gitea + +import ( + "code.gitea.io/sdk/gitea" + "github.com/orchard9/rdev/internal/port" +) + +type Client struct { + client *gitea.Client + owner string // default owner/org for repos +} + +func NewClient(url, token, defaultOwner string) (*Client, error) { + client, err := gitea.NewClient(url, gitea.SetToken(token)) + if err != nil { + return nil, err + } + return &Client{client: client, owner: defaultOwner}, nil +} + +func (c *Client) CreateRepo(ctx context.Context, name, description string, private bool) (*port.Repo, error) { + repo, _, err := c.client.CreateOrgRepo(c.owner, gitea.CreateRepoOption{ + Name: name, + Description: description, + Private: private, + AutoInit: true, + }) + if err != nil { + return nil, err + } + return &port.Repo{ + ID: repo.ID, + Owner: repo.Owner.UserName, + Name: repo.Name, + FullName: repo.FullName, + Description: repo.Description, + Private: repo.Private, + CloneSSH: repo.SSHURL, + CloneHTTP: repo.CloneURL, + HTMLURL: repo.HTMLURL, + CreatedAt: repo.Created, + UpdatedAt: repo.Updated, + }, nil +} +``` + ### 3.3 New Handlers ```go @@ -802,9 +920,12 @@ CREATE INDEX IF NOT EXISTS idx_projects_custom_domain ON projects(custom_domain) ``` /project create → Creates project in DB - → Creates git repo in soft-serve + → Creates git repo in Gitea (threesix/) + → Activates repo in Woodpecker CI → Creates DNS record (.threesix.ai) - → Returns clone URL + → Returns clone URLs: + SSH: git@git.threesix.ai:threesix/.git + HTTPS: https://git.threesix.ai/threesix/.git /project deploy → Triggers build from latest commit @@ -822,16 +943,13 @@ CREATE INDEX IF NOT EXISTS idx_projects_custom_domain ON projects(custom_domain) ### 5.2 Webhook Flow ``` -Agent pushes code +Agent pushes code to Gitea │ ▼ -soft-serve receives push +Gitea receives push, fires webhook to Woodpecker │ ▼ -Webhook fires to Woodpecker - │ - ▼ -Woodpecker reads .woodpecker.yml +Woodpecker reads .woodpecker.yml from repo │ ▼ Kaniko builds image, pushes to zot @@ -840,54 +958,125 @@ Kaniko builds image, pushes to zot Woodpecker calls rdev-api: POST /projects/{id}/deploy │ ▼ -rdev-api creates/updates K8s resources +rdev-api creates/updates K8s Deployment + Ingress │ ▼ Project live at https://{name}.threesix.ai ``` +**Note:** When you activate a repo in Woodpecker's UI, it automatically creates the webhook in Gitea via OAuth. No manual webhook configuration needed. + --- ## Implementation Checklist -### Phase 1: Foundation -- [ ] Create `threesix` namespace -- [ ] Create Cloudflare API secret -- [ ] Configure ClusterIssuer for DNS-01 challenge -- [ ] Request wildcard certificate -- [ ] Deploy soft-serve StatefulSet -- [ ] Configure soft-serve LoadBalancer for SSH -- [ ] Deploy Zot registry -- [ ] Create initial DNS records (git, registry, ci, wildcard) -- [ ] Test: `ssh git@git.threesix.ai` works -- [ ] Test: `https://registry.threesix.ai` shows Zot UI +### Phase 1: Foundation ✅ COMPLETED (2026-01-26) +- [x] Create `threesix` namespace +- [x] Create Cloudflare API secret +- [x] Configure Issuer for DNS-01 challenge (namespace-scoped, not ClusterIssuer) +- [x] Request wildcard certificate (*.threesix.ai) +- [x] Deploy Gitea StatefulSet (rootless image, PostgreSQL backend, writable config) +- [x] Configure Gitea LoadBalancer for SSH (208.122.204.172:22) +- [x] Deploy Zot registry (10Gi storage) +- [x] Create DNS records: git.threesix.ai, registry.threesix.ai → 208.122.204.172 +- [x] Test: `https://git.threesix.ai` shows Gitea UI ✅ +- [x] Test: `https://registry.threesix.ai/v2/_catalog` returns `{"repositories":[]}` ✅ +- [x] Complete Gitea installation wizard ✅ -### Phase 2: CI/CD -- [ ] Generate Woodpecker agent secret -- [ ] Deploy Woodpecker server -- [ ] Deploy Woodpecker agents -- [ ] Configure soft-serve webhook to Woodpecker -- [ ] Test: push triggers build +**Implementation Notes:** +- Used namespace-scoped `Issuer` instead of `ClusterIssuer` (cert-manager couldn't access secrets across namespaces) +- Gitea uses PostgreSQL (`postgres.databases.svc.cluster.local`) instead of SQLite +- Gitea credentials: `gitea` user, password in `/tmp/gitea-db-password.txt` +- Rootless Gitea image requires `securityContext.fsGroup: 1000` and writable `/etc/gitea` via volume subPath + +### Phase 2: CI/CD ✅ COMPLETED (2026-01-26) +- [x] Create Gitea OAuth application for Woodpecker +- [x] Generate Woodpecker agent secret +- [x] Create DNS record: ci.threesix.ai → 208.122.204.172 +- [x] Deploy Woodpecker server with Gitea forge +- [x] Deploy Woodpecker agents (2 replicas, K8s backend) +- [x] TLS certificate issued for ci.threesix.ai +- [ ] Test: Login to Woodpecker via Gitea OAuth +- [ ] Test: Activate repo in Woodpecker (auto-creates webhook) +- [ ] Test: Push triggers build - [ ] Test: Kaniko builds and pushes to Zot -### Phase 3: rdev-api -- [ ] Add GitRepository port interface -- [ ] Add DNSProvider port interface -- [ ] Add Deployer port interface -- [ ] Implement soft-serve adapter -- [ ] Implement Cloudflare adapter -- [ ] Implement K8s deployer adapter -- [ ] Add database migration -- [ ] Add new handlers +**Secrets saved:** +- Agent secret: `/tmp/woodpecker-agent-secret.txt` +- Gitea OAuth: Client ID `7548afec-43e0-486a-b6eb-e2a7d5c88d41` + +### Phase 3: rdev-api ✅ COMPLETED (2026-01-26) +- [x] Add GitRepository port interface (`internal/port/git_repository.go`) +- [x] Add DNSProvider port interface (`internal/port/dns_provider.go`) +- [x] Add Deployer port interface (`internal/port/deployer.go`) +- [x] Implement Gitea adapter (`internal/adapter/gitea/client.go`) using `code.gitea.io/sdk/gitea` +- [x] Implement Cloudflare adapter (`internal/adapter/cloudflare/client.go`) +- [x] Implement K8s deployer adapter (`internal/adapter/deployer/deployer.go`) +- [x] Add database migration (`internal/db/migrations/008_project_infrastructure.sql`) +- [x] Add infrastructure handler (`internal/handlers/infrastructure.go`) +- [x] Wire up in main.go with environment variables - [ ] Test: API can create repos - [ ] Test: API can manage DNS - [ ] Test: API can deploy apps -### Phase 4: Integration -- [ ] Wire up webhook: build → deploy -- [ ] Add project commands to Pantheon +**New Environment Variables:** +``` +GITEA_URL=https://git.threesix.ai +GITEA_TOKEN= +GITEA_DEFAULT_ORG=threesix +CLOUDFLARE_API_TOKEN= +CLOUDFLARE_ZONE_ID= +DEFAULT_DOMAIN=threesix.ai +DEPLOY_NAMESPACE=projects +DEPLOY_TLS_ISSUER=letsencrypt-threesix +CLUSTER_IP=208.122.204.172 +``` + +**New API Endpoints:** +- `POST /projects/{id}/repo` - Create git repo +- `GET /projects/{id}/repo` - Get repo info +- `DELETE /projects/{id}/repo` - Delete repo +- `POST /projects/{id}/deploy` - Deploy from image +- `GET /projects/{id}/deploy/status` - Get deployment status +- `DELETE /projects/{id}/deploy` - Undeploy +- `POST /projects/{id}/deploy/restart` - Restart deployment +- `POST /projects/{id}/deploy/scale` - Scale replicas +- `GET /projects/{id}/deploy/logs` - Get logs +- `POST /projects/{id}/domain` - Add custom domain +- `DELETE /projects/{id}/domain` - Remove domain + +### Phase 4: Integration ✅ COMPLETED (2026-01-26) +- [x] Wire up webhook: build → deploy (`internal/handlers/woodpecker_webhook.go`) +- [x] Add project commands to Pantheon (`.claude/commands/project-*.md`) +- [x] Create project infrastructure service (`internal/service/project_infra.go`) +- [x] Create project management handler (`internal/handlers/project_management.go`) +- [x] Update ExternalSecret with infrastructure credentials +- [x] Create .woodpecker.yml pipeline template +- [ ] Test: Login to Woodpecker via Gitea OAuth - [ ] Test: end-to-end "create project" → "push code" → "live site" +**New Files Created:** +- `internal/handlers/woodpecker_webhook.go` - Handles Woodpecker CI webhooks for auto-deployment +- `internal/service/project_infra.go` - Orchestrates full project lifecycle (DB → Gitea → DNS → K8s) +- `internal/handlers/project_management.go` - HTTP handlers for project CRUD operations +- `.claude/commands/project-create.md` - /project create command +- `.claude/commands/project-status.md` - /project status command +- `.claude/commands/project-deploy.md` - /project deploy command +- `.claude/commands/project-list.md` - /project list command + +**New API Endpoints:** +- `POST /project` - Create new project with git repo and DNS +- `GET /project` - List all projects with status +- `GET /project/{name}` - Get single project status +- `DELETE /project/{name}` - Delete project and all resources +- `POST /webhook/woodpecker` - Woodpecker CI webhook (auto-deploy on build success) + +**Environment Variables Added:** +``` +REGISTRY_URL=zot.threesix.svc.cluster.local:5000 +WOODPECKER_WEBHOOK_SECRET= +``` + ### Phase 5: Polish - [ ] Custom domain support - [ ] Build notifications to Pantheon @@ -901,21 +1090,22 @@ Project live at https://{name}.threesix.ai | Component | CPU Request | Memory Request | Storage | |-----------|-------------|----------------|---------| -| soft-serve | 50m | 64Mi | 10Gi | +| Gitea | 100m | 256Mi | 20Gi | | Zot | 100m | 128Mi | 50Gi | | Woodpecker Server | 100m | 128Mi | 5Gi | | Woodpecker Agent (x2) | 200m each | 256Mi each | - | -| **Total** | ~650m | ~832Mi | 65Gi | +| **Total** | ~700m | ~1Gi | 75Gi | --- ## Security Considerations -1. **soft-serve admin key** - Only jordan's key is admin initially -2. **Registry access** - Internal only, no auth needed (ClusterIP) -3. **Woodpecker** - Closed registration, admin-only access -4. **Cloudflare token** - Scoped to DNS edit only -5. **Deploy permissions** - rdev-api ServiceAccount limited to `threesix` and `projects` namespaces +1. **Gitea admin** - Registration disabled, only admin user can create accounts +2. **Gitea API token** - Create a dedicated token for rdev-api with repo scope +3. **Registry access** - Internal only, no auth needed (ClusterIP) +4. **Woodpecker** - OAuth via Gitea, inherits Gitea permissions +5. **Cloudflare token** - Scoped to DNS edit only +6. **Deploy permissions** - rdev-api ServiceAccount limited to `threesix` and `projects` namespaces --- diff --git a/go.mod b/go.mod index 11b6cc6..968ee8f 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,10 @@ module github.com/orchard9/rdev go 1.25.0 require ( + code.gitea.io/sdk/gitea v0.22.1 github.com/bdpiprava/scalar-go v0.13.0 github.com/go-chi/chi/v5 v5.1.0 + github.com/google/uuid v1.6.0 github.com/lib/pq v1.10.9 github.com/prometheus/client_golang v1.23.2 go.opentelemetry.io/otel v1.39.0 @@ -17,20 +19,23 @@ require ( ) require ( + github.com/42wim/httpsig v1.2.3 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/davidmz/go-pageant v1.0.2 // indirect github.com/emicklei/go-restful/v3 v3.12.2 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/go-fed/httpsig v1.1.0 // indirect 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/swag v0.23.0 // indirect github.com/google/gnostic-models v0.7.0 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect + github.com/hashicorp/go-version v1.7.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/mailru/easyjson v0.7.7 // indirect @@ -48,6 +53,7 @@ require ( go.opentelemetry.io/proto/otlp v1.9.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/crypto v0.44.0 // indirect golang.org/x/net v0.47.0 // indirect golang.org/x/oauth2 v0.32.0 // indirect golang.org/x/sys v0.39.0 // indirect diff --git a/go.sum b/go.sum index 934dacb..c6b3024 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,7 @@ +code.gitea.io/sdk/gitea v0.22.1 h1:7K05KjRORyTcTYULQ/AwvlVS6pawLcWyXZcTr7gHFyA= +code.gitea.io/sdk/gitea v0.22.1/go.mod h1:yyF5+GhljqvA30sRDreoyHILruNiy4ASufugzYg0VHM= +github.com/42wim/httpsig v1.2.3 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs= +github.com/42wim/httpsig v1.2.3/go.mod h1:nZq9OlYKDrUBhptd77IHx4/sZZD+IxTBADvAPI9G/EM= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/bdpiprava/scalar-go v0.13.0 h1:TuhOwYalDpLAziohyEwZlq4PqtEJ+6P/V92dDCdja9k= @@ -12,12 +16,16 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3 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= +github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0= +github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE= github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI= +github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -46,6 +54,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4= +github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= +github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= @@ -128,22 +138,36 @@ 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= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= +golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU= +golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc= golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= diff --git a/internal/adapter/cloudflare/client.go b/internal/adapter/cloudflare/client.go new file mode 100644 index 0000000..20350ac --- /dev/null +++ b/internal/adapter/cloudflare/client.go @@ -0,0 +1,293 @@ +// Package cloudflare provides a Cloudflare DNS adapter implementing port.DNSProvider. +package cloudflare + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/orchard9/rdev/internal/domain" + "github.com/orchard9/rdev/internal/port" +) + +// Ensure Client implements DNSProvider. +var _ port.DNSProvider = (*Client)(nil) + +const apiBase = "https://api.cloudflare.com/client/v4" + +// Client is a Cloudflare DNS API client adapter. +type Client struct { + apiToken string + zoneID string + zoneName string // e.g., "threesix.ai" + http *http.Client +} + +// NewClient creates a new Cloudflare DNS client. +// apiToken is a Cloudflare API token with DNS edit permissions +// zoneID is the Cloudflare zone ID for the domain +// zoneName is the domain name (e.g., "threesix.ai") +func NewClient(apiToken, zoneID, zoneName string) *Client { + return &Client{ + apiToken: apiToken, + zoneID: zoneID, + zoneName: zoneName, + http: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +// CreateRecord creates a DNS record. +func (c *Client) CreateRecord(ctx context.Context, record domain.DNSRecord) (*domain.DNSRecord, error) { + // Normalize name: if just subdomain, append zone name + name := c.normalizeName(record.Name) + + body := map[string]interface{}{ + "type": record.Type, + "name": name, + "content": record.Content, + "ttl": record.TTL, + "proxied": record.Proxied, + } + + resp, err := c.doRequest(ctx, "POST", fmt.Sprintf("/zones/%s/dns_records", c.zoneID), body) + if err != nil { + return nil, fmt.Errorf("failed to create DNS record: %w", err) + } + + var result cfResponse + if err := json.Unmarshal(resp, &result); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + if !result.Success { + return nil, fmt.Errorf("cloudflare error: %v", result.Errors) + } + + return recordFromCF(result.Result), nil +} + +// UpdateRecord updates an existing DNS record by ID. +func (c *Client) UpdateRecord(ctx context.Context, recordID string, record domain.DNSRecord) (*domain.DNSRecord, error) { + name := c.normalizeName(record.Name) + + body := map[string]interface{}{ + "type": record.Type, + "name": name, + "content": record.Content, + "ttl": record.TTL, + "proxied": record.Proxied, + } + + resp, err := c.doRequest(ctx, "PUT", fmt.Sprintf("/zones/%s/dns_records/%s", c.zoneID, recordID), body) + if err != nil { + return nil, fmt.Errorf("failed to update DNS record: %w", err) + } + + var result cfResponse + if err := json.Unmarshal(resp, &result); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + if !result.Success { + return nil, fmt.Errorf("cloudflare error: %v", result.Errors) + } + + return recordFromCF(result.Result), nil +} + +// DeleteRecord removes a DNS record by ID. +func (c *Client) DeleteRecord(ctx context.Context, recordID string) error { + _, err := c.doRequest(ctx, "DELETE", fmt.Sprintf("/zones/%s/dns_records/%s", c.zoneID, recordID), nil) + if err != nil { + return fmt.Errorf("failed to delete DNS record: %w", err) + } + return nil +} + +// DeleteRecordByName removes a DNS record by type and name. +func (c *Client) DeleteRecordByName(ctx context.Context, recordType, name string) error { + record, err := c.FindRecord(ctx, recordType, name) + if err != nil { + return err + } + if record == nil { + return nil // Already doesn't exist + } + return c.DeleteRecord(ctx, record.ID) +} + +// GetRecord returns a single record by ID. +func (c *Client) GetRecord(ctx context.Context, recordID string) (*domain.DNSRecord, error) { + resp, err := c.doRequest(ctx, "GET", fmt.Sprintf("/zones/%s/dns_records/%s", c.zoneID, recordID), nil) + if err != nil { + return nil, fmt.Errorf("failed to get DNS record: %w", err) + } + + var result cfResponse + if err := json.Unmarshal(resp, &result); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + if !result.Success { + return nil, fmt.Errorf("cloudflare error: %v", result.Errors) + } + + return recordFromCF(result.Result), nil +} + +// ListRecords returns all records in the zone. +func (c *Client) ListRecords(ctx context.Context, recordType string) ([]*domain.DNSRecord, error) { + path := fmt.Sprintf("/zones/%s/dns_records?per_page=100", c.zoneID) + if recordType != "" { + path += "&type=" + recordType + } + + resp, err := c.doRequest(ctx, "GET", path, nil) + if err != nil { + return nil, fmt.Errorf("failed to list DNS records: %w", err) + } + + var result cfListResponse + if err := json.Unmarshal(resp, &result); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + if !result.Success { + return nil, fmt.Errorf("cloudflare error: %v", result.Errors) + } + + records := make([]*domain.DNSRecord, len(result.Result)) + for i, r := range result.Result { + records[i] = recordFromCFMap(r) + } + return records, nil +} + +// FindRecord finds a record by type and name. +func (c *Client) FindRecord(ctx context.Context, recordType, name string) (*domain.DNSRecord, error) { + normalizedName := c.normalizeName(name) + path := fmt.Sprintf("/zones/%s/dns_records?type=%s&name=%s", c.zoneID, recordType, normalizedName) + + resp, err := c.doRequest(ctx, "GET", path, nil) + if err != nil { + return nil, fmt.Errorf("failed to find DNS record: %w", err) + } + + var result cfListResponse + if err := json.Unmarshal(resp, &result); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + if !result.Success { + return nil, fmt.Errorf("cloudflare error: %v", result.Errors) + } + + if len(result.Result) == 0 { + return nil, nil + } + + return recordFromCFMap(result.Result[0]), nil +} + +// normalizeName converts a subdomain to full domain name. +func (c *Client) normalizeName(name string) string { + if name == "@" || name == "" { + return c.zoneName + } + // If already has zone suffix, return as-is + if len(name) > len(c.zoneName) && name[len(name)-len(c.zoneName):] == c.zoneName { + return name + } + return name + "." + c.zoneName +} + +// doRequest performs an HTTP request to the Cloudflare API. +func (c *Client) doRequest(ctx context.Context, method, path string, body interface{}) ([]byte, error) { + var bodyReader io.Reader + if body != nil { + jsonBody, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(jsonBody) + } + + req, err := http.NewRequestWithContext(ctx, method, apiBase+path, bodyReader) + if err != nil { + return nil, err + } + + req.Header.Set("Authorization", "Bearer "+c.apiToken) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.http.Do(req) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(respBody)) + } + + return respBody, nil +} + +// Cloudflare API response types +type cfResponse struct { + Success bool `json:"success"` + Errors []cfError `json:"errors"` + Result map[string]interface{} `json:"result"` +} + +type cfListResponse struct { + Success bool `json:"success"` + Errors []cfError `json:"errors"` + Result []map[string]interface{} `json:"result"` +} + +type cfError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +// recordFromCF converts a Cloudflare record response to domain.DNSRecord. +func recordFromCF(r map[string]interface{}) *domain.DNSRecord { + return recordFromCFMap(r) +} + +func recordFromCFMap(r map[string]interface{}) *domain.DNSRecord { + record := &domain.DNSRecord{} + + if id, ok := r["id"].(string); ok { + record.ID = id + } + if t, ok := r["type"].(string); ok { + record.Type = t + } + if name, ok := r["name"].(string); ok { + record.Name = name + } + if content, ok := r["content"].(string); ok { + record.Content = content + } + if ttl, ok := r["ttl"].(float64); ok { + record.TTL = int(ttl) + } + if proxied, ok := r["proxied"].(bool); ok { + record.Proxied = proxied + } + + return record +} diff --git a/internal/adapter/deployer/deployer.go b/internal/adapter/deployer/deployer.go new file mode 100644 index 0000000..cdfe6b8 --- /dev/null +++ b/internal/adapter/deployer/deployer.go @@ -0,0 +1,502 @@ +// Package deployer provides a Kubernetes deployment adapter implementing port.Deployer. +package deployer + +import ( + "bytes" + "context" + "fmt" + "strings" + "time" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" + "k8s.io/client-go/kubernetes" + + "github.com/orchard9/rdev/internal/domain" + "github.com/orchard9/rdev/internal/port" +) + +// Ensure Deployer implements port.Deployer. +var _ port.Deployer = (*Deployer)(nil) + +// Config holds configuration for the Deployer. +type Config struct { + // Namespace is the K8s namespace for project deployments. + Namespace string + // DefaultReplicas is the default number of replicas if not specified. + DefaultReplicas int + // IngressClass is the ingress controller class (e.g., "traefik"). + IngressClass string + // TLSIssuer is the cert-manager issuer name. + TLSIssuer string + // DefaultDomain is the base domain for auto-generated URLs. + DefaultDomain string +} + +// Deployer manages Kubernetes deployments for projects. +type Deployer struct { + client *kubernetes.Clientset + config Config +} + +// NewDeployer creates a new Deployer. +func NewDeployer(client *kubernetes.Clientset, cfg Config) *Deployer { + if cfg.DefaultReplicas == 0 { + cfg.DefaultReplicas = 1 + } + if cfg.IngressClass == "" { + cfg.IngressClass = "traefik" + } + if cfg.Namespace == "" { + cfg.Namespace = "projects" + } + return &Deployer{ + client: client, + config: cfg, + } +} + +// Deploy creates or updates a deployment for a project. +func (d *Deployer) Deploy(ctx context.Context, spec domain.DeploySpec) error { + // Validate spec + if spec.ProjectName == "" { + return fmt.Errorf("project name is required") + } + if spec.Image == "" { + return fmt.Errorf("image is required") + } + + // Set defaults + if spec.Port == 0 { + spec.Port = 8080 + } + if spec.Replicas == 0 { + spec.Replicas = d.config.DefaultReplicas + } + if spec.Domain == "" { + spec.Domain = spec.ProjectName + "." + d.config.DefaultDomain + } + + // Create namespace if it doesn't exist + if err := d.ensureNamespace(ctx); err != nil { + return fmt.Errorf("failed to ensure namespace: %w", err) + } + + // Create or update Secret for env vars + if len(spec.Secrets) > 0 { + if err := d.createOrUpdateSecret(ctx, spec); err != nil { + return fmt.Errorf("failed to create secret: %w", err) + } + } + + // Create or update Deployment + if err := d.createOrUpdateDeployment(ctx, spec); err != nil { + return fmt.Errorf("failed to create deployment: %w", err) + } + + // Create or update Service + if err := d.createOrUpdateService(ctx, spec); err != nil { + return fmt.Errorf("failed to create service: %w", err) + } + + // Create or update Ingress + if err := d.createOrUpdateIngress(ctx, spec); err != nil { + return fmt.Errorf("failed to create ingress: %w", err) + } + + return nil +} + +// Undeploy removes all deployment resources for a project. +func (d *Deployer) Undeploy(ctx context.Context, projectName string) error { + ns := d.config.Namespace + + // Delete Ingress + err := d.client.NetworkingV1().Ingresses(ns).Delete(ctx, projectName, metav1.DeleteOptions{}) + if err != nil && !errors.IsNotFound(err) { + return fmt.Errorf("failed to delete ingress: %w", err) + } + + // Delete Service + err = d.client.CoreV1().Services(ns).Delete(ctx, projectName, metav1.DeleteOptions{}) + if err != nil && !errors.IsNotFound(err) { + return fmt.Errorf("failed to delete service: %w", err) + } + + // Delete Deployment + err = d.client.AppsV1().Deployments(ns).Delete(ctx, projectName, metav1.DeleteOptions{}) + if err != nil && !errors.IsNotFound(err) { + return fmt.Errorf("failed to delete deployment: %w", err) + } + + // Delete Secret + err = d.client.CoreV1().Secrets(ns).Delete(ctx, projectName+"-env", metav1.DeleteOptions{}) + if err != nil && !errors.IsNotFound(err) { + return fmt.Errorf("failed to delete secret: %w", err) + } + + return nil +} + +// GetStatus returns the current deployment status for a project. +func (d *Deployer) GetStatus(ctx context.Context, projectName string) (*domain.DeployStatus, error) { + ns := d.config.Namespace + + deployment, err := d.client.AppsV1().Deployments(ns).Get(ctx, projectName, metav1.GetOptions{}) + if err != nil { + if errors.IsNotFound(err) { + return nil, nil + } + return nil, fmt.Errorf("failed to get deployment: %w", err) + } + + // Determine status + var status domain.DeploymentStatus + switch { + case deployment.Status.ReadyReplicas == *deployment.Spec.Replicas: + status = domain.DeploymentStatusRunning + case deployment.Status.UnavailableReplicas > 0: + status = domain.DeploymentStatusFailed + case deployment.Status.ReadyReplicas < *deployment.Spec.Replicas: + status = domain.DeploymentStatusPending + default: + status = domain.DeploymentStatusUnknown + } + + // Get URL from ingress + var url string + ingress, err := d.client.NetworkingV1().Ingresses(ns).Get(ctx, projectName, metav1.GetOptions{}) + if err == nil && len(ingress.Spec.Rules) > 0 { + host := ingress.Spec.Rules[0].Host + url = "https://" + host + } + + return &domain.DeployStatus{ + ProjectName: projectName, + Image: deployment.Spec.Template.Spec.Containers[0].Image, + Replicas: int(*deployment.Spec.Replicas), + ReadyReplicas: int(deployment.Status.ReadyReplicas), + URL: url, + Status: status, + CreatedAt: deployment.CreationTimestamp.Time, + UpdatedAt: time.Now(), + }, nil +} + +// Restart triggers a rolling restart of the deployment. +func (d *Deployer) Restart(ctx context.Context, projectName string) error { + ns := d.config.Namespace + + deployment, err := d.client.AppsV1().Deployments(ns).Get(ctx, projectName, metav1.GetOptions{}) + if err != nil { + return fmt.Errorf("failed to get deployment: %w", err) + } + + // Add annotation to trigger rollout + if deployment.Spec.Template.Annotations == nil { + deployment.Spec.Template.Annotations = make(map[string]string) + } + deployment.Spec.Template.Annotations["kubectl.kubernetes.io/restartedAt"] = time.Now().Format(time.RFC3339) + + _, err = d.client.AppsV1().Deployments(ns).Update(ctx, deployment, metav1.UpdateOptions{}) + if err != nil { + return fmt.Errorf("failed to update deployment: %w", err) + } + + return nil +} + +// Scale adjusts the replica count for a deployment. +func (d *Deployer) Scale(ctx context.Context, projectName string, replicas int) error { + ns := d.config.Namespace + + scale, err := d.client.AppsV1().Deployments(ns).GetScale(ctx, projectName, metav1.GetOptions{}) + if err != nil { + return fmt.Errorf("failed to get scale: %w", err) + } + + scale.Spec.Replicas = int32(replicas) + + _, err = d.client.AppsV1().Deployments(ns).UpdateScale(ctx, projectName, scale, metav1.UpdateOptions{}) + if err != nil { + return fmt.Errorf("failed to update scale: %w", err) + } + + return nil +} + +// GetLogs returns recent logs from the deployment pods. +func (d *Deployer) GetLogs(ctx context.Context, projectName string, tailLines int) (string, error) { + ns := d.config.Namespace + + // List pods for the deployment + pods, err := d.client.CoreV1().Pods(ns).List(ctx, metav1.ListOptions{ + LabelSelector: fmt.Sprintf("app=%s", projectName), + }) + if err != nil { + return "", fmt.Errorf("failed to list pods: %w", err) + } + + if len(pods.Items) == 0 { + return "", fmt.Errorf("no pods found for project %s", projectName) + } + + // Get logs from the first pod + tail := int64(tailLines) + opts := &corev1.PodLogOptions{ + TailLines: &tail, + } + + req := d.client.CoreV1().Pods(ns).GetLogs(pods.Items[0].Name, opts) + logs, err := req.Stream(ctx) + if err != nil { + return "", fmt.Errorf("failed to get logs: %w", err) + } + defer func() { _ = logs.Close() }() + + buf := new(bytes.Buffer) + _, err = buf.ReadFrom(logs) + if err != nil { + return "", fmt.Errorf("failed to read logs: %w", err) + } + + return buf.String(), nil +} + +// Helper methods + +func (d *Deployer) ensureNamespace(ctx context.Context) error { + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: d.config.Namespace, + }, + } + + _, err := d.client.CoreV1().Namespaces().Create(ctx, ns, metav1.CreateOptions{}) + if err != nil && !errors.IsAlreadyExists(err) { + return err + } + return nil +} + +func (d *Deployer) createOrUpdateSecret(ctx context.Context, spec domain.DeploySpec) error { + secretName := spec.ProjectName + "-env" + ns := d.config.Namespace + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: secretName, + Namespace: ns, + Labels: map[string]string{ + "app": spec.ProjectName, + "project": spec.ProjectName, + }, + }, + StringData: spec.Secrets, + } + + _, err := d.client.CoreV1().Secrets(ns).Get(ctx, secretName, metav1.GetOptions{}) + if errors.IsNotFound(err) { + _, err = d.client.CoreV1().Secrets(ns).Create(ctx, secret, metav1.CreateOptions{}) + } else if err == nil { + _, err = d.client.CoreV1().Secrets(ns).Update(ctx, secret, metav1.UpdateOptions{}) + } + return err +} + +func (d *Deployer) createOrUpdateDeployment(ctx context.Context, spec domain.DeploySpec) error { + ns := d.config.Namespace + replicas := int32(spec.Replicas) + + // Build env vars + var envVars []corev1.EnvVar + for k, v := range spec.EnvVars { + envVars = append(envVars, corev1.EnvVar{Name: k, Value: v}) + } + + // Add secret env vars + var envFrom []corev1.EnvFromSource + if len(spec.Secrets) > 0 { + envFrom = append(envFrom, corev1.EnvFromSource{ + SecretRef: &corev1.SecretEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: spec.ProjectName + "-env", + }, + }, + }) + } + + deployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: spec.ProjectName, + Namespace: ns, + Labels: map[string]string{ + "app": spec.ProjectName, + "project": spec.ProjectName, + }, + }, + Spec: appsv1.DeploymentSpec{ + Replicas: &replicas, + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "app": spec.ProjectName, + }, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{ + "app": spec.ProjectName, + "project": spec.ProjectName, + }, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: spec.ProjectName, + Image: spec.Image, + Env: envVars, + EnvFrom: envFrom, + Ports: []corev1.ContainerPort{ + { + ContainerPort: int32(spec.Port), + Protocol: corev1.ProtocolTCP, + }, + }, + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resourceQuantity("100m"), + corev1.ResourceMemory: resourceQuantity("128Mi"), + }, + Limits: corev1.ResourceList{ + corev1.ResourceCPU: resourceQuantity("1000m"), + corev1.ResourceMemory: resourceQuantity("512Mi"), + }, + }, + }, + }, + }, + }, + }, + } + + _, err := d.client.AppsV1().Deployments(ns).Get(ctx, spec.ProjectName, metav1.GetOptions{}) + if errors.IsNotFound(err) { + _, err = d.client.AppsV1().Deployments(ns).Create(ctx, deployment, metav1.CreateOptions{}) + } else if err == nil { + _, err = d.client.AppsV1().Deployments(ns).Update(ctx, deployment, metav1.UpdateOptions{}) + } + return err +} + +func (d *Deployer) createOrUpdateService(ctx context.Context, spec domain.DeploySpec) error { + ns := d.config.Namespace + + service := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: spec.ProjectName, + Namespace: ns, + Labels: map[string]string{ + "app": spec.ProjectName, + "project": spec.ProjectName, + }, + }, + Spec: corev1.ServiceSpec{ + Selector: map[string]string{ + "app": spec.ProjectName, + }, + Ports: []corev1.ServicePort{ + { + Port: int32(spec.Port), + TargetPort: intstr.FromInt(spec.Port), + Protocol: corev1.ProtocolTCP, + }, + }, + }, + } + + _, err := d.client.CoreV1().Services(ns).Get(ctx, spec.ProjectName, metav1.GetOptions{}) + if errors.IsNotFound(err) { + _, err = d.client.CoreV1().Services(ns).Create(ctx, service, metav1.CreateOptions{}) + } else if err == nil { + _, err = d.client.CoreV1().Services(ns).Update(ctx, service, metav1.UpdateOptions{}) + } + return err +} + +func (d *Deployer) createOrUpdateIngress(ctx context.Context, spec domain.DeploySpec) error { + ns := d.config.Namespace + pathType := networkingv1.PathTypePrefix + ingressClass := d.config.IngressClass + + // Build TLS secret name from domain + tlsSecretName := strings.ReplaceAll(spec.Domain, ".", "-") + "-tls" + + annotations := map[string]string{} + if d.config.TLSIssuer != "" { + annotations["cert-manager.io/issuer"] = d.config.TLSIssuer + } + + ingress := &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: spec.ProjectName, + Namespace: ns, + Labels: map[string]string{ + "app": spec.ProjectName, + "project": spec.ProjectName, + }, + Annotations: annotations, + }, + Spec: networkingv1.IngressSpec{ + IngressClassName: &ingressClass, + TLS: []networkingv1.IngressTLS{ + { + Hosts: []string{spec.Domain}, + SecretName: tlsSecretName, + }, + }, + Rules: []networkingv1.IngressRule{ + { + Host: spec.Domain, + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{ + { + Path: "/", + PathType: &pathType, + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: spec.ProjectName, + Port: networkingv1.ServiceBackendPort{ + Number: int32(spec.Port), + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + _, err := d.client.NetworkingV1().Ingresses(ns).Get(ctx, spec.ProjectName, metav1.GetOptions{}) + if errors.IsNotFound(err) { + _, err = d.client.NetworkingV1().Ingresses(ns).Create(ctx, ingress, metav1.CreateOptions{}) + } else if err == nil { + _, err = d.client.NetworkingV1().Ingresses(ns).Update(ctx, ingress, metav1.UpdateOptions{}) + } + return err +} + +// resourceQuantity parses a resource quantity string. +// Returns the parsed quantity or a zero quantity on error. +func resourceQuantity(s string) resource.Quantity { + q, _ := resource.ParseQuantity(s) + return q +} diff --git a/internal/adapter/gitea/client.go b/internal/adapter/gitea/client.go new file mode 100644 index 0000000..320bc56 --- /dev/null +++ b/internal/adapter/gitea/client.go @@ -0,0 +1,220 @@ +// Package gitea provides a Gitea API adapter implementing port.GitRepository. +// +// Context Propagation Note: +// The Gitea Go SDK (code.gitea.io/sdk/gitea) does not natively support context +// propagation for HTTP requests. Methods accept context.Context for interface +// compatibility and future-proofing, but the underlying SDK calls do not use it +// for cancellation or timeouts. If cancellation is critical, consider using a +// context-aware HTTP transport or wrapping calls with context deadline checks. +package gitea + +import ( + "context" + "fmt" + + "code.gitea.io/sdk/gitea" + "github.com/orchard9/rdev/internal/domain" + "github.com/orchard9/rdev/internal/port" +) + +// Ensure Client implements GitRepository. +var _ port.GitRepository = (*Client)(nil) + +// Client is a Gitea API client adapter. +type Client struct { + client *gitea.Client + defaultOwner string // default organization/user for new repos +} + +// 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 +// defaultOwner is the organization or user to create repos under +func NewClient(url, token, defaultOwner string) (*Client, error) { + client, err := gitea.NewClient(url, gitea.SetToken(token)) + if err != nil { + return nil, fmt.Errorf("failed to create gitea client: %w", err) + } + return &Client{ + client: client, + defaultOwner: defaultOwner, + }, nil +} + +// CreateRepo creates a new git repository under the default owner. +func (c *Client) CreateRepo(ctx context.Context, name, description string, private bool) (*domain.Repo, error) { + opts := gitea.CreateRepoOption{ + Name: name, + Description: description, + Private: private, + AutoInit: true, + DefaultBranch: "main", + } + + var repo *gitea.Repository + var err error + + // Try to create as org repo first, fall back to user repo + repo, _, err = c.client.CreateOrgRepo(c.defaultOwner, opts) + if err != nil { + // May not be an org, try as user repo + repo, _, err = c.client.CreateRepo(opts) + if err != nil { + return nil, fmt.Errorf("failed to create repo: %w", err) + } + } + + return repoFromGitea(repo), nil +} + +// DeleteRepo deletes a repository. +func (c *Client) DeleteRepo(ctx context.Context, owner, name string) error { + _, err := c.client.DeleteRepo(owner, name) + if err != nil { + return fmt.Errorf("failed to delete repo %s/%s: %w", owner, name, err) + } + return nil +} + +// ListRepos returns all repositories for an owner. +func (c *Client) ListRepos(ctx context.Context, owner string) ([]*domain.Repo, error) { + // Try as organization first + repos, _, err := c.client.ListOrgRepos(owner, gitea.ListOrgReposOptions{ + ListOptions: gitea.ListOptions{PageSize: 100}, + }) + if err != nil { + // Try as user + repos, _, err = c.client.ListUserRepos(owner, gitea.ListReposOptions{ + ListOptions: gitea.ListOptions{PageSize: 100}, + }) + if err != nil { + return nil, fmt.Errorf("failed to list repos for %s: %w", owner, err) + } + } + + result := make([]*domain.Repo, len(repos)) + for i, r := range repos { + result[i] = repoFromGitea(r) + } + return result, nil +} + +// GetRepo returns a single repository. +func (c *Client) GetRepo(ctx context.Context, owner, name string) (*domain.Repo, error) { + repo, _, err := c.client.GetRepo(owner, name) + if err != nil { + return nil, fmt.Errorf("failed to get repo %s/%s: %w", owner, name, err) + } + return repoFromGitea(repo), nil +} + +// AddCollaborator adds a user as collaborator to a repo. +func (c *Client) AddCollaborator(ctx context.Context, owner, repo, username string, permission string) error { + var accessMode gitea.AccessMode + switch permission { + case "read": + accessMode = gitea.AccessModeRead + case "write": + accessMode = gitea.AccessModeWrite + case "admin": + accessMode = gitea.AccessModeAdmin + default: + accessMode = gitea.AccessModeRead + } + + _, err := c.client.AddCollaborator(owner, repo, username, gitea.AddCollaboratorOption{ + Permission: &accessMode, + }) + if err != nil { + return fmt.Errorf("failed to add collaborator %s to %s/%s: %w", username, owner, repo, err) + } + return nil +} + +// RemoveCollaborator removes a collaborator from a repo. +func (c *Client) RemoveCollaborator(ctx context.Context, owner, repo, username string) error { + _, err := c.client.DeleteCollaborator(owner, repo, username) + if err != nil { + return fmt.Errorf("failed to remove collaborator %s from %s/%s: %w", username, owner, repo, err) + } + return nil +} + +// AddDeployKey adds a deploy key to a repo. +func (c *Client) AddDeployKey(ctx context.Context, owner, repo, title, publicKey string, readOnly bool) (*domain.DeployKey, error) { + key, _, err := c.client.CreateDeployKey(owner, repo, gitea.CreateKeyOption{ + Title: title, + Key: publicKey, + ReadOnly: readOnly, + }) + if err != nil { + return nil, fmt.Errorf("failed to add deploy key to %s/%s: %w", owner, repo, err) + } + return &domain.DeployKey{ + ID: key.ID, + Title: key.Title, + PublicKey: key.Key, + ReadOnly: key.ReadOnly, + CreatedAt: key.Created, + }, nil +} + +// DeleteDeployKey removes a deploy key from a repo. +func (c *Client) DeleteDeployKey(ctx context.Context, owner, repo string, keyID int64) error { + _, err := c.client.DeleteDeployKey(owner, repo, keyID) + if err != nil { + return fmt.Errorf("failed to delete deploy key %d from %s/%s: %w", keyID, owner, repo, err) + } + return nil +} + +// CreateWebhook creates a webhook on a repository. +func (c *Client) CreateWebhook(ctx context.Context, owner, repo, url, secret string, events []string) (*domain.RepoWebhook, error) { + hook, _, err := c.client.CreateRepoHook(owner, repo, gitea.CreateHookOption{ + Type: gitea.HookTypeGitea, + Config: map[string]string{ + "url": url, + "content_type": "json", + "secret": secret, + }, + Events: events, + Active: true, + }) + if err != nil { + return nil, fmt.Errorf("failed to create webhook on %s/%s: %w", owner, repo, err) + } + return &domain.RepoWebhook{ + ID: hook.ID, + URL: hook.Config["url"], + Secret: secret, + Events: hook.Events, + Active: hook.Active, + HookType: string(hook.Type), + }, nil +} + +// DeleteWebhook removes a webhook from a repo. +func (c *Client) DeleteWebhook(ctx context.Context, owner, repo string, webhookID int64) error { + _, err := c.client.DeleteRepoHook(owner, repo, webhookID) + if err != nil { + return fmt.Errorf("failed to delete webhook %d from %s/%s: %w", webhookID, owner, repo, err) + } + return nil +} + +// repoFromGitea converts a gitea.Repository to domain.Repo. +func repoFromGitea(r *gitea.Repository) *domain.Repo { + return &domain.Repo{ + ID: r.ID, + Owner: r.Owner.UserName, + Name: r.Name, + FullName: r.FullName, + Description: r.Description, + Private: r.Private, + CloneSSH: r.SSHURL, + CloneHTTP: r.CloneURL, + HTMLURL: r.HTMLURL, + CreatedAt: r.Created, + UpdatedAt: r.Updated, + } +} diff --git a/internal/db/migrations/008_project_infrastructure.sql b/internal/db/migrations/008_project_infrastructure.sql new file mode 100644 index 0000000..86aaf6a --- /dev/null +++ b/internal/db/migrations/008_project_infrastructure.sql @@ -0,0 +1,84 @@ +-- Add infrastructure fields to projects table for git/deployment tracking. +-- This enables projects to be associated with git repos, domains, and deployments. + +-- Projects table for persistent project management +CREATE TABLE IF NOT EXISTS projects ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + description TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Git repository fields +ALTER TABLE projects ADD COLUMN IF NOT EXISTS + git_repo_owner VARCHAR(255); + +ALTER TABLE projects ADD COLUMN IF NOT EXISTS + git_repo_name VARCHAR(255); + +ALTER TABLE projects ADD COLUMN IF NOT EXISTS + git_clone_ssh VARCHAR(512); + +ALTER TABLE projects ADD COLUMN IF NOT EXISTS + git_clone_http VARCHAR(512); + +ALTER TABLE projects ADD COLUMN IF NOT EXISTS + git_html_url VARCHAR(512); + +-- Deployment fields +ALTER TABLE projects ADD COLUMN IF NOT EXISTS + domain VARCHAR(255); + +ALTER TABLE projects ADD COLUMN IF NOT EXISTS + custom_domain VARCHAR(255); + +ALTER TABLE projects ADD COLUMN IF NOT EXISTS + deployment_image VARCHAR(512); + +ALTER TABLE projects ADD COLUMN IF NOT EXISTS + deployment_status VARCHAR(50) DEFAULT 'none'; + +ALTER TABLE projects ADD COLUMN IF NOT EXISTS + deployment_replicas INTEGER DEFAULT 1; + +ALTER TABLE projects ADD COLUMN IF NOT EXISTS + deployment_port INTEGER DEFAULT 8080; + +-- Index for domain lookups (for routing) +CREATE INDEX IF NOT EXISTS idx_projects_domain ON projects(domain); +CREATE INDEX IF NOT EXISTS idx_projects_custom_domain ON projects(custom_domain); + +-- Index for git repo lookups +CREATE INDEX IF NOT EXISTS idx_projects_git_repo ON projects(git_repo_owner, git_repo_name); + +-- Update trigger for updated_at +CREATE OR REPLACE FUNCTION update_projects_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS projects_updated_at ON projects; +CREATE TRIGGER projects_updated_at + BEFORE UPDATE ON projects + FOR EACH ROW + EXECUTE FUNCTION update_projects_updated_at(); + +COMMENT ON TABLE projects IS 'Projects with associated git repos and deployments'; +COMMENT ON COLUMN projects.id IS 'Unique project identifier'; +COMMENT ON COLUMN projects.name IS 'Human-readable project name (unique)'; +COMMENT ON COLUMN projects.description IS 'Optional project description'; +COMMENT ON COLUMN projects.git_repo_owner IS 'Git repository owner/org (e.g., threesix)'; +COMMENT ON COLUMN projects.git_repo_name IS 'Git repository name'; +COMMENT ON COLUMN projects.git_clone_ssh IS 'SSH clone URL (git@git.threesix.ai:owner/name.git)'; +COMMENT ON COLUMN projects.git_clone_http IS 'HTTPS clone URL'; +COMMENT ON COLUMN projects.git_html_url IS 'Web UI URL for the repository'; +COMMENT ON COLUMN projects.domain IS 'Auto-assigned domain (e.g., myproject.threesix.ai)'; +COMMENT ON COLUMN projects.custom_domain IS 'User-configured custom domain'; +COMMENT ON COLUMN projects.deployment_image IS 'Container image for deployment'; +COMMENT ON COLUMN projects.deployment_status IS 'Current deployment status: none, pending, running, failed'; +COMMENT ON COLUMN projects.deployment_replicas IS 'Number of deployment replicas'; +COMMENT ON COLUMN projects.deployment_port IS 'Container port for the deployment'; diff --git a/internal/domain/deployment.go b/internal/domain/deployment.go new file mode 100644 index 0000000..d55defd --- /dev/null +++ b/internal/domain/deployment.go @@ -0,0 +1,44 @@ +// Package domain contains pure domain models with no external dependencies. +package domain + +import "time" + +// DeploySpec defines a deployment request. +type DeploySpec struct { + ProjectName string // Project identifier + Image string // Container image (e.g., zot.threesix.svc:5000/myapp:latest) + Domain string // Domain for ingress (e.g., myapp.threesix.ai) + Port int // Container port to expose + Replicas int // Number of replicas + EnvVars map[string]string // Plain environment variables + Secrets map[string]string // Secret environment variables (stored in K8s Secret) +} + +// DeployStatus represents the current state of a deployment. +type DeployStatus struct { + ProjectName string + Image string + Replicas int + ReadyReplicas int + URL string + Status DeploymentStatus + CreatedAt time.Time + UpdatedAt time.Time +} + +// DeploymentStatus represents the state of a deployment. +type DeploymentStatus string + +const ( + DeploymentStatusNone DeploymentStatus = "none" // No deployment exists + DeploymentStatusPending DeploymentStatus = "pending" // Deployment created, waiting for pods + DeploymentStatusRunning DeploymentStatus = "running" // All pods are ready + DeploymentStatusFailed DeploymentStatus = "failed" // Pods failed to start + DeploymentStatusUpdating DeploymentStatus = "updating" // Rolling update in progress + DeploymentStatusUnknown DeploymentStatus = "unknown" // Status could not be determined +) + +// IsReady returns true if the deployment is fully available. +func (s DeploymentStatus) IsReady() bool { + return s == DeploymentStatusRunning +} diff --git a/internal/domain/dns.go b/internal/domain/dns.go new file mode 100644 index 0000000..43bb262 --- /dev/null +++ b/internal/domain/dns.go @@ -0,0 +1,21 @@ +// Package domain contains pure domain models with no external dependencies. +package domain + +// DNSRecord represents a DNS record in a zone. +type DNSRecord struct { + ID string // Provider-specific ID + Type string // A, AAAA, CNAME, TXT, etc. + Name string // Subdomain or @ for root + Content string // IP address or target + TTL int // TTL in seconds, 1 = auto + Proxied bool // Cloudflare proxy enabled +} + +// DNSRecordType constants for common record types. +const ( + DNSRecordTypeA = "A" + DNSRecordTypeAAAA = "AAAA" + DNSRecordTypeCNAME = "CNAME" + DNSRecordTypeTXT = "TXT" + DNSRecordTypeMX = "MX" +) diff --git a/internal/domain/git.go b/internal/domain/git.go new file mode 100644 index 0000000..636af97 --- /dev/null +++ b/internal/domain/git.go @@ -0,0 +1,40 @@ +// Package domain contains pure domain models with no external dependencies. +package domain + +import "time" + +// Repo represents a git repository. +type Repo struct { + ID int64 + Owner string + Name string + FullName string // owner/name + Description string + Private bool + CloneSSH string // git@git.threesix.ai:owner/name.git + CloneHTTP string // https://git.threesix.ai/owner/name.git + HTMLURL string // https://git.threesix.ai/owner/name + CreatedAt time.Time + UpdatedAt time.Time +} + +// DeployKey represents an SSH key for repository access. +type DeployKey struct { + ID int64 + RepoID int64 + Title string + PublicKey string + ReadOnly bool + CreatedAt time.Time +} + +// RepoWebhook represents a webhook configuration on a repository. +type RepoWebhook struct { + ID int64 + RepoID int64 + URL string + Secret string + Events []string + Active bool + HookType string // gitea, woodpecker, etc. +} diff --git a/internal/handlers/infrastructure.go b/internal/handlers/infrastructure.go new file mode 100644 index 0000000..96df12f --- /dev/null +++ b/internal/handlers/infrastructure.go @@ -0,0 +1,592 @@ +// Package handlers provides HTTP handlers for the rdev API. +package handlers + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "regexp" + "time" + + "github.com/go-chi/chi/v5" + "github.com/orchard9/rdev/internal/domain" + "github.com/orchard9/rdev/internal/port" + "github.com/orchard9/rdev/pkg/api" +) + +// InfrastructureHandler handles git, deployment, and DNS endpoints. +type InfrastructureHandler struct { + gitRepo port.GitRepository + dns port.DNSProvider + deployer port.Deployer + projects port.ProjectRepository + + // Config + defaultGitOwner string + defaultDomain string + clusterIP string +} + +// projectIDRegex validates project IDs (alphanumeric, dash, underscore only). +var projectIDRegex = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_-]*$`) + +// validateProjectID validates that a project ID is safe for use as repo/deployment name. +func validateProjectID(id string) error { + if id == "" { + return errors.New("project ID cannot be empty") + } + if len(id) > 63 { // K8s name limit + return errors.New("project ID too long (max 63 characters)") + } + if !projectIDRegex.MatchString(id) { + return errors.New("project ID must start with a letter and contain only alphanumeric characters, dashes, or underscores") + } + return nil +} + +// InfrastructureConfig configures the infrastructure handler. +type InfrastructureConfig struct { + // DefaultGitOwner is the default org/user for new repos (e.g., "threesix") + DefaultGitOwner string + // DefaultDomain is the base domain for auto-generated URLs (e.g., "threesix.ai") + DefaultDomain string + // ClusterIP is the external IP address for DNS records (e.g., "208.122.204.172") + ClusterIP string +} + +// NewInfrastructureHandler creates a new infrastructure handler. +func NewInfrastructureHandler( + gitRepo port.GitRepository, + dns port.DNSProvider, + deployer port.Deployer, + projects port.ProjectRepository, + cfg InfrastructureConfig, +) *InfrastructureHandler { + return &InfrastructureHandler{ + gitRepo: gitRepo, + dns: dns, + deployer: deployer, + projects: projects, + defaultGitOwner: cfg.DefaultGitOwner, + defaultDomain: cfg.DefaultDomain, + clusterIP: cfg.ClusterIP, + } +} + +// 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) + + // 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) + + // Domain endpoints + r.Post("/{id}/domain", h.AddDomain) + r.Delete("/{id}/domain", h.RemoveDomain) + }) +} + +// CreateRepoRequest is the request body for POST /projects/{id}/repo. +type CreateRepoRequest struct { + Description string `json:"description,omitempty"` + Private bool `json:"private,omitempty"` +} + +// CreateRepoResponse is the response for POST /projects/{id}/repo. +type CreateRepoResponse struct { + ID int64 `json:"id"` + Owner string `json:"owner"` + Name string `json:"name"` + FullName string `json:"full_name"` + Description string `json:"description,omitempty"` + Private bool `json:"private"` + CloneSSH string `json:"clone_ssh"` + CloneHTTP string `json:"clone_http"` + HTMLURL string `json:"html_url"` +} + +// CreateRepo creates a git repository for a project. +// POST /projects/{id}/repo +func (h *InfrastructureHandler) CreateRepo(w http.ResponseWriter, r *http.Request) { + projectID := chi.URLParam(r, "id") + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + + // Validate project ID + if err := validateProjectID(projectID); err != nil { + api.WriteBadRequest(w, r, err.Error()) + return + } + + if h.gitRepo == nil { + api.WriteInternalError(w, r, "git repository not configured") + return + } + + var req CreateRepoRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil && err.Error() != "EOF" { + api.WriteBadRequest(w, r, "invalid request body") + return + } + + // Create the repo + repo, err := h.gitRepo.CreateRepo(ctx, projectID, req.Description, req.Private) + if err != nil { + api.WriteInternalError(w, r, fmt.Sprintf("failed to create repo: %v", err)) + return + } + + // Note: Could update project with repo info here if we had a project repository + // For now, just return the repo info directly + _ = h.projects // Silence unused warning if present + + api.WriteCreated(w, r, CreateRepoResponse{ + ID: repo.ID, + Owner: repo.Owner, + Name: repo.Name, + FullName: repo.FullName, + Description: repo.Description, + Private: repo.Private, + CloneSSH: repo.CloneSSH, + CloneHTTP: repo.CloneHTTP, + HTMLURL: repo.HTMLURL, + }) +} + +// GetRepo returns the git repository for a project. +// GET /projects/{id}/repo +func (h *InfrastructureHandler) GetRepo(w http.ResponseWriter, r *http.Request) { + projectID := chi.URLParam(r, "id") + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + // Validate project ID + if err := validateProjectID(projectID); err != nil { + api.WriteBadRequest(w, r, err.Error()) + return + } + + if h.gitRepo == nil { + api.WriteInternalError(w, r, "git repository not configured") + return + } + + repo, err := h.gitRepo.GetRepo(ctx, h.defaultGitOwner, projectID) + if err != nil { + api.WriteNotFound(w, r, fmt.Sprintf("repo not found: %s/%s", h.defaultGitOwner, projectID)) + return + } + + api.WriteSuccess(w, r, CreateRepoResponse{ + ID: repo.ID, + Owner: repo.Owner, + Name: repo.Name, + FullName: repo.FullName, + Description: repo.Description, + Private: repo.Private, + CloneSSH: repo.CloneSSH, + CloneHTTP: repo.CloneHTTP, + HTMLURL: repo.HTMLURL, + }) +} + +// DeleteRepo deletes the git repository for a project. +// DELETE /projects/{id}/repo +func (h *InfrastructureHandler) DeleteRepo(w http.ResponseWriter, r *http.Request) { + projectID := chi.URLParam(r, "id") + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + + // Validate project ID + if err := validateProjectID(projectID); err != nil { + api.WriteBadRequest(w, r, err.Error()) + return + } + + if h.gitRepo == nil { + api.WriteInternalError(w, r, "git repository not configured") + return + } + + err := h.gitRepo.DeleteRepo(ctx, h.defaultGitOwner, projectID) + if err != nil { + api.WriteInternalError(w, r, fmt.Sprintf("failed to delete repo: %v", err)) + return + } + + api.WriteSuccess(w, r, map[string]string{ + "status": "deleted", + "project": projectID, + }) +} + +// DeployRequest is the request body for POST /projects/{id}/deploy. +type DeployRequest struct { + Image string `json:"image"` // Container image + Domain string `json:"domain,omitempty"` // Custom domain (optional) + Port int `json:"port,omitempty"` // Container port (default 8080) + Replicas int `json:"replicas,omitempty"` // Number of replicas (default 1) + EnvVars map[string]string `json:"env_vars,omitempty"` // Plain environment variables + Secrets map[string]string `json:"secrets,omitempty"` // Secret environment variables +} + +// DeployResponse is the response for POST /projects/{id}/deploy. +type DeployResponse struct { + ProjectName string `json:"project_name"` + Image string `json:"image"` + Domain string `json:"domain"` + URL string `json:"url"` + Status string `json:"status"` +} + +// Deploy deploys a project. +// POST /projects/{id}/deploy +func (h *InfrastructureHandler) Deploy(w http.ResponseWriter, r *http.Request) { + projectID := chi.URLParam(r, "id") + ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second) + defer cancel() + + // Validate project ID + if err := validateProjectID(projectID); err != nil { + api.WriteBadRequest(w, r, err.Error()) + return + } + + if h.deployer == nil { + api.WriteInternalError(w, r, "deployer not configured") + return + } + + var req DeployRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + api.WriteBadRequest(w, r, "invalid request body") + return + } + + if req.Image == "" { + api.WriteBadRequest(w, r, "image is required") + return + } + + // Build domain + deployDomain := req.Domain + if deployDomain == "" { + deployDomain = projectID + "." + h.defaultDomain + } + + // Create DNS record if DNS provider is configured + if h.dns != nil && h.clusterIP != "" { + _, err := h.dns.CreateRecord(ctx, domain.DNSRecord{ + Type: "A", + Name: projectID, + Content: h.clusterIP, + TTL: 1, + Proxied: false, + }) + if err != nil { + // Check if this is a "record already exists" error (not a real failure) + // Cloudflare returns specific error codes we could check, but for now + // we log and continue - the record might already exist from a previous deploy + // TODO: Add proper duplicate detection once we have structured errors from adapter + _ = err // acknowledge error - may be duplicate record which is acceptable + } + } + + // Deploy + spec := domain.DeploySpec{ + ProjectName: projectID, + Image: req.Image, + Domain: deployDomain, + Port: req.Port, + Replicas: req.Replicas, + EnvVars: req.EnvVars, + Secrets: req.Secrets, + } + + if err := h.deployer.Deploy(ctx, spec); err != nil { + api.WriteInternalError(w, r, fmt.Sprintf("failed to deploy: %v", err)) + return + } + + api.WriteCreated(w, r, DeployResponse{ + ProjectName: projectID, + Image: req.Image, + Domain: deployDomain, + URL: "https://" + deployDomain, + Status: "deploying", + }) +} + +// GetDeployStatus returns the deployment status for a project. +// GET /projects/{id}/deploy/status +func (h *InfrastructureHandler) GetDeployStatus(w http.ResponseWriter, r *http.Request) { + projectID := chi.URLParam(r, "id") + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + if h.deployer == nil { + api.WriteInternalError(w, r, "deployer not configured") + return + } + + status, err := h.deployer.GetStatus(ctx, projectID) + if err != nil { + api.WriteInternalError(w, r, fmt.Sprintf("failed to get status: %v", err)) + return + } + + if status == nil { + api.WriteNotFound(w, r, fmt.Sprintf("no deployment found for project: %s", projectID)) + return + } + + api.WriteSuccess(w, r, map[string]any{ + "project_name": status.ProjectName, + "image": status.Image, + "replicas": status.Replicas, + "ready_replicas": status.ReadyReplicas, + "url": status.URL, + "status": status.Status, + "created_at": status.CreatedAt, + "updated_at": status.UpdatedAt, + }) +} + +// Undeploy removes the deployment for a project. +// DELETE /projects/{id}/deploy +func (h *InfrastructureHandler) Undeploy(w http.ResponseWriter, r *http.Request) { + projectID := chi.URLParam(r, "id") + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + + if h.deployer == nil { + api.WriteInternalError(w, r, "deployer not configured") + return + } + + if err := h.deployer.Undeploy(ctx, projectID); err != nil { + api.WriteInternalError(w, r, fmt.Sprintf("failed to undeploy: %v", err)) + return + } + + // Remove DNS record if DNS provider is configured + if h.dns != nil { + _ = h.dns.DeleteRecordByName(ctx, "A", projectID) + } + + api.WriteSuccess(w, r, map[string]string{ + "status": "undeployed", + "project": projectID, + }) +} + +// RestartDeploy restarts the deployment for a project. +// POST /projects/{id}/deploy/restart +func (h *InfrastructureHandler) RestartDeploy(w http.ResponseWriter, r *http.Request) { + projectID := chi.URLParam(r, "id") + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + + if h.deployer == nil { + api.WriteInternalError(w, r, "deployer not configured") + return + } + + if err := h.deployer.Restart(ctx, projectID); err != nil { + api.WriteInternalError(w, r, fmt.Sprintf("failed to restart: %v", err)) + return + } + + api.WriteSuccess(w, r, map[string]string{ + "status": "restarting", + "project": projectID, + }) +} + +// ScaleRequest is the request body for POST /projects/{id}/deploy/scale. +type ScaleRequest struct { + Replicas int `json:"replicas"` +} + +// ScaleDeploy scales the deployment for a project. +// POST /projects/{id}/deploy/scale +func (h *InfrastructureHandler) ScaleDeploy(w http.ResponseWriter, r *http.Request) { + projectID := chi.URLParam(r, "id") + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + + if h.deployer == nil { + api.WriteInternalError(w, r, "deployer not configured") + return + } + + var req ScaleRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + api.WriteBadRequest(w, r, "invalid request body") + return + } + + if req.Replicas < 0 || req.Replicas > 10 { + api.WriteBadRequest(w, r, "replicas must be between 0 and 10") + return + } + + if err := h.deployer.Scale(ctx, projectID, req.Replicas); err != nil { + api.WriteInternalError(w, r, fmt.Sprintf("failed to scale: %v", err)) + return + } + + api.WriteSuccess(w, r, map[string]any{ + "status": "scaled", + "project": projectID, + "replicas": req.Replicas, + }) +} + +// GetDeployLogs returns recent logs from the deployment. +// GET /projects/{id}/deploy/logs +func (h *InfrastructureHandler) GetDeployLogs(w http.ResponseWriter, r *http.Request) { + projectID := chi.URLParam(r, "id") + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + + if h.deployer == nil { + api.WriteInternalError(w, r, "deployer not configured") + return + } + + // Default to 100 lines + tailLines := 100 + + logs, err := h.deployer.GetLogs(ctx, projectID, tailLines) + if err != nil { + api.WriteInternalError(w, r, fmt.Sprintf("failed to get logs: %v", err)) + return + } + + api.WriteSuccess(w, r, map[string]string{ + "project": projectID, + "logs": logs, + }) +} + +// AddDomainRequest is the request body for POST /projects/{id}/domain. +type AddDomainRequest struct { + Domain string `json:"domain"` // Custom domain (e.g., "myapp.example.com") +} + +// AddDomain adds a custom domain to a project. +// POST /projects/{id}/domain +func (h *InfrastructureHandler) AddDomain(w http.ResponseWriter, r *http.Request) { + projectID := chi.URLParam(r, "id") + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + + // Validate project ID + if err := validateProjectID(projectID); err != nil { + api.WriteBadRequest(w, r, err.Error()) + return + } + + var req AddDomainRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + api.WriteBadRequest(w, r, "invalid request body") + return + } + + if req.Domain == "" { + api.WriteBadRequest(w, r, "domain is required") + return + } + + // Create DNS record if it's a threesix.ai subdomain + if h.dns != nil && h.clusterIP != "" && isSubdomain(req.Domain, h.defaultDomain) { + subdomain := getSubdomain(req.Domain, h.defaultDomain) + _, err := h.dns.CreateRecord(ctx, domain.DNSRecord{ + Type: "A", + Name: subdomain, + Content: h.clusterIP, + TTL: 1, + Proxied: false, + }) + if err != nil { + api.WriteInternalError(w, r, fmt.Sprintf("failed to create DNS record: %v", err)) + return + } + } + + // TODO: Update ingress with new domain + // This would require getting the current deployment and updating it + + note := "Domain configured" + if !isSubdomain(req.Domain, h.defaultDomain) && h.clusterIP != "" { + note = fmt.Sprintf("External domain configured. Point your DNS to %s", h.clusterIP) + } + + api.WriteCreated(w, r, map[string]string{ + "project": projectID, + "domain": req.Domain, + "status": "configured", + "note": note, + }) +} + +// RemoveDomain removes a custom domain from a project. +// DELETE /projects/{id}/domain +func (h *InfrastructureHandler) RemoveDomain(w http.ResponseWriter, r *http.Request) { + projectID := chi.URLParam(r, "id") + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + + // Validate project ID + if err := validateProjectID(projectID); err != nil { + api.WriteBadRequest(w, r, err.Error()) + return + } + + domainName := r.URL.Query().Get("domain") + if domainName == "" { + api.WriteBadRequest(w, r, "domain query parameter is required") + return + } + + // Delete DNS record if it's a threesix.ai subdomain + if h.dns != nil && isSubdomain(domainName, h.defaultDomain) { + subdomain := getSubdomain(domainName, h.defaultDomain) + _ = h.dns.DeleteRecordByName(ctx, "A", subdomain) + } + + // TODO: Update ingress to remove the domain + + api.WriteSuccess(w, r, map[string]string{ + "project": projectID, + "domain": domainName, + "status": "removed", + }) +} + +// Helper functions + +func isSubdomain(domain, baseDomain string) bool { + suffix := "." + baseDomain + return len(domain) > len(suffix) && domain[len(domain)-len(suffix):] == suffix +} + +func getSubdomain(domain, baseDomain string) string { + suffix := "." + baseDomain + if len(domain) > len(suffix) && domain[len(domain)-len(suffix):] == suffix { + return domain[:len(domain)-len(suffix)] + } + return domain +} diff --git a/internal/handlers/project_management.go b/internal/handlers/project_management.go new file mode 100644 index 0000000..fd692f9 --- /dev/null +++ b/internal/handlers/project_management.go @@ -0,0 +1,221 @@ +// Package handlers provides HTTP handlers for the rdev API. +package handlers + +import ( + "context" + "encoding/json" + "log/slog" + "net/http" + "strings" + "time" + + "github.com/go-chi/chi/v5" + "github.com/orchard9/rdev/internal/service" + "github.com/orchard9/rdev/pkg/api" +) + +// ProjectManagementHandler handles project lifecycle operations. +type ProjectManagementHandler struct { + infraService *service.ProjectInfraService +} + +// NewProjectManagementHandler creates a new project management handler. +func NewProjectManagementHandler(infraService *service.ProjectInfraService) *ProjectManagementHandler { + return &ProjectManagementHandler{ + infraService: infraService, + } +} + +// Mount registers the project management routes. +func (h *ProjectManagementHandler) Mount(r api.Router) { + r.Route("/project", func(r chi.Router) { + r.Post("/", h.Create) // POST /project - Create new project + r.Get("/", h.List) // GET /project - List all projects + r.Get("/{name}", h.Status) // GET /project/{name} - Get project status + r.Delete("/{name}", h.Delete) // DELETE /project/{name} - Delete project + }) +} + +// CreateRequest is the request body for POST /project. +type CreateRequest struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Private bool `json:"private,omitempty"` +} + +// Create creates a new project with git repo and DNS. +// POST /project +func (h *ProjectManagementHandler) Create(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second) + defer cancel() + + if h.infraService == nil { + api.WriteInternalError(w, r, "project infrastructure service not configured") + return + } + + var req CreateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + api.WriteBadRequest(w, r, "invalid request body") + return + } + + if req.Name == "" { + api.WriteBadRequest(w, r, "name is required") + return + } + + result, err := h.infraService.CreateProject(ctx, service.CreateProjectRequest{ + Name: req.Name, + Description: req.Description, + Private: req.Private, + }) + if err != nil { + // Check for validation errors (user input) vs internal errors + if strings.Contains(err.Error(), "invalid project name") { + api.WriteBadRequest(w, r, err.Error()) + return + } + // Log internal errors but return generic message to client + slog.Error("project creation failed", "error", err, "name", req.Name) + api.WriteInternalError(w, r, "failed to create project") + return + } + + api.WriteCreated(w, r, map[string]any{ + "project_id": result.ProjectID, + "name": result.Name, + "description": result.Description, + "git": map[string]string{ + "owner": result.GitRepoOwner, + "name": result.GitRepoName, + "clone_ssh": result.CloneSSH, + "clone_http": result.CloneHTTP, + "html_url": result.HTMLURL, + }, + "domain": result.Domain, + "url": result.URL, + "next_steps": result.NextSteps, + }) +} + +// List returns all projects. +// GET /project +func (h *ProjectManagementHandler) List(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + if h.infraService == nil { + api.WriteInternalError(w, r, "project infrastructure service not configured") + return + } + + projects, err := h.infraService.ListProjects(ctx) + if err != nil { + slog.Error("failed to list projects", "error", err) + api.WriteInternalError(w, r, "failed to list projects") + return + } + + // Convert to response format + response := make([]map[string]any, len(projects)) + for i, p := range projects { + response[i] = map[string]any{ + "project_id": p.ProjectID, + "name": p.Name, + "description": p.Description, + "git": map[string]string{ + "clone_ssh": p.CloneSSH, + "clone_http": p.CloneHTTP, + "html_url": p.HTMLURL, + }, + "domain": p.Domain, + "url": p.URL, + "deployment": map[string]any{ + "status": p.DeploymentStatus, + "image": p.DeploymentImage, + "replicas": p.DeploymentReplicas, + "ready_replicas": p.ReadyReplicas, + }, + } + } + + api.WriteSuccess(w, r, response) +} + +// Status returns the status of a specific project. +// GET /project/{name} +func (h *ProjectManagementHandler) Status(w http.ResponseWriter, r *http.Request) { + name := chi.URLParam(r, "name") + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + if h.infraService == nil { + api.WriteInternalError(w, r, "project infrastructure service not configured") + return + } + + 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") { + api.WriteNotFound(w, r, "project not found") + return + } + slog.Error("failed to get project status", "error", err, "name", name) + api.WriteInternalError(w, r, "failed to get project status") + return + } + + api.WriteSuccess(w, r, map[string]any{ + "project_id": status.ProjectID, + "name": status.Name, + "description": status.Description, + "git": map[string]string{ + "owner": status.GitRepoOwner, + "name": status.GitRepoName, + "clone_ssh": status.CloneSSH, + "clone_http": status.CloneHTTP, + "html_url": status.HTMLURL, + }, + "domain": status.Domain, + "custom_domain": status.CustomDomain, + "url": status.URL, + "deployment": map[string]any{ + "status": status.DeploymentStatus, + "image": status.DeploymentImage, + "replicas": status.DeploymentReplicas, + "ready_replicas": status.ReadyReplicas, + }, + }) +} + +// Delete removes a project and its associated resources. +// DELETE /project/{name} +func (h *ProjectManagementHandler) Delete(w http.ResponseWriter, r *http.Request) { + name := chi.URLParam(r, "name") + ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second) + defer cancel() + + if h.infraService == nil { + api.WriteInternalError(w, r, "project infrastructure service not configured") + return + } + + err := h.infraService.DeleteProject(ctx, name) + if err != nil { + // Check if it's a "not found" error + if strings.Contains(err.Error(), "not found") { + api.WriteNotFound(w, r, "project not found") + return + } + slog.Error("failed to delete project", "error", err, "name", name) + api.WriteInternalError(w, r, "failed to delete project") + return + } + + api.WriteSuccess(w, r, map[string]string{ + "status": "deleted", + "project": name, + }) +} diff --git a/internal/handlers/woodpecker_webhook.go b/internal/handlers/woodpecker_webhook.go new file mode 100644 index 0000000..ce6dd19 --- /dev/null +++ b/internal/handlers/woodpecker_webhook.go @@ -0,0 +1,260 @@ +// Package handlers provides HTTP handlers for the rdev API. +package handlers + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "strings" + "time" + + "github.com/orchard9/rdev/internal/domain" + "github.com/orchard9/rdev/internal/port" + "github.com/orchard9/rdev/pkg/api" +) + +// WoodpeckerWebhookHandler handles webhooks from Woodpecker CI. +type WoodpeckerWebhookHandler struct { + deployer port.Deployer + dns port.DNSProvider + logger *slog.Logger + + // Config + webhookSecret string + defaultDomain string + registryURL string + clusterIP string +} + +// WoodpeckerWebhookConfig configures the webhook handler. +type WoodpeckerWebhookConfig struct { + WebhookSecret string // HMAC secret for verifying webhooks + DefaultDomain string // e.g., "threesix.ai" + RegistryURL string // e.g., "zot.threesix.svc.cluster.local:5000" + ClusterIP string // e.g., "208.122.204.172" + Logger *slog.Logger +} + +// NewWoodpeckerWebhookHandler creates a new Woodpecker webhook handler. +func NewWoodpeckerWebhookHandler( + deployer port.Deployer, + dns port.DNSProvider, + cfg WoodpeckerWebhookConfig, +) *WoodpeckerWebhookHandler { + logger := cfg.Logger + if logger == nil { + logger = slog.Default() + } + return &WoodpeckerWebhookHandler{ + deployer: deployer, + dns: dns, + logger: logger, + webhookSecret: cfg.WebhookSecret, + defaultDomain: cfg.DefaultDomain, + registryURL: cfg.RegistryURL, + clusterIP: cfg.ClusterIP, + } +} + +// Mount registers the webhook routes. +func (h *WoodpeckerWebhookHandler) Mount(r api.Router) { + // Woodpecker webhook endpoint - no API key auth, uses HMAC signature + r.Post("/webhooks/woodpecker", h.HandleWebhook) +} + +// WoodpeckerPayload represents a Woodpecker webhook payload. +// See: https://woodpecker-ci.org/docs/usage/webhooks +type WoodpeckerPayload struct { + Event string `json:"event"` // "push", "pull_request", "tag", "deployment" + Repo WoodpeckerRepo `json:"repo"` + Build WoodpeckerBuild `json:"build"` + Pipeline WoodpeckerPipeline `json:"pipeline"` +} + +// WoodpeckerRepo represents repository info in the webhook. +type WoodpeckerRepo struct { + ID int64 `json:"id"` + Owner string `json:"owner"` + Name string `json:"name"` + FullName string `json:"full_name"` // owner/name + CloneURL string `json:"clone_url"` + HTMLURL string `json:"html_url"` +} + +// WoodpeckerBuild represents build info in the webhook. +type WoodpeckerBuild struct { + ID int64 `json:"id"` + Number int64 `json:"number"` + Status string `json:"status"` // "success", "failure", "pending", "running" + Event string `json:"event"` + Branch string `json:"branch"` + Commit string `json:"commit"` + Message string `json:"message"` + Author string `json:"author"` + Started int64 `json:"started"` + Finished int64 `json:"finished"` +} + +// WoodpeckerPipeline represents pipeline info in the webhook. +type WoodpeckerPipeline struct { + ID int64 `json:"id"` + Number int64 `json:"number"` + Status string `json:"status"` + Event string `json:"event"` + Branch string `json:"branch"` + Commit string `json:"commit"` + Started int64 `json:"started"` + Finished int64 `json:"finished"` +} + +// HandleWebhook processes incoming Woodpecker webhooks. +// POST /webhooks/woodpecker +func (h *WoodpeckerWebhookHandler) HandleWebhook(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second) + defer cancel() + + // Read body + body, err := io.ReadAll(r.Body) + if err != nil { + h.logger.Error("failed to read webhook body", "error", err) + api.WriteBadRequest(w, r, "failed to read request body") + return + } + + // Debug log the raw payload for troubleshooting + if h.logger.Enabled(ctx, slog.LevelDebug) { + h.logger.Debug("webhook payload received", "body", string(body)) + } + + // Verify signature if secret is configured + if h.webhookSecret != "" { + signature := r.Header.Get("X-Woodpecker-Signature") + if !h.verifySignature(body, signature) { + h.logger.Warn("webhook signature verification failed") + api.WriteError(w, r, http.StatusUnauthorized, "UNAUTHORIZED", "invalid signature") + return + } + } + + // Parse payload + var payload WoodpeckerPayload + if err := json.Unmarshal(body, &payload); err != nil { + h.logger.Error("failed to parse webhook payload", "error", err) + api.WriteBadRequest(w, r, "invalid JSON payload") + return + } + + h.logger.Info("received woodpecker webhook", + "event", payload.Event, + "repo", payload.Repo.FullName, + "build_status", payload.Build.Status, + "build_number", payload.Build.Number, + ) + + // Only process successful builds on main/master branch + if payload.Build.Status != "success" { + api.WriteSuccess(w, r, map[string]string{ + "status": "ignored", + "reason": "build not successful", + "build": payload.Build.Status, + }) + return + } + + if payload.Build.Branch != "main" && payload.Build.Branch != "master" { + api.WriteSuccess(w, r, map[string]string{ + "status": "ignored", + "reason": "not main/master branch", + "branch": payload.Build.Branch, + }) + return + } + + // Extract project name from repo name + projectName := payload.Repo.Name + + // Build image tag from commit SHA (short) + commitShort := payload.Build.Commit + if len(commitShort) > 8 { + commitShort = commitShort[:8] + } + imageTag := fmt.Sprintf("%s/%s:%s", h.registryURL, projectName, commitShort) + imageLatest := fmt.Sprintf("%s/%s:latest", h.registryURL, projectName) + + h.logger.Info("triggering deployment", + "project", projectName, + "image", imageTag, + "commit", payload.Build.Commit, + ) + + // Create DNS record if needed + if h.dns != nil { + _, err := h.dns.CreateRecord(ctx, domain.DNSRecord{ + Type: "A", + Name: projectName, + Content: h.clusterIP, + TTL: 1, + Proxied: false, + }) + if err != nil { + h.logger.Warn("failed to create DNS record", "error", err, "project", projectName) + // Continue anyway - DNS might already exist + } + } + + // Deploy + if h.deployer == nil { + api.WriteInternalError(w, r, "deployer not configured") + return + } + + deployDomain := projectName + "." + h.defaultDomain + + err = h.deployer.Deploy(ctx, domain.DeploySpec{ + ProjectName: projectName, + Image: imageLatest, // Use :latest tag, Woodpecker should push both + Domain: deployDomain, + Port: 8080, + Replicas: 1, + }) + if err != nil { + h.logger.Error("deployment failed", "error", err, "project", projectName) + api.WriteInternalError(w, r, "deployment failed") + return + } + + h.logger.Info("deployment triggered successfully", + "project", projectName, + "url", "https://"+deployDomain, + ) + + api.WriteSuccess(w, r, map[string]any{ + "status": "deployed", + "project": projectName, + "image": imageTag, + "url": "https://" + deployDomain, + "commit": payload.Build.Commit, + }) +} + +// verifySignature verifies the HMAC-SHA256 signature of the webhook payload. +func (h *WoodpeckerWebhookHandler) verifySignature(body []byte, signature string) bool { + if signature == "" { + return false + } + + // Woodpecker sends signature as "sha256=" + signature = strings.TrimPrefix(signature, "sha256=") + + mac := hmac.New(sha256.New, []byte(h.webhookSecret)) + mac.Write(body) + expected := hex.EncodeToString(mac.Sum(nil)) + + return hmac.Equal([]byte(signature), []byte(expected)) +} diff --git a/internal/handlers/woodpecker_webhook_test.go b/internal/handlers/woodpecker_webhook_test.go new file mode 100644 index 0000000..46e6a2a --- /dev/null +++ b/internal/handlers/woodpecker_webhook_test.go @@ -0,0 +1,85 @@ +package handlers + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "testing" +) + +func TestVerifySignature_ValidSignature(t *testing.T) { + h := &WoodpeckerWebhookHandler{webhookSecret: "test-secret"} + body := []byte(`{"event":"push","repo":{"name":"test"},"build":{"status":"success"}}`) + + // Generate a valid signature + mac := hmac.New(sha256.New, []byte("test-secret")) + mac.Write(body) + signature := "sha256=" + hex.EncodeToString(mac.Sum(nil)) + + if !h.verifySignature(body, signature) { + t.Error("expected valid signature to pass verification") + } +} + +func TestVerifySignature_InvalidSignature(t *testing.T) { + h := &WoodpeckerWebhookHandler{webhookSecret: "test-secret"} + body := []byte(`{"event":"push","repo":{"name":"test"},"build":{"status":"success"}}`) + + if h.verifySignature(body, "sha256=invalid") { + t.Error("expected invalid signature to fail verification") + } +} + +func TestVerifySignature_EmptySignature(t *testing.T) { + h := &WoodpeckerWebhookHandler{webhookSecret: "test-secret"} + body := []byte(`{"event":"push"}`) + + if h.verifySignature(body, "") { + t.Error("expected empty signature to fail verification") + } +} + +func TestVerifySignature_WrongSecret(t *testing.T) { + h := &WoodpeckerWebhookHandler{webhookSecret: "test-secret"} + body := []byte(`{"event":"push"}`) + + // Generate signature with different secret + mac := hmac.New(sha256.New, []byte("wrong-secret")) + mac.Write(body) + signature := "sha256=" + hex.EncodeToString(mac.Sum(nil)) + + if h.verifySignature(body, signature) { + t.Error("expected signature with wrong secret to fail verification") + } +} + +func TestVerifySignature_WithoutPrefix(t *testing.T) { + h := &WoodpeckerWebhookHandler{webhookSecret: "test-secret"} + body := []byte(`{"event":"push"}`) + + // Generate valid signature without sha256= prefix + mac := hmac.New(sha256.New, []byte("test-secret")) + mac.Write(body) + signature := hex.EncodeToString(mac.Sum(nil)) + + // Should still work - we strip the prefix + if !h.verifySignature(body, signature) { + t.Error("expected signature without prefix to pass verification") + } +} + +func TestVerifySignature_TamperedBody(t *testing.T) { + h := &WoodpeckerWebhookHandler{webhookSecret: "test-secret"} + originalBody := []byte(`{"event":"push","repo":{"name":"test"}}`) + tamperedBody := []byte(`{"event":"push","repo":{"name":"hacked"}}`) + + // Generate signature for original body + mac := hmac.New(sha256.New, []byte("test-secret")) + mac.Write(originalBody) + signature := "sha256=" + hex.EncodeToString(mac.Sum(nil)) + + // Verify against tampered body should fail + if h.verifySignature(tamperedBody, signature) { + t.Error("expected tampered body to fail verification") + } +} diff --git a/internal/port/deployer.go b/internal/port/deployer.go new file mode 100644 index 0000000..89278a7 --- /dev/null +++ b/internal/port/deployer.go @@ -0,0 +1,33 @@ +// Package port defines interfaces (ports) for external dependencies. +package port + +import ( + "context" + + "github.com/orchard9/rdev/internal/domain" +) + +// Deployer manages application deployments to Kubernetes. +type Deployer interface { + // Deploy creates or updates a deployment for a project. + // This includes creating/updating Deployment, Service, and Ingress resources. + Deploy(ctx context.Context, spec domain.DeploySpec) error + + // Undeploy removes all deployment resources for a project. + Undeploy(ctx context.Context, projectName string) error + + // GetStatus returns the current deployment status for a project. + // Returns nil if no deployment exists. + GetStatus(ctx context.Context, projectName string) (*domain.DeployStatus, error) + + // Restart triggers a rolling restart of the deployment. + // This is useful for picking up new images with the same tag. + Restart(ctx context.Context, projectName string) error + + // Scale adjusts the replica count for a deployment. + Scale(ctx context.Context, projectName string, replicas int) error + + // GetLogs returns recent logs from the deployment pods. + // tailLines specifies how many recent lines to return. + GetLogs(ctx context.Context, projectName string, tailLines int) (string, error) +} diff --git a/internal/port/dns_provider.go b/internal/port/dns_provider.go new file mode 100644 index 0000000..a2ca585 --- /dev/null +++ b/internal/port/dns_provider.go @@ -0,0 +1,36 @@ +// Package port defines interfaces (ports) for external dependencies. +package port + +import ( + "context" + + "github.com/orchard9/rdev/internal/domain" +) + +// DNSProvider manages DNS records for a zone. +type DNSProvider interface { + // CreateRecord creates a DNS record. + // If a record with the same name and type exists, it may be updated or error depending on implementation. + CreateRecord(ctx context.Context, record domain.DNSRecord) (*domain.DNSRecord, error) + + // UpdateRecord updates an existing DNS record by ID. + UpdateRecord(ctx context.Context, recordID string, record domain.DNSRecord) (*domain.DNSRecord, error) + + // DeleteRecord removes a DNS record by ID. + DeleteRecord(ctx context.Context, recordID string) error + + // DeleteRecordByName removes a DNS record by type and name. + // This is useful when you don't have the record ID. + DeleteRecordByName(ctx context.Context, recordType, name string) error + + // GetRecord returns a single record by ID. + GetRecord(ctx context.Context, recordID string) (*domain.DNSRecord, error) + + // ListRecords returns all records in the zone. + // Optionally filter by record type. + ListRecords(ctx context.Context, recordType string) ([]*domain.DNSRecord, error) + + // FindRecord finds a record by type and name. + // Returns nil if not found (no error). + FindRecord(ctx context.Context, recordType, name string) (*domain.DNSRecord, error) +} diff --git a/internal/port/git_repository.go b/internal/port/git_repository.go new file mode 100644 index 0000000..6876420 --- /dev/null +++ b/internal/port/git_repository.go @@ -0,0 +1,42 @@ +// Package port defines interfaces (ports) for external dependencies. +package port + +import ( + "context" + + "github.com/orchard9/rdev/internal/domain" +) + +// GitRepository manages git repositories via external git server (Gitea). +type GitRepository interface { + // CreateRepo creates a new git repository. + CreateRepo(ctx context.Context, name, description string, private bool) (*domain.Repo, error) + + // DeleteRepo deletes a repository. + DeleteRepo(ctx context.Context, owner, name string) error + + // ListRepos returns all repositories for an owner. + ListRepos(ctx context.Context, owner string) ([]*domain.Repo, error) + + // GetRepo returns a single repository. + GetRepo(ctx context.Context, owner, name string) (*domain.Repo, error) + + // AddCollaborator adds a user as collaborator to a repo. + // permission can be "read", "write", or "admin". + AddCollaborator(ctx context.Context, owner, repo, username string, permission string) error + + // RemoveCollaborator removes a collaborator from a repo. + RemoveCollaborator(ctx context.Context, owner, repo, username string) error + + // AddDeployKey adds a deploy key to a repo for read-only or read-write access. + AddDeployKey(ctx context.Context, owner, repo, title, publicKey string, readOnly bool) (*domain.DeployKey, error) + + // DeleteDeployKey removes a deploy key from a repo. + DeleteDeployKey(ctx context.Context, owner, repo string, keyID int64) error + + // CreateWebhook creates a webhook to trigger on specified events. + CreateWebhook(ctx context.Context, owner, repo, url, secret string, events []string) (*domain.RepoWebhook, error) + + // DeleteWebhook removes a webhook from a repo. + DeleteWebhook(ctx context.Context, owner, repo string, webhookID int64) error +} diff --git a/internal/service/project_infra.go b/internal/service/project_infra.go new file mode 100644 index 0000000..8c41814 --- /dev/null +++ b/internal/service/project_infra.go @@ -0,0 +1,383 @@ +// Package service provides business logic services. +package service + +import ( + "context" + "database/sql" + "errors" + "fmt" + "log/slog" + "regexp" + "time" + + "github.com/orchard9/rdev/internal/domain" + "github.com/orchard9/rdev/internal/port" +) + +// projectNameRegex validates project names for DNS and K8s compatibility. +// Must be lowercase, start with a letter, contain only letters, numbers, and dashes. +var projectNameRegex = regexp.MustCompile(`^[a-z][a-z0-9-]*$`) + +// reservedProjectNames are names that cannot be used for projects. +var reservedProjectNames = map[string]bool{ + "www": true, + "api": true, + "git": true, + "ci": true, + "registry": true, + "admin": true, + "root": true, + "rdev": true, + "pantheon": true, +} + +// ValidateProjectName validates that a project name is safe for use as +// a DNS subdomain, K8s resource name, and git repository name. +func ValidateProjectName(name string) error { + if name == "" { + return errors.New("project name cannot be empty") + } + if len(name) > 63 { + return errors.New("project name too long (max 63 characters)") + } + if !projectNameRegex.MatchString(name) { + return errors.New("project name must be lowercase, start with a letter, and contain only letters, numbers, and dashes") + } + if reservedProjectNames[name] { + return fmt.Errorf("'%s' is a reserved name", name) + } + return nil +} + +// ProjectInfraService orchestrates project infrastructure operations. +// It coordinates git repo creation, DNS, and deployment. +type ProjectInfraService struct { + db *sql.DB + gitRepo port.GitRepository + dns port.DNSProvider + deployer port.Deployer + logger *slog.Logger + + // Config + defaultGitOwner string + defaultDomain string + clusterIP string +} + +// ProjectInfraConfig configures the project infrastructure service. +type ProjectInfraConfig struct { + DefaultGitOwner string // e.g., "threesix" + DefaultDomain string // e.g., "threesix.ai" + ClusterIP string // e.g., "208.122.204.172" + Logger *slog.Logger +} + +// NewProjectInfraService creates a new project infrastructure service. +func NewProjectInfraService( + db *sql.DB, + gitRepo port.GitRepository, + dns port.DNSProvider, + deployer port.Deployer, + cfg ProjectInfraConfig, +) *ProjectInfraService { + logger := cfg.Logger + if logger == nil { + logger = slog.Default() + } + return &ProjectInfraService{ + db: db, + gitRepo: gitRepo, + dns: dns, + deployer: deployer, + logger: logger, + defaultGitOwner: cfg.DefaultGitOwner, + defaultDomain: cfg.DefaultDomain, + clusterIP: cfg.ClusterIP, + } +} + +// CreateProjectRequest contains parameters for creating a new project. +type CreateProjectRequest struct { + Name string + Description string + Private bool +} + +// CreateProjectResult contains the result of project creation. +type CreateProjectResult struct { + ProjectID string + Name string + Description string + + // Git info + GitRepoOwner string + GitRepoName string + CloneSSH string + CloneHTTP string + HTMLURL string + + // Domain info + Domain string + URL string + + // Next steps + NextSteps []string +} + +// CreateProject creates a new project with git repo and DNS. +// This is the main orchestration method for /project create. +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) + } + + s.logger.Info("creating project", "name", req.Name) + + // 1. Create project in database + projectID := req.Name // Use name as ID for simplicity + now := time.Now() + + _, err := s.db.ExecContext(ctx, ` + INSERT INTO projects (id, name, description, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (id) DO UPDATE SET + description = EXCLUDED.description, + updated_at = EXCLUDED.updated_at + `, projectID, req.Name, req.Description, now, now) + if err != nil { + return nil, fmt.Errorf("failed to create project in database: %w", err) + } + + result := &CreateProjectResult{ + ProjectID: projectID, + Name: req.Name, + Description: req.Description, + Domain: req.Name + "." + s.defaultDomain, + } + result.URL = "https://" + result.Domain + + // 2. Create git repository + if s.gitRepo != nil { + repo, err := s.gitRepo.CreateRepo(ctx, req.Name, req.Description, req.Private) + if err != nil { + s.logger.Error("failed to create git repo", "error", err) + result.NextSteps = append(result.NextSteps, "Create git repo manually: failed to auto-create") + } else { + result.GitRepoOwner = repo.Owner + result.GitRepoName = repo.Name + result.CloneSSH = repo.CloneSSH + result.CloneHTTP = repo.CloneHTTP + result.HTMLURL = repo.HTMLURL + + // Update database with git info + _, err = s.db.ExecContext(ctx, ` + UPDATE projects SET + git_repo_owner = $1, + git_repo_name = $2, + git_clone_ssh = $3, + git_clone_http = $4, + git_html_url = $5, + updated_at = $6 + WHERE id = $7 + `, repo.Owner, repo.Name, repo.CloneSSH, repo.CloneHTTP, repo.HTMLURL, time.Now(), projectID) + if err != nil { + s.logger.Error("failed to update project with git info", "error", err, "project", projectID) + // Continue - the git repo was created, we just failed to record it + } + } + } else { + result.NextSteps = append(result.NextSteps, "Git repository service not configured") + } + + // 3. Create DNS record + if s.dns != nil { + _, err := s.dns.CreateRecord(ctx, domain.DNSRecord{ + Type: "A", + Name: req.Name, + Content: s.clusterIP, + TTL: 1, + Proxied: false, + }) + if err != nil { + s.logger.Warn("failed to create DNS record", "error", err) + result.NextSteps = append(result.NextSteps, "Create DNS record manually: "+req.Name+"."+s.defaultDomain+" → "+s.clusterIP) + } else { + // Update database with domain + _, err = s.db.ExecContext(ctx, ` + UPDATE projects SET domain = $1, updated_at = $2 WHERE id = $3 + `, result.Domain, time.Now(), projectID) + if err != nil { + s.logger.Error("failed to update project with domain", "error", err, "project", projectID) + // Continue - the DNS was created, we just failed to record it + } + } + } else { + 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", + ) + } + + s.logger.Info("project created successfully", + "project", req.Name, + "git_repo", result.CloneSSH, + "domain", result.Domain, + ) + + return result, nil +} + +// GetProjectStatus returns the current status of a project. +type ProjectStatus struct { + ProjectID string + Name string + Description string + + // Git + GitRepoOwner string + GitRepoName string + CloneSSH string + CloneHTTP string + HTMLURL string + + // Domain + Domain string + CustomDomain string + URL string + + // Deployment + DeploymentImage string + DeploymentStatus string + DeploymentReplicas int + ReadyReplicas int +} + +// GetStatus returns the current status of a project. +func (s *ProjectInfraService) GetStatus(ctx context.Context, projectID string) (*ProjectStatus, error) { + var status ProjectStatus + + err := s.db.QueryRowContext(ctx, ` + SELECT + id, name, COALESCE(description, ''), + COALESCE(git_repo_owner, ''), COALESCE(git_repo_name, ''), + COALESCE(git_clone_ssh, ''), COALESCE(git_clone_http, ''), COALESCE(git_html_url, ''), + COALESCE(domain, ''), COALESCE(custom_domain, ''), + COALESCE(deployment_image, ''), COALESCE(deployment_status, 'none'), + COALESCE(deployment_replicas, 1) + FROM projects WHERE id = $1 + `, projectID).Scan( + &status.ProjectID, &status.Name, &status.Description, + &status.GitRepoOwner, &status.GitRepoName, + &status.CloneSSH, &status.CloneHTTP, &status.HTMLURL, + &status.Domain, &status.CustomDomain, + &status.DeploymentImage, &status.DeploymentStatus, &status.DeploymentReplicas, + ) + if err == sql.ErrNoRows { + return nil, fmt.Errorf("project not found: %s", projectID) + } + if err != nil { + return nil, fmt.Errorf("failed to get project: %w", err) + } + + if status.Domain != "" { + status.URL = "https://" + status.Domain + } + + // Get live deployment status if deployer is available + if s.deployer != nil { + deployStatus, err := s.deployer.GetStatus(ctx, projectID) + if err == nil && deployStatus != nil { + status.DeploymentStatus = string(deployStatus.Status) + status.ReadyReplicas = deployStatus.ReadyReplicas + if deployStatus.URL != "" { + status.URL = deployStatus.URL + } + } + } + + return &status, nil +} + +// ListProjects returns all projects. +func (s *ProjectInfraService) ListProjects(ctx context.Context) ([]*ProjectStatus, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT + id, name, COALESCE(description, ''), + COALESCE(git_repo_owner, ''), COALESCE(git_repo_name, ''), + COALESCE(git_clone_ssh, ''), COALESCE(git_clone_http, ''), COALESCE(git_html_url, ''), + COALESCE(domain, ''), COALESCE(custom_domain, ''), + COALESCE(deployment_image, ''), COALESCE(deployment_status, 'none'), + COALESCE(deployment_replicas, 1) + FROM projects + ORDER BY created_at DESC + `) + if err != nil { + return nil, fmt.Errorf("failed to list projects: %w", err) + } + defer func() { _ = rows.Close() }() + + var projects []*ProjectStatus + for rows.Next() { + var status ProjectStatus + err := rows.Scan( + &status.ProjectID, &status.Name, &status.Description, + &status.GitRepoOwner, &status.GitRepoName, + &status.CloneSSH, &status.CloneHTTP, &status.HTMLURL, + &status.Domain, &status.CustomDomain, + &status.DeploymentImage, &status.DeploymentStatus, &status.DeploymentReplicas, + ) + if err != nil { + continue + } + if status.Domain != "" { + status.URL = "https://" + status.Domain + } + projects = append(projects, &status) + } + + return projects, nil +} + +// DeleteProject removes a project and its associated resources. +func (s *ProjectInfraService) DeleteProject(ctx context.Context, projectID string) error { + s.logger.Info("deleting project", "project", projectID) + + // Get project info first + status, err := s.GetStatus(ctx, projectID) + if err != nil { + return err + } + + // 1. Undeploy if deployed + if s.deployer != nil && status.DeploymentStatus != "none" { + if err := s.deployer.Undeploy(ctx, projectID); err != nil { + s.logger.Warn("failed to undeploy", "error", err) + } + } + + // 2. Delete DNS record + if s.dns != nil && status.Domain != "" { + subdomain := status.Name + if err := s.dns.DeleteRecordByName(ctx, "A", subdomain); err != nil { + s.logger.Warn("failed to delete DNS record", "error", err) + } + } + + // 3. Delete git repo (optional - might want to keep it) + // Skipping git repo deletion for safety + + // 4. Delete from database + _, err = s.db.ExecContext(ctx, `DELETE FROM projects WHERE id = $1`, projectID) + if err != nil { + return fmt.Errorf("failed to delete project from database: %w", err) + } + + s.logger.Info("project deleted", "project", projectID) + return nil +}