rdev/internal/adapter/deployer/resources_ingress.go
jordan a9ad3d8304
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
chore: accumulated platform hardening and CI fixes
CI / Woodpecker:
- Add explicit depends_on to all .woodpecker.yml steps (rdev + templates)
- Fix skip_tls_verify -> skip-tls-verify (correct Kaniko flag name)
- Add replicasets get/list to deployer RBAC for rollout status
- Skeleton template: add failure:ignore on docs steps, Traefik TLS
  annotations on ingress, depends_on on verify step

Component templates:
- Fix container name in deploy steps (PROJECT_NAME-COMPONENT_NAME)
- Replace kubectl scale with kubectl patch for replicas
- Add post-deploy image verification and rollout status checks
- Applied consistently across all 5 component templates

Adapters:
- gitea: Add HTTP client timeout (30s), context cancellation checks,
  handle 404 on GetRepo/DeleteRepo
- zot: Add retry with exponential backoff (doWithRetry), limit response
  body reads to 10MB
- cockroach: Use net.JoinHostPort for IPv6-safe DSN construction
- woodpecker: Fix error wrapping (%v -> %w)
- redis: Fix error wrapping (%v -> %w)
- deployer: Add context cancellation checks

Services:
- apikey_service: Fix error wrapping (%v -> %w)
- component_deploy: Fix error wrapping (%v -> %w)
- project_infra: Fix error wrapping (%v -> %w)
- webhook/dispatcher: Fix error wrapping (%v -> %w)

Other:
- CLAUDE.md: Add guide links for Gitea, Go 1.25, Woodpecker v3,
  Traefik v3, Zot registry
- circuitbreaker: Add test for error wrapping
- docs: Update deployment, troubleshooting, and runbook docs
- health: Fix error wrapping (%v -> %w)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 23:16:56 -07:00

264 lines
7.3 KiB
Go

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
}
annotations["traefik.ingress.kubernetes.io/router.entrypoints"] = "websecure"
annotations["traefik.ingress.kubernetes.io/router.tls"] = "true"
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)
})
}