// Package deployer provides a Kubernetes deployment adapter implementing port.Deployer. package deployer import ( "bytes" "context" "errors" "fmt" "time" corev1 "k8s.io/api/core/v1" k8serr "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.Interface ingressClient IngressClient config Config } // NewDeployer creates a new Deployer. func NewDeployer(client kubernetes.Interface, 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, ingressClient: &k8sIngressClient{clientset: client}, config: cfg, } } // NewDeployerWithIngressClient creates a Deployer with a custom IngressClient for testing. func NewDeployerWithIngressClient(client kubernetes.Interface, ingressClient IngressClient, 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, ingressClient: ingressClient, 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 } switch { case spec.Replicas < 0: spec.Replicas = 0 // Explicitly zero: create deployment with no pods case 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 for single-app projects only. // Monorepo components (with ComponentPath set) use unified project-level Ingress // managed via AddIngressPath instead. if spec.ComponentPath == "" { 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 && !k8serr.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 && !k8serr.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 && !k8serr.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 && !k8serr.IsNotFound(err) { return fmt.Errorf("failed to delete secret: %w", err) } return nil } // UndeployAll removes all deployment resources matching the project label. // Unlike Undeploy which deletes by exact name, this uses label selectors to find // and delete all resources (including monorepo components like {project}-{component}). // Errors are accumulated so that a single resource failure doesn't prevent cleanup of others. func (d *Deployer) UndeployAll(ctx context.Context, projectName string) error { ns := d.config.Namespace selector := fmt.Sprintf("project=%s", projectName) propagation := metav1.DeletePropagationForeground deleteOpts := metav1.DeleteOptions{PropagationPolicy: &propagation} listOpts := metav1.ListOptions{LabelSelector: selector} var errs []error // Delete Ingresses ingresses, err := d.client.NetworkingV1().Ingresses(ns).List(ctx, listOpts) if err != nil { errs = append(errs, fmt.Errorf("failed to list ingresses: %w", err)) } else { for _, ing := range ingresses.Items { if err := d.client.NetworkingV1().Ingresses(ns).Delete(ctx, ing.Name, deleteOpts); err != nil && !k8serr.IsNotFound(err) { errs = append(errs, fmt.Errorf("failed to delete ingress %s: %w", ing.Name, err)) } } } // Delete Services services, err := d.client.CoreV1().Services(ns).List(ctx, listOpts) if err != nil { errs = append(errs, fmt.Errorf("failed to list services: %w", err)) } else { for _, svc := range services.Items { if err := d.client.CoreV1().Services(ns).Delete(ctx, svc.Name, deleteOpts); err != nil && !k8serr.IsNotFound(err) { errs = append(errs, fmt.Errorf("failed to delete service %s: %w", svc.Name, err)) } } } // Delete Deployments deployments, err := d.client.AppsV1().Deployments(ns).List(ctx, listOpts) if err != nil { errs = append(errs, fmt.Errorf("failed to list deployments: %w", err)) } else { for _, dep := range deployments.Items { if err := d.client.AppsV1().Deployments(ns).Delete(ctx, dep.Name, deleteOpts); err != nil && !k8serr.IsNotFound(err) { errs = append(errs, fmt.Errorf("failed to delete deployment %s: %w", dep.Name, err)) } } } // Delete Secrets secrets, err := d.client.CoreV1().Secrets(ns).List(ctx, listOpts) if err != nil { errs = append(errs, fmt.Errorf("failed to list secrets: %w", err)) } else { for _, sec := range secrets.Items { if err := d.client.CoreV1().Secrets(ns).Delete(ctx, sec.Name, deleteOpts); err != nil && !k8serr.IsNotFound(err) { errs = append(errs, fmt.Errorf("failed to delete secret %s: %w", sec.Name, err)) } } } return errors.Join(errs...) } // ListProjectLabels returns unique project label values from all deployments in the namespace. // This is used by the GC reconciliation worker to discover orphaned resources. func (d *Deployer) ListProjectLabels(ctx context.Context) ([]string, error) { ns := d.config.Namespace deployments, err := d.client.AppsV1().Deployments(ns).List(ctx, metav1.ListOptions{ LabelSelector: "project", }) if err != nil { return nil, fmt.Errorf("failed to list deployments: %w", err) } seen := make(map[string]struct{}) var labels []string for _, dep := range deployments.Items { project := dep.Labels["project"] if project == "" { continue } if _, ok := seen[project]; !ok { seen[project] = struct{}{} labels = append(labels, project) } } return labels, nil } // GetOldestResourceTime returns the creation time of the oldest deployment // matching the given project label. Returns false if no resources exist. func (d *Deployer) GetOldestResourceTime(ctx context.Context, projectName string) (time.Time, bool, error) { ns := d.config.Namespace deployments, err := d.client.AppsV1().Deployments(ns).List(ctx, metav1.ListOptions{ LabelSelector: fmt.Sprintf("project=%s", projectName), }) if err != nil { return time.Time{}, false, fmt.Errorf("failed to list deployments: %w", err) } if len(deployments.Items) == 0 { return time.Time{}, false, nil } oldest := deployments.Items[0].CreationTimestamp.Time for _, dep := range deployments.Items[1:] { if dep.CreationTimestamp.Time.Before(oldest) { oldest = dep.CreationTimestamp.Time } } return oldest, true, 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 k8serr.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 }