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,206 @@
|
||||
package edgeconfig
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// --- Fragment / Writer ---
|
||||
|
||||
type testFrag struct {
|
||||
name string
|
||||
data string
|
||||
err error
|
||||
}
|
||||
|
||||
func (f testFrag) Name() string { return f.name }
|
||||
func (f testFrag) Render() ([]byte, error) {
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
return []byte(f.data), nil
|
||||
}
|
||||
|
||||
func TestWriterAtomicAndOrdering(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
w := NewWriter(dir)
|
||||
|
||||
changed, err := w.Write(
|
||||
testFrag{name: "20-routes.conf", data: "# routes"},
|
||||
testFrag{name: "10-auth.conf", data: "# auth"},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(changed) != 2 || changed[0] != "10-auth.conf" || changed[1] != "20-routes.conf" {
|
||||
t.Fatalf("changed = %v, want sorted [10-auth.conf 20-routes.conf]", changed)
|
||||
}
|
||||
for _, name := range []string{"10-auth.conf", "20-routes.conf"} {
|
||||
data, err := os.ReadFile(filepath.Join(dir, name))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
t.Fatalf("%s: empty", name)
|
||||
}
|
||||
}
|
||||
// no tmp leftovers
|
||||
entries, _ := os.ReadDir(dir)
|
||||
if len(entries) != 2 {
|
||||
t.Fatalf("dir has %d entries, want exactly the 2 fragments", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriterRenderErrorAbortsFlush(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
w := NewWriter(dir)
|
||||
|
||||
_, err := w.Write(
|
||||
testFrag{name: "10-auth.conf", data: "# ok"},
|
||||
testFrag{name: "20-bad.conf", err: errors.New("boom")},
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("want render error")
|
||||
}
|
||||
// nothing written — previous complete set keeps serving
|
||||
if _, statErr := os.Stat(filepath.Join(dir, "10-auth.conf")); !os.IsNotExist(statErr) {
|
||||
t.Fatal("flush must be all-or-nothing on render error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriterRejectsBadNames(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
w := NewWriter(dir)
|
||||
for _, bad := range []string{"../evil", "sub/dir.conf", "", ".."} {
|
||||
if _, err := w.Write(testFrag{name: bad, data: "x"}); err == nil {
|
||||
t.Fatalf("name %q accepted", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- HTTPReloader + PostReload against a fake handler ---
|
||||
|
||||
func TestHTTPReloaderFanOut(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
var hits int
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
hits++
|
||||
mu.Unlock()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"reloaded":true,"checksum":42}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
host := srv.Listener.Addr().String()
|
||||
r, err := NewHTTPReloader(host + "," + host)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res := r.Reload(context.Background())
|
||||
if !res.OK() {
|
||||
t.Fatalf("want OK, errs=%v", res.Errs())
|
||||
}
|
||||
if hits != 2 {
|
||||
t.Fatalf("hits=%d, want 2 (one per target)", hits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPReloaderPartialFailure(t *testing.T) {
|
||||
ok := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(`{"reloaded":true}`))
|
||||
}))
|
||||
defer ok.Close()
|
||||
bad := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, `{"message":"nginx -t failed"}`, http.StatusInternalServerError)
|
||||
}))
|
||||
defer bad.Close()
|
||||
|
||||
r, err := NewHTTPReloader(ok.Listener.Addr().String() + "," + bad.Listener.Addr().String())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res := r.Reload(context.Background())
|
||||
if res.OK() {
|
||||
t.Fatal("want failure when one target fails")
|
||||
}
|
||||
errs := res.Errs()
|
||||
if len(errs) != 1 {
|
||||
t.Fatalf("errs=%v, want exactly one (the failing target)", errs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewHTTPReloaderValidation(t *testing.T) {
|
||||
if _, err := NewHTTPReloader(""); err == nil {
|
||||
t.Fatal("empty targets accepted")
|
||||
}
|
||||
if _, err := NewHTTPReloader("no-port-here"); err == nil {
|
||||
t.Fatal("target without port accepted")
|
||||
}
|
||||
if _, err := NewHTTPReloader(" a:1 , b:2 "); err != nil {
|
||||
t.Fatalf("whitespace-separated list rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- KubeReloader with injected lister/post (no k8s client in tests) ---
|
||||
|
||||
func TestKubeReloaderFansOutToAllPods(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
got := map[string]bool{}
|
||||
|
||||
k, err := NewKubeReloader(func(ctx context.Context) ([]string, error) {
|
||||
return []string{"10.0.0.4", "10.0.0.5"}, nil
|
||||
}, "8081")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
k.post = func(ctx context.Context, target string) (HandlerResult, error) {
|
||||
mu.Lock()
|
||||
got[target] = true
|
||||
mu.Unlock()
|
||||
return HandlerResult{Reloaded: true}, nil
|
||||
}
|
||||
|
||||
res := k.Reload(context.Background())
|
||||
if !res.OK() {
|
||||
t.Fatalf("want OK, errs=%v", res.Errs())
|
||||
}
|
||||
for _, want := range []string{"10.0.0.4:8081", "10.0.0.5:8081"} {
|
||||
if !got[want] {
|
||||
t.Fatalf("pod %s not reloaded; got %v", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestKubeReloaderPodListFailure(t *testing.T) {
|
||||
k, err := NewKubeReloader(func(ctx context.Context) ([]string, error) {
|
||||
return nil, errors.New("api server down")
|
||||
}, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res := k.Reload(context.Background())
|
||||
if res.OK() {
|
||||
t.Fatal("pod-list failure must surface as error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKubeReloaderNoPods(t *testing.T) {
|
||||
k, _ := NewKubeReloader(func(ctx context.Context) ([]string, error) { return nil, nil }, "")
|
||||
if res := k.Reload(context.Background()); res.OK() {
|
||||
t.Fatal("zero pods must be an error, not silent success")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNullReloader(t *testing.T) {
|
||||
if res := (NullReloader{}).Reload(context.Background()); !res.OK() {
|
||||
t.Fatal("null reloader must succeed")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user