mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-15 17:11:30 +00:00
15dede64ad
* feat: add predastore compaction E2E
Inject a short compaction interval into test-cluster predastore config via a
new --predastore-compaction-interval flag on admin init/join, gated so
production output stays byte-identical when unset. Add a harness helper that
drives write-then-delete churn against predastore S3, brackets it with
du-over-SSH usage, and asserts end-of-churn usage settles below 70% of the
churn peak; wire it into the multinode suite as TestPredastoreCompaction.
* fix: compaction E2E gate writes churn to its own bucket
The reclaim gate signed PutObject against the config-defined system
'predastore' bucket, which belongs to account 000000000000 with only a
static [[auth]] grant. The harness admin identity is a different IAM
account, so predastore returned 403 AccessDenied cross-account.
Create a per-run bucket the admin identity owns (AdministratorAccess)
and churn into it. Objects still erasure-code into distributed/nodes/
node-* so the du-based reclaim measurement is unchanged. The gateway
(:9999) does not proxy S3, so the client stays predastore-direct.
* test: run predastore compaction gate before destructive node tests
The compaction reclaim gate was declared last in the multinode suite, so it
measured shard-store usage after NodeFailure/NodeRecovery had torn down and
restarted node2. NodeRecovery only gates on NATS/gateway/EC2 readiness, not
predastore, so node2's shard store was still settling and the du baseline came
back empty. Move the test before the destructive node tests so it runs against
a pristine, fully-converged cluster primed by the volume tests.
* fix: race-free SSH output capture + empty-tolerant du probe
The compaction reclaim gate crashed with ParseInt("") when node1's du
probe returned empty combined output. Harden the probe and the SSH layer:
- ssh.Run used one bytes.Buffer for both session.Stdout and session.Stderr,
which x/crypto/ssh copies in two concurrent goroutines — a data race on a
non-concurrent-safe type. Switch to session.CombinedOutput, which guards the
combined stream with a mutex-wrapped singleWriter.
- shardStoreUsageBytes now terminates the remote command with a sentinel echo.
A missing sentinel is reported as output lost in SSH retrieval; a present
sentinel with no number is treated as a 0 measurement (anomalous, logged)
instead of crashing the gate.
The sentinel lets the next run distinguish a transport-layer drop from a
genuinely empty remote measurement.
* test(e2e): replace predastore du-reclaim gate with object lifecycle contract
The multinode compaction gate measured shard-store disk usage over SSH (du)
and asserted reclaim after churn. It could never pass: segments only seal at
the 4 GiB roll threshold, so test-scale churn produces no compactable segment,
and the du probe read 0 bytes before and after. Reclaim correctness is already
covered deterministically by predastore's store-level unit tests.
Delete AssertPredastoreCompaction and its du/churn/poll/sentinel machinery,
the harness unit tests that exercised them, and the multinode TestPredastore
Compaction entry. Add a single-node TestPredastoreObjectLifecycle that checks
the user-visible contract: an object is readable after PutObject, gone after
DeleteObject (GET returns NoSuchKey and ListObjectsV2 is empty), and the store
keeps serving fresh writes. newPredastoreS3 is retained, refactored to take a
host so it works outside the multinode cluster type.
* test(e2e): drop orphaned multinode TestPredastoreCompaction
Complete the du-reclaim -> object-lifecycle refactor: remove the
multinode TestPredastoreCompaction caller (and its now-unused context
and harness imports) that still referenced the deleted
harness.AssertPredastoreCompaction, which broke e2e-build. Wire the
single-node TestPredastoreObjectLifecycle entry that replaces it.
123 lines
3.4 KiB
Go
123 lines
3.4 KiB
Go
//go:build e2e
|
|
|
|
package harness
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/ssh"
|
|
)
|
|
|
|
// SSH is the transport scenarios use to run commands on a remote Node.
|
|
//
|
|
// It is an interface so dry-run mode and unit tests can stub it without
|
|
// opening real network connections. Run returns the command's combined
|
|
// stdout+stderr and a non-nil error if the remote command exited non-zero or
|
|
// the session failed.
|
|
type SSH interface {
|
|
Run(ctx context.Context, node Node, cmd string) ([]byte, error)
|
|
Close() error
|
|
}
|
|
|
|
// sshClient is the production SSH transport, backed by
|
|
// golang.org/x/crypto/ssh. One client is opened per (user, key, node) tuple
|
|
// on first use and cached for the harness lifetime.
|
|
type sshClient struct {
|
|
user string
|
|
signer ssh.Signer
|
|
conns map[string]*ssh.Client
|
|
connectDur time.Duration
|
|
runDur time.Duration
|
|
}
|
|
|
|
var _ SSH = (*sshClient)(nil)
|
|
|
|
// NewSSH returns an SSH transport configured from a Cluster. Host keys are not
|
|
// verified: the harness targets short-lived infra where TOFU adds noise and no
|
|
// security benefit.
|
|
func NewSSH(cluster *Cluster) (SSH, error) {
|
|
keyBytes, err := os.ReadFile(cluster.SSHKeyPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("e2e harness: read ssh key %s: %w", cluster.SSHKeyPath, err)
|
|
}
|
|
signer, err := ssh.ParsePrivateKey(keyBytes)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("e2e harness: parse ssh key %s: %w", cluster.SSHKeyPath, err)
|
|
}
|
|
return &sshClient{
|
|
user: cluster.SSHUser,
|
|
signer: signer,
|
|
conns: make(map[string]*ssh.Client),
|
|
connectDur: 10 * time.Second,
|
|
runDur: 2 * time.Minute,
|
|
}, nil
|
|
}
|
|
|
|
func (s *sshClient) dial(node Node) (*ssh.Client, error) {
|
|
if c, ok := s.conns[node.Addr]; ok {
|
|
return c, nil
|
|
}
|
|
cfg := &ssh.ClientConfig{
|
|
User: s.user,
|
|
Auth: []ssh.AuthMethod{ssh.PublicKeys(s.signer)},
|
|
HostKeyCallback: ssh.InsecureIgnoreHostKey(), //nolint:gosec // test harness, not production
|
|
Timeout: s.connectDur,
|
|
}
|
|
c, err := ssh.Dial("tcp", net.JoinHostPort(node.Addr, "22"), cfg)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("e2e harness: ssh dial %s: %w", node.Addr, err)
|
|
}
|
|
s.conns[node.Addr] = c
|
|
return c, nil
|
|
}
|
|
|
|
func (s *sshClient) Run(ctx context.Context, node Node, cmd string) ([]byte, error) {
|
|
client, err := s.dial(node)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
session, err := client.NewSession()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("e2e harness: ssh new session %s: %w", node.Name, err)
|
|
}
|
|
defer func() { _ = session.Close() }()
|
|
|
|
// Signal(KILL) is the portable way to interrupt a running command.
|
|
done := make(chan struct{})
|
|
defer close(done)
|
|
go func() {
|
|
select {
|
|
case <-ctx.Done():
|
|
_ = session.Signal(ssh.SIGKILL)
|
|
_ = session.Close()
|
|
case <-done:
|
|
}
|
|
}()
|
|
|
|
// CombinedOutput captures stdout+stderr through x/crypto/ssh's mutex-guarded
|
|
// singleWriter. Assigning one bytes.Buffer to both session.Stdout and
|
|
// session.Stderr races: the library copies the two streams in separate
|
|
// goroutines and bytes.Buffer is not concurrent-safe.
|
|
out, err := session.CombinedOutput(cmd)
|
|
if err != nil {
|
|
return out, fmt.Errorf("e2e harness: ssh run on %s (%q): %w\noutput: %s",
|
|
node.Name, cmd, err, out)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *sshClient) Close() error {
|
|
var firstErr error
|
|
for addr, c := range s.conns {
|
|
if err := c.Close(); err != nil && firstErr == nil {
|
|
firstErr = fmt.Errorf("e2e harness: ssh close %s: %w", addr, err)
|
|
}
|
|
delete(s.conns, addr)
|
|
}
|
|
return firstErr
|
|
}
|