package deployer import ( "context" "fmt" "slices" "strings" networkingv1 "k8s.io/api/networking/v1" "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) // AddIngressPath adds or updates a path rule in the project's unified Ingress. // For monorepo projects, all components share a single Ingress with path-based routing. // Paths are ordered by specificity (most specific first) to ensure correct matching. // Uses retry with exponential backoff on K8s conflict errors. func (d *Deployer) AddIngressPath(ctx context.Context, projectName, host, path, serviceName string, servicePort int) error { ns := d.config.Namespace return retryOnConflict(ctx, func() error { // Get existing ingress or create a new one ingress, err := d.ingressClient.GetIngress(ctx, ns, projectName) if errors.IsNotFound(err) { // Create new ingress with this path return d.createUnifiedIngress(ctx, projectName, host, path, serviceName, servicePort) } if err != nil { return fmt.Errorf("failed to get ingress: %w", err) } // Find the rule for this host var ruleIndex = -1 for i, rule := range ingress.Spec.Rules { if rule.Host == host { ruleIndex = i break } } pathType := networkingv1.PathTypePrefix newPath := networkingv1.HTTPIngressPath{ Path: path, PathType: &pathType, Backend: networkingv1.IngressBackend{ Service: &networkingv1.IngressServiceBackend{ Name: serviceName, Port: networkingv1.ServiceBackendPort{ Number: int32(servicePort), }, }, }, } if ruleIndex >= 0 { // Update existing rule - add or replace path paths := ingress.Spec.Rules[ruleIndex].HTTP.Paths updated := false for i, p := range paths { if p.Path == path { paths[i] = newPath updated = true break } } if !updated { paths = append(paths, newPath) } // Sort paths by specificity (longer paths first) sortIngressPaths(paths) ingress.Spec.Rules[ruleIndex].HTTP.Paths = paths } else { // Add new rule for this host ingress.Spec.Rules = append(ingress.Spec.Rules, networkingv1.IngressRule{ Host: host, IngressRuleValue: networkingv1.IngressRuleValue{ HTTP: &networkingv1.HTTPIngressRuleValue{ Paths: []networkingv1.HTTPIngressPath{newPath}, }, }, }) // Add TLS entry for the new host if not already present hostHasTLS := false for _, tls := range ingress.Spec.TLS { if slices.Contains(tls.Hosts, host) { hostHasTLS = true break } } if !hostHasTLS { tlsSecretName := strings.ReplaceAll(host, ".", "-") + "-tls" ingress.Spec.TLS = append(ingress.Spec.TLS, networkingv1.IngressTLS{ Hosts: []string{host}, SecretName: tlsSecretName, }) } } // Update the ingress _, err = d.ingressClient.UpdateIngress(ctx, ns, ingress) if err != nil { return fmt.Errorf("failed to update ingress: %w", err) } return nil }) } // createUnifiedIngress creates a new project-level Ingress with a single path. func (d *Deployer) createUnifiedIngress(ctx context.Context, projectName, host, path, serviceName string, servicePort int) error { ns := d.config.Namespace pathType := networkingv1.PathTypePrefix ingressClass := d.config.IngressClass tlsSecretName := strings.ReplaceAll(host, ".", "-") + "-tls" annotations := map[string]string{} if d.config.TLSIssuer != "" { annotations["cert-manager.io/cluster-issuer"] = d.config.TLSIssuer } ingress := &networkingv1.Ingress{ ObjectMeta: metav1.ObjectMeta{ Name: projectName, Namespace: ns, Labels: map[string]string{"project": projectName}, Annotations: annotations, }, Spec: networkingv1.IngressSpec{ IngressClassName: &ingressClass, TLS: []networkingv1.IngressTLS{ { Hosts: []string{host}, SecretName: tlsSecretName, }, }, Rules: []networkingv1.IngressRule{ { Host: host, IngressRuleValue: networkingv1.IngressRuleValue{ HTTP: &networkingv1.HTTPIngressRuleValue{ Paths: []networkingv1.HTTPIngressPath{ { Path: path, PathType: &pathType, Backend: networkingv1.IngressBackend{ Service: &networkingv1.IngressServiceBackend{ Name: serviceName, Port: networkingv1.ServiceBackendPort{ Number: int32(servicePort), }, }, }, }, }, }, }, }, }, }, } _, err := d.ingressClient.CreateIngress(ctx, ns, ingress) if err != nil { return fmt.Errorf("failed to create ingress: %w", err) } return nil } // RemoveIngressPath removes a path rule from the project's unified Ingress. // If no paths remain for a host, the host rule is removed. // If no rules remain, the Ingress is deleted. // Uses retry with exponential backoff on K8s conflict errors. func (d *Deployer) RemoveIngressPath(ctx context.Context, projectName, host, path string) error { ns := d.config.Namespace return retryOnConflict(ctx, func() error { ingress, err := d.ingressClient.GetIngress(ctx, ns, projectName) if errors.IsNotFound(err) { return nil // Already gone } if err != nil { return fmt.Errorf("failed to get ingress: %w", err) } // Find and update the rule for this host var newRules []networkingv1.IngressRule for _, rule := range ingress.Spec.Rules { if rule.Host == host { // Filter out the path to remove var newPaths []networkingv1.HTTPIngressPath for _, p := range rule.HTTP.Paths { if p.Path != path { newPaths = append(newPaths, p) } } // Keep the rule only if it still has paths if len(newPaths) > 0 { rule.HTTP.Paths = newPaths newRules = append(newRules, rule) } // If no paths left, skip adding this rule (effectively removing it) } else { newRules = append(newRules, rule) } } // If no rules left, delete the ingress if len(newRules) == 0 { err = d.ingressClient.DeleteIngress(ctx, ns, projectName) if err != nil && !errors.IsNotFound(err) { return fmt.Errorf("failed to delete ingress: %w", err) } return nil } // Update TLS to remove hosts that no longer have rules activeHosts := make(map[string]bool) for _, rule := range newRules { activeHosts[rule.Host] = true } var newTLS []networkingv1.IngressTLS for _, tls := range ingress.Spec.TLS { var activeHostsInTLS []string for _, h := range tls.Hosts { if activeHosts[h] { activeHostsInTLS = append(activeHostsInTLS, h) } } if len(activeHostsInTLS) > 0 { tls.Hosts = activeHostsInTLS newTLS = append(newTLS, tls) } } ingress.Spec.Rules = newRules ingress.Spec.TLS = newTLS _, err = d.ingressClient.UpdateIngress(ctx, ns, ingress) if err != nil { return fmt.Errorf("failed to update ingress: %w", err) } return nil }) } // sortIngressPaths sorts paths by specificity (longer paths first). // This ensures more specific paths like /api/auth match before /api. func sortIngressPaths(paths []networkingv1.HTTPIngressPath) { slices.SortFunc(paths, func(a, b networkingv1.HTTPIngressPath) int { // Longer paths first (descending length) if len(a.Path) != len(b.Path) { return len(b.Path) - len(a.Path) } // Same length: alphabetical for consistency return strings.Compare(a.Path, b.Path) }) }