rdev/internal/adapter/kubernetes/preview.go
jordan 7249575dea
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
feat(sessions): add command execution endpoint and activity tracking
- Add POST /sessions/:id/exec endpoint for executing commands in sessions
- Add session activity tracking (last_activity_at timestamp)
- Add database migration 024 for session activity column
- Add comprehensive tests for session handlers and service layer
- Add wildcard TLS certificate for preview.threesix.ai subdomain
- Add infrastructure mocks for testing preview service
- Refactor preview cleanup logic to remove unused methods
- Add AIOS core documentation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-13 08:41:05 -07:00

183 lines
5.2 KiB
Go

package kubernetes
import (
"context"
"fmt"
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 := 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
// Use the shared wildcard TLS secret (preview-wildcard-tls) for all preview
// ingresses. This avoids per-session cert-manager certificate requests.
tlsSecretName := "preview-wildcard-tls"
annotations := map[string]string{
"traefik.ingress.kubernetes.io/router.entrypoints": "websecure",
"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
}