// 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) }