Files
spinifex/tests/e2e/kvquorum/replication_test.go
T
joshsiv-mulga 3f91a3dbcd Merge remote-tracking branch 'origin/dev' into refactor/spinifex-package-boundaries
# Conflicts:
#	spinifex/daemon/daemon.go
#	spinifex/foundation/aws/arn/arn.go
#	spinifex/foundation/aws/arn/arn_test.go
#	spinifex/foundation/state/kvlease/bucket.go
#	spinifex/foundation/state/kvlease/bucket_test.go
#	spinifex/foundation/state/kvstore/wait.go
#	spinifex/foundation/state/kvstore/wait_test.go
#	spinifex/foundation/state/kvutil/bucket_creation_invariants_test.go
#	spinifex/foundation/state/kvutil/export_test.go
#	spinifex/foundation/state/kvutil/kvutil.go
#	spinifex/foundation/state/kvutil/kvutil_test.go
#	spinifex/foundation/state/kvutil/replicas.go
#	spinifex/foundation/state/kvutil/replicas_health_test.go
#	spinifex/foundation/state/kvutil/replicas_test.go
#	spinifex/foundation/state/kvutil/state_test.go
#	spinifex/gateway/auth.go
#	spinifex/gateway/bedrock/logging.go
#	spinifex/gateway/ec2_authz_test.go
#	spinifex/gateway/ecrapi/authz.go
#	spinifex/gateway/eks.go
#	spinifex/gateway/gateway_test.go
#	spinifex/handlers/ec2/eigw/service_impl.go
#	spinifex/handlers/ec2/igw/service_impl.go
#	spinifex/handlers/ec2/instance/service_impl.go
#	spinifex/handlers/ec2/launchtemplate/service_impl_test.go
#	spinifex/handlers/ec2/placementgroup/service_impl.go
#	spinifex/handlers/ec2/placementgroup/service_impl_test.go
#	spinifex/handlers/ec2/routetable/service_impl.go
#	spinifex/handlers/ec2/spotinstance/service_impl.go
#	spinifex/handlers/ec2/volume/service_impl.go
#	spinifex/handlers/ec2/vpc/eni.go
#	spinifex/handlers/ec2/vpc/security_group.go
#	spinifex/handlers/ec2/vpc/service_impl.go
#	spinifex/handlers/ecs/tags.go
#	spinifex/handlers/eks/k3s_ha_control_plane.go
#	spinifex/handlers/iam/groups.go
#	spinifex/handlers/iam/retry.go
#	spinifex/handlers/rds/subnetgroup_test.go
#	spinifex/handlers/sts/assume_role.go
#	spinifex/ingress/aws/query/parser_fuzz_test.go
#	spinifex/runtime/compute/cache/cache_test.go
#	spinifex/runtime/roles/nats/servername.go
#	spinifex/services/awsgw/awsgw.go
#	spinifex/services/viperblockd/volume_dirty.go
#	spinifex/utils/account.go
2026-10-01 13:31:45 +10:00

149 lines
5.5 KiB
Go

//go:build e2e
package kvquorum
import (
"context"
"encoding/json"
"fmt"
"sort"
"strings"
"testing"
"time"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
"github.com/mulgadc/spinifex/tests/e2e/harness"
"github.com/stretchr/testify/require"
)
// replicasCmd is the operator surface under test. No --config: on a node the
// command finds /etc/spinifex/spinifex.toml itself, which is how anyone
// gating a deployment on it would actually run it.
const replicasCmd = "sudo spx admin kv replicas --json"
// runBucketsAreReplicated sweeps every KV bucket in the cluster rather than a
// named list. A replica count is set where a bucket is created, and there is a
// bucket-creating call in most services, so a named list only ever covers the
// ones somebody remembered.
func runBucketsAreReplicated(t *testing.T, fix *Fixture) {
harness.Phase(t, "KV quorum — every bucket is replicated across the cluster")
nodes := len(fix.Cluster.Nodes)
want := harness.WantKVReplicas(nodes)
harness.Detail(t, "cluster_nodes", nodes, "want_replicas", want)
harness.Step(t, "audit every KV bucket")
reports := harness.KVReplication(t, fix.Env, nodes)
require.NotEmpty(t, reports, "the cluster reports no KV buckets at all, so nothing was checked")
for _, r := range reports {
harness.Detail(t, "bucket", r.Bucket, "replicas", r.Replicas, "held_by", strings.Join(r.Peers, ","))
}
harness.RequireKVQuorum(t, fix.Env, nodes)
// The leader-lease buckets are named as well as swept. They were the worst
// case of the defect this suite guards — created with no replica count at
// all — and a regression that reached only them would otherwise be one more
// line in a long list rather than the headline.
harness.Step(t, "confirm the leader-lease buckets are among those checked")
leases := leaseBuckets(reports)
require.NotEmpty(t, leases,
"no leader-lease bucket was found; either the cluster has never run a reconciler or the audit missed them")
harness.Detail(t, "lease_buckets", strings.Join(leases, ","))
}
// leaseBuckets returns the names of the reconciler leader-lease buckets, which
// are the ones a reconciler must acquire before it can do anything at all.
func leaseBuckets(reports []kvutil.BucketReport) []string {
var out []string
for _, r := range reports {
if strings.HasSuffix(r.Bucket, "-leader") || strings.HasSuffix(r.Bucket, "-reconcile") {
out = append(out, r.Bucket)
}
}
sort.Strings(out)
return out
}
// runReplicasCommand proves the operator surface on every node, not one. The
// command reads cluster-global raft metadata, so every node must give the same
// answer — a node that disagrees is reporting on a cluster it is not fully part
// of, which is worth knowing before anything is gated on its exit status.
func runReplicasCommand(t *testing.T, fix *Fixture) {
harness.Phase(t, "KV quorum — spx admin kv replicas agrees on every node")
want := harness.WantKVReplicas(len(fix.Cluster.Nodes))
counts := make(map[string]int, len(fix.Cluster.Nodes))
for _, node := range fix.Cluster.Nodes {
harness.Step(t, "run the replica audit on %s", node.Name)
reports, err := nodeReplicaReport(t, node)
require.NoErrorf(t, err,
"%s could not audit its own KV buckets, so nothing can be gated on its exit status", node.Name)
under := underReplicated(reports, want)
harness.Detail(t, "node", node.Name, "buckets", len(reports), "under_replicated", len(under))
require.Emptyf(t, under,
"%s reports %d under-replicated bucket(s): %s", node.Name, len(under), strings.Join(under, ", "))
counts[node.Name] = len(reports)
}
harness.Step(t, "confirm every node sees the same set of buckets")
var first string
for _, node := range fix.Cluster.Nodes {
if first == "" {
first = node.Name
continue
}
require.Equalf(t, counts[first], counts[node.Name],
"%s sees %d KV buckets and %s sees %d; the two are not looking at the same cluster",
first, counts[first], node.Name, counts[node.Name])
}
}
// nodeReplicaReport runs the replica audit on one node and decodes it. A
// non-zero exit is returned as an error rather than fatalled, because callers
// polling a recovering cluster need to retry it.
func nodeReplicaReport(t *testing.T, node harness.Node) ([]kvutil.BucketReport, error) {
t.Helper()
ssh := harness.NewPeerSSH()
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
out, err := ssh.Run(ctx, node.Addr, replicasCmd)
if err != nil {
// The command exits 1 when a bucket is under-replicated, and it still
// prints its report, so the output is decoded either way — a report
// naming the bucket is a better failure than "exit status 1".
if len(out) == 0 {
return nil, fmt.Errorf("%s: %w", node.Name, err)
}
}
var reports []kvutil.BucketReport
if jsonErr := json.Unmarshal(trimToJSON(out), &reports); jsonErr != nil {
return nil, fmt.Errorf("%s: decode replica report (%q): %w", node.Name, string(out), jsonErr)
}
return reports, nil
}
// trimToJSON drops anything before the JSON document, since sudo and the login
// shell can both write to the same stream.
func trimToJSON(out []byte) []byte {
if i := strings.IndexAny(string(out), "[{"); i > 0 {
return out[i:]
}
return out
}
// underReplicated names every bucket below want replicas.
func underReplicated(reports []kvutil.BucketReport, want int) []string {
var out []string
for _, r := range reports {
if r.Replicas < want {
out = append(out, fmt.Sprintf("%s(%d/%d)", r.Bucket, r.Replicas, want))
}
}
sort.Strings(out)
return out
}