rdev/internal/adapter/deployer/deployer.go
jordan 812b8341be refactor: Split large files to comply with 500-line limit
- cmd/rdev-api/main.go: Extract OpenAPI spec to openapi.go (1073→386 lines)
- internal/adapter/deployer/deployer.go: Extract K8s resources to resources.go (502→264 lines)
- internal/handlers/infrastructure.go: Extract deploy handlers to infrastructure_deploy.go (592→342 lines)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 23:02:31 -07:00

265 lines
7.5 KiB
Go

// Package deployer provides a Kubernetes deployment adapter implementing port.Deployer.
package deployer
import (
"bytes"
"context"
"fmt"
"time"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"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
}