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=. 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=, 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 }