rdev/internal/adapter/deployer/resources.go
jordan a8c8a0a14d
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
feat: add GCS-based persistent media storage, AI generation pipeline, and composable skeleton packages
Adds complete media storage pipeline with GCS presigned uploads, AI image/video/text generation
via queue-based workers, realtime SSE event streaming, and comprehensive skeleton packages
(storage, mediagen, textgen, generation, realtime, persona, routing, ai-client). Includes
security fixes for media delete authorization, nil pointer guards in handlers, video persistence
via download-then-upload, consistent signed URLs, and Image→ImageIcon rename to avoid DOM collision.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 21:29:09 -07:00

460 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)
}
// Apply extra labels (e.g., citadel.io/environment for log routing)
for k, v := range spec.ExtraLabels {
labels[k] = v
}
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
}
annotations["traefik.ingress.kubernetes.io/router.entrypoints"] = "websecure"
annotations["traefik.ingress.kubernetes.io/router.tls"] = "true"
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
}