- 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>
265 lines
7.5 KiB
Go
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
|
|
}
|