// 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 }