All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
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>
264 lines
7.3 KiB
Go
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)
|
|
})
|
|
}
|