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