rdev/internal/adapter/kubernetes/preview.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

186 lines
5.2 KiB
Go

package kubernetes
import (
"context"
"fmt"
"strings"
corev1 "k8s.io/api/core/v1"
networkingv1 "k8s.io/api/networking/v1"
"k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/intstr"
"k8s.io/client-go/kubernetes"
"github.com/orchard9/rdev/internal/port"
)
// Ensure PreviewManager implements port.PreviewManager at compile time.
var _ port.PreviewManager = (*PreviewManager)(nil)
// PreviewConfig holds configuration for the preview manager.
type PreviewConfig struct {
// Namespace is the K8s namespace for preview resources.
Namespace string
// IngressClass is the ingress controller class (e.g., "traefik").
IngressClass string
// TLSIssuer is the cert-manager cluster issuer name.
TLSIssuer string
}
// PreviewManager manages ephemeral preview URLs via K8s Service + Ingress.
type PreviewManager struct {
client *kubernetes.Clientset
config PreviewConfig
}
// NewPreviewManager creates a new K8s preview manager.
func NewPreviewManager(client *kubernetes.Clientset, cfg PreviewConfig) *PreviewManager {
if cfg.IngressClass == "" {
cfg.IngressClass = "traefik"
}
return &PreviewManager{
client: client,
config: cfg,
}
}
// resourceName returns the K8s resource name for a session.
func resourceName(sessionID string) string {
return "session-" + sessionID
}
// CreatePreview creates a K8s Service + Ingress for the session preview.
func (m *PreviewManager) CreatePreview(ctx context.Context, opts port.PreviewOptions) error {
if opts.Port == 0 {
opts.Port = 8080
}
name := resourceName(opts.SessionID)
ns := opts.Namespace
if ns == "" {
ns = m.config.Namespace
}
// Create Service targeting the pod by its rdev project label.
// Project pods are labeled with rdev.orchard9.ai/name=<project-id>.
svc := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: ns,
Labels: map[string]string{
"rdev.orchard9.ai/session": opts.SessionID,
"rdev.orchard9.ai/preview": "true",
},
},
Spec: corev1.ServiceSpec{
// Use ExternalName-like routing via a selector that matches the pod.
// Since project pods have rdev.orchard9.ai/name=<id>, we select by pod name
// using the statefulset.kubernetes.io/pod-name label (set on StatefulSet pods).
Selector: map[string]string{
"statefulset.kubernetes.io/pod-name": opts.PodName,
},
Ports: []corev1.ServicePort{
{
Name: "http",
Port: int32(opts.Port),
TargetPort: intstr.FromInt32(int32(opts.Port)),
Protocol: corev1.ProtocolTCP,
},
},
Type: corev1.ServiceTypeClusterIP,
},
}
_, err := m.client.CoreV1().Services(ns).Create(ctx, svc, metav1.CreateOptions{})
if err != nil && !errors.IsAlreadyExists(err) {
return fmt.Errorf("create preview service: %w", err)
}
// Create Ingress for TLS-terminated route.
pathType := networkingv1.PathTypePrefix
tlsSecretName := strings.ReplaceAll(opts.Host, ".", "-") + "-tls"
annotations := map[string]string{}
if m.config.TLSIssuer != "" {
annotations["cert-manager.io/cluster-issuer"] = m.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: name,
Namespace: ns,
Labels: map[string]string{
"rdev.orchard9.ai/session": opts.SessionID,
"rdev.orchard9.ai/preview": "true",
},
Annotations: annotations,
},
Spec: networkingv1.IngressSpec{
IngressClassName: &m.config.IngressClass,
TLS: []networkingv1.IngressTLS{
{
Hosts: []string{opts.Host},
SecretName: tlsSecretName,
},
},
Rules: []networkingv1.IngressRule{
{
Host: opts.Host,
IngressRuleValue: networkingv1.IngressRuleValue{
HTTP: &networkingv1.HTTPIngressRuleValue{
Paths: []networkingv1.HTTPIngressPath{
{
Path: "/",
PathType: &pathType,
Backend: networkingv1.IngressBackend{
Service: &networkingv1.IngressServiceBackend{
Name: name,
Port: networkingv1.ServiceBackendPort{
Number: int32(opts.Port),
},
},
},
},
},
},
},
},
},
},
}
_, err = m.client.NetworkingV1().Ingresses(ns).Create(ctx, ingress, metav1.CreateOptions{})
if err != nil && !errors.IsAlreadyExists(err) {
// Clean up the service if ingress creation fails.
_ = m.client.CoreV1().Services(ns).Delete(ctx, name, metav1.DeleteOptions{})
return fmt.Errorf("create preview ingress: %w", err)
}
return nil
}
// DeletePreview removes the K8s Service + Ingress for a session preview.
func (m *PreviewManager) DeletePreview(ctx context.Context, sessionID string) error {
name := resourceName(sessionID)
ns := m.config.Namespace
// Delete Ingress (ignore not-found).
err := m.client.NetworkingV1().Ingresses(ns).Delete(ctx, name, metav1.DeleteOptions{})
if err != nil && !errors.IsNotFound(err) {
return fmt.Errorf("delete preview ingress: %w", err)
}
// Delete Service (ignore not-found).
err = m.client.CoreV1().Services(ns).Delete(ctx, name, metav1.DeleteOptions{})
if err != nil && !errors.IsNotFound(err) {
return fmt.Errorf("delete preview service: %w", err)
}
return nil
}