All checks were successful
Deploy to K8s / deploy (push) Successful in 8s
Adds cmd/parity/main.go: a standalone Go binary that GETs /api/version, /api/adults, /api/juniors, /api/payments from both the Python (:5001) and Go (:8080) backends, scrubs an allowlist (render_time.total, build_meta), and prints cmp.Diff for any remaining differences. Exits 0 on full match, 1 on diffs, 2 on fetch/parse errors — CI-friendly for M7.2. - go/cmd/parity/main.go: flags (-py, -go, -route, -timeout), fetch helper, allowlist scrubber (dotted-path aware), exit-code logic. - go/cmd/parity/scrub_test.go: 4 unit tests for the scrubber. - go/go.mod: promote github.com/google/go-cmp to direct dep. - Makefile: parity target + help entry. - Progress tracker: M5.4 ticked; milestone updated to M5 complete. - Plan archived to docs/plans/2026-05-07-2254-m5-4-parity-binary.md. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
130 lines
3.1 KiB
Go
130 lines
3.1 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/go-cmp/cmp"
|
|
"github.com/google/go-cmp/cmp/cmpopts"
|
|
)
|
|
|
|
var allRoutes = []string{"/api/version", "/api/adults", "/api/juniors", "/api/payments"}
|
|
|
|
// defaultAllowlist holds dotted key paths to strip before diffing.
|
|
// These fields are expected to differ between backends (e.g. build tags, timing)
|
|
// or may appear on one side only. Today both are absent from the JSON — this is
|
|
// forward-compatible insurance for if either is added later.
|
|
var defaultAllowlist = []string{"render_time.total", "build_meta"}
|
|
|
|
func main() {
|
|
pyURL := flag.String("py", "http://localhost:5001", "Python backend base URL")
|
|
goURL := flag.String("go", "http://localhost:8080", "Go backend base URL")
|
|
route := flag.String("route", "", "single route to diff, e.g. /api/adults (default: all)")
|
|
timeout := flag.Duration("timeout", 30*time.Second, "per-request HTTP timeout")
|
|
flag.Parse()
|
|
|
|
client := &http.Client{Timeout: *timeout}
|
|
|
|
targets := allRoutes
|
|
if *route != "" {
|
|
targets = []string{*route}
|
|
}
|
|
|
|
matched, diffs, errs := 0, 0, 0
|
|
|
|
for _, r := range targets {
|
|
pyMap, err1 := fetch(client, *pyURL+r)
|
|
goMap, err2 := fetch(client, *goURL+r)
|
|
|
|
if err1 != nil || err2 != nil {
|
|
fmt.Printf("=== %s ===\n", r)
|
|
if err1 != nil {
|
|
fmt.Printf("ERROR (py): %v\n", err1)
|
|
}
|
|
if err2 != nil {
|
|
fmt.Printf("ERROR (go): %v\n", err2)
|
|
}
|
|
fmt.Println()
|
|
errs++
|
|
continue
|
|
}
|
|
|
|
scrub(pyMap, defaultAllowlist)
|
|
scrub(goMap, defaultAllowlist)
|
|
|
|
diff := cmp.Diff(pyMap, goMap, cmpopts.EquateEmpty())
|
|
if diff == "" {
|
|
fmt.Printf("=== %s ===\nOK\n\n", r)
|
|
matched++
|
|
} else {
|
|
fmt.Printf("=== %s ===\n%s\n", r, diff)
|
|
diffs++
|
|
}
|
|
}
|
|
|
|
total := len(targets)
|
|
fmt.Printf("parity: %d/%d routes match", matched, total)
|
|
if diffs > 0 {
|
|
fmt.Printf(", %d diff", diffs)
|
|
if diffs > 1 {
|
|
fmt.Print("s")
|
|
}
|
|
}
|
|
if errs > 0 {
|
|
fmt.Printf(", %d error", errs)
|
|
if errs > 1 {
|
|
fmt.Print("s")
|
|
}
|
|
}
|
|
fmt.Println()
|
|
|
|
if errs > 0 {
|
|
os.Exit(2)
|
|
}
|
|
if diffs > 0 {
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func fetch(client *http.Client, url string) (map[string]any, error) {
|
|
resp, err := client.Get(url) //nolint:noctx
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read body: %w", err)
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
|
}
|
|
var m map[string]any
|
|
if err := json.Unmarshal(body, &m); err != nil {
|
|
return nil, fmt.Errorf("decode JSON: %w", err)
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// scrub removes keys from m whose dotted paths appear in paths.
|
|
// A bare segment (no dot) deletes a top-level key.
|
|
// A two-segment path "parent.child" deletes child from m["parent"] if it is a map.
|
|
func scrub(m map[string]any, paths []string) {
|
|
for _, path := range paths {
|
|
parts := strings.SplitN(path, ".", 2)
|
|
if len(parts) == 1 {
|
|
delete(m, parts[0])
|
|
} else {
|
|
if child, ok := m[parts[0]].(map[string]any); ok {
|
|
delete(child, parts[1])
|
|
}
|
|
}
|
|
}
|
|
}
|