Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 1 addition & 9 deletions .github/workflows/lint.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -49,21 +49,13 @@ jobs:
- uses: "authzed/actions/golangci-lint@main"

extra-lint:
name: "Lint YAML & Markdown"
name: "Lint YAML"
runs-on: "depot-ubuntu-24.04"
steps:
- uses: "actions/checkout@v6"
- uses: "authzed/actions/yaml-lint@main"
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've never found this action useful and I often find it frustrating. I'd vote to get rid of it; I can restore and fix the issues if we don't want to do that.

- uses: "stefanprodan/kube-tools@v1"
with:
command: "kustomize build ./config"
# Disabled due to issues with Kustomize, see:
# - https://github.com/instrumenta/kubeval-action/pull/3
# - https://github.com/instrumenta/kubeval/issues/232
# - uses: "instrumenta/kubeval-action@5915e4adba5adccac07cb156b82e54c3fed74921"
# with:
# files: "config"
- uses: "authzed/actions/markdown-lint@main"

codeql:
if: "${{ github.event_name == 'pull_request' }}"
Expand Down
133 changes: 133 additions & 0 deletions diff-update-graphs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
#!/usr/bin/env python3
"""
Semantic diff of two update-graph YAML files.

Treats the `channels` list as an unordered set keyed by (name, datastore metadata).
Reports per-channel differences in nodes and edges.

Usage:
python3 diff-update-graphs.py <old-graph.yaml> <new-graph.yaml>
python3 diff-update-graphs.py # defaults to old-proposed-update-graph.yaml vs proposed-update-graph.yaml
"""

import sys
import yaml


def load_graph(path):
with open(path) as f:
return yaml.safe_load(f)


def index_channels(graph):
idx = {}
for ch in graph.get("channels", []):
key = (ch["name"], ch.get("metadata", {}).get("datastore", ""))
idx[key] = ch
return idx


def semver_sort_key(s):
# Simple sort key: split on dots and dashes, numeric parts as ints
import re
return [int(p) if p.isdigit() else p for p in re.split(r"[.\-]", s.lstrip("v"))]


def diff_graphs(old_path, new_path):
old = load_graph(old_path)
new = load_graph(new_path)

old_ch = index_channels(old)
new_ch = index_channels(new)

old_keys = set(old_ch.keys())
new_keys = set(new_ch.keys())

print(f"Comparing:\n OLD: {old_path}\n NEW: {new_path}\n")

only_old = sorted(old_keys - new_keys)
only_new = sorted(new_keys - old_keys)

if only_old:
print("=== Channels only in OLD ===")
for k in only_old:
print(f" {k}")
print()

if only_new:
print("=== Channels only in NEW ===")
for k in only_new:
print(f" {k}")
print()

print("=== Per-channel comparison ===")
any_diff = False
for key in sorted(old_keys & new_keys):
oc = old_ch[key]
nc = new_ch[key]

old_nodes = {n["id"]: n for n in oc.get("nodes", [])}
new_nodes = {n["id"]: n for n in nc.get("nodes", [])}

only_old_nodes = sorted(set(old_nodes) - set(new_nodes), key=semver_sort_key)
only_new_nodes = sorted(set(new_nodes) - set(old_nodes), key=semver_sort_key)

old_edges = {k: set(v) for k, v in (oc.get("edges") or {}).items()}
new_edges = {k: set(v) for k, v in (nc.get("edges") or {}).items()}

all_from_nodes = set(old_edges) | set(new_edges)
edge_diffs = {}
for fn in all_from_nodes:
oe = old_edges.get(fn, set())
ne = new_edges.get(fn, set())
only_old_e = sorted(oe - ne, key=semver_sort_key)
only_new_e = sorted(ne - oe, key=semver_sort_key)
if only_old_e or only_new_e:
edge_diffs[fn] = (only_old_e, only_new_e)

# Node field-level diffs
field_diffs = {}
for nid in sorted(set(old_nodes) & set(new_nodes), key=semver_sort_key):
on = old_nodes[nid]
nn = new_nodes[nid]
diffs = {}
for f in set(on) | set(nn):
if on.get(f) != nn.get(f):
diffs[f] = (on.get(f), nn.get(f))
if diffs:
field_diffs[nid] = diffs

channel_label = f"{key[1]}/{key[0]}"
if not only_old_nodes and not only_new_nodes and not edge_diffs and not field_diffs:
print(f"\n {channel_label}: IDENTICAL")
else:
any_diff = True
print(f"\n {channel_label}: DIFFERS")
if only_old_nodes:
print(f" Nodes only in OLD: {only_old_nodes}")
if only_new_nodes:
print(f" Nodes only in NEW: {only_new_nodes}")
for fn in sorted(edge_diffs, key=semver_sort_key):
only_old_e, only_new_e = edge_diffs[fn]
if only_old_e:
print(f" Edge {fn} -> REMOVED targets: {only_old_e}")
if only_new_e:
print(f" Edge {fn} -> ADDED targets: {only_new_e}")
for nid, diffs in sorted(field_diffs.items(), key=lambda x: semver_sort_key(x[0])):
print(f" Node {nid} field diffs: {diffs}")

if not any_diff and not only_old and not only_new:
print("\nGraphs are SEMANTICALLY EQUIVALENT (order-agnostic).")


if __name__ == "__main__":
if len(sys.argv) == 3:
old_path, new_path = sys.argv[1], sys.argv[2]
elif len(sys.argv) == 1:
old_path = "old-proposed-update-graph.yaml"
new_path = "proposed-update-graph.yaml"
else:
print("Usage: diff-update-graphs.py [<old.yaml> <new.yaml>]")
sys.exit(1)

diff_graphs(old_path, new_path)
4 changes: 2 additions & 2 deletions e2e/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,8 @@ github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpv
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
Expand Down
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ require (
k8s.io/kubectl v0.36.0-alpha.0
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4
sigs.k8s.io/kind v0.31.0
sigs.k8s.io/yaml v1.6.0
)

require (
Expand Down Expand Up @@ -59,6 +60,7 @@ require (
github.com/go-openapi/swag v0.23.0 // indirect
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
github.com/gobuffalo/flect v1.0.3 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/btree v1.1.3 // indirect
Expand Down Expand Up @@ -147,7 +149,6 @@ require (
sigs.k8s.io/kustomize/kyaml v0.20.1 // indirect
sigs.k8s.io/randfill v1.0.0 // indirect
sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect
sigs.k8s.io/yaml v1.6.0 // indirect
)

tool (
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,8 @@ github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1v
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/gobuffalo/flect v1.0.3 h1:xeWBM2nui+qnVvNM4S3foBhCAL2XgPU+a7FdpelbTq4=
github.com/gobuffalo/flect v1.0.3/go.mod h1:A5msMlrHtLqh9umBSnvabjsMrCcCpAyzglnDvkbYKHs=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
Expand Down
74 changes: 74 additions & 0 deletions graph-diff-summary.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# Update Graph Diff: old-proposed-update-graph.yaml vs proposed-update-graph.yaml

The graphs are **not fully equivalent**. Below is every difference found.

---

## `memory/stable`: IDENTICAL — no differences.

---

## All four DB channels: `v1.37.1` edge set differs

All of cockroachdb, mysql, postgres, and spanner are affected.

| Graph | `v1.37.1` edges to |
|-------|-------------------|
| Old | `v1.38.0, v1.39.1, v1.40.1, v1.42.1, v1.45.4, v1.47.1, v1.48.0, v1.49.2, v1.51.1` |
| New | `v1.38.0` only |

The old code let `v1.37.1` skip past `v1.38.0`; the new waypoint algorithm blocks that
because `v1.38.0` is now a mandatory stop (`waypoint: true`).

---

## Phase nodes have broader outgoing edges in the new graph

The old code treated phase nodes as strict "step through me to the immediate next version"
nodes. The new waypoint algorithm allows phase nodes to reach any target up to (but not
past) the next waypoint. Affected nodes:

- **`cockroachdb/stable`**: `v1.30.0-phase1`
- Old: only `→ v1.30.0`
- New: `→ v1.30.0` through `v1.36.2`
- **`postgres/stable`**: `v1.14.0-phase2`
- Old: only `→ v1.14.0`
- New: `→ v1.14.0` through `v1.36.2`
- **`spanner/stable`**: `v1.22.2-phase2` and `v1.29.5-phase1`
- Old: each pointed only to the immediate next version
- New: each can reach further (up to the `v1.38.0` waypoint)

---

## `spanner/stable`: `v1.51.1` node missing from old graph

The old graph encoded the latest spanner version as a quirk: node `v1.49.2` had `tag:
v1.51.1`. The new graph correctly has `v1.49.2` with `tag: v1.49.2` and a separate
`v1.51.1` node, which adds outgoing edges from all existing spanner nodes to `v1.51.1`.

---

## Node field differences

| Channel | Node | Field | Old value | New value |
|---------|------|-------|-----------|-----------|
| `cockroachdb/stable` | `v1.30.0-phase1` | `phase` | missing | `write-both-read-new` |
| `spanner/stable` | `v1.29.5-phase1` | `phase` | missing | `write-both-read-new` |
| `spanner/stable` | `v1.49.2` | `tag` | `v1.51.1` | `v1.49.2` |

---

## Bottom line

The graphs differ in four ways:

1. **`v1.37.1` edge narrowing** (all DB channels) — likely correct; `v1.38.0` is now a
hard waypoint.
2. **Phase-node outgoing edge expansion** — semantic change: old code was "phase node →
immediate next release only"; new algorithm is "phase node → anything up to the next
waypoint". Whether multi-hop skips from a phase node are safe depends on whether
those intermediate versions require a migration stop.
3. **`spanner/stable` `v1.51.1` node added** — likely a bug fix in the old graph where
`v1.49.2` was carrying the wrong tag.
4. **`phase` field missing on phase nodes in old graph** — old serialization omitted the
`phase` field from those nodes; new graph includes it.
86 changes: 83 additions & 3 deletions magefiles/magefile.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"os"
"os/exec"
"path/filepath"
"strings"

"github.com/cespare/xxhash/v2"
"github.com/jzelinskie/stringz"
Expand All @@ -19,8 +20,24 @@ import (
kind "sigs.k8s.io/kind/pkg/cluster"
"sigs.k8s.io/kind/pkg/cmd"
"sigs.k8s.io/kind/pkg/fs"
"sigs.k8s.io/yaml"
)

const versionsYamlFile = "magefiles/versions.yaml"

type datastoreConfig struct {
Migration string `json:"migration,omitempty"`
Phase string `json:"phase,omitempty"`
Deprecated bool `json:"deprecated,omitempty"`
Waypoint bool `json:"waypoint,omitempty"`
}

type versionEntry struct {
ID string `json:"id"`
Tag string `json:"tag"`
Config map[string]datastoreConfig `json:"config"`
}

var Aliases = map[string]interface{}{
"test": Test.Unit,
"e2e": Test.E2e,
Expand Down Expand Up @@ -124,13 +141,76 @@ func (Gen) Graph() error {
return sh.RunV("go", "generate", "./tools/generate-update-graph/main.go")
}

// If the update graph definition
// Append_version appends a new entry to the version list given a docker tag of a
// SpiceDB release, automatically discovering the migration head for each datastore.
func (Gen) Append_version(tag string) error {
mg.Deps(checkDocker)

existing, err := os.ReadFile(versionsYamlFile)
if err != nil && !os.IsNotExist(err) {
return err
}
for _, line := range strings.Split(string(existing), "\n") {
if line == "tag: "+tag {
return fmt.Errorf("version %s is already in the version list", tag)
}
}

versionOutput, err := sh.Output("docker", "run", "--rm", "--platform=linux/amd64",
"ghcr.io/authzed/spicedb:"+tag, "spicedb", "version")
if err != nil {
return fmt.Errorf("failed to get version from image: %w", err)
}
// Output format: "spicedb v1.52.0"
parts := strings.Fields(versionOutput)
if len(parts) < 2 {
return fmt.Errorf("unexpected version output: %q", versionOutput)
}
id := parts[1]

cfg := make(map[string]datastoreConfig)
for _, ds := range []string{"postgres", "cockroachdb", "spanner", "mysql"} {
migration, err := sh.Output("docker", "run", "--rm", "--platform=linux/amd64",
"ghcr.io/authzed/spicedb:"+tag, "spicedb",
"datastore", "head", "--log-level=error", "--datastore-engine="+ds)
if err != nil {
return fmt.Errorf("failed to get migration head for %s: %w", ds, err)
}
cfg[ds] = datastoreConfig{Migration: strings.TrimSpace(migration)}
}
cfg["memory"] = datastoreConfig{}

v := versionEntry{ID: id, Tag: tag, Config: cfg}
vBytes, err := yaml.Marshal(v)
if err != nil {
return err
}

f, err := os.OpenFile(versionsYamlFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
return err
}
defer f.Close()

if _, err := f.WriteString("---\n"); err != nil {
return err
}
_, err = f.Write(vBytes)
return err
}

// generateGraphIfSourcesChanged regenerates the update graph if versions.yaml or
// the generator source have changed since the last generation.
func (g Gen) generateGraphIfSourcesChanged() error {
regen, err := target.Dir("proposed-update-graph.yaml", "tools/generate-update-graph")
versionsChanged, err := target.Path("proposed-update-graph.yaml", versionsYamlFile)
if err != nil {
return err
}
generatorChanged, err := target.Dir("proposed-update-graph.yaml", "tools/generate-update-graph")
if err != nil {
return err
}
if regen {
if versionsChanged || generatorChanged {
return g.Graph()
}
return nil
Expand Down
Loading
Loading