mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-23 12:47:48 +00:00
3f91a3dbcd
# 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
149 lines
5.5 KiB
Go
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
|
|
}
|