Files
platform/edgeconfig/reloader_http.go
T
mats 4337110ae2 platform/edgeconfig: publish-time nginx fragment seam + switchable reloaders
Implements steps 1-2 and the RBAC half of step 4 of
docs/plan-storefront-edge-config.md (storefront auth gating, generalised
to dynamic grain routes).

- platform/edgeconfig: Fragment/Reloader interfaces, atomic Writer
  (all-or-nothing flush), HTTPReloader (dev), NullReloader,
  KubeReloader (injected PodLister keeps platform stdlib-only).
  go vet clean; tests pass under -race.
- deploy/reloader-lib.sh + nginx-reloader.sh: reload handler
  (cksum -> nginx -t -> graceful reload) + CGI endpoint on :8082
  (pod IP only) + 60s fallback poll. Include-only risk model: main
  config never changes at runtime; broken fragments fail validation
  and the previous config keeps serving.
- k8s: reloader wired into the nginx Deployment, writable cms-output
  mount, fragment include line, SA/Role/RoleBinding for backoffice
  pod listing; backoffice uses the new ServiceAccount.
- compose/dev: same handler on stock nginx:alpine, EDGE_RELOAD_PROVIDER=http
  targeting nginx:8082 from backoffice.
- docs: plan-storefront-edge-config.md (supersedes earlier gating plans)
  and the fixed-zones variant kept for drift analysis.
2026-08-24 21:59:02 +02:00

127 lines
3.9 KiB
Go

package edgeconfig
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"strings"
"sync"
"time"
)
// reloadPath is the endpoint the nginx-side handler serves. The handler
// (baked into the nginx image) does: content check → nginx -t → nginx -s
// reload, and is idempotent — reloading an unchanged config is a no-op.
const reloadPath = "/reload"
// HTTPReloader fans reloads out to a fixed list of nginx instances by
// host:port. This is the dev/compose provider (Docker embedded DNS names)
// and the transport KubeReloader uses per discovered pod.
type HTTPReloader struct {
client *http.Client
targets []string // host:port list, e.g. ["nginx:8081"]
}
// NewHTTPReloader targets is a comma-separated host:port list
// (EDGE_RELOAD_TARGETS=nginx:8081 or "10.0.0.4:8081,10.0.0.5:8081").
func NewHTTPReloader(targets string) (*HTTPReloader, error) {
var out []string
for _, t := range strings.Split(targets, ",") {
t = strings.TrimSpace(t)
if t == "" {
continue
}
if _, _, err := net.SplitHostPort(t); err != nil {
return nil, fmt.Errorf("edgeconfig: bad target %q: %w", t, err)
}
out = append(out, t)
}
if len(out) == 0 {
return nil, fmt.Errorf("edgeconfig: no reload targets configured")
}
return &HTTPReloader{
client: &http.Client{Timeout: 10 * time.Second},
targets: out,
}, nil
}
// Reload posts to every target concurrently and aggregates results.
// A target that fails validation (nginx -t) returns its error in the
// result; the caller decides on retry/alert. Never panics on partial
// failure — partial success is reported, not swallowed.
func (h *HTTPReloader) Reload(ctx context.Context) Result {
res := Result{Targets: make([]TargetResult, len(h.targets))}
var wg sync.WaitGroup
for i, t := range h.targets {
wg.Add(1)
go func(i int, target string) {
defer wg.Done()
res.Targets[i] = TargetResult{Target: target, Err: h.post(ctx, target)}
}(i, t)
}
wg.Wait()
return res
}
func (h *HTTPReloader) post(ctx context.Context, target string) error {
url := "http://" + target + reloadPath
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil)
if err != nil {
return err
}
resp, err := h.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10))
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("reload failed: %s: %s", resp.Status, strings.TrimSpace(string(body)))
}
return nil
}
// NullReloader succeeds without doing anything — tests and deployments
// where the static floor config is sufficient.
type NullReloader struct{}
func (NullReloader) Reload(context.Context) Result { return Result{} }
// HandlerResult is the JSON body the nginx-side handler replies with;
// shared here so producers can decode it in tests/integrations.
type HandlerResult struct {
Reloaded bool `json:"reloaded"` // false = content unchanged, no-op
Checksum uint32 `json:"checksum"` // adler32 of current fragment set
Message string `json:"message,omitempty"` // validation error detail on failure
}
// PostReload is a helper for tests and the kube provider's per-pod call:
// one POST to target, decoding the handler's JSON reply.
func PostReload(ctx context.Context, client *http.Client, target string) (HandlerResult, error) {
if client == nil {
client = &http.Client{Timeout: 10 * time.Second}
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
"http://"+target+reloadPath, bytes.NewReader(nil))
if err != nil {
return HandlerResult{}, err
}
resp, err := client.Do(req)
if err != nil {
return HandlerResult{}, err
}
defer resp.Body.Close()
var hr HandlerResult
if err := json.NewDecoder(io.LimitReader(resp.Body, 4<<10)).Decode(&hr); err != nil {
return HandlerResult{}, fmt.Errorf("decode reload reply (%s): %w", resp.Status, err)
}
if resp.StatusCode != http.StatusOK {
return hr, fmt.Errorf("reload failed: %s: %s", resp.Status, hr.Message)
}
return hr, nil
}