feat(11-06): add the tide fake Centrifugo recorder, broadcast goldens and parity:broadcasts

- CentrifugoRecorder records publish/broadcast requests (method, path,
  whether the API key matched, JSON body) and binds loopback only
- BroadcastGolden load/write, NormalizePublications (timestamps, actor,
  captured ids only) and DiffPublications (structural, key order ignored)
- RecordBroadcasts runs a flow or one step against a loopback backend
- summer parity:broadcasts wraps it; README documents format and rules
This commit is contained in:
Jakub Zych
2026-09-30 13:21:23 +02:00
parent fb4aed176a
commit 9ecbf74a22
7 changed files with 1336 additions and 4 deletions

View File

@@ -3,8 +3,10 @@ package main
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"git.golem15.com/golem15/summercms/modules/bonfire"
"git.golem15.com/golem15/summercms/modules/tide"
@@ -65,6 +67,118 @@ func parityReplayCommand() bonfire.Command {
}
}
func parityBroadcastsCommand() bonfire.Command {
return bonfire.Command{
Name: "parity:broadcasts",
Description: "Record the Centrifugo publications a reference backend sends during a flow into a golden",
Flags: []bonfire.Flag{
{Name: "flow", Description: "YAML flow spec to run against the target"},
{Name: "target", Description: "Loopback base URL of the reference backend"},
{Name: "vars", Description: "Private mode-0600 variable store outside fixtures"},
{Name: "listen", Description: "Loopback address of the fake Centrifugo recorder", Default: tide.DefaultCentrifugoListen},
{Name: "out", Description: "Destination golden path"},
{Name: "name", Description: "Golden name (default: the step, else the flow name)"},
{Name: "step", Description: "Record only this step's publications; earlier steps run as setup"},
{Name: "ids", Description: "Comma-separated id:* variables to replace with placeholders"},
{Name: "rules", Description: "Committed YAML capture rules"},
{Name: "api-key", Description: "Centrifugo API key the backend sends (default: $PARITY_CENTRIFUGO_API_KEY)"},
{Name: "settle", Description: "Wait for late publications after each step (Go duration)", Default: tide.DefaultBroadcastSettle.String()},
{Name: "pending", Description: "Mark the golden recorded but not yet asserted, with this reason"},
},
Run: runParityBroadcasts,
}
}
func runParityBroadcasts(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
flowPath, err := requireFlag(in, "flow", "parity:broadcasts")
if err != nil {
return err
}
target, err := requireFlag(in, "target", "parity:broadcasts")
if err != nil {
return err
}
outPath, err := requireFlag(in, "out", "parity:broadcasts")
if err != nil {
return err
}
varsPath, err := requireFlag(in, "vars", "parity:broadcasts")
if err != nil {
return err
}
if err := varsOutsideDir(varsPath, filepath.Dir(outPath)); err != nil {
return err
}
apiKey := flagValue(in, "api-key")
if apiKey == "" {
apiKey = strings.TrimSpace(os.Getenv("PARITY_CENTRIFUGO_API_KEY"))
}
if apiKey == "" {
return fmt.Errorf("parity:broadcasts requires --api-key or PARITY_CENTRIFUGO_API_KEY")
}
spec, err := tide.LoadFlow(flowPath)
if err != nil {
return err
}
cfg := tide.BroadcastConfig{
Target: target,
Listen: flagValue(in, "listen"),
APIKey: apiKey,
Step: flagValue(in, "step"),
Name: flagValue(in, "name"),
}
if s := flagValue(in, "settle"); s != "" {
d, err := time.ParseDuration(s)
if err != nil {
return fmt.Errorf("parity:broadcasts --settle: %w", err)
}
cfg.Settle = d
}
for _, id := range strings.Split(flagValue(in, "ids"), ",") {
if id = strings.TrimSpace(id); id != "" {
cfg.IDs = append(cfg.IDs, id)
}
}
rc := tide.RecordConfig{}
if err := attachRulesAndVars(in, &rc); err != nil {
return err
}
cfg.Rules, cfg.Store = rc.Rules, rc.Store
golden, err := tide.RecordBroadcasts(ctx, spec, cfg)
if err != nil {
return err
}
golden.Pending = flagValue(in, "pending")
if err := tide.WriteBroadcastGolden(outPath, golden); err != nil {
return err
}
out.Success(fmt.Sprintf("recorded %d publications into %s", len(golden.Publications), outPath))
return nil
}
// varsOutsideDir refuses a vars store inside the directory a golden is
// written to, so captured secrets never sit next to committed files.
func varsOutsideDir(varsPath, dir string) error {
absVars, err := filepath.Abs(varsPath)
if err != nil {
return err
}
absDir, err := filepath.Abs(dir)
if err != nil {
return err
}
if eval, err := filepath.EvalSymlinks(absDir); err == nil {
absDir = eval
}
if eval, err := filepath.EvalSymlinks(filepath.Dir(absVars)); err == nil {
absVars = filepath.Join(eval, filepath.Base(absVars))
}
if absVars == absDir || strings.HasPrefix(absVars, absDir+string(os.PathSeparator)) {
return fmt.Errorf("parity:broadcasts: vars file %q must be outside %q", varsPath, dir)
}
return nil
}
func runParityProxy(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
rulesPath, err := requireFlag(in, "rules", "parity:proxy")
if err != nil {