package deployer import ( "context" "fmt" "strings" 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" "github.com/orchard9/rdev/internal/domain" ) // ensureNamespace verifies the deployment namespace exists, creating it only if needed. func (d *Deployer) ensureNamespace(ctx context.Context) error { _, err := d.client.CoreV1().Namespaces().Get(ctx, d.config.Namespace, metav1.GetOptions{}) if err == nil { return nil // namespace exists } if !errors.IsNotFound(err) { return err } // Namespace doesn't exist, try to create it 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 } // createOrUpdateSecret manages the secret for environment variables. 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 } // createOrUpdateDeployment manages the Kubernetes Deployment resource. 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 := d.buildDeployment(spec, ns, replicas, envVars, envFrom) _, 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) buildDeployment(spec domain.DeploySpec, ns string, replicas int32, envVars []corev1.EnvVar, envFrom []corev1.EnvFromSource) *appsv1.Deployment { return &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"), }, }, }, }, }, }, }, } } // createOrUpdateService manages the Kubernetes Service resource. 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 } // createOrUpdateIngress manages the Kubernetes Ingress resource. 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/cluster-issuer"] = d.config.TLSIssuer } ingress := d.buildIngress(spec, ns, pathType, ingressClass, tlsSecretName, annotations) _, 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 } func (d *Deployer) buildIngress(spec domain.DeploySpec, ns string, pathType networkingv1.PathType, ingressClass, tlsSecretName string, annotations map[string]string) *networkingv1.Ingress { return &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), }, }, }, }, }, }, }, }, }, }, } } // 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 } // AddIngressHost adds a new host to an existing project's ingress. // This is used when adding domain aliases to a project. // The host is added to both the TLS configuration and the routing rules. func (d *Deployer) AddIngressHost(ctx context.Context, projectName, host string) error { ns := d.config.Namespace // Get existing ingress ingress, err := d.client.NetworkingV1().Ingresses(ns).Get(ctx, projectName, metav1.GetOptions{}) if err != nil { return fmt.Errorf("failed to get ingress: %w", err) } // Check if host already exists for _, rule := range ingress.Spec.Rules { if rule.Host == host { return nil // Host already exists, nothing to do } } // Get the service port from existing rules (assumes all rules use same backend) var servicePort int32 = 80 if len(ingress.Spec.Rules) > 0 && ingress.Spec.Rules[0].HTTP != nil && len(ingress.Spec.Rules[0].HTTP.Paths) > 0 { servicePort = ingress.Spec.Rules[0].HTTP.Paths[0].Backend.Service.Port.Number } // Add TLS entry for the new host tlsSecretName := strings.ReplaceAll(host, ".", "-") + "-tls" ingress.Spec.TLS = append(ingress.Spec.TLS, networkingv1.IngressTLS{ Hosts: []string{host}, SecretName: tlsSecretName, }) // Add routing rule for the new host pathType := networkingv1.PathTypePrefix ingress.Spec.Rules = append(ingress.Spec.Rules, networkingv1.IngressRule{ Host: host, IngressRuleValue: networkingv1.IngressRuleValue{ HTTP: &networkingv1.HTTPIngressRuleValue{ Paths: []networkingv1.HTTPIngressPath{ { Path: "/", PathType: &pathType, Backend: networkingv1.IngressBackend{ Service: &networkingv1.IngressServiceBackend{ Name: projectName, Port: networkingv1.ServiceBackendPort{ Number: servicePort, }, }, }, }, }, }, }, }) // Update the ingress _, err = d.client.NetworkingV1().Ingresses(ns).Update(ctx, ingress, metav1.UpdateOptions{}) if err != nil { return fmt.Errorf("failed to update ingress: %w", err) } return nil } // RemoveIngressHost removes a host from an existing project's ingress. // This is used when removing domain aliases from a project. func (d *Deployer) RemoveIngressHost(ctx context.Context, projectName, host string) error { ns := d.config.Namespace // Get existing ingress ingress, err := d.client.NetworkingV1().Ingresses(ns).Get(ctx, projectName, metav1.GetOptions{}) if err != nil { return fmt.Errorf("failed to get ingress: %w", err) } // Remove from TLS entries var newTLS []networkingv1.IngressTLS for _, tls := range ingress.Spec.TLS { // Keep TLS entries that don't contain this host var newHosts []string for _, h := range tls.Hosts { if h != host { newHosts = append(newHosts, h) } } if len(newHosts) > 0 { tls.Hosts = newHosts newTLS = append(newTLS, tls) } } ingress.Spec.TLS = newTLS // Remove from routing rules var newRules []networkingv1.IngressRule for _, rule := range ingress.Spec.Rules { if rule.Host != host { newRules = append(newRules, rule) } } ingress.Spec.Rules = newRules // Update the ingress _, err = d.client.NetworkingV1().Ingresses(ns).Update(ctx, ingress, metav1.UpdateOptions{}) if err != nil { return fmt.Errorf("failed to update ingress: %w", err) } return nil }