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,147 @@
|
||||
// Package edgeconfig is the seam for publish-time generated nginx config.
|
||||
//
|
||||
// Producers (auth gating, grain routes, redirects, …) implement Fragment;
|
||||
// a Writer renders fragments atomically to the shared nginx include
|
||||
// directory. A Reloader fans the change out to every running nginx
|
||||
// instance, whose reload handler validates (nginx -t) before reloading.
|
||||
//
|
||||
// Risk model: only files inside the include directory ever change — the
|
||||
// main nginx config is static and never touched at runtime. A broken
|
||||
// fragment fails `nginx -t` and the previous config keeps serving.
|
||||
//
|
||||
// Dependency rule: imports only stdlib and other platform packages.
|
||||
package edgeconfig
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash/adler32"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Fragment is one named piece of generated nginx config owned by exactly
|
||||
// one producer. Name determines include order: it must start with a number
|
||||
// prefix ("10-auth", "20-routes"); the writer sorts by name.
|
||||
type Fragment interface {
|
||||
Name() string
|
||||
Render() ([]byte, error)
|
||||
}
|
||||
|
||||
// Reloader fans a config change out to all nginx instances. Providers are
|
||||
// selected per environment: kube in-cluster, http for compose/dev, null for
|
||||
// tests. Implementations must be safe for concurrent use.
|
||||
type Reloader interface {
|
||||
Reload(ctx context.Context) Result
|
||||
}
|
||||
|
||||
// TargetResult is the outcome for one nginx instance.
|
||||
type TargetResult struct {
|
||||
Target string // host:port or pod identifier
|
||||
Err error // nil on success
|
||||
}
|
||||
|
||||
// Result aggregates per-target outcomes of one Reload call.
|
||||
type Result struct {
|
||||
Targets []TargetResult
|
||||
}
|
||||
|
||||
// Errs returns the non-nil errors, or nil if every target succeeded.
|
||||
func (r Result) Errs() []error {
|
||||
var errs []error
|
||||
for _, t := range r.Targets {
|
||||
if t.Err != nil {
|
||||
errs = append(errs, fmt.Errorf("%s: %w", t.Target, t.Err))
|
||||
}
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
// OK reports whether every target reloaded successfully.
|
||||
func (r Result) OK() bool { return len(r.Errs()) == 0 }
|
||||
|
||||
// Writer renders fragments into dir (the nginx include directory mounted
|
||||
// from the RWX volume). Every file write is atomic: tmp file + rename, so
|
||||
// a reader (nginx reload handler) never sees a partial fragment.
|
||||
type Writer struct {
|
||||
dir string
|
||||
}
|
||||
|
||||
// NewWriter returns a Writer that renders fragments into dir.
|
||||
func NewWriter(dir string) *Writer { return &Writer{dir: dir} }
|
||||
|
||||
// Dir is the include directory fragments are rendered into.
|
||||
func (w *Writer) Dir() string { return w.dir }
|
||||
|
||||
// Write renders each fragment atomically and reports which names changed
|
||||
// since the last known content. Fragments whose Render returns an error
|
||||
// abort the whole flush: nothing is written, so nginx keeps serving the
|
||||
// previous complete set. The returned names are sorted.
|
||||
func (w *Writer) Write(fragments ...Fragment) (changed []string, err error) {
|
||||
type rendered struct {
|
||||
frag Fragment
|
||||
data []byte
|
||||
}
|
||||
rs := make([]rendered, 0, len(fragments))
|
||||
for _, f := range fragments {
|
||||
data, err := f.Render()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("edgeconfig: render %s: %w", f.Name(), err)
|
||||
}
|
||||
rs = append(rs, rendered{f, data})
|
||||
}
|
||||
sort.Slice(rs, func(i, j int) bool { return rs[i].frag.Name() < rs[j].frag.Name() })
|
||||
|
||||
for _, r := range rs {
|
||||
if err := w.writeAtomic(r.frag.Name(), r.data); err != nil {
|
||||
return nil, fmt.Errorf("edgeconfig: write %s: %w", r.frag.Name(), err)
|
||||
}
|
||||
}
|
||||
for _, r := range rs {
|
||||
changed = append(changed, r.frag.Name())
|
||||
}
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
// writeAtomic writes data to name via a tmp file + rename in dir.
|
||||
func (w *Writer) writeAtomic(name string, data []byte) error {
|
||||
if err := validName(name); err != nil {
|
||||
return err
|
||||
}
|
||||
final := filepath.Join(w.dir, name)
|
||||
tmp, err := os.CreateTemp(w.dir, ".edgeconfig-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer os.Remove(tmpName) // no-op after successful rename
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmpName, final)
|
||||
}
|
||||
|
||||
func validName(name string) error {
|
||||
if name == "" || strings.ContainsAny(name, "/\\") || strings.Contains(name, "..") ||
|
||||
filepath.Base(name) != name {
|
||||
return errors.New("invalid fragment name " + strconv.Quote(name))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Checksum is the content fingerprint the reload handler compares against
|
||||
// (adler32 — cheap, stable across processes; matches nothing security-
|
||||
// sensitive, this is change detection only).
|
||||
func Checksum(data []byte) uint32 { return adler32.Checksum(data) }
|
||||
Reference in new issue
Block a user