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.
127 lines
3.9 KiB
Go
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
|
|
}
|