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
129 changes: 127 additions & 2 deletions pkg/webhook/plan_drift.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ package webhook
import (
"context"
"fmt"
"slices"
"strings"

"github.com/block/schemabot/pkg/api"
"github.com/block/schemabot/pkg/apitypes"
ternv1 "github.com/block/schemabot/pkg/proto/ternv1"
"github.com/block/schemabot/pkg/routing"
"github.com/block/schemabot/pkg/tern"
Expand Down Expand Up @@ -105,12 +107,135 @@ func deploymentDriftPreview(rollup api.PlanRollup) *templates.DeploymentDriftDat
}
entries[i] = entry
}
return &templates.DeploymentDriftData{
independent := rollup.Planning == api.PlanIndependent
data := &templates.DeploymentDriftData{
Deployments: entries,
Clean: rollup.Clean,
Computed: true,
Independent: rollup.Planning == api.PlanIndependent,
Independent: independent,
}
// Grouping describes the targets that were planned, so it is only meaningful
// once every one of them was. A blocked rollup lists each member on its own
// instead: the operator's next step is the member that could not be planned,
// not the plans of an apply that cannot run.
//
// Mirrored members are left ungrouped because a clean mirrored rollup has
// already proved they are one group. Saying so a second time, in the
// vocabulary of a fleet that may diverge, would suggest the agreement was an
// outcome rather than the requirement that let the check pass.
if rollup.Clean && independent {
data.Plans = deploymentPlanGroups(rollup)
}
return data
}

// deploymentPlanGroups groups the rollout's members by the plan each would run,
// one entry per distinct plan.
//
// Members are grouped on the plan fingerprint, which two members share exactly
// when their plans are the same work — so a group can be described once and
// attributed to all of its members without comparing every pair. Groups come out
// in the rollout order of their first member, with the primary's group first:
// the reviewed plan is the one an operator has already seen, and a fixed order
// keeps a comment that is re-rendered on a later push from reshuffling under a
// reader who is looking for what changed.
func deploymentPlanGroups(rollup api.PlanRollup) []templates.DeploymentPlanGroup {
names := rollupMemberNames(rollup)
var groups []templates.DeploymentPlanGroup
byPlan := make(map[string]int, len(rollup.Entries))
for i, e := range rollup.Entries {
at, ok := byPlan[e.PlanFingerprint]
if !ok {
groups = append(groups, templates.DeploymentPlanGroup{
Primary: i == 0,
Changes: memberPlanChanges(e.ChangeSet),
})
at = len(groups) - 1
byPlan[e.PlanFingerprint] = at
}
groups[at].Members = append(groups[at].Members, names[i])
}
// The primary is the first member, so its group is already first. Ordering is
// stated as a property of the result rather than left to that coincidence,
// which a later change to rollout order would silently break.
slices.SortStableFunc(groups, func(a, b templates.DeploymentPlanGroup) int {
switch {
case a.Primary == b.Primary:
return 0
case a.Primary:
return -1
default:
return 1
}
})
return groups
}

// memberPlanChanges renders one member's plan in the shape the comment renders
// the reviewed plan in, so a group's changes are described by the same code that
// describes the plan a reviewer has already read.
//
// A sharded namespace carries its changes twice: once per shard, and once in a
// collapsed namespace view that dedupes tables across shards. Both are kept, the
// same way the reviewed plan keeps them, so the rendering can show what applies
// where rather than a namespace-level view that hides a shard.
//
// A namespace that appears only on shard rows still gets an entry. Dropping it
// would silently remove work from a plan the comment claims to describe in full.
func memberPlanChanges(cs tern.ChangeSet) []templates.KeyspaceChangeData {
shardsByNamespace := make(map[string][]templates.KeyspaceShardChange, len(cs.Shards))
var shardedNamespaces []string
for _, sp := range cs.Shards {
if sp == nil {
continue
}
shard := templates.KeyspaceShardChange{Shard: sp.GetShard()}
for _, tc := range sp.GetChanges() {
if tc.GetDdl() == "" {
continue
}
shard.Statements = append(shard.Statements, tc.GetDdl())
}
// A shard with nothing to run already matches the desired schema while
// its siblings change. It is carried as a satisfied group rather than
// dropped, so a partially-applied namespace shows its divergent state.
shard.Satisfied = len(shard.Statements) == 0
if _, seen := shardsByNamespace[sp.GetNamespace()]; !seen {
shardedNamespaces = append(shardedNamespaces, sp.GetNamespace())
}
shardsByNamespace[sp.GetNamespace()] = append(shardsByNamespace[sp.GetNamespace()], shard)
}

changes := make([]templates.KeyspaceChangeData, 0, len(cs.Changes))
named := make(map[string]bool, len(cs.Changes))
for _, sc := range cs.Changes {
if sc == nil {
continue
}
named[sc.GetNamespace()] = true
ks := templates.KeyspaceChangeData{
Keyspace: sc.GetNamespace(),
Shards: shardsByNamespace[sc.GetNamespace()],
}
for _, tc := range sc.GetTableChanges() {
if tc.GetDdl() == "" {
continue
}
ks.Statements = append(ks.Statements, tc.GetDdl())
}
if sc.GetMetadata()[apitypes.VSchemaChangedMetadataKey] == "true" {
ks.VSchemaChanged = true
ks.VSchemaDiff = sc.GetMetadata()[apitypes.VSchemaDiffMetadataKey]
}
changes = append(changes, ks)
}
for _, ns := range shardedNamespaces {
if named[ns] {
continue
}
changes = append(changes, templates.KeyspaceChangeData{Keyspace: ns, Shards: shardsByNamespace[ns]})
}
return changes
}

// describeDriftDiff renders a short, count-based summary of how a diverged
Expand Down
Loading
Loading