Major changes: - Add internal/logging package with field constants, context propagation, sensitive data auto-redaction, and per-component log levels - Add worker timeout constants (TimeoutQuickOp, TimeoutHealthCheck, etc.) - Extend SDLC with callback handlers, generate endpoints, and executor - Add new cookbook trees for aeries and slackpath progression - Add skeleton templates for queue, realtime, and microservices - Add worker component template with async job processing - Refactor services and handlers to use new logging infrastructure - Split component.go into component_infra.go and component_listing.go Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
454 lines
13 KiB
Go
454 lines
13 KiB
Go
package deployer
|
|
|
|
import (
|
|
"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"
|
|
|
|
"github.com/orchard9/rdev/internal/domain"
|
|
)
|
|
|
|
const (
|
|
maxIngressRetries = 3
|
|
retryBaseDelay = 100 * time.Millisecond
|
|
)
|
|
|
|
// retryOnConflict executes fn with retry logic for K8s conflict errors.
|
|
// Uses exponential backoff: 100ms, 200ms, 400ms.
|
|
func retryOnConflict(ctx context.Context, fn func() error) error {
|
|
var lastErr error
|
|
for attempt := 0; attempt < maxIngressRetries; attempt++ {
|
|
err := fn()
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
lastErr = err
|
|
|
|
// Only retry on conflict errors (optimistic locking failure)
|
|
if !errors.IsConflict(err) {
|
|
return err
|
|
}
|
|
|
|
// Check context before sleeping
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-time.After(retryBaseDelay * time.Duration(1<<attempt)):
|
|
}
|
|
}
|
|
return fmt.Errorf("failed after %d retries: %w", maxIngressRetries, lastErr)
|
|
}
|
|
|
|
// sanitizeLabelValue converts a component path to a valid K8s label value.
|
|
// K8s labels must be alphanumeric with '-', '_', or '.' and must start/end with alphanumeric.
|
|
// Example: "services/api" -> "services-api"
|
|
func sanitizeLabelValue(path string) string {
|
|
return strings.ReplaceAll(path, "/", "-")
|
|
}
|
|
|
|
// 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 {
|
|
deploymentName := spec.DeploymentName()
|
|
secretName := deploymentName + "-env"
|
|
ns := d.config.Namespace
|
|
|
|
secret := &corev1.Secret{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: secretName,
|
|
Namespace: ns,
|
|
Labels: map[string]string{
|
|
"app": deploymentName,
|
|
"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)
|
|
deploymentName := spec.DeploymentName()
|
|
|
|
// Build env vars
|
|
var envVars []corev1.EnvVar
|
|
for k, v := range spec.EnvVars {
|
|
envVars = append(envVars, corev1.EnvVar{Name: k, Value: v})
|
|
}
|
|
|
|
// Inject sibling service URLs for service discovery
|
|
for k, v := range spec.SiblingServices {
|
|
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: deploymentName + "-env",
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
deployment := d.buildDeployment(spec, ns, replicas, envVars, envFrom)
|
|
|
|
_, err := d.client.AppsV1().Deployments(ns).Get(ctx, deploymentName, 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 {
|
|
deploymentName := spec.DeploymentName()
|
|
|
|
// Build labels - always include project, component if present
|
|
labels := map[string]string{
|
|
"app": deploymentName,
|
|
"project": spec.ProjectName,
|
|
}
|
|
if spec.ComponentPath != "" {
|
|
labels["component"] = sanitizeLabelValue(spec.ComponentPath)
|
|
}
|
|
|
|
return &appsv1.Deployment{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: deploymentName,
|
|
Namespace: ns,
|
|
Labels: labels,
|
|
},
|
|
Spec: appsv1.DeploymentSpec{
|
|
Replicas: &replicas,
|
|
Selector: &metav1.LabelSelector{
|
|
MatchLabels: map[string]string{
|
|
"app": deploymentName,
|
|
},
|
|
},
|
|
Template: corev1.PodTemplateSpec{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Labels: labels,
|
|
},
|
|
Spec: corev1.PodSpec{
|
|
Containers: []corev1.Container{
|
|
{
|
|
Name: deploymentName,
|
|
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
|
|
deploymentName := spec.DeploymentName()
|
|
|
|
// Build labels
|
|
labels := map[string]string{
|
|
"app": deploymentName,
|
|
"project": spec.ProjectName,
|
|
}
|
|
if spec.ComponentPath != "" {
|
|
labels["component"] = sanitizeLabelValue(spec.ComponentPath)
|
|
}
|
|
|
|
service := &corev1.Service{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: deploymentName,
|
|
Namespace: ns,
|
|
Labels: labels,
|
|
},
|
|
Spec: corev1.ServiceSpec{
|
|
Selector: map[string]string{
|
|
"app": deploymentName,
|
|
},
|
|
Ports: []corev1.ServicePort{
|
|
{
|
|
Port: int32(spec.Port),
|
|
TargetPort: intstr.FromInt(spec.Port),
|
|
Protocol: corev1.ProtocolTCP,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
_, err := d.client.CoreV1().Services(ns).Get(ctx, deploymentName, 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
|
|
deploymentName := spec.DeploymentName()
|
|
|
|
// 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, deploymentName, 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 {
|
|
deploymentName := spec.DeploymentName()
|
|
|
|
// Build labels
|
|
labels := map[string]string{
|
|
"app": deploymentName,
|
|
"project": spec.ProjectName,
|
|
}
|
|
if spec.ComponentPath != "" {
|
|
labels["component"] = sanitizeLabelValue(spec.ComponentPath)
|
|
}
|
|
|
|
// Use BasePath if specified, otherwise default to "/"
|
|
path := "/"
|
|
if spec.BasePath != "" {
|
|
path = spec.BasePath
|
|
}
|
|
|
|
return &networkingv1.Ingress{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: deploymentName,
|
|
Namespace: ns,
|
|
Labels: labels,
|
|
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: path,
|
|
PathType: &pathType,
|
|
Backend: networkingv1.IngressBackend{
|
|
Service: &networkingv1.IngressServiceBackend{
|
|
Name: deploymentName,
|
|
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
|
|
}
|