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"
|
||||
"time"
|
||||
|
||||
"github.com/orchard9/rdev/internal/adapter/cloudflare"
|
||||
"github.com/orchard9/rdev/internal/adapter/deployer"
|
||||
"github.com/orchard9/rdev/internal/adapter/gitea"
|
||||
"github.com/orchard9/rdev/internal/adapter/kubernetes"
|
||||
"github.com/orchard9/rdev/internal/adapter/memory"
|
||||
"github.com/orchard9/rdev/internal/adapter/postgres"
|
||||
@ -140,6 +143,36 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Initialize infrastructure adapters (optional - only if configured)
|
||||
var giteaClient *gitea.Client
|
||||
if cfg.GiteaToken != "" && cfg.GiteaURL != "" {
|
||||
var err error
|
||||
giteaClient, err = gitea.NewClient(cfg.GiteaURL, cfg.GiteaToken, cfg.GiteaDefaultOrg)
|
||||
if err != nil {
|
||||
logger.Warn("failed to initialize gitea client", "error", err)
|
||||
} else {
|
||||
logger.Info("gitea client initialized", "url", cfg.GiteaURL, "org", cfg.GiteaDefaultOrg)
|
||||
}
|
||||
}
|
||||
|
||||
var dnsClient *cloudflare.Client
|
||||
if cfg.CloudflareToken != "" && cfg.CloudflareZoneID != "" {
|
||||
dnsClient = cloudflare.NewClient(cfg.CloudflareToken, cfg.CloudflareZoneID, cfg.DefaultDomain)
|
||||
logger.Info("cloudflare DNS client initialized", "domain", cfg.DefaultDomain)
|
||||
}
|
||||
|
||||
var deployerAdapter *deployer.Deployer
|
||||
if k8sClient != nil {
|
||||
deployerAdapter = deployer.NewDeployer(k8sClient, deployer.Config{
|
||||
Namespace: cfg.DeployNamespace,
|
||||
IngressClass: "traefik",
|
||||
TLSIssuer: cfg.DeployTLSIssuer,
|
||||
DefaultDomain: cfg.DefaultDomain,
|
||||
DefaultReplicas: 1,
|
||||
})
|
||||
logger.Info("deployer initialized", "namespace", cfg.DeployNamespace)
|
||||
}
|
||||
|
||||
// Create services
|
||||
projectService := service.NewProjectService(projectRepo, k8sExecutor, streamPub).
|
||||
WithAuditLogger(auditLogger).
|
||||
@ -177,6 +210,48 @@ func main() {
|
||||
queueHandler := handlers.NewQueueHandler(commandQueue, projectRepo)
|
||||
webhookHandler := handlers.NewWebhookHandler(webhookRepo, projectRepo)
|
||||
|
||||
// Initialize infrastructure handler (for threesix.ai git/deploy/dns)
|
||||
infraHandler := handlers.NewInfrastructureHandler(
|
||||
giteaClient,
|
||||
dnsClient,
|
||||
deployerAdapter,
|
||||
projectRepo,
|
||||
handlers.InfrastructureConfig{
|
||||
DefaultGitOwner: cfg.GiteaDefaultOrg,
|
||||
DefaultDomain: cfg.DefaultDomain,
|
||||
},
|
||||
)
|
||||
|
||||
// Initialize project infrastructure service (orchestrates full project lifecycle)
|
||||
projectInfraService := service.NewProjectInfraService(
|
||||
database.DB,
|
||||
giteaClient,
|
||||
dnsClient,
|
||||
deployerAdapter,
|
||||
service.ProjectInfraConfig{
|
||||
DefaultGitOwner: cfg.GiteaDefaultOrg,
|
||||
DefaultDomain: cfg.DefaultDomain,
|
||||
ClusterIP: cfg.ClusterIP,
|
||||
Logger: logger,
|
||||
},
|
||||
)
|
||||
|
||||
// Initialize project management handler
|
||||
projectMgmtHandler := handlers.NewProjectManagementHandler(projectInfraService)
|
||||
|
||||
// Initialize Woodpecker webhook handler (for CI/CD auto-deploy)
|
||||
woodpeckerHandler := handlers.NewWoodpeckerWebhookHandler(
|
||||
deployerAdapter,
|
||||
dnsClient,
|
||||
handlers.WoodpeckerWebhookConfig{
|
||||
WebhookSecret: cfg.WoodpeckerWebhookSecret,
|
||||
DefaultDomain: cfg.DefaultDomain,
|
||||
RegistryURL: cfg.RegistryURL,
|
||||
ClusterIP: cfg.ClusterIP,
|
||||
Logger: logger,
|
||||
},
|
||||
)
|
||||
|
||||
// Register routes
|
||||
projectsHandler.Mount(app.Router())
|
||||
keysHandler.Mount(app.Router())
|
||||
@ -184,6 +259,9 @@ func main() {
|
||||
auditHandler.Mount(app.Router())
|
||||
queueHandler.Mount(app.Router())
|
||||
webhookHandler.Mount(app.Router())
|
||||
infraHandler.Mount(app.Router())
|
||||
projectMgmtHandler.Mount(app.Router())
|
||||
woodpeckerHandler.Mount(app.Router())
|
||||
|
||||
// Start queue processor worker
|
||||
queueProcessor := worker.NewQueueProcessor(
|
||||
@ -245,6 +323,19 @@ type Config struct {
|
||||
DBName string
|
||||
DBSSLMode string
|
||||
AdminKey string
|
||||
|
||||
// Infrastructure adapters (threesix.ai)
|
||||
GiteaURL string
|
||||
GiteaToken string
|
||||
GiteaDefaultOrg string
|
||||
CloudflareToken string
|
||||
CloudflareZoneID string
|
||||
DefaultDomain string
|
||||
DeployNamespace string
|
||||
DeployTLSIssuer string
|
||||
ClusterIP string
|
||||
RegistryURL string
|
||||
WoodpeckerWebhookSecret string
|
||||
}
|
||||
|
||||
func loadConfig() Config {
|
||||
@ -271,6 +362,19 @@ func loadConfig() Config {
|
||||
DBName: getEnv("DB_NAME", "rdev"),
|
||||
DBSSLMode: getEnv("DB_SSL_MODE", "disable"),
|
||||
AdminKey: os.Getenv("RDEV_ADMIN_KEY"),
|
||||
|
||||
// Infrastructure adapters
|
||||
GiteaURL: getEnv("GITEA_URL", "https://git.threesix.ai"),
|
||||
GiteaToken: os.Getenv("GITEA_TOKEN"),
|
||||
GiteaDefaultOrg: getEnv("GITEA_DEFAULT_ORG", "threesix"),
|
||||
CloudflareToken: os.Getenv("CLOUDFLARE_API_TOKEN"),
|
||||
CloudflareZoneID: os.Getenv("CLOUDFLARE_ZONE_ID"),
|
||||
DefaultDomain: getEnv("DEFAULT_DOMAIN", "threesix.ai"),
|
||||
DeployNamespace: getEnv("DEPLOY_NAMESPACE", "projects"),
|
||||
DeployTLSIssuer: getEnv("DEPLOY_TLS_ISSUER", "letsencrypt-threesix"),
|
||||
ClusterIP: getEnv("CLUSTER_IP", "208.122.204.172"),
|
||||
RegistryURL: getEnv("REGISTRY_URL", "zot.threesix.svc.cluster.local:5000"),
|
||||
WoodpeckerWebhookSecret: os.Getenv("WOODPECKER_WEBHOOK_SECRET"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
## Overview
|
||||
|
||||
Replace GitHub dependency with self-hosted infrastructure on k3s:
|
||||
- **soft-serve** - Git server (SSH-based, minimal)
|
||||
- **Gitea** - Git server (full-featured, web UI, native Woodpecker integration)
|
||||
- **Zot** - Container registry (OCI-native)
|
||||
- **Woodpecker** - CI/CD pipelines
|
||||
- **rdev-api** - Orchestration layer with DNS management
|
||||
@ -16,7 +16,7 @@ Replace GitHub dependency with self-hosted infrastructure on k3s:
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ threesix.ai │
|
||||
│ │
|
||||
│ git.threesix.ai ──────▶ soft-serve (SSH :22) │
|
||||
│ git.threesix.ai ──────▶ gitea (web UI + SSH :22) │
|
||||
│ registry.threesix.ai ─▶ zot (internal only, HTTPS for UI) │
|
||||
│ ci.threesix.ai ───────▶ woodpecker (web UI) │
|
||||
│ *.threesix.ai ────────▶ project deployments │
|
||||
@ -27,7 +27,7 @@ Replace GitHub dependency with self-hosted infrastructure on k3s:
|
||||
│ k3s cluster │
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ soft-serve │───▶│ woodpecker │───▶│ zot │ │
|
||||
│ │ gitea │───▶│ woodpecker │───▶│ zot │ │
|
||||
│ │ (git repos) │ │ (CI/CD) │ │ (registry) │ │
|
||||
│ └──────────────┘ └──────────────┘ └──────────────┘ │
|
||||
│ │ │ │ │
|
||||
@ -57,6 +57,12 @@ Replace GitHub dependency with self-hosted infrastructure on k3s:
|
||||
|-----|-------|---------|
|
||||
| CLOUDFLARE_API_TOKEN | `nGoDhG6Za...` | DNS management |
|
||||
| CLOUDFLARE_ZONE_ID | `e0bc8d51...` | threesix.ai zone |
|
||||
| GITEA_ADMIN_PASSWORD | (generate) | Gitea admin login |
|
||||
| GITEA_SECRET_KEY | (generate: `openssl rand -hex 32`) | Gitea internal security |
|
||||
| GITEA_API_TOKEN | (create in Gitea UI) | rdev-api access to Gitea |
|
||||
| GITEA_OAUTH_CLIENT_ID | (create in Gitea UI) | Woodpecker OAuth |
|
||||
| GITEA_OAUTH_CLIENT_SECRET | (create in Gitea UI) | Woodpecker OAuth |
|
||||
| WOODPECKER_AGENT_SECRET | (generate: `openssl rand -hex 32`) | Agent-server auth |
|
||||
|
||||
### Network
|
||||
|
||||
@ -68,8 +74,9 @@ Replace GitHub dependency with self-hosted infrastructure on k3s:
|
||||
|
||||
### Admin Access
|
||||
|
||||
Admin SSH key (add in Gitea UI: Settings → SSH/GPG Keys):
|
||||
```
|
||||
SSH Public Key: ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDZwQF0Ro0E0foFo0oro/NrfUb5abEec/A0OP2qO8dVn jordanwashburn@jordanmacstudio.lan
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDZwQF0Ro0E0foFo0oro/NrfUb5abEec/A0OP2qO8dVn jordanwashburn@jordanmacstudio.lan
|
||||
```
|
||||
|
||||
---
|
||||
@ -137,77 +144,119 @@ spec:
|
||||
- "*.threesix.ai"
|
||||
```
|
||||
|
||||
### 1.3 Deploy soft-serve
|
||||
### 1.3 Deploy Gitea
|
||||
|
||||
```yaml
|
||||
# deployments/k8s/base/threesix/soft-serve.yaml
|
||||
# deployments/k8s/base/threesix/gitea.yaml
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: gitea-admin
|
||||
namespace: threesix
|
||||
type: Opaque
|
||||
stringData:
|
||||
username: jordan
|
||||
password: "${GITEA_ADMIN_PASSWORD}"
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: soft-serve-config
|
||||
name: gitea-config
|
||||
namespace: threesix
|
||||
data:
|
||||
config.yaml: |
|
||||
name: threesix
|
||||
log_format: text
|
||||
ssh:
|
||||
listen_addr: :22
|
||||
public_url: ssh://git.threesix.ai
|
||||
max_timeout: 30
|
||||
idle_timeout: 120
|
||||
http:
|
||||
listen_addr: :23231
|
||||
public_url: https://git.threesix.ai
|
||||
stats:
|
||||
listen_addr: :23233
|
||||
initial_admin_keys:
|
||||
- "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDZwQF0Ro0E0foFo0oro/NrfUb5abEec/A0OP2qO8dVn jordanwashburn"
|
||||
# Allow anyone to read public repos, admins can create
|
||||
anon_access: read-only
|
||||
app.ini: |
|
||||
APP_NAME = threesix
|
||||
RUN_MODE = prod
|
||||
|
||||
[server]
|
||||
DOMAIN = git.threesix.ai
|
||||
SSH_DOMAIN = git.threesix.ai
|
||||
ROOT_URL = https://git.threesix.ai/
|
||||
HTTP_PORT = 3000
|
||||
SSH_PORT = 22
|
||||
SSH_LISTEN_PORT = 22
|
||||
LFS_START_SERVER = true
|
||||
|
||||
[database]
|
||||
DB_TYPE = sqlite3
|
||||
PATH = /data/gitea/gitea.db
|
||||
|
||||
[repository]
|
||||
ROOT = /data/git/repositories
|
||||
DEFAULT_BRANCH = main
|
||||
|
||||
[security]
|
||||
INSTALL_LOCK = true
|
||||
SECRET_KEY = ${GITEA_SECRET_KEY}
|
||||
|
||||
[service]
|
||||
DISABLE_REGISTRATION = true
|
||||
REQUIRE_SIGNIN_VIEW = false
|
||||
|
||||
[oauth2_client]
|
||||
ENABLE_AUTO_REGISTRATION = false
|
||||
|
||||
[webhook]
|
||||
ALLOWED_HOST_LIST = woodpecker-server.threesix.svc.cluster.local
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: soft-serve
|
||||
name: gitea
|
||||
namespace: threesix
|
||||
spec:
|
||||
serviceName: soft-serve
|
||||
serviceName: gitea
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: soft-serve
|
||||
app: gitea
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: soft-serve
|
||||
app: gitea
|
||||
spec:
|
||||
containers:
|
||||
- name: soft-serve
|
||||
image: charmcli/soft-serve:latest
|
||||
ports:
|
||||
- containerPort: 22
|
||||
name: ssh
|
||||
- containerPort: 23231
|
||||
name: http
|
||||
- containerPort: 23233
|
||||
name: stats
|
||||
initContainers:
|
||||
- name: init-config
|
||||
image: gitea/gitea:latest
|
||||
command: ['sh', '-c', 'cp /etc/gitea/app.ini /data/gitea/conf/app.ini']
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /soft-serve
|
||||
mountPath: /data
|
||||
- name: config
|
||||
mountPath: /soft-serve/config.yaml
|
||||
subPath: config.yaml
|
||||
mountPath: /etc/gitea
|
||||
containers:
|
||||
- name: gitea
|
||||
image: gitea/gitea:latest
|
||||
ports:
|
||||
- containerPort: 3000
|
||||
name: http
|
||||
- containerPort: 22
|
||||
name: ssh
|
||||
env:
|
||||
- name: GITEA_ADMIN_USERNAME
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: gitea-admin
|
||||
key: username
|
||||
- name: GITEA_ADMIN_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: gitea-admin
|
||||
key: password
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /data
|
||||
resources:
|
||||
requests:
|
||||
memory: "64Mi"
|
||||
cpu: "50m"
|
||||
limits:
|
||||
memory: "256Mi"
|
||||
cpu: "500m"
|
||||
cpu: "100m"
|
||||
limits:
|
||||
memory: "1Gi"
|
||||
cpu: "1000m"
|
||||
volumes:
|
||||
- name: config
|
||||
configMap:
|
||||
name: soft-serve-config
|
||||
name: gitea-config
|
||||
volumeClaimTemplates:
|
||||
- metadata:
|
||||
name: data
|
||||
@ -216,37 +265,34 @@ spec:
|
||||
storageClassName: longhorn
|
||||
resources:
|
||||
requests:
|
||||
storage: 10Gi
|
||||
storage: 20Gi
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: soft-serve
|
||||
name: gitea
|
||||
namespace: threesix
|
||||
spec:
|
||||
selector:
|
||||
app: soft-serve
|
||||
app: gitea
|
||||
ports:
|
||||
- name: http
|
||||
port: 3000
|
||||
targetPort: 3000
|
||||
- name: ssh
|
||||
port: 22
|
||||
targetPort: 22
|
||||
- name: http
|
||||
port: 80
|
||||
targetPort: 23231
|
||||
- name: stats
|
||||
port: 23233
|
||||
targetPort: 23233
|
||||
---
|
||||
# External SSH access via LoadBalancer
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: soft-serve-ssh
|
||||
name: gitea-ssh
|
||||
namespace: threesix
|
||||
spec:
|
||||
type: LoadBalancer
|
||||
selector:
|
||||
app: soft-serve
|
||||
app: gitea
|
||||
ports:
|
||||
- name: ssh
|
||||
port: 22
|
||||
@ -256,7 +302,7 @@ spec:
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: soft-serve
|
||||
name: gitea
|
||||
namespace: threesix
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-threesix
|
||||
@ -274,9 +320,9 @@ spec:
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: soft-serve
|
||||
name: gitea
|
||||
port:
|
||||
number: 80
|
||||
number: 3000
|
||||
```
|
||||
|
||||
### 1.4 Deploy Zot Registry
|
||||
@ -419,7 +465,17 @@ Create via Cloudflare API or dashboard:
|
||||
|
||||
## Phase 2: CI/CD (Woodpecker)
|
||||
|
||||
### 2.1 Deploy Woodpecker Server
|
||||
### 2.1 Create Gitea OAuth Application
|
||||
|
||||
Before deploying Woodpecker, create an OAuth application in Gitea:
|
||||
|
||||
1. Login to https://git.threesix.ai as admin
|
||||
2. Go to Site Administration → Applications → Create OAuth2 Application
|
||||
3. Application Name: `Woodpecker CI`
|
||||
4. Redirect URI: `https://ci.threesix.ai/authorize`
|
||||
5. Save the Client ID and Client Secret
|
||||
|
||||
### 2.2 Deploy Woodpecker Server
|
||||
|
||||
```yaml
|
||||
# deployments/k8s/base/threesix/woodpecker-server.yaml
|
||||
@ -432,6 +488,9 @@ type: Opaque
|
||||
stringData:
|
||||
# Generate with: openssl rand -hex 32
|
||||
WOODPECKER_AGENT_SECRET: "${WOODPECKER_AGENT_SECRET}"
|
||||
# From Gitea OAuth application
|
||||
WOODPECKER_GITEA_CLIENT: "${GITEA_OAUTH_CLIENT_ID}"
|
||||
WOODPECKER_GITEA_SECRET: "${GITEA_OAUTH_CLIENT_SECRET}"
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
@ -457,14 +516,14 @@ spec:
|
||||
- name: WOODPECKER_HOST
|
||||
value: "https://ci.threesix.ai"
|
||||
- name: WOODPECKER_OPEN
|
||||
value: "false"
|
||||
value: "true"
|
||||
- name: WOODPECKER_ADMIN
|
||||
value: "jordan"
|
||||
# Soft-serve / generic git integration
|
||||
# Gitea forge integration
|
||||
- name: WOODPECKER_GITEA
|
||||
value: "false"
|
||||
- name: WOODPECKER_WEBHOOK_HOST
|
||||
value: "http://woodpecker-server.threesix.svc:8000"
|
||||
value: "true"
|
||||
- name: WOODPECKER_GITEA_URL
|
||||
value: "https://git.threesix.ai"
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: woodpecker-secrets
|
||||
@ -530,7 +589,7 @@ spec:
|
||||
number: 8000
|
||||
```
|
||||
|
||||
### 2.2 Deploy Woodpecker Agent (with Kaniko)
|
||||
### 2.3 Deploy Woodpecker Agent (with Kaniko)
|
||||
|
||||
```yaml
|
||||
# deployments/k8s/base/threesix/woodpecker-agent.yaml
|
||||
@ -611,34 +670,43 @@ package port
|
||||
|
||||
import "context"
|
||||
|
||||
// GitRepository manages git repositories.
|
||||
// GitRepository manages git repositories via Gitea API.
|
||||
type GitRepository interface {
|
||||
// CreateRepo creates a new git repository.
|
||||
CreateRepo(ctx context.Context, name, description string) (*Repo, error)
|
||||
CreateRepo(ctx context.Context, name, description string, private bool) (*Repo, error)
|
||||
|
||||
// DeleteRepo deletes a repository.
|
||||
DeleteRepo(ctx context.Context, name string) error
|
||||
DeleteRepo(ctx context.Context, owner, name string) error
|
||||
|
||||
// ListRepos returns all repositories.
|
||||
ListRepos(ctx context.Context) ([]*Repo, error)
|
||||
// ListRepos returns all repositories for an owner.
|
||||
ListRepos(ctx context.Context, owner string) ([]*Repo, error)
|
||||
|
||||
// GetRepo returns a single repository.
|
||||
GetRepo(ctx context.Context, name string) (*Repo, error)
|
||||
GetRepo(ctx context.Context, owner, name string) (*Repo, error)
|
||||
|
||||
// AddCollaborator adds a user's SSH key to a repo.
|
||||
AddCollaborator(ctx context.Context, repo, keyName, publicKey string) error
|
||||
// AddCollaborator adds a user as collaborator to a repo.
|
||||
AddCollaborator(ctx context.Context, owner, repo, username string, permission string) error
|
||||
|
||||
// AddWebhook adds a webhook to trigger on push.
|
||||
AddWebhook(ctx context.Context, repo, url, secret string) error
|
||||
// AddDeployKey adds a deploy key to a repo for read-only or read-write access.
|
||||
AddDeployKey(ctx context.Context, owner, repo, title, publicKey string, readOnly bool) error
|
||||
|
||||
// CreateWebhook creates a webhook to trigger on push events.
|
||||
CreateWebhook(ctx context.Context, owner, repo, url, secret string, events []string) error
|
||||
}
|
||||
|
||||
// Repo represents a git repository.
|
||||
type Repo struct {
|
||||
ID int64
|
||||
Owner string
|
||||
Name string
|
||||
FullName string // owner/name
|
||||
Description string
|
||||
CloneSSH string // ssh://git@git.threesix.ai/name.git
|
||||
CloneHTTP string // https://git.threesix.ai/name.git
|
||||
Private bool
|
||||
CloneSSH string // git@git.threesix.ai:owner/name.git
|
||||
CloneHTTP string // https://git.threesix.ai/owner/name.git
|
||||
HTMLURL string // https://git.threesix.ai/owner/name
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
```
|
||||
|
||||
@ -714,7 +782,7 @@ type DeployStatus struct {
|
||||
|
||||
```
|
||||
internal/adapter/
|
||||
├── softserve/ # soft-serve SSH/API client
|
||||
├── gitea/ # Gitea REST API client
|
||||
│ └── client.go
|
||||
├── cloudflare/ # Cloudflare DNS API client
|
||||
│ └── client.go
|
||||
@ -724,6 +792,56 @@ internal/adapter/
|
||||
└── client.go
|
||||
```
|
||||
|
||||
#### Gitea Client Example
|
||||
|
||||
```go
|
||||
// internal/adapter/gitea/client.go
|
||||
package gitea
|
||||
|
||||
import (
|
||||
"code.gitea.io/sdk/gitea"
|
||||
"github.com/orchard9/rdev/internal/port"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
client *gitea.Client
|
||||
owner string // default owner/org for repos
|
||||
}
|
||||
|
||||
func NewClient(url, token, defaultOwner string) (*Client, error) {
|
||||
client, err := gitea.NewClient(url, gitea.SetToken(token))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Client{client: client, owner: defaultOwner}, nil
|
||||
}
|
||||
|
||||
func (c *Client) CreateRepo(ctx context.Context, name, description string, private bool) (*port.Repo, error) {
|
||||
repo, _, err := c.client.CreateOrgRepo(c.owner, gitea.CreateRepoOption{
|
||||
Name: name,
|
||||
Description: description,
|
||||
Private: private,
|
||||
AutoInit: true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &port.Repo{
|
||||
ID: repo.ID,
|
||||
Owner: repo.Owner.UserName,
|
||||
Name: repo.Name,
|
||||
FullName: repo.FullName,
|
||||
Description: repo.Description,
|
||||
Private: repo.Private,
|
||||
CloneSSH: repo.SSHURL,
|
||||
CloneHTTP: repo.CloneURL,
|
||||
HTMLURL: repo.HTMLURL,
|
||||
CreatedAt: repo.Created,
|
||||
UpdatedAt: repo.Updated,
|
||||
}, nil
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 New Handlers
|
||||
|
||||
```go
|
||||
@ -802,9 +920,12 @@ CREATE INDEX IF NOT EXISTS idx_projects_custom_domain ON projects(custom_domain)
|
||||
```
|
||||
/project create <name>
|
||||
→ Creates project in DB
|
||||
→ Creates git repo in soft-serve
|
||||
→ Creates git repo in Gitea (threesix/<name>)
|
||||
→ Activates repo in Woodpecker CI
|
||||
→ Creates DNS record (<name>.threesix.ai)
|
||||
→ Returns clone URL
|
||||
→ Returns clone URLs:
|
||||
SSH: git@git.threesix.ai:threesix/<name>.git
|
||||
HTTPS: https://git.threesix.ai/threesix/<name>.git
|
||||
|
||||
/project deploy <name>
|
||||
→ Triggers build from latest commit
|
||||
@ -822,16 +943,13 @@ CREATE INDEX IF NOT EXISTS idx_projects_custom_domain ON projects(custom_domain)
|
||||
### 5.2 Webhook Flow
|
||||
|
||||
```
|
||||
Agent pushes code
|
||||
Agent pushes code to Gitea
|
||||
│
|
||||
▼
|
||||
soft-serve receives push
|
||||
Gitea receives push, fires webhook to Woodpecker
|
||||
│
|
||||
▼
|
||||
Webhook fires to Woodpecker
|
||||
│
|
||||
▼
|
||||
Woodpecker reads .woodpecker.yml
|
||||
Woodpecker reads .woodpecker.yml from repo
|
||||
│
|
||||
▼
|
||||
Kaniko builds image, pushes to zot
|
||||
@ -840,54 +958,125 @@ Kaniko builds image, pushes to zot
|
||||
Woodpecker calls rdev-api: POST /projects/{id}/deploy
|
||||
│
|
||||
▼
|
||||
rdev-api creates/updates K8s resources
|
||||
rdev-api creates/updates K8s Deployment + Ingress
|
||||
│
|
||||
▼
|
||||
Project live at https://{name}.threesix.ai
|
||||
```
|
||||
|
||||
**Note:** When you activate a repo in Woodpecker's UI, it automatically creates the webhook in Gitea via OAuth. No manual webhook configuration needed.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
### Phase 1: Foundation
|
||||
- [ ] Create `threesix` namespace
|
||||
- [ ] Create Cloudflare API secret
|
||||
- [ ] Configure ClusterIssuer for DNS-01 challenge
|
||||
- [ ] Request wildcard certificate
|
||||
- [ ] Deploy soft-serve StatefulSet
|
||||
- [ ] Configure soft-serve LoadBalancer for SSH
|
||||
- [ ] Deploy Zot registry
|
||||
- [ ] Create initial DNS records (git, registry, ci, wildcard)
|
||||
- [ ] Test: `ssh git@git.threesix.ai` works
|
||||
- [ ] Test: `https://registry.threesix.ai` shows Zot UI
|
||||
### Phase 1: Foundation ✅ COMPLETED (2026-01-26)
|
||||
- [x] Create `threesix` namespace
|
||||
- [x] Create Cloudflare API secret
|
||||
- [x] Configure Issuer for DNS-01 challenge (namespace-scoped, not ClusterIssuer)
|
||||
- [x] Request wildcard certificate (*.threesix.ai)
|
||||
- [x] Deploy Gitea StatefulSet (rootless image, PostgreSQL backend, writable config)
|
||||
- [x] Configure Gitea LoadBalancer for SSH (208.122.204.172:22)
|
||||
- [x] Deploy Zot registry (10Gi storage)
|
||||
- [x] Create DNS records: git.threesix.ai, registry.threesix.ai → 208.122.204.172
|
||||
- [x] Test: `https://git.threesix.ai` shows Gitea UI ✅
|
||||
- [x] Test: `https://registry.threesix.ai/v2/_catalog` returns `{"repositories":[]}` ✅
|
||||
- [x] Complete Gitea installation wizard ✅
|
||||
|
||||
### Phase 2: CI/CD
|
||||
- [ ] Generate Woodpecker agent secret
|
||||
- [ ] Deploy Woodpecker server
|
||||
- [ ] Deploy Woodpecker agents
|
||||
- [ ] Configure soft-serve webhook to Woodpecker
|
||||
- [ ] Test: push triggers build
|
||||
**Implementation Notes:**
|
||||
- Used namespace-scoped `Issuer` instead of `ClusterIssuer` (cert-manager couldn't access secrets across namespaces)
|
||||
- Gitea uses PostgreSQL (`postgres.databases.svc.cluster.local`) instead of SQLite
|
||||
- Gitea credentials: `gitea` user, password in `/tmp/gitea-db-password.txt`
|
||||
- Rootless Gitea image requires `securityContext.fsGroup: 1000` and writable `/etc/gitea` via volume subPath
|
||||
|
||||
### Phase 2: CI/CD ✅ COMPLETED (2026-01-26)
|
||||
- [x] Create Gitea OAuth application for Woodpecker
|
||||
- [x] Generate Woodpecker agent secret
|
||||
- [x] Create DNS record: ci.threesix.ai → 208.122.204.172
|
||||
- [x] Deploy Woodpecker server with Gitea forge
|
||||
- [x] Deploy Woodpecker agents (2 replicas, K8s backend)
|
||||
- [x] TLS certificate issued for ci.threesix.ai
|
||||
- [ ] Test: Login to Woodpecker via Gitea OAuth
|
||||
- [ ] Test: Activate repo in Woodpecker (auto-creates webhook)
|
||||
- [ ] Test: Push triggers build
|
||||
- [ ] Test: Kaniko builds and pushes to Zot
|
||||
|
||||
### Phase 3: rdev-api
|
||||
- [ ] Add GitRepository port interface
|
||||
- [ ] Add DNSProvider port interface
|
||||
- [ ] Add Deployer port interface
|
||||
- [ ] Implement soft-serve adapter
|
||||
- [ ] Implement Cloudflare adapter
|
||||
- [ ] Implement K8s deployer adapter
|
||||
- [ ] Add database migration
|
||||
- [ ] Add new handlers
|
||||
**Secrets saved:**
|
||||
- Agent secret: `/tmp/woodpecker-agent-secret.txt`
|
||||
- Gitea OAuth: Client ID `7548afec-43e0-486a-b6eb-e2a7d5c88d41`
|
||||
|
||||
### Phase 3: rdev-api ✅ COMPLETED (2026-01-26)
|
||||
- [x] Add GitRepository port interface (`internal/port/git_repository.go`)
|
||||
- [x] Add DNSProvider port interface (`internal/port/dns_provider.go`)
|
||||
- [x] Add Deployer port interface (`internal/port/deployer.go`)
|
||||
- [x] Implement Gitea adapter (`internal/adapter/gitea/client.go`) using `code.gitea.io/sdk/gitea`
|
||||
- [x] Implement Cloudflare adapter (`internal/adapter/cloudflare/client.go`)
|
||||
- [x] Implement K8s deployer adapter (`internal/adapter/deployer/deployer.go`)
|
||||
- [x] Add database migration (`internal/db/migrations/008_project_infrastructure.sql`)
|
||||
- [x] Add infrastructure handler (`internal/handlers/infrastructure.go`)
|
||||
- [x] Wire up in main.go with environment variables
|
||||
- [ ] Test: API can create repos
|
||||
- [ ] Test: API can manage DNS
|
||||
- [ ] Test: API can deploy apps
|
||||
|
||||
### Phase 4: Integration
|
||||
- [ ] Wire up webhook: build → deploy
|
||||
- [ ] Add project commands to Pantheon
|
||||
**New Environment Variables:**
|
||||
```
|
||||
GITEA_URL=https://git.threesix.ai
|
||||
GITEA_TOKEN=<from Gitea UI: Settings → Applications → Access Tokens>
|
||||
GITEA_DEFAULT_ORG=threesix
|
||||
CLOUDFLARE_API_TOKEN=<existing>
|
||||
CLOUDFLARE_ZONE_ID=<existing>
|
||||
DEFAULT_DOMAIN=threesix.ai
|
||||
DEPLOY_NAMESPACE=projects
|
||||
DEPLOY_TLS_ISSUER=letsencrypt-threesix
|
||||
CLUSTER_IP=208.122.204.172
|
||||
```
|
||||
|
||||
**New API Endpoints:**
|
||||
- `POST /projects/{id}/repo` - Create git repo
|
||||
- `GET /projects/{id}/repo` - Get repo info
|
||||
- `DELETE /projects/{id}/repo` - Delete repo
|
||||
- `POST /projects/{id}/deploy` - Deploy from image
|
||||
- `GET /projects/{id}/deploy/status` - Get deployment status
|
||||
- `DELETE /projects/{id}/deploy` - Undeploy
|
||||
- `POST /projects/{id}/deploy/restart` - Restart deployment
|
||||
- `POST /projects/{id}/deploy/scale` - Scale replicas
|
||||
- `GET /projects/{id}/deploy/logs` - Get logs
|
||||
- `POST /projects/{id}/domain` - Add custom domain
|
||||
- `DELETE /projects/{id}/domain` - Remove domain
|
||||
|
||||
### Phase 4: Integration ✅ COMPLETED (2026-01-26)
|
||||
- [x] Wire up webhook: build → deploy (`internal/handlers/woodpecker_webhook.go`)
|
||||
- [x] Add project commands to Pantheon (`.claude/commands/project-*.md`)
|
||||
- [x] Create project infrastructure service (`internal/service/project_infra.go`)
|
||||
- [x] Create project management handler (`internal/handlers/project_management.go`)
|
||||
- [x] Update ExternalSecret with infrastructure credentials
|
||||
- [x] Create .woodpecker.yml pipeline template
|
||||
- [ ] Test: Login to Woodpecker via Gitea OAuth
|
||||
- [ ] Test: end-to-end "create project" → "push code" → "live site"
|
||||
|
||||
**New Files Created:**
|
||||
- `internal/handlers/woodpecker_webhook.go` - Handles Woodpecker CI webhooks for auto-deployment
|
||||
- `internal/service/project_infra.go` - Orchestrates full project lifecycle (DB → Gitea → DNS → K8s)
|
||||
- `internal/handlers/project_management.go` - HTTP handlers for project CRUD operations
|
||||
- `.claude/commands/project-create.md` - /project create command
|
||||
- `.claude/commands/project-status.md` - /project status command
|
||||
- `.claude/commands/project-deploy.md` - /project deploy command
|
||||
- `.claude/commands/project-list.md` - /project list command
|
||||
|
||||
**New API Endpoints:**
|
||||
- `POST /project` - Create new project with git repo and DNS
|
||||
- `GET /project` - List all projects with status
|
||||
- `GET /project/{name}` - Get single project status
|
||||
- `DELETE /project/{name}` - Delete project and all resources
|
||||
- `POST /webhook/woodpecker` - Woodpecker CI webhook (auto-deploy on build success)
|
||||
|
||||
**Environment Variables Added:**
|
||||
```
|
||||
REGISTRY_URL=zot.threesix.svc.cluster.local:5000
|
||||
WOODPECKER_WEBHOOK_SECRET=<from GCP Secret Manager>
|
||||
```
|
||||
|
||||
### Phase 5: Polish
|
||||
- [ ] Custom domain support
|
||||
- [ ] Build notifications to Pantheon
|
||||
@ -901,21 +1090,22 @@ Project live at https://{name}.threesix.ai
|
||||
|
||||
| Component | CPU Request | Memory Request | Storage |
|
||||
|-----------|-------------|----------------|---------|
|
||||
| soft-serve | 50m | 64Mi | 10Gi |
|
||||
| Gitea | 100m | 256Mi | 20Gi |
|
||||
| Zot | 100m | 128Mi | 50Gi |
|
||||
| Woodpecker Server | 100m | 128Mi | 5Gi |
|
||||
| Woodpecker Agent (x2) | 200m each | 256Mi each | - |
|
||||
| **Total** | ~650m | ~832Mi | 65Gi |
|
||||
| **Total** | ~700m | ~1Gi | 75Gi |
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **soft-serve admin key** - Only jordan's key is admin initially
|
||||
2. **Registry access** - Internal only, no auth needed (ClusterIP)
|
||||
3. **Woodpecker** - Closed registration, admin-only access
|
||||
4. **Cloudflare token** - Scoped to DNS edit only
|
||||
5. **Deploy permissions** - rdev-api ServiceAccount limited to `threesix` and `projects` namespaces
|
||||
1. **Gitea admin** - Registration disabled, only admin user can create accounts
|
||||
2. **Gitea API token** - Create a dedicated token for rdev-api with repo scope
|
||||
3. **Registry access** - Internal only, no auth needed (ClusterIP)
|
||||
4. **Woodpecker** - OAuth via Gitea, inherits Gitea permissions
|
||||
5. **Cloudflare token** - Scoped to DNS edit only
|
||||
6. **Deploy permissions** - rdev-api ServiceAccount limited to `threesix` and `projects` namespaces
|
||||
|
||||
---
|
||||
|
||||
|
||||
8
go.mod
8
go.mod
@ -3,8 +3,10 @@ module github.com/orchard9/rdev
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
code.gitea.io/sdk/gitea v0.22.1
|
||||
github.com/bdpiprava/scalar-go v0.13.0
|
||||
github.com/go-chi/chi/v5 v5.1.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
go.opentelemetry.io/otel v1.39.0
|
||||
@ -17,20 +19,23 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/42wim/httpsig v1.2.3 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/davidmz/go-pageant v1.0.2 // indirect
|
||||
github.com/emicklei/go-restful/v3 v3.12.2 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
||||
github.com/go-fed/httpsig v1.1.0 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
||||
github.com/go-openapi/jsonreference v0.20.2 // indirect
|
||||
github.com/go-openapi/swag v0.23.0 // indirect
|
||||
github.com/google/gnostic-models v0.7.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect
|
||||
github.com/hashicorp/go-version v1.7.0 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
@ -48,6 +53,7 @@ require (
|
||||
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/crypto v0.44.0 // indirect
|
||||
golang.org/x/net v0.47.0 // indirect
|
||||
golang.org/x/oauth2 v0.32.0 // indirect
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
|
||||
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/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||
github.com/bdpiprava/scalar-go v0.13.0 h1:TuhOwYalDpLAziohyEwZlq4PqtEJ+6P/V92dDCdja9k=
|
||||
@ -12,12 +16,16 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0=
|
||||
github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE=
|
||||
github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU=
|
||||
github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
|
||||
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
||||
github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI=
|
||||
github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
@ -46,6 +54,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4=
|
||||
github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY=
|
||||
github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
@ -128,22 +138,36 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
||||
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
|
||||
golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU=
|
||||
golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc=
|
||||
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
|
||||
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
|
||||
golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
|
||||
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
|
||||
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
||||
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
|
||||
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