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.
This commit is contained in:
1 parent
6be6aa891a
commit
4337110ae2
4 files changed
+564
No files matched your search
@@ -0,0 +1,126 @@
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user