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 <noreply@anthropic.com>
This commit is contained in:
jordan 2026-01-25 22:49:58 -07:00
parent 72d16929ca
commit 0fd4e32073
20 changed files with 3319 additions and 126 deletions

13
.golangci.yml Normal file
View File

@ -0,0 +1,13 @@
version: "2"
run:
timeout: 5m
tests: false
linters:
enable:
- errcheck
- govet
- staticcheck
- unused
- ineffassign

View File

@ -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"),
}
}

View File

@ -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 <name>
→ Creates project in DB
→ Creates git repo in soft-serve
→ Creates git repo in Gitea (threesix/<name>)
→ Activates repo in Woodpecker CI
→ Creates DNS record (<name>.threesix.ai)
→ Returns clone URL
→ Returns clone URLs:
SSH: git@git.threesix.ai:threesix/<name>.git
HTTPS: https://git.threesix.ai/threesix/<name>.git
/project deploy <name>
→ 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=<from Gitea UI: Settings Applications Access Tokens>
GITEA_DEFAULT_ORG=threesix
CLOUDFLARE_API_TOKEN=<existing>
CLOUDFLARE_ZONE_ID=<existing>
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=<from GCP Secret Manager>
```
### 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
---

8
go.mod
View File

@ -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

24
go.sum
View File

@ -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=

View File

@ -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
}

View File

@ -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
}

View File

@ -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,
}
}

View File

@ -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';

View File

@ -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
}

21
internal/domain/dns.go Normal file
View File

@ -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"
)

40
internal/domain/git.go Normal file
View File

@ -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.
}

View File

@ -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
}

View File

@ -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,
})
}

View File

@ -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=<hex>"
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))
}

View File

@ -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")
}
}

33
internal/port/deployer.go Normal file
View File

@ -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)
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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
}