Files
homelab-frontend/internal/serviceadapter/informer.go
T
Admin Bot 977058d0b0
CI / Vet, test, build (push) Successful in 2m4s
CI / Build and push image (push) Successful in 42s
fix: route k8s API through nginx proxy, drop CiliumNetworkPolicy
2026-08-26 16:24:43 -07:00

186 lines
5.0 KiB
Go

package serviceadapter
import (
"crypto/tls"
"crypto/x509"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"sync"
"time"
)
// Loader watches ServiceAdapter CRs via the Kubernetes REST API and populates the registry.
// No client-go dependency — uses the in-cluster service account token directly.
type Loader struct {
registry *Registry
namespace string
client *http.Client
token string
baseURL string
stopChan chan struct{}
once sync.Once
}
// NewLoader creates a loader that reads ServiceAdapters from the k8s API.
// Returns an error if not running inside a cluster (missing SA token/ca).
func NewLoader(registry *Registry, namespace string) (*Loader, error) {
if registry == nil {
return nil, fmt.Errorf("registry cannot be nil")
}
if namespace == "" {
namespace = "api"
}
tokenBytes, err := os.ReadFile("/var/run/secrets/kubernetes.io/serviceaccount/token")
if err != nil {
return nil, fmt.Errorf("not in cluster: %w", err)
}
caBytes, err := os.ReadFile("/var/run/secrets/kubernetes.io/serviceaccount/ca.crt")
if err != nil {
return nil, fmt.Errorf("missing CA cert: %w", err)
}
pool := x509.NewCertPool()
pool.AppendCertsFromPEM(caBytes)
// Use kube-api-proxy (nginx) in the same namespace to reach the API server.
// This avoids needing direct egress to the API server ClusterIP which
// standard NetworkPolicy can't allow through Cilium.
proxyHost := os.Getenv("KUBE_API_PROXY_URL")
if proxyHost == "" {
proxyHost = "https://kube-api-proxy.api.svc.cluster.local:8443"
}
return &Loader{
registry: registry,
namespace: namespace,
token: string(tokenBytes),
baseURL: proxyHost,
client: &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{RootCAs: pool, InsecureSkipVerify: true},
},
},
stopChan: make(chan struct{}),
}, nil
}
// Start begins loading ServiceAdapters in the background. Non-blocking.
// Retries on failure so the gateway can start serving immediately.
func (l *Loader) Start(interval time.Duration) {
go func() {
// Initial load with retries (API server may be slow on startup)
for attempt := 1; ; attempt++ {
if err := l.load(); err != nil {
backoff := time.Duration(min(attempt*5, 30)) * time.Second
log.Printf("serviceadapter loader: attempt %d failed (%v), retry in %s", attempt, err, backoff)
time.Sleep(backoff)
continue
}
break
}
// Poll for changes
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
if err := l.load(); err != nil {
log.Printf("serviceadapter loader poll error: %v", err)
}
case <-l.stopChan:
return
}
}
}()
}
// Stop stops the background poll.
func (l *Loader) Stop() {
l.once.Do(func() { close(l.stopChan) })
}
// load fetches all ServiceAdapters from the k8s API and syncs the registry.
func (l *Loader) load() error {
url := fmt.Sprintf("%s/apis/gateway.riotpiao.com/v1/namespaces/%s/serviceadapters", l.baseURL, l.namespace)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+l.token)
req.Header.Set("Accept", "application/json")
resp, err := l.client.Do(req)
if err != nil {
return fmt.Errorf("k8s API request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == 429 {
return fmt.Errorf("k8s API: storage reinitializing (429)")
}
if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("k8s API: %d %s", resp.StatusCode, string(body[:min(len(body), 200)]))
}
var list crList
if err := json.NewDecoder(resp.Body).Decode(&list); err != nil {
return fmt.Errorf("decoding response: %w", err)
}
// Sync: add/update all found, registry handles dedup
for i := range list.Items {
item := &list.Items[i]
adapter := &ServiceAdapter{
Name: item.Metadata.Name,
Namespace: item.Metadata.Namespace,
ServiceName: item.Spec.ServiceName,
CreatedAt: time.Now(),
}
adapter.Spec.Upstream.URL = item.Spec.Upstream.URL
adapter.Spec.Upstream.TimeoutSeconds = item.Spec.Upstream.TimeoutSeconds
adapter.Spec.Auth.Required = item.Spec.Auth.Required
adapter.Spec.Auth.Capability = item.Spec.Auth.Capability
if existing := l.registry.Get(item.Spec.ServiceName); existing != nil {
l.registry.Update(adapter)
} else {
l.registry.Add(adapter)
log.Printf("serviceadapter loaded: %s → %s", item.Spec.ServiceName, item.Spec.Upstream.URL)
}
}
return nil
}
// Minimal JSON structs for the k8s list response — no client-go needed.
type crList struct {
Items []crItem `json:"items"`
}
type crItem struct {
Metadata struct {
Name string `json:"name"`
Namespace string `json:"namespace"`
} `json:"metadata"`
Spec struct {
ServiceName string `json:"serviceName"`
Upstream struct {
URL string `json:"url"`
TimeoutSeconds int32 `json:"timeoutSeconds"`
} `json:"upstream"`
Auth struct {
Required bool `json:"required"`
Capability string `json:"capability"`
} `json:"auth"`
} `json:"spec"`
}