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" "strconv"
"time" "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/kubernetes"
"github.com/orchard9/rdev/internal/adapter/memory" "github.com/orchard9/rdev/internal/adapter/memory"
"github.com/orchard9/rdev/internal/adapter/postgres" "github.com/orchard9/rdev/internal/adapter/postgres"
@ -140,6 +143,36 @@ func main() {
os.Exit(1) 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 // Create services
projectService := service.NewProjectService(projectRepo, k8sExecutor, streamPub). projectService := service.NewProjectService(projectRepo, k8sExecutor, streamPub).
WithAuditLogger(auditLogger). WithAuditLogger(auditLogger).
@ -177,6 +210,48 @@ func main() {
queueHandler := handlers.NewQueueHandler(commandQueue, projectRepo) queueHandler := handlers.NewQueueHandler(commandQueue, projectRepo)
webhookHandler := handlers.NewWebhookHandler(webhookRepo, 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 // Register routes
projectsHandler.Mount(app.Router()) projectsHandler.Mount(app.Router())
keysHandler.Mount(app.Router()) keysHandler.Mount(app.Router())
@ -184,6 +259,9 @@ func main() {
auditHandler.Mount(app.Router()) auditHandler.Mount(app.Router())
queueHandler.Mount(app.Router()) queueHandler.Mount(app.Router())
webhookHandler.Mount(app.Router()) webhookHandler.Mount(app.Router())
infraHandler.Mount(app.Router())
projectMgmtHandler.Mount(app.Router())
woodpeckerHandler.Mount(app.Router())
// Start queue processor worker // Start queue processor worker
queueProcessor := worker.NewQueueProcessor( queueProcessor := worker.NewQueueProcessor(
@ -245,6 +323,19 @@ type Config struct {
DBName string DBName string
DBSSLMode string DBSSLMode string
AdminKey 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 { func loadConfig() Config {
@ -271,6 +362,19 @@ func loadConfig() Config {
DBName: getEnv("DB_NAME", "rdev"), DBName: getEnv("DB_NAME", "rdev"),
DBSSLMode: getEnv("DB_SSL_MODE", "disable"), DBSSLMode: getEnv("DB_SSL_MODE", "disable"),
AdminKey: os.Getenv("RDEV_ADMIN_KEY"), 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 ## Overview
Replace GitHub dependency with self-hosted infrastructure on k3s: 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) - **Zot** - Container registry (OCI-native)
- **Woodpecker** - CI/CD pipelines - **Woodpecker** - CI/CD pipelines
- **rdev-api** - Orchestration layer with DNS management - **rdev-api** - Orchestration layer with DNS management
@ -16,7 +16,7 @@ Replace GitHub dependency with self-hosted infrastructure on k3s:
┌─────────────────────────────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────────────────────────┐
│ threesix.ai │ │ 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) │ │ registry.threesix.ai ─▶ zot (internal only, HTTPS for UI) │
│ ci.threesix.ai ───────▶ woodpecker (web UI) │ │ ci.threesix.ai ───────▶ woodpecker (web UI) │
│ *.threesix.ai ────────▶ project deployments │ │ *.threesix.ai ────────▶ project deployments │
@ -27,7 +27,7 @@ Replace GitHub dependency with self-hosted infrastructure on k3s:
│ k3s cluster │ │ k3s cluster │
│ │ │ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ soft-serve │───▶│ woodpecker │───▶│ zot │ │ │ │ gitea │───▶│ woodpecker │───▶│ zot │ │
│ │ (git repos) │ │ (CI/CD) │ │ (registry) │ │ │ │ (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_API_TOKEN | `nGoDhG6Za...` | DNS management |
| CLOUDFLARE_ZONE_ID | `e0bc8d51...` | threesix.ai zone | | 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 ### Network
@ -68,8 +74,9 @@ Replace GitHub dependency with self-hosted infrastructure on k3s:
### Admin Access ### 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" - "*.threesix.ai"
``` ```
### 1.3 Deploy soft-serve ### 1.3 Deploy Gitea
```yaml ```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 apiVersion: v1
kind: ConfigMap kind: ConfigMap
metadata: metadata:
name: soft-serve-config name: gitea-config
namespace: threesix namespace: threesix
data: data:
config.yaml: | app.ini: |
name: threesix APP_NAME = threesix
log_format: text RUN_MODE = prod
ssh:
listen_addr: :22 [server]
public_url: ssh://git.threesix.ai DOMAIN = git.threesix.ai
max_timeout: 30 SSH_DOMAIN = git.threesix.ai
idle_timeout: 120 ROOT_URL = https://git.threesix.ai/
http: HTTP_PORT = 3000
listen_addr: :23231 SSH_PORT = 22
public_url: https://git.threesix.ai SSH_LISTEN_PORT = 22
stats: LFS_START_SERVER = true
listen_addr: :23233
initial_admin_keys: [database]
- "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDZwQF0Ro0E0foFo0oro/NrfUb5abEec/A0OP2qO8dVn jordanwashburn" DB_TYPE = sqlite3
# Allow anyone to read public repos, admins can create PATH = /data/gitea/gitea.db
anon_access: read-only
[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 apiVersion: apps/v1
kind: StatefulSet kind: StatefulSet
metadata: metadata:
name: soft-serve name: gitea
namespace: threesix namespace: threesix
spec: spec:
serviceName: soft-serve serviceName: gitea
replicas: 1 replicas: 1
selector: selector:
matchLabels: matchLabels:
app: soft-serve app: gitea
template: template:
metadata: metadata:
labels: labels:
app: soft-serve app: gitea
spec: spec:
containers: initContainers:
- name: soft-serve - name: init-config
image: charmcli/soft-serve:latest image: gitea/gitea:latest
ports: command: ['sh', '-c', 'cp /etc/gitea/app.ini /data/gitea/conf/app.ini']
- containerPort: 22
name: ssh
- containerPort: 23231
name: http
- containerPort: 23233
name: stats
volumeMounts: volumeMounts:
- name: data - name: data
mountPath: /soft-serve mountPath: /data
- name: config - name: config
mountPath: /soft-serve/config.yaml mountPath: /etc/gitea
subPath: config.yaml 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: resources:
requests: requests:
memory: "64Mi"
cpu: "50m"
limits:
memory: "256Mi" memory: "256Mi"
cpu: "500m" cpu: "100m"
limits:
memory: "1Gi"
cpu: "1000m"
volumes: volumes:
- name: config - name: config
configMap: configMap:
name: soft-serve-config name: gitea-config
volumeClaimTemplates: volumeClaimTemplates:
- metadata: - metadata:
name: data name: data
@ -216,37 +265,34 @@ spec:
storageClassName: longhorn storageClassName: longhorn
resources: resources:
requests: requests:
storage: 10Gi storage: 20Gi
--- ---
apiVersion: v1 apiVersion: v1
kind: Service kind: Service
metadata: metadata:
name: soft-serve name: gitea
namespace: threesix namespace: threesix
spec: spec:
selector: selector:
app: soft-serve app: gitea
ports: ports:
- name: http
port: 3000
targetPort: 3000
- name: ssh - name: ssh
port: 22 port: 22
targetPort: 22 targetPort: 22
- name: http
port: 80
targetPort: 23231
- name: stats
port: 23233
targetPort: 23233
--- ---
# External SSH access via LoadBalancer # External SSH access via LoadBalancer
apiVersion: v1 apiVersion: v1
kind: Service kind: Service
metadata: metadata:
name: soft-serve-ssh name: gitea-ssh
namespace: threesix namespace: threesix
spec: spec:
type: LoadBalancer type: LoadBalancer
selector: selector:
app: soft-serve app: gitea
ports: ports:
- name: ssh - name: ssh
port: 22 port: 22
@ -256,7 +302,7 @@ spec:
apiVersion: networking.k8s.io/v1 apiVersion: networking.k8s.io/v1
kind: Ingress kind: Ingress
metadata: metadata:
name: soft-serve name: gitea
namespace: threesix namespace: threesix
annotations: annotations:
cert-manager.io/cluster-issuer: letsencrypt-threesix cert-manager.io/cluster-issuer: letsencrypt-threesix
@ -274,9 +320,9 @@ spec:
pathType: Prefix pathType: Prefix
backend: backend:
service: service:
name: soft-serve name: gitea
port: port:
number: 80 number: 3000
``` ```
### 1.4 Deploy Zot Registry ### 1.4 Deploy Zot Registry
@ -419,7 +465,17 @@ Create via Cloudflare API or dashboard:
## Phase 2: CI/CD (Woodpecker) ## 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 ```yaml
# deployments/k8s/base/threesix/woodpecker-server.yaml # deployments/k8s/base/threesix/woodpecker-server.yaml
@ -432,6 +488,9 @@ type: Opaque
stringData: stringData:
# Generate with: openssl rand -hex 32 # Generate with: openssl rand -hex 32
WOODPECKER_AGENT_SECRET: "${WOODPECKER_AGENT_SECRET}" 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 apiVersion: apps/v1
kind: Deployment kind: Deployment
@ -457,14 +516,14 @@ spec:
- name: WOODPECKER_HOST - name: WOODPECKER_HOST
value: "https://ci.threesix.ai" value: "https://ci.threesix.ai"
- name: WOODPECKER_OPEN - name: WOODPECKER_OPEN
value: "false" value: "true"
- name: WOODPECKER_ADMIN - name: WOODPECKER_ADMIN
value: "jordan" value: "jordan"
# Soft-serve / generic git integration # Gitea forge integration
- name: WOODPECKER_GITEA - name: WOODPECKER_GITEA
value: "false" value: "true"
- name: WOODPECKER_WEBHOOK_HOST - name: WOODPECKER_GITEA_URL
value: "http://woodpecker-server.threesix.svc:8000" value: "https://git.threesix.ai"
envFrom: envFrom:
- secretRef: - secretRef:
name: woodpecker-secrets name: woodpecker-secrets
@ -530,7 +589,7 @@ spec:
number: 8000 number: 8000
``` ```
### 2.2 Deploy Woodpecker Agent (with Kaniko) ### 2.3 Deploy Woodpecker Agent (with Kaniko)
```yaml ```yaml
# deployments/k8s/base/threesix/woodpecker-agent.yaml # deployments/k8s/base/threesix/woodpecker-agent.yaml
@ -611,34 +670,43 @@ package port
import "context" import "context"
// GitRepository manages git repositories. // GitRepository manages git repositories via Gitea API.
type GitRepository interface { type GitRepository interface {
// CreateRepo creates a new git repository. // 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 deletes a repository.
DeleteRepo(ctx context.Context, name string) error DeleteRepo(ctx context.Context, owner, name string) error
// ListRepos returns all repositories. // ListRepos returns all repositories for an owner.
ListRepos(ctx context.Context) ([]*Repo, error) ListRepos(ctx context.Context, owner string) ([]*Repo, error)
// GetRepo returns a single repository. // 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 adds a user as collaborator to a repo.
AddCollaborator(ctx context.Context, repo, keyName, publicKey string) error AddCollaborator(ctx context.Context, owner, repo, username string, permission string) error
// AddWebhook adds a webhook to trigger on push. // AddDeployKey adds a deploy key to a repo for read-only or read-write access.
AddWebhook(ctx context.Context, repo, url, secret string) error 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. // Repo represents a git repository.
type Repo struct { type Repo struct {
ID int64
Owner string
Name string Name string
FullName string // owner/name
Description string Description string
CloneSSH string // ssh://git@git.threesix.ai/name.git Private bool
CloneHTTP string // https://git.threesix.ai/name.git 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 CreatedAt time.Time
UpdatedAt time.Time
} }
``` ```
@ -714,7 +782,7 @@ type DeployStatus struct {
``` ```
internal/adapter/ internal/adapter/
├── softserve/ # soft-serve SSH/API client ├── gitea/ # Gitea REST API client
│ └── client.go │ └── client.go
├── cloudflare/ # Cloudflare DNS API client ├── cloudflare/ # Cloudflare DNS API client
│ └── client.go │ └── client.go
@ -724,6 +792,56 @@ internal/adapter/
└── client.go └── 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 ### 3.3 New Handlers
```go ```go
@ -802,9 +920,12 @@ CREATE INDEX IF NOT EXISTS idx_projects_custom_domain ON projects(custom_domain)
``` ```
/project create <name> /project create <name>
→ Creates project in DB → 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) → 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> /project deploy <name>
→ Triggers build from latest commit → 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 ### 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 from repo
Woodpecker reads .woodpecker.yml
Kaniko builds image, pushes to zot Kaniko builds image, pushes to zot
@ -840,54 +958,125 @@ Kaniko builds image, pushes to zot
Woodpecker calls rdev-api: POST /projects/{id}/deploy 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 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 ## Implementation Checklist
### Phase 1: Foundation ### Phase 1: Foundation ✅ COMPLETED (2026-01-26)
- [ ] Create `threesix` namespace - [x] Create `threesix` namespace
- [ ] Create Cloudflare API secret - [x] Create Cloudflare API secret
- [ ] Configure ClusterIssuer for DNS-01 challenge - [x] Configure Issuer for DNS-01 challenge (namespace-scoped, not ClusterIssuer)
- [ ] Request wildcard certificate - [x] Request wildcard certificate (*.threesix.ai)
- [ ] Deploy soft-serve StatefulSet - [x] Deploy Gitea StatefulSet (rootless image, PostgreSQL backend, writable config)
- [ ] Configure soft-serve LoadBalancer for SSH - [x] Configure Gitea LoadBalancer for SSH (208.122.204.172:22)
- [ ] Deploy Zot registry - [x] Deploy Zot registry (10Gi storage)
- [ ] Create initial DNS records (git, registry, ci, wildcard) - [x] Create DNS records: git.threesix.ai, registry.threesix.ai → 208.122.204.172
- [ ] Test: `ssh git@git.threesix.ai` works - [x] Test: `https://git.threesix.ai` shows Gitea UI ✅
- [ ] Test: `https://registry.threesix.ai` shows Zot UI - [x] Test: `https://registry.threesix.ai/v2/_catalog` returns `{"repositories":[]}`
- [x] Complete Gitea installation wizard ✅
### Phase 2: CI/CD **Implementation Notes:**
- [ ] Generate Woodpecker agent secret - Used namespace-scoped `Issuer` instead of `ClusterIssuer` (cert-manager couldn't access secrets across namespaces)
- [ ] Deploy Woodpecker server - Gitea uses PostgreSQL (`postgres.databases.svc.cluster.local`) instead of SQLite
- [ ] Deploy Woodpecker agents - Gitea credentials: `gitea` user, password in `/tmp/gitea-db-password.txt`
- [ ] Configure soft-serve webhook to Woodpecker - Rootless Gitea image requires `securityContext.fsGroup: 1000` and writable `/etc/gitea` via volume subPath
- [ ] Test: push triggers build
### 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 - [ ] Test: Kaniko builds and pushes to Zot
### Phase 3: rdev-api **Secrets saved:**
- [ ] Add GitRepository port interface - Agent secret: `/tmp/woodpecker-agent-secret.txt`
- [ ] Add DNSProvider port interface - Gitea OAuth: Client ID `7548afec-43e0-486a-b6eb-e2a7d5c88d41`
- [ ] Add Deployer port interface
- [ ] Implement soft-serve adapter ### Phase 3: rdev-api ✅ COMPLETED (2026-01-26)
- [ ] Implement Cloudflare adapter - [x] Add GitRepository port interface (`internal/port/git_repository.go`)
- [ ] Implement K8s deployer adapter - [x] Add DNSProvider port interface (`internal/port/dns_provider.go`)
- [ ] Add database migration - [x] Add Deployer port interface (`internal/port/deployer.go`)
- [ ] Add new handlers - [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 create repos
- [ ] Test: API can manage DNS - [ ] Test: API can manage DNS
- [ ] Test: API can deploy apps - [ ] Test: API can deploy apps
### Phase 4: Integration **New Environment Variables:**
- [ ] Wire up webhook: build → deploy ```
- [ ] Add project commands to Pantheon 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" - [ ] 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 ### Phase 5: Polish
- [ ] Custom domain support - [ ] Custom domain support
- [ ] Build notifications to Pantheon - [ ] Build notifications to Pantheon
@ -901,21 +1090,22 @@ Project live at https://{name}.threesix.ai
| Component | CPU Request | Memory Request | Storage | | Component | CPU Request | Memory Request | Storage |
|-----------|-------------|----------------|---------| |-----------|-------------|----------------|---------|
| soft-serve | 50m | 64Mi | 10Gi | | Gitea | 100m | 256Mi | 20Gi |
| Zot | 100m | 128Mi | 50Gi | | Zot | 100m | 128Mi | 50Gi |
| Woodpecker Server | 100m | 128Mi | 5Gi | | Woodpecker Server | 100m | 128Mi | 5Gi |
| Woodpecker Agent (x2) | 200m each | 256Mi each | - | | Woodpecker Agent (x2) | 200m each | 256Mi each | - |
| **Total** | ~650m | ~832Mi | 65Gi | | **Total** | ~700m | ~1Gi | 75Gi |
--- ---
## Security Considerations ## Security Considerations
1. **soft-serve admin key** - Only jordan's key is admin initially 1. **Gitea admin** - Registration disabled, only admin user can create accounts
2. **Registry access** - Internal only, no auth needed (ClusterIP) 2. **Gitea API token** - Create a dedicated token for rdev-api with repo scope
3. **Woodpecker** - Closed registration, admin-only access 3. **Registry access** - Internal only, no auth needed (ClusterIP)
4. **Cloudflare token** - Scoped to DNS edit only 4. **Woodpecker** - OAuth via Gitea, inherits Gitea permissions
5. **Deploy permissions** - rdev-api ServiceAccount limited to `threesix` and `projects` namespaces 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 go 1.25.0
require ( require (
code.gitea.io/sdk/gitea v0.22.1
github.com/bdpiprava/scalar-go v0.13.0 github.com/bdpiprava/scalar-go v0.13.0
github.com/go-chi/chi/v5 v5.1.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/lib/pq v1.10.9
github.com/prometheus/client_golang v1.23.2 github.com/prometheus/client_golang v1.23.2
go.opentelemetry.io/otel v1.39.0 go.opentelemetry.io/otel v1.39.0
@ -17,20 +19,23 @@ require (
) )
require ( require (
github.com/42wim/httpsig v1.2.3 // indirect
github.com/beorn7/perks v1.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // 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/emicklei/go-restful/v3 v3.12.2 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // 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/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/jsonreference v0.20.2 // indirect github.com/go-openapi/jsonreference v0.20.2 // indirect
github.com/go-openapi/swag v0.23.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect
github.com/google/gnostic-models v0.7.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/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/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/mailru/easyjson v0.7.7 // indirect github.com/mailru/easyjson v0.7.7 // indirect
@ -48,6 +53,7 @@ require (
go.opentelemetry.io/proto/otlp v1.9.0 // indirect go.opentelemetry.io/proto/otlp v1.9.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect
go.yaml.in/yaml/v3 v3.0.4 // 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/net v0.47.0 // indirect
golang.org/x/oauth2 v0.32.0 // indirect golang.org/x/oauth2 v0.32.0 // indirect
golang.org/x/sys v0.39.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 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= 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= 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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU=
github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= 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 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= 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 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= 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.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 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 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/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 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4= 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 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 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/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 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= 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 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= 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 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= 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 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= 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 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 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 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 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 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= 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 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= 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 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 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 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= 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
}