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:
parent
72d16929ca
commit
0fd4e32073
13
.golangci.yml
Normal file
13
.golangci.yml
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
version: "2"
|
||||||
|
|
||||||
|
run:
|
||||||
|
timeout: 5m
|
||||||
|
tests: false
|
||||||
|
|
||||||
|
linters:
|
||||||
|
enable:
|
||||||
|
- errcheck
|
||||||
|
- govet
|
||||||
|
- staticcheck
|
||||||
|
- unused
|
||||||
|
- ineffassign
|
||||||
@ -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"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
8
go.mod
@ -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
24
go.sum
@ -1,3 +1,7 @@
|
|||||||
|
code.gitea.io/sdk/gitea v0.22.1 h1:7K05KjRORyTcTYULQ/AwvlVS6pawLcWyXZcTr7gHFyA=
|
||||||
|
code.gitea.io/sdk/gitea v0.22.1/go.mod h1:yyF5+GhljqvA30sRDreoyHILruNiy4ASufugzYg0VHM=
|
||||||
|
github.com/42wim/httpsig v1.2.3 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs=
|
||||||
|
github.com/42wim/httpsig v1.2.3/go.mod h1:nZq9OlYKDrUBhptd77IHx4/sZZD+IxTBADvAPI9G/EM=
|
||||||
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
|
github.com/Masterminds/semver/v3 v3.4.0 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=
|
||||||
|
|||||||
293
internal/adapter/cloudflare/client.go
Normal file
293
internal/adapter/cloudflare/client.go
Normal 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
|
||||||
|
}
|
||||||
502
internal/adapter/deployer/deployer.go
Normal file
502
internal/adapter/deployer/deployer.go
Normal 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
|
||||||
|
}
|
||||||
220
internal/adapter/gitea/client.go
Normal file
220
internal/adapter/gitea/client.go
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
84
internal/db/migrations/008_project_infrastructure.sql
Normal file
84
internal/db/migrations/008_project_infrastructure.sql
Normal 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';
|
||||||
44
internal/domain/deployment.go
Normal file
44
internal/domain/deployment.go
Normal 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
21
internal/domain/dns.go
Normal 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
40
internal/domain/git.go
Normal 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.
|
||||||
|
}
|
||||||
592
internal/handlers/infrastructure.go
Normal file
592
internal/handlers/infrastructure.go
Normal 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
|
||||||
|
}
|
||||||
221
internal/handlers/project_management.go
Normal file
221
internal/handlers/project_management.go
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
260
internal/handlers/woodpecker_webhook.go
Normal file
260
internal/handlers/woodpecker_webhook.go
Normal 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))
|
||||||
|
}
|
||||||
85
internal/handlers/woodpecker_webhook_test.go
Normal file
85
internal/handlers/woodpecker_webhook_test.go
Normal 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
33
internal/port/deployer.go
Normal 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)
|
||||||
|
}
|
||||||
36
internal/port/dns_provider.go
Normal file
36
internal/port/dns_provider.go
Normal 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)
|
||||||
|
}
|
||||||
42
internal/port/git_repository.go
Normal file
42
internal/port/git_repository.go
Normal 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
|
||||||
|
}
|
||||||
383
internal/service/project_infra.go
Normal file
383
internal/service/project_infra.go
Normal 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
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user