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:
@@ -38,6 +38,7 @@ func toolCommands() []bonfire.Command {
|
||||
parityProxyCommand(),
|
||||
parityRecordCommand(),
|
||||
parityReplayCommand(),
|
||||
parityBroadcastsCommand(),
|
||||
delegateCommand("migrate", "Run plugin migrations in the app binary"),
|
||||
delegateRollbackCommand(),
|
||||
delegateCommand("migrate:status", "Show per-plugin migration history in the app binary"),
|
||||
|
||||
@@ -19,7 +19,7 @@ func TestToolCommandNames(t *testing.T) {
|
||||
for _, c := range toolCommands() {
|
||||
names = append(names, c.Name)
|
||||
}
|
||||
for _, want := range []string{"build", "make:plugin", "make:model", "make:migration", "make:command", "make:job", "make:admin-controller", "plugin:add", "dev", "migrate", "migrate:rollback", "migrate:status", "serve", "queue:work", "queue:clear", "schedule:run"} {
|
||||
for _, want := range []string{"build", "make:plugin", "make:model", "make:migration", "make:command", "make:job", "make:admin-controller", "plugin:add", "dev", "migrate", "migrate:rollback", "migrate:status", "serve", "queue:work", "queue:clear", "schedule:run", "parity:broadcasts"} {
|
||||
if !slices.Contains(names, want) {
|
||||
t.Fatalf("missing %s in %v", want, names)
|
||||
}
|
||||
@@ -33,6 +33,7 @@ func TestToolCommandNames(t *testing.T) {
|
||||
"make:job": {"[plugin] [name]"},
|
||||
"make:admin-controller": {"[plugin] [name]"},
|
||||
"schedule:run": {"--once"},
|
||||
"parity:broadcasts": {"--flow", "--step", "--ids", "127.0.0.1:8424"},
|
||||
}
|
||||
for cmd, wants := range helpWants {
|
||||
var buf bytes.Buffer
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user