All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- 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>
183 lines
5.2 KiB
Go
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
|
|
}
|