Compare commits

...

9 Commits

Author SHA1 Message Date
Julian Sommer 8404ed0123 refactor(ecr): delete the in-memory MetaStore and run its tests against JetStream KV (#1474) 2026-11-03 12:32:44 +11:00
Julian Sommer 428573c829 refactor(e2e): delete harness helpers that no test calls (#1473) 2026-11-03 12:32:36 +11:00
Julian Sommer 37c86bfb8a refactor(ec2): delete the unused NATSInstanceService, which only its own test called (#1469) 2026-11-03 11:53:58 +11:00
Josh Siviour cd2618bebf feat(admin): non-finalizing DeleteAccount exercises the quiesced EC2 platform kinds (#1458)
* feat(admin): non-finalizing DeleteAccount exercises the quiesced EC2 platform kinds

A non-finalizing DeleteAccount quiesces the account, records the S9 evaluation, cleans only the key-pair, placement-group and launch-template kinds it recorded as quiesced, then re-reads the owner inventory and accounts for what remains. The job ends EXERCISED with the account still TERMINATING; a later ordinary delete takes the same operation over.

Only an exercise records a quiesced scope, so ordinary deletes still record none. The cleanup capability gains a kinds selector, awsgw binds the in-process owners, and the instance reference reader fails closed.

* refactor(admin): name the takeover of a finished exercise exercise_finished
2026-11-03 10:50:28 +10:00
Josh Siviour 4507b0cb60 feat(lambda): add GetFunctionCodeSigningConfig for unsigned functions (#1467)
Code signing is unsupported, so the read only proves the function exists and
returns AWS's body for a function with no code signing config: both members
present and null. A missing function is ResourceNotFoundException. It is
authorized as lambda:GetFunctionCodeSigningConfig on the function ARN and
needs neither the code store nor environment decryption.
2026-11-03 10:49:39 +10:00
Josh Siviour 1ca56642be feat(lambda): add GetFunctionConfiguration for $LATEST (#1465)
Serves GET /2015-03-31/functions/{FunctionName}/configuration with the
same name, qualifier, ownership and environment handling as GetFunction,
authorized as lambda:GetFunctionConfiguration on the function ARN. It is
a metadata read, so it neither opens nor presigns the code store.
2026-11-03 10:49:39 +10:00
Josh Siviour 7692627708 feat(lambda): run a ZIP package in the test-link guest (#1459)
Dev-only code delivery behind the lambdatestlink tag: the host attaches the
ZIP as a read-only disk and passes its exact size and SHA-256 through
fw_cfg. The agent reads those values before sealing fw_cfg, and installs
the package only after authenticating and receiving the host's first
frame: it checks size and hash, validates every entry before writing any,
extracts through os.Root into a nosuid,nodev tmpfs at /var/task, and makes
it read-only. Any failure exits without starting the bootstrap.

Entry names that could escape the task root, entries other than regular
files and directories, and sizes over the dev bounds are refused; declared
sizes cannot wrap the running total, and actual bytes are counted whatever
the header claims. The bounds are dev limits, not AWS limits.

StartBootstrap now starts the bootstrap in its own directory, so packaged
functions can open their files by relative path.
2026-11-03 10:49:39 +10:00
Bryn Mailer 89c7fac5e3 feat(daemon): lock a cluster to one EBS provider (#1428)
* feat(config): refuse a spinifex.toml whose nodes resolve to different EBS providers

* feat(daemon): lock a cluster to one EBS provider in spinifex-cluster-config

The first daemon records its provider at ebs-provider with Create, which
also backfills an existing cluster. A daemon whose provider differs from
the recorded one refuses to start, naming both providers and the recovery.

* fix(config): compare only explicit EBS providers across nodes

A generated spinifex.toml writes no ebs stanza for peers, so treating an
absent peer provider as viperblockd refused every non-default cluster.

* fix(daemon): exit on an EBS provider lock refusal and refuse a malformed lock

A mismatch now exits 1 so the unit shows failed. A recorded value that
does not decode or names no supported provider is a distinct permanent
refusal quoting the raw value. Permanence is a sentinel matched with
errors.Is, and the cluster-config bucket is declared beside the others.

* style(daemon): align JetStreamManager fields
2026-11-03 09:41:38 +10:00
Bryn Mailer 54ecdde17e feat(ebs): contract schema version 3 for the provider data path (#1436)
* feat(ebs): contract schema version 3 for the provider data path

Servers accept schema versions 1 to 3 and answer every request in the
version it was sent at, while every client still sends version 1.

Version 3 adds no_seal to UnpublishVolumeRequest, refused below 3, and
torn_down to UnpublishVolumeResponse. UnpublishVolume now returns an
UnpublishedVolume so the torn-down result reaches callers.

A publish refused because another node holds the volume returns
volume_held_elsewhere at version 3 and volume_in_use below it, which is
what v1.23.0 returns. A read-only mismatch on republish stays
volume_in_use at every version.

GetCapabilities lists the contract versions served and is also answered
on a per-node subject. The unsealed-writes subject is part of
EBSProvider, and write-through providers answer it with nothing held. A
fence-notice event subject and message are defined. Unpublish gets the
data-movement timeout.

The memory provider, natsserve and qemunbdd implement all of this. A
qemunbdd republish whose qemu-nbd has died re-exports the volume instead
of returning the dead socket.

* feat(viperblock): serve contract schema version 3 from viperblockd

Every provider subject replies in its request's version, and
GetCapabilities lists the contract versions and is also served on the
node's own capabilities subject.

PublishVolume no longer returns an existing entry early. It goes through
mountVolume, which hands back a live export and drops and remounts one
whose nbdkit has died. Mount failures map to not_found, held_elsewhere
(version 3 only, volume_in_use below it), volume_in_use for a mode
mismatch, unavailable for a backing or lease store that is not ready,
and internal otherwise.

UnpublishVolume with no_seal abandons the export without sealing it,
and reports whether an export was torn down.

* test(ebs): conformance cases for contract schema version 3

Every provider run through the suite now has to:
- list ascending contract versions including the legacy one, answer each
  in that version, and refuse an unlisted one without changing state;
- refuse a publish elsewhere with volume_held_elsewhere only at version 3
  and volume_in_use at versions 1 and 2;
- refuse a republish in the other access mode as volume_in_use, never
  held elsewhere;
- report torn_down truthfully, refuse no_seal below version 3, and either
  honour no_seal at version 3 or refuse it as unsupported;
- answer the unsealed-writes query with a subset of what was asked.

SuiteConfig.KillExport lets a provider whose export is a process prove
that a republish after the export died returns a serving export.
qemunbdd and viperblockd supply it.

* fix(ebs): stop a running export before remounting over its lost socket

An export whose socket is gone was treated as dead even while its
process ran, and a republish started a second export beside it. A
client connected before the socket went keeps writing through the old
process, so that made two writers on one volume.

qemunbdd now SIGTERMs that qemu-nbd and waits for it to exit before
exporting again. viperblockd stops the nbdkit through unmountVolume, so
the old copy is sealed as a normal unmount would seal it. If the process
will not stop, the publish is refused as unavailable and the entry is
kept for the next attempt.

* test(viperblock): give the no-seal tests a seal they could reach

The no-seal unpublish and abandon tests left BaseDir empty, so
volumeNeedsSeal was false and no path could seal whatever the code did.
Both now create local state under BaseDir first, and check that a seal
is reachable before asserting none ran.

* refactor(ebs): leave version rules to the provider and trim the v3 surface

The provider decides what a request's version allows, so natsserve no
longer gates TornDown or held-elsewhere on it: classifyError takes no
version, and the reply carries what the provider returned. viperblockd
applies the TornDown rule in unpublishVolume, its provider half.

The unpublish handlers check the version once, through
CheckUnpublishVersion, and classify its refusal. Each handler binds its
reply version once.

UnpublishedVolume is reduced to TornDown. movesData classifies a subject
by its parsed verb, which replaces isNodeSubject and the owner-verb
list. nodeSubjectWildcard is unexported. The conformance suite reads
capabilities once.

* test(ebs): check v3 success replies, timeouts and refusals end to end

- Unpublish is sized as data movement, and publish, unsealed writes and
  per-node capabilities keep the caller's timeout.
- A mount whose lease store or backing store is not ready reaches the
  wire as unavailable.
- A successful publish replies in the request's version at every served
  version, in natsserve and in viperblockd. Every natsserve case now gets
  its own seeded provider, so none relies on an earlier case's state.
  The conformance republish runs at every listed version.
- viperblockd's per-node capabilities list the versions it serves.
- A no_seal unpublish above the served version is refused, and the
  export is neither torn down nor sealed.
- The dead-entry republish test asserts unconditionally. The snapshot
  lease test runs against real volume state and asserts nothing was
  written. The conformance torn-down case skips a provider that does
  not list version 3.

* fix(ebs): wrap the stop failure behind an unavailable publish

The refusal now wraps the stop error as well as ErrUnavailable, so a
caller can still inspect why the old export did not stop. Also fixes
import order and spacing that the linter flagged in the new tests.
2026-11-03 09:41:19 +10:00
97 changed files with 4350 additions and 1641 deletions
+276
View File
@@ -0,0 +1,276 @@
//go:build lambdatestlink
package main
import (
"archive/zip"
"bytes"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"io/fs"
"os"
"os/exec"
"path"
"path/filepath"
"strconv"
"strings"
"time"
"golang.org/x/sys/unix"
)
// Dev-only code delivery for the test path: the host attaches the ZIP as a
// read-only disk and passes its exact size and SHA-256 through fw_cfg. This
// is not the production delivery, and these bounds are not AWS limits.
const (
entryPackageSize = "test-package-size"
entryPackageSHA256 = "test-package-sha256"
devMaxZipBytes = 16 << 20
devMaxUnzipBytes = 64 << 20
devMaxEntries = 1024
packageDevice = "/dev/vda"
taskTmpfsSizeOpts = "mode=0755,size=72m"
)
type packageSpec struct {
size int64
sha256 [sha256.Size]byte
}
type unpackLimits struct {
entries int
bytes int64
}
var devLimits = unpackLimits{entries: devMaxEntries, bytes: devMaxUnzipBytes}
// readPackageSpec reads the expected size and SHA-256 from fw_cfg; it must
// run before fw_cfg is sealed.
func readPackageSpec(fwCfgDir string) (packageSpec, error) {
var spec packageSpec
read := func(name string) (string, error) {
b, err := os.ReadFile(filepath.Join(fwCfgDir, name, "raw"))
if err != nil {
return "", fmt.Errorf("read %s: %w", name, err)
}
return strings.TrimSpace(string(b)), nil
}
s, err := read(entryPackageSize)
if err != nil {
return spec, err
}
spec.size, err = strconv.ParseInt(s, 10, 64)
if err != nil || spec.size <= 0 || spec.size > devMaxZipBytes {
return spec, fmt.Errorf("package size %q outside 1..%d bytes", s, devMaxZipBytes)
}
h, err := read(entryPackageSHA256)
if err != nil {
return spec, err
}
b, err := hex.DecodeString(h)
if err != nil || len(b) != sha256.Size {
return spec, errors.New("package sha256 is not 64 hex digits")
}
copy(spec.sha256[:], b)
return spec, nil
}
// taskPackage puts the package under dir: a fresh nosuid,nodev tmpfs, made
// read-only once the files are in place.
type taskPackage struct {
device string
dir string
loadDriver func() error
mountDir func(dir string) error
sealDir func(dir string) error
}
func devTaskPackage() taskPackage {
return taskPackage{device: packageDevice, dir: taskRoot, loadDriver: loadBlockDriver, mountDir: mountTaskDir, sealDir: sealTaskDir}
}
func (p taskPackage) install(spec packageSpec) error {
if err := p.loadDriver(); err != nil {
return fmt.Errorf("load block driver: %w", err)
}
dev, err := os.Open(p.device)
if err != nil {
return fmt.Errorf("open package disk: %w", err)
}
defer dev.Close()
if err := p.mountDir(p.dir); err != nil {
return fmt.Errorf("mount %s: %w", p.dir, err)
}
if err := unpack(dev, spec, p.dir, devLimits); err != nil {
return err
}
if err := p.sealDir(p.dir); err != nil {
return fmt.Errorf("make %s read-only: %w", p.dir, err)
}
return nil
}
// unpack reads exactly spec.size bytes once, checks their SHA-256, then
// validates every entry before writing any of them under dir.
func unpack(r io.ReaderAt, spec packageSpec, dir string, lim unpackLimits) error {
buf := make([]byte, spec.size)
if _, err := io.ReadFull(io.NewSectionReader(r, 0, spec.size), buf); err != nil {
return fmt.Errorf("read package: %w", err)
}
if sha256.Sum256(buf) != spec.sha256 {
return errors.New("package sha256 mismatch")
}
zr, err := zip.NewReader(bytes.NewReader(buf), spec.size)
if err != nil {
return fmt.Errorf("open package: %w", err)
}
if len(zr.File) > lim.entries {
return fmt.Errorf("package has %d entries, limit %d", len(zr.File), lim.entries)
}
names := make([]string, len(zr.File))
var declared int64
for i, f := range zr.File {
name, err := entryName(f.Name)
if err != nil {
return err
}
names[i] = name
switch mode := f.Mode(); {
case mode.IsDir():
case mode.IsRegular():
// Compared against what remains, so a huge size cannot wrap the sum.
if f.UncompressedSize64 > uint64(lim.bytes-declared) {
return fmt.Errorf("%q: unpacked size over the %d-byte limit", f.Name, lim.bytes)
}
declared += int64(f.UncompressedSize64)
default:
return fmt.Errorf("%q: %s entries are refused", f.Name, mode.Type())
}
}
root, err := os.OpenRoot(dir)
if err != nil {
return err
}
defer root.Close()
var written int64
for i, f := range zr.File {
n, err := extract(root, names[i], f, lim.bytes-written)
written += n
if err != nil {
return fmt.Errorf("%q: %w", f.Name, err)
}
}
return nil
}
// extract writes one entry, refusing it if its actual bytes exceed budget
// whatever its header claims.
func extract(root *os.Root, name string, f *zip.File, budget int64) (int64, error) {
if f.Mode().IsDir() {
return 0, mkdirs(root, name)
}
if d := path.Dir(name); d != "." {
if err := mkdirs(root, d); err != nil {
return 0, err
}
}
perm := os.FileMode(0o644)
if f.Mode()&0o111 != 0 {
perm = 0o755
}
in, err := f.Open()
if err != nil {
return 0, err
}
defer in.Close()
out, err := root.OpenFile(name, os.O_CREATE|os.O_EXCL|os.O_WRONLY, perm)
if err != nil {
return 0, err
}
// The umask applies at create; fchmod does not, so the mode is exact.
if err := out.Chmod(perm); err != nil {
out.Close()
return 0, err
}
n, err := io.Copy(out, io.LimitReader(in, budget+1))
if cerr := out.Close(); err == nil {
err = cerr
}
if err == nil && n > budget {
err = errors.New("unpacked bytes over the limit")
}
return n, err
}
// mkdirs creates each component of dir as mode 0755 whatever the umask. An
// existing component must be a real directory, never a symlink.
func mkdirs(root *os.Root, dir string) error {
cur := ""
for _, part := range strings.Split(dir, "/") {
cur = path.Join(cur, part)
if err := root.Mkdir(cur, 0o755); err != nil {
if !errors.Is(err, fs.ErrExist) {
return err
}
fi, err := root.Lstat(cur)
if err != nil {
return err
}
if !fi.IsDir() {
return fmt.Errorf("%q exists and is not a directory", cur)
}
}
if err := root.Chmod(cur, 0o755); err != nil {
return err
}
}
return nil
}
// entryName returns a clean relative path, refusing anything that could name
// a location outside the task root.
func entryName(name string) (string, error) {
if name == "" || strings.ContainsAny(name, "\\\x00") || path.IsAbs(name) {
return "", fmt.Errorf("%q: entry name refused", name)
}
c := path.Clean(name)
if c == "." || c == ".." || strings.HasPrefix(c, "../") {
return "", fmt.Errorf("%q: entry name refused", name)
}
return c, nil
}
// loadBlockDriver loads virtio_blk, which the image ships but init does not
// load, then waits briefly for the package disk to appear.
func loadBlockDriver() error {
if err := exec.Command("/sbin/modprobe", "virtio_blk").Run(); err != nil {
var uts unix.Utsname
if uerr := unix.Uname(&uts); uerr != nil {
return uerr
}
ko := "/lib/modules/" + unix.ByteSliceToString(uts.Release[:]) + "/kernel/drivers/block/virtio_blk.ko"
if err := exec.Command("/sbin/insmod", ko).Run(); err != nil {
return fmt.Errorf("modprobe and insmod virtio_blk failed: %w", err)
}
}
for range 50 {
if _, err := os.Stat(packageDevice); err == nil {
return nil
}
time.Sleep(20 * time.Millisecond)
}
return fmt.Errorf("%s did not appear", packageDevice)
}
func mountTaskDir(dir string) error {
return unix.Mount("tmpfs", dir, "tmpfs", unix.MS_NOSUID|unix.MS_NODEV, taskTmpfsSizeOpts)
}
func sealTaskDir(dir string) error {
return unix.Mount("", dir, "", unix.MS_REMOUNT|unix.MS_RDONLY|unix.MS_NOSUID|unix.MS_NODEV, "")
}
+209
View File
@@ -0,0 +1,209 @@
//go:build lambdatestlink
package main
import (
"archive/zip"
"bytes"
"crypto/sha256"
"hash/crc32"
"io/fs"
"math"
"os"
"path/filepath"
"sort"
"syscall"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// testZip builds a ZIP; an entry named bootstrap is marked executable.
func testZip(t *testing.T, files map[string]string) []byte {
t.Helper()
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
names := make([]string, 0, len(files))
for n := range files {
names = append(names, n)
}
sort.Strings(names)
for _, n := range names {
h := &zip.FileHeader{Name: n, Method: zip.Deflate}
h.SetMode(0o644)
if n == "bootstrap" {
h.SetMode(0o755)
}
w, err := zw.CreateHeader(h)
require.NoError(t, err)
_, err = w.Write([]byte(files[n]))
require.NoError(t, err)
}
require.NoError(t, zw.Close())
return buf.Bytes()
}
// rawZip writes entries with headers set by the caller, so a test can claim
// sizes or modes the data does not have.
func rawZip(t *testing.T, entries ...*zip.FileHeader) []byte {
t.Helper()
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
for _, h := range entries {
w, err := zw.CreateRaw(h)
require.NoError(t, err)
_, err = w.Write([]byte("x"))
require.NoError(t, err)
}
require.NoError(t, zw.Close())
return buf.Bytes()
}
func specFor(b []byte) packageSpec {
return packageSpec{size: int64(len(b)), sha256: sha256.Sum256(b)}
}
// Not parallel: the umask is process-wide, so the test sets a restrictive one
// and restores it.
func TestUnpackInstallsFilesWithFixedModes(t *testing.T) {
old := syscall.Umask(0o077)
t.Cleanup(func() { syscall.Umask(old) })
b := testZip(t, map[string]string{"bootstrap": "#!/bin/sh\n", "lib/data.txt": "data"})
dir := t.TempDir()
require.NoError(t, unpack(bytes.NewReader(b), specFor(b), dir, devLimits))
pfi, err := os.Stat(filepath.Join(dir, "lib"))
require.NoError(t, err)
assert.Equal(t, fs.FileMode(0o755), pfi.Mode().Perm(), "implicit parent dir")
fi, err := os.Stat(filepath.Join(dir, "bootstrap"))
require.NoError(t, err)
assert.Equal(t, fs.FileMode(0o755), fi.Mode().Perm(), "executable")
fi, err = os.Stat(filepath.Join(dir, "lib", "data.txt"))
require.NoError(t, err)
assert.Equal(t, fs.FileMode(0o644), fi.Mode().Perm())
got, err := os.ReadFile(filepath.Join(dir, "lib", "data.txt"))
require.NoError(t, err)
assert.Equal(t, "data", string(got))
}
func TestUnpackRefusesBeforeWritingAnything(t *testing.T) {
symlink := &zip.FileHeader{Name: "link", Method: zip.Store, CRC32: crc32.ChecksumIEEE([]byte("x")), CompressedSize64: 1, UncompressedSize64: 1}
symlink.SetMode(fs.ModeSymlink | 0o777)
fifo := &zip.FileHeader{Name: "pipe", Method: zip.Store, CRC32: crc32.ChecksumIEEE([]byte("x")), CompressedSize64: 1, UncompressedSize64: 1}
fifo.SetMode(fs.ModeNamedPipe | 0o644)
// The second size would wrap a plain running sum back under the limit.
big := func(name string, n uint64) *zip.FileHeader {
return &zip.FileHeader{Name: name, Method: zip.Store, CRC32: crc32.ChecksumIEEE([]byte("x")), CompressedSize64: 1, UncompressedSize64: n}
}
cases := map[string]struct {
zip []byte
want string
}{
"parent traversal": {testZip(t, map[string]string{"a": "ok", "../escape": "x"}), "entry name refused"},
"nested traversal": {testZip(t, map[string]string{"a": "ok", "lib/../../escape": "x"}), "entry name refused"},
"absolute path": {testZip(t, map[string]string{"a": "ok", "/etc/escape": "x"}), "entry name refused"},
"backslash": {testZip(t, map[string]string{"a": "ok", `..\escape`: "x"}), "entry name refused"},
"symlink": {rawZip(t, big("a", 1), symlink), "entries are refused"},
"named pipe": {rawZip(t, big("a", 1), fifo), "entries are refused"},
"declared size": {rawZip(t, big("a", devMaxUnzipBytes+1)), "over the"},
"wrapping sizes": {rawZip(t, big("a", devMaxUnzipBytes), big("b", math.MaxUint64)), "over the"},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
dir := t.TempDir()
err := unpack(bytes.NewReader(tc.zip), specFor(tc.zip), dir, devLimits)
assert.ErrorContains(t, err, tc.want)
left, rerr := os.ReadDir(dir)
require.NoError(t, rerr)
assert.Empty(t, left, "files written before the refusal")
})
}
}
func TestUnpackRefusesTooManyEntries(t *testing.T) {
b := testZip(t, map[string]string{"a": "1", "b": "2", "c": "3"})
err := unpack(bytes.NewReader(b), specFor(b), t.TempDir(), unpackLimits{entries: 2, bytes: devMaxUnzipBytes})
assert.ErrorContains(t, err, "limit 2")
}
// The header claims a size within the limit, but the data is longer: the
// actual bytes are refused, whatever the header says.
func TestUnpackRefusesBytesBeyondTheLimit(t *testing.T) {
data := bytes.Repeat([]byte("a"), 64)
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
h := &zip.FileHeader{Name: "a", Method: zip.Store, CRC32: crc32.ChecksumIEEE(data), CompressedSize64: uint64(len(data)), UncompressedSize64: 8}
w, err := zw.CreateRaw(h)
require.NoError(t, err)
_, err = w.Write(data)
require.NoError(t, err)
require.NoError(t, zw.Close())
b := buf.Bytes()
dir := t.TempDir()
err = unpack(bytes.NewReader(b), specFor(b), dir, unpackLimits{entries: 4, bytes: 16})
require.Error(t, err)
fi, serr := os.Stat(filepath.Join(dir, "a"))
if serr == nil {
assert.LessOrEqual(t, fi.Size(), int64(17))
}
}
func TestUnpackChecksSizeAndHash(t *testing.T) {
b := testZip(t, map[string]string{"bootstrap": "x"})
wrongHash := specFor(b)
wrongHash.sha256[0] ^= 1
assert.ErrorContains(t, unpack(bytes.NewReader(b), wrongHash, t.TempDir(), devLimits), "sha256 mismatch")
longer := specFor(b)
longer.size++
assert.ErrorContains(t, unpack(bytes.NewReader(b), longer, t.TempDir(), devLimits), "read package")
}
func TestReadPackageSpecBounds(t *testing.T) {
sum := "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
cases := map[string]struct {
size, hash string
want string
}{
"valid": {"10", sum, ""},
"zero": {"0", sum, "outside"},
"too large": {"16777217", sum, "outside"},
"not hex": {"10", "zz", "64 hex digits"},
"short": {"10", sum[:62], "64 hex digits"},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
dir := t.TempDir()
for n, v := range map[string]string{entryPackageSize: tc.size, entryPackageSHA256: tc.hash} {
require.NoError(t, os.MkdirAll(filepath.Join(dir, n), 0o700))
require.NoError(t, os.WriteFile(filepath.Join(dir, n, "raw"), []byte(v), 0o600))
}
spec, err := readPackageSpec(dir)
if tc.want == "" {
require.NoError(t, err)
assert.Equal(t, int64(10), spec.size)
return
}
assert.ErrorContains(t, err, tc.want)
})
}
}
// A pre-existing symlink where a directory belongs is refused, not followed
// or chmodded.
func TestMkdirsRefusesSymlinkComponent(t *testing.T) {
dir := t.TempDir()
outside := t.TempDir()
require.NoError(t, os.Chmod(outside, 0o700))
require.NoError(t, os.Symlink(outside, filepath.Join(dir, "lib")))
root, err := os.OpenRoot(dir)
require.NoError(t, err)
defer root.Close()
assert.ErrorContains(t, mkdirs(root, "lib/sub"), "not a directory")
fi, err := os.Stat(outside)
require.NoError(t, err)
assert.Equal(t, fs.FileMode(0o700), fi.Mode().Perm(), "symlink target was chmodded")
}
+11 -1
View File
@@ -30,6 +30,7 @@ var handler = flag.String("handler", os.Getenv("_HANDLER"), "function handler pa
// connection; the carrier is not the guest protocol.
type testlink struct {
startup
pkg taskPackage
runtimeAddr string
startBootstrap func(ctx context.Context, env []string) (*exec.Cmd, error)
}
@@ -37,6 +38,7 @@ type testlink struct {
func start(ctx context.Context) int {
t := testlink{
startup: defaultStartup(),
pkg: devTaskPackage(),
runtimeAddr: runtimeAddr,
startBootstrap: func(ctx context.Context, env []string) (*exec.Cmd, error) {
return agent.StartBootstrap(ctx, taskRoot+"/bootstrap", env, os.Stdout, os.Stderr)
@@ -50,11 +52,16 @@ func start(ctx context.Context) int {
}
func (t testlink) run(parent context.Context) (int, error) {
// The package values are fw_cfg entries, so read them before connect seals it.
spec, specErr := readPackageSpec(t.fwCfgDir)
conn, code, err := t.connect(parent)
if err != nil {
return code, err
return code, errors.Join(err, specErr)
}
defer conn.Close()
if specErr != nil {
return exitRefused, specErr
}
link := hostlink.NewTestStream(conn)
// The handshake can return before the host refuses the guest, so nothing
@@ -69,6 +76,9 @@ func (t testlink) run(parent context.Context) (int, error) {
if parent.Err() != nil {
return exitLink, context.Cause(parent)
}
if err := t.pkg.install(spec); err != nil {
return exitRefused, fmt.Errorf("install package: %w", err)
}
srv := runtimeapi.New()
ln, err := net.Listen("tcp", t.runtimeAddr)
+73 -6
View File
@@ -5,10 +5,14 @@ package main
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"strconv"
"testing"
"time"
@@ -43,12 +47,31 @@ func TestHelperBootstrap(t *testing.T) {
type bootstrapStarts struct {
n int
started chan struct{}
task string
sealed bool
}
func (b *bootstrapStarts) testlink(g *guest) testlink {
// testlink builds a guest whose fw_cfg also carries a controlled package,
// installed into a temp dir without mounting anything.
func (b *bootstrapStarts) testlink(t *testing.T, entries map[string][]byte) testlink {
t.Helper()
zipBytes := testZip(t, map[string]string{"bootstrap": "#!/bin/sh\n", "lib/data.txt": "data"})
disk := filepath.Join(t.TempDir(), "disk")
require.NoError(t, os.WriteFile(disk, zipBytes, 0o600))
sum := sha256.Sum256(zipBytes)
entries[entryPackageSize] = []byte(strconv.Itoa(len(zipBytes)))
entries[entryPackageSHA256] = []byte(hex.EncodeToString(sum[:]))
g := newGuest(t, entries)
b.started = make(chan struct{}, 1)
b.task = t.TempDir()
return testlink{
startup: g.startup(),
startup: g.startup(),
pkg: taskPackage{
device: disk, dir: b.task,
loadDriver: func() error { return nil },
mountDir: func(string) error { return nil },
sealDir: func(string) error { b.sealed = true; return nil },
},
runtimeAddr: "127.0.0.1:0",
startBootstrap: func(ctx context.Context, env []string) (*exec.Cmd, error) {
b.n++
@@ -80,7 +103,7 @@ func TestTestlinkServesOneInvocationThenStops(t *testing.T) {
h := newHost(t)
h.accept(ctx)
var b bootstrapStarts
done := runTestlinkAsync(ctx, b.testlink(newGuest(t, h.entries())))
done := runTestlinkAsync(ctx, b.testlink(t, h.entries()))
conn := h.guest(t)
defer conn.Close()
@@ -109,6 +132,8 @@ func TestTestlinkServesOneInvocationThenStops(t *testing.T) {
assert.Error(t, err, "agent answered a second invocation")
}
assert.Equal(t, 1, b.n)
assert.True(t, b.sealed, "task dir not made read-only")
assert.FileExists(t, filepath.Join(b.task, "lib", "data.txt"))
}
func TestTestlinkShutdownBeforeInvocationNeverStartsBootstrap(t *testing.T) {
@@ -117,7 +142,7 @@ func TestTestlinkShutdownBeforeInvocationNeverStartsBootstrap(t *testing.T) {
h := newHost(t)
h.accept(ctx)
var b bootstrapStarts
done := runTestlinkAsync(ctx, b.testlink(newGuest(t, h.entries())))
done := runTestlinkAsync(ctx, b.testlink(t, h.entries()))
conn := h.guest(t)
defer conn.Close()
@@ -142,7 +167,7 @@ func TestTestlinkHostRefusalNeverStartsBootstrap(t *testing.T) {
entries[lambdav1.EntryGuestKey], entries[lambdav1.EntryGuestCert] = wrong[lambdav1.EntryGuestKey], wrong[lambdav1.EntryGuestCert]
var b bootstrapStarts
res := wait(t, runTestlinkAsync(ctx, b.testlink(newGuest(t, entries))), "agent to stop")
res := wait(t, runTestlinkAsync(ctx, b.testlink(t, entries)), "agent to stop")
assert.Equal(t, exitLink, res.code)
require.Error(t, res.err)
assert.Contains(t, res.err.Error(), "bad certificate")
@@ -155,7 +180,7 @@ func TestTestlinkHostCloseBeforeFrameNeverStartsBootstrap(t *testing.T) {
h := newHost(t)
h.accept(ctx)
var b bootstrapStarts
done := runTestlinkAsync(ctx, b.testlink(newGuest(t, h.entries())))
done := runTestlinkAsync(ctx, b.testlink(t, h.entries()))
require.NoError(t, h.guest(t).Close())
res := wait(t, done, "agent to stop")
@@ -177,3 +202,45 @@ func TestOneInvocationHonoursCancelledContext(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, "r-1", req.RequestID)
}
// A package that fails its check is refused after authentication, and the
// bootstrap never starts.
func TestTestlinkBadPackageNeverStartsBootstrap(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
h := newHost(t)
h.accept(ctx)
var b bootstrapStarts
tl := b.testlink(t, h.entries())
tl.pkg.device = filepath.Join(t.TempDir(), "other")
require.NoError(t, os.WriteFile(tl.pkg.device, testZip(t, map[string]string{"bootstrap": "x"}), 0o600))
done := runTestlinkAsync(ctx, tl)
conn := h.guest(t)
defer conn.Close()
require.NoError(t, hostlink.NewTestStream(conn).HostSend(hostlink.Request{RequestID: "r-1"}))
res := wait(t, done, "agent to stop")
assert.Equal(t, exitRefused, res.code)
assert.ErrorContains(t, res.err, "install package")
assert.Zero(t, b.n)
assert.False(t, b.sealed)
}
// Missing package values still seal fw_cfg, and the agent refuses before
// waiting for any frame.
func TestTestlinkMissingPackageSpecRefuses(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
h := newHost(t)
h.accept(ctx)
var b bootstrapStarts
tl := b.testlink(t, h.entries())
require.NoError(t, os.RemoveAll(filepath.Join(tl.fwCfgDir, entryPackageSHA256)))
res := wait(t, runTestlinkAsync(ctx, tl), "agent to stop")
assert.Equal(t, exitRefused, res.code)
assert.ErrorContains(t, res.err, "read "+entryPackageSHA256)
assert.Zero(t, b.n)
_, err := os.Stat(tl.fwCfgRoot)
assert.ErrorIs(t, err, os.ErrNotExist, "fw_cfg not sealed")
}
+74 -2
View File
@@ -25,6 +25,10 @@ const (
DeletionStateRunning = "RUNNING"
DeletionStateCompleted = "COMPLETED"
DeletionStateFailed = "FAILED"
// DeletionStateExercised ends a non-finalizing run: the account is left
// TERMINATING and a later DeleteAccount takes the same operation over.
DeletionStateExercised = "EXERCISED"
)
// CreateAccountRequest is the /admin/CreateAccount request body.
@@ -52,6 +56,10 @@ type DeleteAccountRequest struct {
ClientToken string `json:"clientToken"`
Force bool `json:"force,omitempty"`
DryRun bool `json:"dryRun,omitempty"`
// NonFinalizing quiesces the account, cleans the quiesced EC2 platform
// kinds and stops. No legacy stage runs and the account record stays.
NonFinalizing bool `json:"nonFinalizing,omitempty"`
}
// DescribeAccountDeletionRequest is the /admin/DescribeAccountDeletion body.
@@ -76,8 +84,8 @@ type ListAccountsResponse struct {
}
// IAMQuiescence is the coordinator's recorded view of IAM quiescence for one
// fence. Recording only: it admits nothing, and QuiescedScope stays empty until
// a final inventory re-read exists to catch a late write.
// fence. Recording only: it admits nothing. QuiescedScope is filled only for a
// non-finalizing run, and holds as of EvaluatedAt: a write after that is not caught.
type IAMQuiescence struct {
OperationID string `json:"operationId"`
Fence uint64 `json:"fence"`
@@ -136,3 +144,67 @@ const (
ReasonOwnerInventoryIncomplete = "owner-inventory-incomplete"
ReasonFinalInventoryRereadNotWired = "final-inventory-reread-not-wired"
)
// ExerciseResult is what a non-finalizing run did after IAM quiescence.
type ExerciseResult struct {
// Skipped is the fixed reason no cleanup ran; Cleanup is then absent.
Skipped string `json:"skipped,omitempty"`
Cleanup *PlatformCleanup `json:"cleanup,omitempty"`
Accounting []KindAccounting `json:"accounting,omitempty"`
}
// PlatformCleanup is the EC2 platform cleanup result. It carries resource IDs,
// fixed reasons and counts only, never names, tags, key material or data.
type PlatformCleanup struct {
Kinds []CleanupKind `json:"kinds"`
Items []CleanupItem `json:"items,omitempty"`
Counts []CleanupCount `json:"counts,omitempty"`
}
// CleanupKind is one kind's scan status after the cleanup's own re-scan.
type CleanupKind struct {
Kind string `json:"kind"`
Status string `json:"status"`
IncompleteReasons []string `json:"incompleteReasons,omitempty"`
}
// CleanupItem is one resource by its ID with what the cleanup did to it.
type CleanupItem struct {
ResourceID string `json:"resourceId"`
Kind string `json:"kind"`
Disposition string `json:"disposition"`
Reason string `json:"reason,omitempty"`
MemberCount int `json:"memberCount,omitempty"`
}
// CleanupCount is the number of records of one class that have no public ID.
type CleanupCount struct {
Kind string `json:"kind"`
Class string `json:"class"`
Disposition string `json:"disposition"`
Reason string `json:"reason,omitempty"`
Count int `json:"count"`
}
// KindAccounting is the final inventory re-read held against the cleanup result
// for one kind. Quiescence is not rewritten from it.
type KindAccounting struct {
Kind string `json:"kind"`
Status string `json:"status"`
Reason string `json:"reason,omitempty"`
Remaining int `json:"remaining"`
}
// Accounting statuses and fixed reasons of a KindAccounting.
const (
AccountingAccounted = "accounted"
AccountingUnaccounted = "unaccounted"
AccountingReasonRereadIncomplete = "reread-incomplete"
AccountingReasonUnnamedRemainder = "unnamed-remainder"
)
// Fixed reasons an exercise ran no cleanup.
const (
ExerciseSkippedNoQuiescedKind = "no-quiesced-kind"
)
+21
View File
@@ -43,6 +43,27 @@ func TestAdminWire_DeletionStates(t *testing.T) {
require.Equal(t, "RUNNING", DeletionStateRunning)
require.Equal(t, "COMPLETED", DeletionStateCompleted)
require.Equal(t, "FAILED", DeletionStateFailed)
require.Equal(t, "EXERCISED", DeletionStateExercised)
}
func TestAdminWire_NonFinalizingDeleteRequest(t *testing.T) {
goldenJSON(t, DeleteAccountRequest{AccountID: "000000000042", AccountName: "acme", ClientToken: "tok-3", NonFinalizing: true},
`{"accountId":"000000000042","accountName":"acme","clientToken":"tok-3","nonFinalizing":true}`)
}
func TestAdminWire_ExerciseResult(t *testing.T) {
goldenJSON(t, ExerciseResult{
Cleanup: &PlatformCleanup{
Kinds: []CleanupKind{{Kind: "key-pair", Status: "observed-clean-at-scan-time"}},
Items: []CleanupItem{{ResourceID: "pg-1", Kind: "placement-group", Disposition: "blocked", Reason: "instance-members", MemberCount: 1}},
Counts: []CleanupCount{{Kind: "key-pair", Class: "public-key-object-without-metadata", Disposition: "deleted", Count: 2}},
},
Accounting: []KindAccounting{{Kind: "placement-group", Status: AccountingAccounted, Remaining: 1}},
}, `{"cleanup":{"kinds":[{"kind":"key-pair","status":"observed-clean-at-scan-time"}],
"items":[{"resourceId":"pg-1","kind":"placement-group","disposition":"blocked","reason":"instance-members","memberCount":1}],
"counts":[{"kind":"key-pair","class":"public-key-object-without-metadata","disposition":"deleted","count":2}]},
"accounting":[{"kind":"placement-group","status":"accounted","remaining":1}]}`)
goldenJSON(t, ExerciseResult{Skipped: ExerciseSkippedNoQuiescedKind}, `{"skipped":"no-quiesced-kind"}`)
}
func TestAdminWire_ListAccountsResponse(t *testing.T) {
+4 -3
View File
@@ -65,9 +65,10 @@ The EC2 registration (`spinifex/domains/ec2/awsapi`, `NewRegistration`) parses t
The gateway communicates with daemons via NATS request/response. Most calls go through `natsmsg.NATSRequest`, which marshals the input, attaches the account ID as a NATS header, and unmarshals the typed response:
```go
func (s *NATSInstanceService) RunInstances(ctx context.Context, input *ec2.RunInstancesInput, accountID string) (*ec2.Reservation, error) {
topic := fmt.Sprintf("ec2.RunInstances.%s", aws.StringValue(input.InstanceType))
return natsmsg.NATSRequest[ec2.Reservation](ctx, s.natsConn, topic, input, 5*time.Minute, accountID)
func runIntoReservation(ctx context.Context, input *ec2.RunInstancesInput, natsConn *nats.Conn, accountID, crID string) (*ec2.Reservation, error) {
subject := "ec2.RunInstances.cr." + crID
reservation, err := natsmsg.NATSRequest[ec2.Reservation](ctx, natsConn, subject, input, 5*time.Minute, accountID)
// ...
}
```
+5 -2
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"io"
"os/exec"
"path/filepath"
goruntime "runtime"
"syscall"
"time"
@@ -18,8 +19,8 @@ import (
const sandboxID = 993
// StartBootstrap starts the function's bootstrap at path as the sandbox uid and
// gid with no supplementary groups, with no_new_privs, and with exactly env.
// The caller waits on the returned command.
// gid with no supplementary groups, with no_new_privs, and with exactly env,
// in the bootstrap's own directory. The caller waits on the returned command.
func StartBootstrap(ctx context.Context, path string, env []string, stdout, stderr io.Writer) (*exec.Cmd, error) {
cred := &syscall.Credential{Uid: sandboxID, Gid: sandboxID, Groups: []uint32{}}
return startBootstrap(ctx, path, env, stdout, stderr, cred)
@@ -30,6 +31,8 @@ func startBootstrap(ctx context.Context, path string, env []string, stdout, stde
// A nil Env inherits the agent's environment; the bootstrap gets only env.
cmd.Env = append([]string{}, env...)
cmd.Stdout, cmd.Stderr = stdout, stderr
// Packaged functions open their files relative to the task root.
cmd.Dir = filepath.Dir(path)
if cred != nil {
cmd.SysProcAttr = &syscall.SysProcAttr{Credential: cred}
}
@@ -169,3 +169,11 @@ func TestStartBootstrapMissingExecutableFails(t *testing.T) {
_, err := startBootstrap(context.Background(), filepath.Join(t.TempDir(), "bootstrap"), nil, nil, nil, nil)
assert.Error(t, err)
}
func TestStartBootstrapRunsInItsOwnDirectory(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "data.txt"), []byte("packaged"), 0o644))
bootstrap := filepath.Join(dir, "bootstrap")
require.NoError(t, os.WriteFile(bootstrap, []byte("#!/bin/sh\ncat data.txt\n"), 0o755))
assert.Equal(t, "packaged", string(run(t, bootstrap, []string{"PATH=/bin:/usr/bin"}, false)))
}
+33 -3
View File
@@ -5,6 +5,7 @@ package config
import (
"fmt"
"log/slog"
"maps"
"net/netip"
"os"
"path/filepath"
@@ -355,6 +356,11 @@ type EBSConfig struct {
CacheSizeMB *int `json:"CacheSizeMB" mapstructure:"cache_size_mb"`
}
// SupportedEBSProvider reports whether name is a provider a node may run.
func SupportedEBSProvider(name string) bool {
return name == EBSProviderViperblockd || name == EBSProviderQEMUNBD
}
// ResolvedProvider normalizes an empty Provider to EBSProviderViperblockd,
// the default provider.
func (c EBSConfig) ResolvedProvider() string {
@@ -801,6 +807,27 @@ func validateVultrPool(cc *ClusterConfig, p ExternalPool) error {
return nil
}
// validateSingleEBSProvider refuses nodes whose explicit [nodes.<name>.ebs]
// providers differ. A peer stanza without one is unknown, not the default: the
// generated config omits it, and the cluster's KV lock is the authoritative check.
func validateSingleEBSProvider(cc *ClusterConfig) error {
var first, want string
for _, name := range slices.Sorted(maps.Keys(cc.Nodes)) {
if !viper.IsSet("nodes." + name + ".ebs.provider") {
continue
}
got := cc.Nodes[name].EBS.ResolvedProvider()
if first == "" {
first, want = name, got
continue
}
if got != want {
return fmt.Errorf("config: [nodes.%s.ebs] provider resolves to %q but [nodes.%s.ebs] resolves to %q; every node in a cluster must use the same EBS provider", first, want, name, got)
}
}
return nil
}
func validateClusterConfig(cc *ClusterConfig) error {
if viper.IsSet("network.external_dhcp") {
return fmt.Errorf("config: [network] external_dhcp is no longer supported; remove the key (static WAN-pool allocation only)")
@@ -809,9 +836,9 @@ func validateClusterConfig(cc *ClusterConfig) error {
if viper.IsSet("nodes." + nodeName + ".vpcd.dhcp_bind_bridge") {
return fmt.Errorf("config: [nodes.%s.vpcd] dhcp_bind_bridge is no longer supported; remove the key (vpcd no longer runs a DHCP client)", nodeName)
}
switch nodeCfg.EBS.Provider {
case "", EBSProviderViperblockd, EBSProviderQEMUNBD:
case EBSProviderEmbedded:
switch {
case nodeCfg.EBS.Provider == "", SupportedEBSProvider(nodeCfg.EBS.Provider):
case nodeCfg.EBS.Provider == EBSProviderEmbedded:
// Refuse rather than silently upgrade: the embedded engine is gone,
// and starting anyway would migrate this node's volumes to
// ebsmetadata one-way without the operator ever asking for it.
@@ -829,6 +856,9 @@ func validateClusterConfig(cc *ClusterConfig) error {
return fmt.Errorf("config: [nodes.%s.ebs] cache_size_mb=%d must not be negative; use 0 to disable the cache, or remove the key for the default of %d", nodeName, *c, DefaultCacheSizeMB)
}
}
if err := validateSingleEBSProvider(cc); err != nil {
return err
}
if err := validateNorthstarCluster(cc); err != nil {
return err
+39
View File
@@ -1108,6 +1108,45 @@ provider = "bogus"
assert.Contains(t, err.Error(), `provider="bogus"`)
}
func TestLoadConfig_EBSProvider_SingleProviderPerCluster(t *testing.T) {
stanza := func(node, provider string) string {
return fmt.Sprintf("[nodes.%s.ebs]\nprovider = %q\n", node, provider)
}
cases := []struct {
name string
ebs []string
wantErr string
}{
{"all unset", []string{"", "", ""}, ""},
{"explicit viperblockd beside unset peers", []string{"", stanza("n2", EBSProviderViperblockd), ""}, ""},
{"explicit qemunbd beside unset peers", []string{stanza("n1", EBSProviderQEMUNBD), "", ""}, ""},
{"all explicit qemunbd", []string{stanza("n1", EBSProviderQEMUNBD), stanza("n2", EBSProviderQEMUNBD), stanza("n3", EBSProviderQEMUNBD)}, ""},
{"two explicit providers differ", []string{stanza("n1", EBSProviderQEMUNBD), stanza("n2", EBSProviderViperblockd), ""},
`[nodes.n1.ebs] provider resolves to "qemunbd" but [nodes.n2.ebs] resolves to "viperblockd"`},
{"only the third node differs", []string{stanza("n1", EBSProviderViperblockd), stanza("n2", EBSProviderViperblockd), stanza("n3", EBSProviderQEMUNBD)},
`[nodes.n1.ebs] provider resolves to "viperblockd" but [nodes.n3.ebs] resolves to "qemunbd"`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
body := "node = \"n1\"\n"
for i, ebs := range tc.ebs {
body += fmt.Sprintf("\n[nodes.n%d]\nregion = \"us-east-1\"\n%s", i+1, ebs)
}
resetViper(t)
path := filepath.Join(t.TempDir(), "spinifex.toml")
require.NoError(t, os.WriteFile(path, []byte(body), 0600))
_, err := LoadConfig(path)
if tc.wantErr == "" {
require.NoError(t, err)
return
}
require.Error(t, err)
assert.Contains(t, err.Error(), tc.wantErr)
})
}
}
func TestEBSConfig_ResolvedTunablesDefault(t *testing.T) {
var c EBSConfig
assert.Equal(t, DefaultNBDKitThreads, c.ResolvedThreads())
+61 -11
View File
@@ -69,6 +69,28 @@ var (
_ TagIndexOwner = (*ec2tags.TagsServiceImpl)(nil)
)
// kindPrefixes maps each cleanable kind to its resource-ID prefix in the tag index.
var kindPrefixes = map[string]string{
ec2v1.PlatformKindKeyPair: keyPairIDPrefix,
ec2v1.PlatformKindPlacementGroup: placementGroupIDPrefix,
ec2v1.PlatformKindLaunchTemplate: launchTemplateIDPrefix,
}
// selectKinds returns the kinds a request cleans, in report order. No kinds
// means all three; a kind that is not cleanable, such as the tag index, is refused.
func selectKinds(requested []string) ([]string, error) {
all := []string{ec2v1.PlatformKindKeyPair, ec2v1.PlatformKindPlacementGroup, ec2v1.PlatformKindLaunchTemplate}
if len(requested) == 0 {
return all, nil
}
for _, kind := range requested {
if !slices.Contains(all, kind) {
return nil, awserrors.Errorf(awserrors.ErrorInvalidParameterValue, "The kind is not one of key-pair, placement-group or launch-template.")
}
}
return slices.DeleteFunc(all, func(kind string) bool { return !slices.Contains(requested, kind) }), nil
}
// DefaultSettleWindow is how old headerless residue must be before it is
// removed; anything younger may be a create still in flight.
const DefaultSettleWindow = 10 * time.Minute
@@ -102,6 +124,7 @@ const (
// CleanupPlatform deletes the account's key pairs, empty placement groups and
// launch templates, removes settled residue and orphaned tag-index entries,
// then re-scans every owner store to build the result. It is safe to re-run.
// Request.Kinds limits the run to those kinds and their tag-index entries.
func (c *Cleanup) CleanupPlatform(ctx context.Context, req *Request) (*Result, error) {
if req == nil || req.AccountID == "" {
return nil, awserrors.Errorf(awserrors.ErrorMissingParameter, "The request must specify an account ID.")
@@ -118,27 +141,54 @@ func (c *Cleanup) CleanupPlatform(ctx context.Context, req *Request) (*Result, e
acct := req.AccountID
res := &Result{AccountID: acct, CorrelationRef: req.CorrelationRef}
refs := c.readReferences(ctx, acct)
keyRun := c.cleanKeyPairs(ctx, acct, settledBefore, refs)
groupRun := c.cleanPlacementGroups(ctx, acct, refs)
templateRun := c.cleanLaunchTemplates(ctx, acct, settledBefore)
kinds, err := selectKinds(req.Kinds)
if err != nil {
return nil, err
}
var prefixes []string
for _, kind := range kinds {
prefixes = append(prefixes, kindPrefixes[kind])
}
var keyRun *keyPairRun
var groupRun *placementGroupRun
var templateRun *launchTemplateRun
if slices.Contains(kinds, ec2v1.PlatformKindKeyPair) || slices.Contains(kinds, ec2v1.PlatformKindPlacementGroup) {
refs := c.readReferences(ctx, acct)
if slices.Contains(kinds, ec2v1.PlatformKindKeyPair) {
keyRun = c.cleanKeyPairs(ctx, acct, settledBefore, refs)
}
if slices.Contains(kinds, ec2v1.PlatformKindPlacementGroup) {
groupRun = c.cleanPlacementGroups(ctx, acct, refs)
}
}
if slices.Contains(kinds, ec2v1.PlatformKindLaunchTemplate) {
templateRun = c.cleanLaunchTemplates(ctx, acct, settledBefore)
}
// Tags are listed before the owner re-scans, so a resource created in
// between is seen as owned and keeps its tags.
tagKind := c.startKind(ec2v1.PlatformKindTagIndex)
tagged, tagErr := c.tags.ScanAccountResourceIDs(ctx, acct, keyPairIDPrefix, placementGroupIDPrefix, launchTemplateIDPrefix)
tagged, tagErr := c.tags.ScanAccountResourceIDs(ctx, acct, prefixes...)
if tagErr != nil {
slog.WarnContext(ctx, "Platform cleanup: tag index listing failed", "err", tagErr)
tagKind.incomplete(ec2v1.PlatformReasonStoreListFailed)
}
owners := map[string]ownedIDs{
keyPairIDPrefix: c.finishKeyPairs(ctx, acct, keyRun, res),
placementGroupIDPrefix: c.finishPlacementGroups(ctx, acct, groupRun, res),
launchTemplateIDPrefix: c.finishLaunchTemplates(ctx, acct, templateRun, res),
owners := map[string]ownedIDs{}
if keyRun != nil {
owners[keyPairIDPrefix] = c.finishKeyPairs(ctx, acct, keyRun, res)
res.Kinds = append(res.Kinds, keyRun.kind.report)
}
res.Kinds = []KindReport{keyRun.kind.report, groupRun.kind.report, templateRun.kind.report}
res.Kinds = append(res.Kinds, c.cleanTagIndex(ctx, acct, tagKind, tagged, tagErr == nil, owners, res))
if groupRun != nil {
owners[placementGroupIDPrefix] = c.finishPlacementGroups(ctx, acct, groupRun, res)
res.Kinds = append(res.Kinds, groupRun.kind.report)
}
if templateRun != nil {
owners[launchTemplateIDPrefix] = c.finishLaunchTemplates(ctx, acct, templateRun, res)
res.Kinds = append(res.Kinds, templateRun.kind.report)
}
res.Kinds = append(res.Kinds, c.cleanTagIndex(ctx, acct, tagKind, tagged, prefixes, tagErr == nil, owners, res))
slog.InfoContext(ctx, "Platform cleanup completed", "accountID", acct, "correlationRef", req.CorrelationRef,
"items", len(res.Items), "duration_ms", otelsetup.Millis(time.Since(start)))
@@ -775,3 +775,38 @@ func (f failingResidue) CleanupAccountResidue(ctx context.Context, acct string,
}
return &ec2launchtemplate.AccountCleanup{KeptTemplateIDs: scan.LiveTemplateIDs, OrphanBodiesFailed: scan.OrphanVersionBodies}, nil
}
// A run limited to one kind cleans that kind and its tag-index entries only:
// the other kinds keep their resources and their tags, and are not reported.
func TestCleanupPlatformLimitedToKindsLeavesTheRestAlone(t *testing.T) {
o := newOwners(t)
keyID := o.importKey(t, testAccount, "k1")
groupID := o.createGroup(t, testAccount, "g1")
o.tag(t, testAccount, keyID)
c := o.newCleanup()
c.now = func() time.Time { return time.Now().Add(time.Hour) }
res, err := c.CleanupPlatform(t.Context(), &Request{AccountID: testAccount, Kinds: []string{ec2v1.PlatformKindPlacementGroup}})
require.NoError(t, err)
assert.Equal(t, []Item{item(groupID, ec2v1.PlatformKindPlacementGroup, DispositionDeleted, "")}, res.Items)
assert.Equal(t, map[string]string{
ec2v1.PlatformKindPlacementGroup: ec2v1.PlatformStatusObservedClean,
ec2v1.PlatformKindTagIndex: ec2v1.PlatformStatusObservedClean,
}, statuses(res))
keys, err := o.keys.ScanAccount(t.Context(), testAccount)
require.NoError(t, err)
assert.Equal(t, []string{keyID}, keys.LiveKeyPairIDs, "an unselected kind is not touched")
tagged, err := o.tags.ScanAccountResourceIDs(t.Context(), testAccount, "key-")
require.NoError(t, err)
assert.Equal(t, []string{keyID}, tagged, "an unselected kind keeps its tags")
}
func TestCleanupPlatformRefusesAKindItCannotClean(t *testing.T) {
o := newOwners(t)
for _, kinds := range [][]string{{ec2v1.PlatformKindTagIndex}, {"volume"}} {
_, err := o.newCleanup().CleanupPlatform(t.Context(), &Request{AccountID: testAccount, Kinds: kinds})
require.Error(t, err, "%v", kinds)
}
}
+4 -4
View File
@@ -282,15 +282,15 @@ func (c *Cleanup) finishLaunchTemplates(ctx context.Context, acct string, run *l
// cleanTagIndex clears entries whose owner re-scan resolved and holds no
// record for the ID, then re-lists the index to prove each clear. Entries of
// resources that still exist are theirs, not residue, and are left alone.
func (c *Cleanup) cleanTagIndex(ctx context.Context, acct string, kind *kindRun, tagged []string, listed bool, owners map[string]ownedIDs, res *Result) KindReport {
func (c *Cleanup) cleanTagIndex(ctx context.Context, acct string, kind *kindRun, tagged, prefixes []string, listed bool, owners map[string]ownedIDs, res *Result) KindReport {
if listed {
c.clearOrphanTags(ctx, acct, kind, tagged, owners, res)
c.clearOrphanTags(ctx, acct, kind, tagged, prefixes, owners, res)
}
c.finishKind(kind)
return kind.report
}
func (c *Cleanup) clearOrphanTags(ctx context.Context, acct string, kind *kindRun, tagged []string, owners map[string]ownedIDs, res *Result) {
func (c *Cleanup) clearOrphanTags(ctx context.Context, acct string, kind *kindRun, tagged, prefixes []string, owners map[string]ownedIDs, res *Result) {
// A resource this run reports as not deleted owns its tags even when its
// kind's re-scan failed.
reported := map[string]bool{}
@@ -317,7 +317,7 @@ func (c *Cleanup) clearOrphanTags(ctx context.Context, acct string, kind *kindRu
cleared = append(cleared, id)
}
}
after, err := c.tags.ScanAccountResourceIDs(ctx, acct, keyPairIDPrefix, placementGroupIDPrefix, launchTemplateIDPrefix)
after, err := c.tags.ScanAccountResourceIDs(ctx, acct, prefixes...)
if err != nil {
slog.WarnContext(ctx, "Platform cleanup: tag index re-scan failed", "err", err)
kind.incomplete(ec2v1.PlatformReasonStoreListFailed)
@@ -7,6 +7,9 @@ import "time"
type Request struct {
AccountID string `json:"account_id"`
CorrelationRef string `json:"correlation_ref,omitempty"`
// Kinds limits the run to these platform kinds. Empty means all three.
Kinds []string `json:"kinds,omitempty"`
}
// Result is what the owner stores hold after one run. Kinds reuse the ec2.v1
@@ -1,80 +0,0 @@
package instance
import (
"context"
"fmt"
"github.com/mulgadc/spinifex/spinifex/foundation/messaging/nats"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/nats-io/nats.go"
)
// NATSInstanceService handles instance operations via NATS. Fan-out methods
// (DescribeInstances, DescribeInstanceTypes) intentionally error — use the
// gateway's scatter-gather helper for cluster-wide results.
type NATSInstanceService struct {
natsConn *nats.Conn
}
var _ InstanceService = (*NATSInstanceService)(nil)
// NewNATSInstanceService creates a new NATS-based instance service.
func NewNATSInstanceService(conn *nats.Conn) InstanceService {
return &NATSInstanceService{natsConn: conn}
}
func (s *NATSInstanceService) RunInstances(ctx context.Context, input *ec2.RunInstancesInput, accountID string) (*ec2.Reservation, error) {
if input == nil || input.InstanceType == nil {
return nil, fmt.Errorf("instance type is required")
}
topic := fmt.Sprintf("ec2.RunInstances.%s", aws.StringValue(input.InstanceType))
return natsmsg.NATSRequest[ec2.Reservation](ctx, s.natsConn, topic, input, 5*time.Minute, accountID)
}
// DescribeInstances is a fan-out operation; use the gateway's scatter-gather
// helper instead of this single-node client.
func (s *NATSInstanceService) DescribeInstances(_ context.Context, _ *ec2.DescribeInstancesInput, _ string) (*ec2.DescribeInstancesOutput, error) {
return nil, fmt.Errorf("DescribeInstances is fan-out; use gateway scatter-gather, not NATSInstanceService")
}
// DescribeInstanceTypes is a fan-out operation; use the gateway's scatter-gather
// helper instead of this single-node client.
func (s *NATSInstanceService) DescribeInstanceTypes(_ context.Context, _ *ec2.DescribeInstanceTypesInput, _ string) (*DescribeInstanceTypesNodeOutput, error) {
return nil, fmt.Errorf("DescribeInstanceTypes is fan-out; use gateway scatter-gather, not NATSInstanceService")
}
// DescribeInstanceStatus is a fan-out operation; use the gateway's scatter-gather
// helper instead of this single-node client.
func (s *NATSInstanceService) DescribeInstanceStatus(_ context.Context, _ *ec2.DescribeInstanceStatusInput, _ string) (*ec2.DescribeInstanceStatusOutput, error) {
return nil, fmt.Errorf("DescribeInstanceStatus is fan-out; use gateway scatter-gather, not NATSInstanceService")
}
func (s *NATSInstanceService) DescribeInstanceAttribute(ctx context.Context, input *ec2.DescribeInstanceAttributeInput, accountID string) (*ec2.DescribeInstanceAttributeOutput, error) {
return natsmsg.NATSRequest[ec2.DescribeInstanceAttributeOutput](ctx, s.natsConn, "ec2.DescribeInstanceAttribute", input, 30*time.Second, accountID)
}
func (s *NATSInstanceService) DescribeStoppedInstances(ctx context.Context, input *ec2.DescribeInstancesInput, accountID string) (*ec2.DescribeInstancesOutput, error) {
return natsmsg.NATSRequest[ec2.DescribeInstancesOutput](ctx, s.natsConn, "ec2.DescribeStoppedInstances", input, 30*time.Second, accountID)
}
func (s *NATSInstanceService) DescribeTerminatedInstances(ctx context.Context, input *ec2.DescribeInstancesInput, accountID string) (*ec2.DescribeInstancesOutput, error) {
return natsmsg.NATSRequest[ec2.DescribeInstancesOutput](ctx, s.natsConn, "ec2.DescribeTerminatedInstances", input, 30*time.Second, accountID)
}
func (s *NATSInstanceService) ModifyInstanceAttribute(ctx context.Context, input *ec2.ModifyInstanceAttributeInput, accountID string) (*ec2.ModifyInstanceAttributeOutput, error) {
return natsmsg.NATSRequest[ec2.ModifyInstanceAttributeOutput](ctx, s.natsConn, "ec2.ModifyInstanceAttribute", input, 30*time.Second, accountID)
}
func (s *NATSInstanceService) ModifyInstanceMetadataOptions(ctx context.Context, input *ec2.ModifyInstanceMetadataOptionsInput, accountID string) (*ec2.ModifyInstanceMetadataOptionsOutput, error) {
return natsmsg.NATSRequest[ec2.ModifyInstanceMetadataOptionsOutput](ctx, s.natsConn, "ec2.ModifyInstanceMetadataOptions", input, 30*time.Second, accountID)
}
func (s *NATSInstanceService) StartStoppedInstance(ctx context.Context, input *StartStoppedInstanceInput, accountID string) (*StartStoppedInstanceOutput, error) {
return natsmsg.NATSRequest[StartStoppedInstanceOutput](ctx, s.natsConn, "ec2.StartStoppedInstance", input, 30*time.Second, accountID)
}
func (s *NATSInstanceService) TerminateStoppedInstance(ctx context.Context, input *TerminateStoppedInstanceInput, accountID string) (*TerminateStoppedInstanceOutput, error) {
return natsmsg.NATSRequest[TerminateStoppedInstanceOutput](ctx, s.natsConn, "ec2.TerminateStoppedInstance", input, 30*time.Second, accountID)
}
@@ -1,364 +0,0 @@
package instance
import (
"context"
"encoding/json"
"fmt"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"os"
"testing"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/internal/testkit"
"github.com/nats-io/nats-server/v2/server"
"github.com/nats-io/nats.go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// startTestNATSServer starts an embedded NATS server for testing.
func startTestNATSServer(t *testing.T) (*server.Server, string) {
t.Helper()
ns, nc := testutil.StartTestNATS(t)
_ = nc // connection managed by testutil cleanup
return ns, ns.ClientURL()
}
// createValidRunInstancesInput creates a valid RunInstancesInput for testing.
func createValidRunInstancesInput() *ec2.RunInstancesInput {
return &ec2.RunInstancesInput{
ImageId: aws.String("ami-0abcdef1234567890"),
InstanceType: aws.String("t3.micro"),
MinCount: aws.Int64(1),
MaxCount: aws.Int64(1),
KeyName: aws.String("test-key-pair"),
SecurityGroupIds: []*string{
aws.String("sg-0123456789abcdef0"),
},
SubnetId: aws.String("subnet-6e7f829e"),
UserData: aws.String("#!/bin/bash\necho 'test'"),
}
}
// createValidReservation creates a valid ec2.Reservation response for testing.
func createValidReservation() *ec2.Reservation {
return &ec2.Reservation{
ReservationId: aws.String("r-0123456789abcdef0"),
OwnerId: aws.String("123456789012"),
Instances: []*ec2.Instance{
{
InstanceId: aws.String("i-0123456789abcdef0"),
InstanceType: aws.String("t3.micro"),
ImageId: aws.String("ami-0abcdef1234567890"),
State: &ec2.InstanceState{
Code: aws.Int64(0),
Name: aws.String("pending"),
},
PrivateIpAddress: aws.String("10.0.1.100"),
SubnetId: aws.String("subnet-6e7f829e"),
},
},
}
}
// TestNATSInstanceService_RunInstances_Success tests successful RunInstances operation.
func TestNATSInstanceService_RunInstances_Success(t *testing.T) {
// Skip if LOG_IGNORE is set
if os.Getenv("LOG_IGNORE") != "" {
t.Setenv("LOG_IGNORE", "1")
}
// Start test NATS server
ns, natsURL := startTestNATSServer(t)
defer ns.Shutdown()
// Create NATS connections
nc, err := nats.Connect(natsURL)
require.NoError(t, err, "Failed to connect to NATS")
defer nc.Close()
// Create mock daemon subscriber that responds with valid reservation
// Subscribe to the per-instance-type topic (t3.micro from createValidRunInstancesInput)
mockReservation := createValidReservation()
_, err = nc.QueueSubscribe("ec2.RunInstances.t3.micro", "spinifex-workers", func(msg *nats.Msg) {
// Validate that request is properly formatted
var input ec2.RunInstancesInput
err := json.Unmarshal(msg.Data, &input)
if err != nil {
t.Errorf("Mock daemon received invalid JSON: %v", err)
return
}
// Send back valid reservation
responseData, _ := json.Marshal(mockReservation)
msg.Respond(responseData)
})
require.NoError(t, err, "Failed to subscribe mock daemon")
// Create NATSInstanceService
service := NewNATSInstanceService(nc)
require.NotNil(t, service)
// Create valid input
input := createValidRunInstancesInput()
// Call RunInstances
reservation, err := service.RunInstances(context.Background(), input, "123456789012")
// Verify success
require.NoError(t, err, "RunInstances should succeed")
require.NotNil(t, reservation, "Reservation should not be nil")
// Verify reservation contents
assert.Equal(t, *mockReservation.ReservationId, *reservation.ReservationId)
assert.Equal(t, *mockReservation.OwnerId, *reservation.OwnerId)
require.Len(t, reservation.Instances, 1)
instance := reservation.Instances[0]
assert.Equal(t, "i-0123456789abcdef0", *instance.InstanceId)
assert.Equal(t, "t3.micro", *instance.InstanceType)
assert.Equal(t, int64(0), *instance.State.Code)
assert.Equal(t, "pending", *instance.State.Name)
}
// TestNATSInstanceService_RunInstances_DaemonError tests error handling when daemon returns error.
func TestNATSInstanceService_RunInstances_DaemonError(t *testing.T) {
if os.Getenv("LOG_IGNORE") != "" {
t.Setenv("LOG_IGNORE", "1")
}
ns, natsURL := startTestNATSServer(t)
defer ns.Shutdown()
nc, err := nats.Connect(natsURL)
require.NoError(t, err)
defer nc.Close()
// Create mock daemon that responds with error
// Subscribe to the per-instance-type topic for the requested type
_, err = nc.QueueSubscribe("ec2.RunInstances.invalid.type", "spinifex-workers", func(msg *nats.Msg) {
// Send back error response
errorResponse := awserrors.GenerateErrorPayload("InvalidInstanceType")
msg.Respond(errorResponse)
})
require.NoError(t, err)
service := NewNATSInstanceService(nc)
input := &ec2.RunInstancesInput{
ImageId: aws.String("ami-0abcdef1234567890"),
InstanceType: aws.String("invalid.type"),
MinCount: aws.Int64(1),
MaxCount: aws.Int64(1),
}
// Call RunInstances
reservation, err := service.RunInstances(context.Background(), input, "123456789012")
// Verify error handling - error should be just the AWS error code for gateway lookup
require.Error(t, err, "Should return error when daemon returns error")
assert.Nil(t, reservation, "Reservation should be nil on error")
assert.Equal(t, "InvalidInstanceType", err.Error(), "Error should be the AWS error code")
}
// TestNATSInstanceService_RunInstances_NoSubscriber tests behavior when no daemon is subscribed.
func TestNATSInstanceService_RunInstances_NoSubscriber(t *testing.T) {
if os.Getenv("LOG_IGNORE") != "" {
t.Setenv("LOG_IGNORE", "1")
}
ns, natsURL := startTestNATSServer(t)
defer ns.Shutdown()
nc, err := nats.Connect(natsURL)
require.NoError(t, err)
defer nc.Close()
// No subscriber - no daemon running
service := NewNATSInstanceService(nc)
input := createValidRunInstancesInput()
// Call RunInstances
start := time.Now()
reservation, err := service.RunInstances(context.Background(), input, "123456789012")
duration := time.Since(start)
// Verify error behavior
// When no subscribers are available, NATS returns ErrNoResponders immediately
require.Error(t, err, "Should fail when no subscriber")
assert.Nil(t, reservation, "Reservation should be nil")
assert.ErrorIs(t, err, nats.ErrNoResponders, "Error should be ErrNoResponders")
// Should fail quickly (within 1 second), not wait for 30 second timeout
assert.Less(t, duration.Seconds(), 1.0, "Should fail immediately when no responders")
}
// TestNATSInstanceService_RunInstances_InvalidResponse tests handling of malformed responses.
func TestNATSInstanceService_RunInstances_InvalidResponse(t *testing.T) {
if os.Getenv("LOG_IGNORE") != "" {
t.Setenv("LOG_IGNORE", "1")
}
ns, natsURL := startTestNATSServer(t)
defer ns.Shutdown()
nc, err := nats.Connect(natsURL)
require.NoError(t, err)
defer nc.Close()
// Create mock daemon that responds with invalid JSON
_, err = nc.QueueSubscribe("ec2.RunInstances.t3.micro", "spinifex-workers", func(msg *nats.Msg) {
// Send back malformed JSON
msg.Respond([]byte(`{"invalid": json response`))
})
require.NoError(t, err)
service := NewNATSInstanceService(nc)
input := createValidRunInstancesInput()
// Call RunInstances
reservation, err := service.RunInstances(context.Background(), input, "123456789012")
// Verify error handling
require.Error(t, err, "Should return error for invalid JSON")
assert.Nil(t, reservation, "Reservation should be nil")
assert.Contains(t, err.Error(), "failed to unmarshal", "Error should indicate unmarshal failure")
}
// TestNATSInstanceService_RunInstances_MarshalError tests handling when input marshaling fails.
func TestNATSInstanceService_RunInstances_MarshalError(t *testing.T) {
if os.Getenv("LOG_IGNORE") != "" {
t.Setenv("LOG_IGNORE", "1")
}
ns, natsURL := startTestNATSServer(t)
defer ns.Shutdown()
nc, err := nats.Connect(natsURL)
require.NoError(t, err)
defer nc.Close()
service := NewNATSInstanceService(nc)
// Nil input should be rejected before hitting NATS
reservation, err := service.RunInstances(context.Background(), nil, "123456789012")
require.Error(t, err, "Should handle nil input")
assert.Nil(t, reservation)
assert.Contains(t, err.Error(), "instance type is required")
}
// TestNATSInstanceService_RunInstances_MultipleInstances tests launching multiple instances.
func TestNATSInstanceService_RunInstances_MultipleInstances(t *testing.T) {
if os.Getenv("LOG_IGNORE") != "" {
t.Setenv("LOG_IGNORE", "1")
}
ns, natsURL := startTestNATSServer(t)
defer ns.Shutdown()
nc, err := nats.Connect(natsURL)
require.NoError(t, err)
defer nc.Close()
// Create mock daemon that returns multiple instances
_, err = nc.QueueSubscribe("ec2.RunInstances.t3.micro", "spinifex-workers", func(msg *nats.Msg) {
var input ec2.RunInstancesInput
json.Unmarshal(msg.Data, &input)
// Create reservation with count matching MinCount
count := int(*input.MinCount)
instances := make([]*ec2.Instance, count)
for i := range count {
instanceId := aws.String(aws.StringValue(aws.String(fmt.Sprintf("i-%d", i))))
instances[i] = &ec2.Instance{
InstanceId: instanceId,
InstanceType: input.InstanceType,
ImageId: input.ImageId,
State: &ec2.InstanceState{
Code: aws.Int64(0),
Name: aws.String("pending"),
},
}
}
reservation := &ec2.Reservation{
ReservationId: aws.String("r-0123456789abcdef0"),
Instances: instances,
}
responseData, _ := json.Marshal(reservation)
msg.Respond(responseData)
})
require.NoError(t, err)
service := NewNATSInstanceService(nc)
// Request 3 instances
input := createValidRunInstancesInput()
input.MinCount = aws.Int64(3)
input.MaxCount = aws.Int64(3)
reservation, err := service.RunInstances(context.Background(), input, "123456789012")
// Verify multiple instances returned
require.NoError(t, err)
require.NotNil(t, reservation)
assert.Len(t, reservation.Instances, 3, "Should have 3 instances")
}
// TestNATSInstanceService_RunInstances_QueueGroup tests load balancing with queue groups.
func TestNATSInstanceService_RunInstances_QueueGroup(t *testing.T) {
if os.Getenv("LOG_IGNORE") != "" {
t.Setenv("LOG_IGNORE", "1")
}
ns, natsURL := startTestNATSServer(t)
defer ns.Shutdown()
nc, err := nats.Connect(natsURL)
require.NoError(t, err)
defer nc.Close()
// Create multiple mock daemon workers in same queue group
worker1Count := 0
worker2Count := 0
_, err = nc.QueueSubscribe("ec2.RunInstances.t3.micro", "spinifex-workers", func(msg *nats.Msg) {
worker1Count++
reservation := createValidReservation()
responseData, _ := json.Marshal(reservation)
msg.Respond(responseData)
})
require.NoError(t, err)
_, err = nc.QueueSubscribe("ec2.RunInstances.t3.micro", "spinifex-workers", func(msg *nats.Msg) {
worker2Count++
reservation := createValidReservation()
responseData, _ := json.Marshal(reservation)
msg.Respond(responseData)
})
require.NoError(t, err)
service := NewNATSInstanceService(nc)
input := createValidRunInstancesInput()
// Send multiple requests
requestCount := 10
for range requestCount {
reservation, err := service.RunInstances(context.Background(), input, "123456789012")
require.NoError(t, err)
require.NotNil(t, reservation)
}
// Verify load was distributed (both workers should have handled some requests)
// With queue groups, NATS distributes messages across subscribers
t.Logf("Worker 1 handled: %d requests", worker1Count)
t.Logf("Worker 2 handled: %d requests", worker2Count)
assert.Positive(t, worker1Count, "Worker 1 should handle some requests")
assert.Positive(t, worker2Count, "Worker 2 should handle some requests")
assert.Equal(t, requestCount, worker1Count+worker2Count, "All requests should be handled")
}
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"github.com/mulgadc/spinifex/internal/testkit"
"net/http"
"net/http/httptest"
"strings"
@@ -25,7 +26,8 @@ import (
// object store + memory meta) and its composed ECR JSON image-action service.
func newImageGateway(t *testing.T) *ecrGW {
t.Helper()
meta := ecrdomain.NewMemoryMetaStore()
_, _, js := testutil.StartTestJetStream(t)
meta := ecrdomain.NewKVMetaStore(js)
reg := ecrregistry.NewRegistry(objectstore.NewMemoryObjectStore(), meta, ecrTestAccount)
gw := newECRGW(nil, awsapi.Deps{Registry: awsapi.NewRegistryActionService(reg, reg, reg, reg), LifecyclePreview: awsapi.NewLifecyclePreviewActionService(meta, reg)})
imageRegistries.Store(gw, reg)
-291
View File
@@ -5,8 +5,6 @@ import (
"encoding/json"
"errors"
"fmt"
"maps"
"slices"
"sort"
"strings"
"sync"
@@ -593,295 +591,6 @@ func (s *KVMetaStore) DeleteUpload(ctx context.Context, accountID, uploadID stri
return nil
}
// MemoryMetaStore is an in-memory MetaStore used by tests and single-process
// dev runs. It is safe for concurrent use.
type MemoryMetaStore struct {
mu sync.Mutex
repos map[string]map[string]RepoMeta // account -> repo -> meta
policies map[string]map[string][]byte // account -> repo -> policyText
lifecycle map[string]map[string][]byte // account -> repo -> lifecyclePolicyText
tags map[string]map[string]string // account -> repo|tag -> digest
manifests map[string]map[string]ManifestMeta // account -> repo|digest -> meta
uploads map[string]map[string]uploadRev // account -> id -> state+rev
}
type uploadRev struct {
state UploadState
rev uint64
}
var _ MetaStore = (*MemoryMetaStore)(nil)
// NewMemoryMetaStore constructs an empty in-memory MetaStore.
func NewMemoryMetaStore() *MemoryMetaStore {
return &MemoryMetaStore{
repos: make(map[string]map[string]RepoMeta),
policies: make(map[string]map[string][]byte),
lifecycle: make(map[string]map[string][]byte),
tags: make(map[string]map[string]string),
manifests: make(map[string]map[string]ManifestMeta),
uploads: make(map[string]map[string]uploadRev),
}
}
func (m *MemoryMetaStore) PutRepo(_ context.Context, accountID string, meta RepoMeta) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.repos[accountID] == nil {
m.repos[accountID] = make(map[string]RepoMeta)
}
m.repos[accountID][meta.Name] = meta
return nil
}
func (m *MemoryMetaStore) GetRepo(_ context.Context, accountID, repo string) (RepoMeta, error) {
m.mu.Lock()
defer m.mu.Unlock()
r, ok := m.repos[accountID][repo]
if !ok {
return RepoMeta{}, ErrNotFound
}
return r, nil
}
func (m *MemoryMetaStore) ListRepos(_ context.Context, accountID string) ([]string, error) {
m.mu.Lock()
defer m.mu.Unlock()
return slices.Sorted(maps.Keys(m.repos[accountID])), nil
}
func (m *MemoryMetaStore) DeleteRepo(_ context.Context, accountID, repo string) error {
m.mu.Lock()
defer m.mu.Unlock()
if _, ok := m.repos[accountID][repo]; !ok {
return ErrNotFound
}
delete(m.repos[accountID], repo)
delete(m.policies[accountID], repo)
delete(m.lifecycle[accountID], repo)
for k := range m.tags[accountID] {
if r, _, ok := strings.Cut(k, "|"); ok && r == repo {
delete(m.tags[accountID], k)
}
}
for k := range m.manifests[accountID] {
if r, _, ok := strings.Cut(k, "|"); ok && r == repo {
delete(m.manifests[accountID], k)
}
}
return nil
}
func (m *MemoryMetaStore) ListManifests(_ context.Context, accountID, repo string) ([]string, error) {
m.mu.Lock()
defer m.mu.Unlock()
var out []string
for k := range m.manifests[accountID] {
if r, d, ok := strings.Cut(k, "|"); ok && r == repo {
out = append(out, d)
}
}
sort.Strings(out)
return out, nil
}
func (m *MemoryMetaStore) PutRepoPolicy(_ context.Context, accountID, repo string, policyText []byte) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.policies[accountID] == nil {
m.policies[accountID] = make(map[string][]byte)
}
m.policies[accountID][repo] = policyText
return nil
}
func (m *MemoryMetaStore) GetRepoPolicy(_ context.Context, accountID, repo string) ([]byte, error) {
m.mu.Lock()
defer m.mu.Unlock()
p, ok := m.policies[accountID][repo]
if !ok {
return nil, ErrNotFound
}
return p, nil
}
func (m *MemoryMetaStore) DeleteRepoPolicy(_ context.Context, accountID, repo string) ([]byte, error) {
m.mu.Lock()
defer m.mu.Unlock()
p, ok := m.policies[accountID][repo]
if !ok {
return nil, ErrNotFound
}
delete(m.policies[accountID], repo)
return p, nil
}
func (m *MemoryMetaStore) PutLifecyclePolicy(_ context.Context, accountID, repo string, policyText []byte) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.lifecycle[accountID] == nil {
m.lifecycle[accountID] = make(map[string][]byte)
}
m.lifecycle[accountID][repo] = policyText
return nil
}
func (m *MemoryMetaStore) GetLifecyclePolicy(_ context.Context, accountID, repo string) ([]byte, error) {
m.mu.Lock()
defer m.mu.Unlock()
p, ok := m.lifecycle[accountID][repo]
if !ok {
return nil, ErrNotFound
}
return p, nil
}
func (m *MemoryMetaStore) DeleteLifecyclePolicy(_ context.Context, accountID, repo string) ([]byte, error) {
m.mu.Lock()
defer m.mu.Unlock()
p, ok := m.lifecycle[accountID][repo]
if !ok {
return nil, ErrNotFound
}
delete(m.lifecycle[accountID], repo)
return p, nil
}
func (m *MemoryMetaStore) PutTag(_ context.Context, accountID, repo, tag, digest string) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.tags[accountID] == nil {
m.tags[accountID] = make(map[string]string)
}
m.tags[accountID][repo+"|"+tag] = digest
return nil
}
func (m *MemoryMetaStore) GetTag(_ context.Context, accountID, repo, tag string) (string, error) {
m.mu.Lock()
defer m.mu.Unlock()
d, ok := m.tags[accountID][repo+"|"+tag]
if !ok {
return "", ErrNotFound
}
return d, nil
}
func (m *MemoryMetaStore) DeleteTag(_ context.Context, accountID, repo, tag string) error {
m.mu.Lock()
defer m.mu.Unlock()
key := repo + "|" + tag
if _, ok := m.tags[accountID][key]; !ok {
return ErrNotFound
}
delete(m.tags[accountID], key)
return nil
}
func (m *MemoryMetaStore) ListTags(_ context.Context, accountID, repo string) ([]string, error) {
m.mu.Lock()
defer m.mu.Unlock()
var out []string
for k := range m.tags[accountID] {
if r, t, ok := strings.Cut(k, "|"); ok && r == repo {
out = append(out, t)
}
}
sort.Strings(out)
return out, nil
}
func (m *MemoryMetaStore) PutManifestMeta(_ context.Context, accountID, repo string, meta ManifestMeta) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.manifests[accountID] == nil {
m.manifests[accountID] = make(map[string]ManifestMeta)
}
m.manifests[accountID][repo+"|"+meta.Digest] = meta
return nil
}
func (m *MemoryMetaStore) GetManifestMeta(_ context.Context, accountID, repo, digest string) (ManifestMeta, error) {
m.mu.Lock()
defer m.mu.Unlock()
meta, ok := m.manifests[accountID][repo+"|"+digest]
if !ok {
return ManifestMeta{}, ErrNotFound
}
return meta, nil
}
func (m *MemoryMetaStore) DeleteManifestMeta(_ context.Context, accountID, repo, digest string) error {
m.mu.Lock()
defer m.mu.Unlock()
key := repo + "|" + digest
if _, ok := m.manifests[accountID][key]; !ok {
return ErrNotFound
}
delete(m.manifests[accountID], key)
return nil
}
func (m *MemoryMetaStore) ListReferrers(_ context.Context, accountID, repo, subjectDigest string) ([]ReferrerDescriptor, error) {
m.mu.Lock()
defer m.mu.Unlock()
out := []ReferrerDescriptor{}
for k, meta := range m.manifests[accountID] {
if r, _, ok := strings.Cut(k, "|"); ok && r == repo && meta.SubjectDigest == subjectDigest {
out = append(out, meta.referrerDescriptor())
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Digest < out[j].Digest })
return out, nil
}
func (m *MemoryMetaStore) PutUpload(_ context.Context, accountID, uploadID string, state UploadState) (uint64, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.uploads[accountID] == nil {
m.uploads[accountID] = make(map[string]uploadRev)
}
rev := m.uploads[accountID][uploadID].rev + 1
m.uploads[accountID][uploadID] = uploadRev{state: state, rev: rev}
return rev, nil
}
func (m *MemoryMetaStore) GetUpload(_ context.Context, accountID, uploadID string) (UploadState, uint64, error) {
m.mu.Lock()
defer m.mu.Unlock()
u, ok := m.uploads[accountID][uploadID]
if !ok {
return UploadState{}, 0, ErrNotFound
}
return u.state, u.rev, nil
}
func (m *MemoryMetaStore) UpdateUpload(_ context.Context, accountID, uploadID string, state UploadState, rev uint64) (uint64, error) {
m.mu.Lock()
defer m.mu.Unlock()
u, ok := m.uploads[accountID][uploadID]
if !ok {
return 0, ErrNotFound
}
if u.rev != rev {
return 0, ErrConflict
}
u.state = state
u.rev = rev + 1
m.uploads[accountID][uploadID] = u
return u.rev, nil
}
func (m *MemoryMetaStore) DeleteUpload(_ context.Context, accountID, uploadID string) error {
m.mu.Lock()
defer m.mu.Unlock()
if _, ok := m.uploads[accountID][uploadID]; !ok {
return ErrNotFound
}
delete(m.uploads[accountID], uploadID)
return nil
}
// mapKVErr normalizes JetStream KV errors to the MetaStore error vocabulary.
func mapKVErr(err error) error {
if errors.Is(err, jetstream.ErrKeyNotFound) {
+16 -53
View File
@@ -8,6 +8,7 @@ import (
"testing"
"time"
"github.com/mulgadc/spinifex/internal/testkit"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -61,8 +62,8 @@ func TestKVKeyHelpers(t *testing.T) {
assert.Equal(t, "sha256-abc", DigestToken("sha256:abc"))
}
func TestMemoryMetaStore_RepoRoundtrip(t *testing.T) {
m := NewMemoryMetaStore()
func TestKVMetaStore_RepoRoundtrip(t *testing.T) {
m := newTestKVMetaStore(t)
const acct = "000000000000"
_, err := m.GetRepo(context.Background(), acct, "team/app")
@@ -80,8 +81,8 @@ func TestMemoryMetaStore_RepoRoundtrip(t *testing.T) {
assert.Equal(t, []string{"team/app", "team/web"}, repos)
}
func TestMemoryMetaStore_TagsAndManifests(t *testing.T) {
m := NewMemoryMetaStore()
func TestKVMetaStore_TagsAndManifests(t *testing.T) {
m := newTestKVMetaStore(t)
const acct = "000000000000"
require.NoError(t, m.PutTag(context.Background(), acct, "r", "v1", "sha256:aaa"))
@@ -112,8 +113,8 @@ func TestMemoryMetaStore_TagsAndManifests(t *testing.T) {
assert.ErrorIs(t, m.DeleteManifestMeta(context.Background(), acct, "r", "sha256:ccc"), ErrNotFound)
}
func TestMemoryMetaStore_UploadCAS(t *testing.T) {
m := NewMemoryMetaStore()
func TestKVMetaStore_UploadCAS(t *testing.T) {
m := newTestKVMetaStore(t)
const acct = "000000000000"
rev, err := m.PutUpload(context.Background(), acct, "u1", UploadState{RepoName: "r"})
@@ -143,8 +144,8 @@ func TestTrimSuffixMeta(t *testing.T) {
assert.Equal(t, "team/app", trimSuffixMeta("repos/team/app/meta"))
}
func TestMemoryMetaStore_DeleteRepoAndListManifests(t *testing.T) {
m := NewMemoryMetaStore()
func TestKVMetaStore_DeleteRepoAndListManifests(t *testing.T) {
m := newTestKVMetaStore(t)
const acct = "000000000000"
require.NoError(t, m.PutRepo(context.Background(), acct, RepoMeta{Name: "team/app", CreatedAt: time.Now()}))
@@ -156,7 +157,7 @@ func TestMemoryMetaStore_DeleteRepoAndListManifests(t *testing.T) {
digs, err := m.ListManifests(context.Background(), acct, "team/app")
require.NoError(t, err)
assert.Equal(t, []string{"sha256:ccc"}, digs)
assert.Equal(t, []string{DigestToken("sha256:ccc")}, digs)
require.NoError(t, m.DeleteRepo(context.Background(), acct, "team/app"))
_, err = m.GetRepo(context.Background(), acct, "team/app")
@@ -180,50 +181,6 @@ func TestMemoryMetaStore_DeleteRepoAndListManifests(t *testing.T) {
assert.ErrorIs(t, m.DeleteRepo(context.Background(), acct, "team/ghost"), ErrNotFound)
}
func TestMemoryMetaStore_RepoPolicy(t *testing.T) {
m := NewMemoryMetaStore()
const acct = "000000000000"
const policy = `{"Version":"2012-10-17"}`
_, err := m.GetRepoPolicy(context.Background(), acct, "team/app")
assert.ErrorIs(t, err, ErrNotFound)
_, err = m.DeleteRepoPolicy(context.Background(), acct, "team/app")
assert.ErrorIs(t, err, ErrNotFound)
require.NoError(t, m.PutRepoPolicy(context.Background(), acct, "team/app", []byte(policy)))
got, err := m.GetRepoPolicy(context.Background(), acct, "team/app")
require.NoError(t, err)
assert.Equal(t, policy, string(got))
deleted, err := m.DeleteRepoPolicy(context.Background(), acct, "team/app")
require.NoError(t, err)
assert.Equal(t, policy, string(deleted))
_, err = m.GetRepoPolicy(context.Background(), acct, "team/app")
assert.ErrorIs(t, err, ErrNotFound)
}
func TestMemoryMetaStore_LifecyclePolicy(t *testing.T) {
m := NewMemoryMetaStore()
const acct = "000000000000"
const policy = `{"rules":[]}`
_, err := m.GetLifecyclePolicy(context.Background(), acct, "team/app")
assert.ErrorIs(t, err, ErrNotFound)
_, err = m.DeleteLifecyclePolicy(context.Background(), acct, "team/app")
assert.ErrorIs(t, err, ErrNotFound)
require.NoError(t, m.PutLifecyclePolicy(context.Background(), acct, "team/app", []byte(policy)))
got, err := m.GetLifecyclePolicy(context.Background(), acct, "team/app")
require.NoError(t, err)
assert.Equal(t, policy, string(got))
deleted, err := m.DeleteLifecyclePolicy(context.Background(), acct, "team/app")
require.NoError(t, err)
assert.Equal(t, policy, string(deleted))
_, err = m.GetLifecyclePolicy(context.Background(), acct, "team/app")
assert.ErrorIs(t, err, ErrNotFound)
}
func TestManifestMeta_DecodesRecordWithoutReferrerFields(t *testing.T) {
var m ManifestMeta
require.NoError(t, json.Unmarshal([]byte(`{"digest":"sha256:ccc","mediaType":"application/vnd.oci.image.manifest.v1+json","size":10,"pushedAt":"2026-01-01T00:00:00Z","childDigests":["sha256:ddd"]}`), &m))
@@ -232,3 +189,9 @@ func TestManifestMeta_DecodesRecordWithoutReferrerFields(t *testing.T) {
assert.Empty(t, m.SubjectDigest)
assert.Empty(t, m.ArtifactType)
}
func newTestKVMetaStore(t *testing.T) *KVMetaStore {
t.Helper()
_, _, js := testutil.StartTestJetStream(t)
return NewKVMetaStore(js)
}
+1 -6
View File
@@ -4,7 +4,6 @@ import (
"context"
"testing"
"github.com/mulgadc/spinifex/internal/testkit"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -30,11 +29,7 @@ func referrerMeta(digest, subject string) ManifestMeta {
// its own referrers state.
func referrerStores(t *testing.T) map[string]MetaStore {
t.Helper()
_, nc, _ := testutil.StartTestJetStream(t)
return map[string]MetaStore{
"memory": NewMemoryMetaStore(),
"kv": NewKVMetaStore(testutil.NewJetStream(t, nc)),
}
return map[string]MetaStore{"kv": newTestKVMetaStore(t)}
}
func TestListReferrers(t *testing.T) {
@@ -50,7 +50,7 @@ func putPolicy(t *testing.T, reg *Registry, account, repo, policy string) {
const untaggedExpirePolicy = `{"rules":[{"rulePriority":1,"selection":{"tagStatus":"untagged","countType":"sinceImagePushed","countUnit":"days","countNumber":7},"action":{"type":"expire"}}]}`
func TestSweepRepo_SinceImagePushed(t *testing.T) {
reg := newTestRegistry()
reg := newTestRegistry(t)
now := time.Now().UTC()
repo := "team/app"
seedTimedImage(t, reg, testAccount, repo, "sha256:olduntagged", now.AddDate(0, 0, -10))
@@ -65,7 +65,7 @@ func TestSweepRepo_SinceImagePushed(t *testing.T) {
}
func TestSweepRepo_ImageCountMoreThan(t *testing.T) {
reg := newTestRegistry()
reg := newTestRegistry(t)
now := time.Now().UTC()
repo := "team/app"
seedTimedImage(t, reg, testAccount, repo, "sha256:a", now.AddDate(0, 0, -1), "a")
@@ -81,7 +81,7 @@ func TestSweepRepo_ImageCountMoreThan(t *testing.T) {
}
func TestSweepRepo_NoPolicy(t *testing.T) {
reg := newTestRegistry()
reg := newTestRegistry(t)
now := time.Now().UTC()
repo := "team/app"
seedTimedImage(t, reg, testAccount, repo, "sha256:x", now.AddDate(0, 0, -100))
@@ -92,7 +92,7 @@ func TestSweepRepo_NoPolicy(t *testing.T) {
}
func TestSweepRepo_Idempotent(t *testing.T) {
reg := newTestRegistry()
reg := newTestRegistry(t)
now := time.Now().UTC()
repo := "team/app"
seedTimedImage(t, reg, testAccount, repo, "sha256:olduntagged", now.AddDate(0, 0, -10))
@@ -105,7 +105,7 @@ func TestSweepRepo_Idempotent(t *testing.T) {
}
func TestSweepOnce_MultiAccount(t *testing.T) {
reg := newTestRegistry()
reg := newTestRegistry(t)
now := time.Now().UTC()
const acct2 = "000000000001"
policy := `{"rules":[{"rulePriority":1,"selection":{"tagStatus":"any","countType":"imageCountMoreThan","countNumber":1},"action":{"type":"expire"}}]}`
@@ -125,7 +125,7 @@ func TestSweepOnce_MultiAccount(t *testing.T) {
}
func TestSweepOnce_AccountsError(t *testing.T) {
reg := newTestRegistry()
reg := newTestRegistry(t)
s := NewLifecycleSweeper(reg, func() ([]string, error) {
return nil, assert.AnError
}, time.Hour)
@@ -133,7 +133,7 @@ func TestSweepOnce_AccountsError(t *testing.T) {
}
func TestNewLifecycleSweeper_DefaultInterval(t *testing.T) {
reg := newTestRegistry()
reg := newTestRegistry(t)
s := NewLifecycleSweeper(reg, func() ([]string, error) { return nil, nil }, 0)
assert.Equal(t, DefaultLifecycleSweepInterval, s.interval)
@@ -147,7 +147,7 @@ func TestNewLifecycleSweeper_DefaultInterval(t *testing.T) {
// TestSweeperRun drives the ticker loop: with a tiny interval and an expiring
// image, the first tick must delete it; cancelling the context stops Run.
func TestSweeperRun(t *testing.T) {
reg := newTestRegistry()
reg := newTestRegistry(t)
now := time.Now().UTC()
repo := "team/app"
seedTimedImage(t, reg, testAccount, repo, "sha256:olduntagged", now.AddDate(0, 0, -10))
@@ -184,7 +184,7 @@ func TestSweeperRun(t *testing.T) {
// TestSweepRepo_InvalidStoredPolicy exercises the evaluate-error branch: a
// malformed stored policy is logged and skipped, deleting nothing.
func TestSweepRepo_InvalidStoredPolicy(t *testing.T) {
reg := newTestRegistry()
reg := newTestRegistry(t)
now := time.Now().UTC()
repo := "team/app"
seedTimedImage(t, reg, testAccount, repo, "sha256:x", now.AddDate(0, 0, -100))
@@ -198,7 +198,7 @@ func TestSweepRepo_InvalidStoredPolicy(t *testing.T) {
// TestSweepRepo_PolicyWithoutRepo exercises the ListImages-not-found branch: a
// policy stored for a repo with no manifests yields nothing to expire.
func TestSweepRepo_PolicyWithoutRepo(t *testing.T) {
reg := newTestRegistry()
reg := newTestRegistry(t)
now := time.Now().UTC()
putPolicy(t, reg, testAccount, "ghost", untaggedExpirePolicy)
@@ -7,7 +7,6 @@ import (
"net/http"
"testing"
"github.com/mulgadc/spinifex/spinifex/domains/ecr"
"github.com/mulgadc/spinifex/spinifex/providers/objectstore"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -32,7 +31,7 @@ func (c *countingStore) EnsureBucket(ctx context.Context, bucket string) error {
func TestRegistry_EnsureBucketFailureReturns500(t *testing.T) {
store := &countingStore{MemoryObjectStore: objectstore.NewMemoryObjectStore(), ensureErr: errors.New("predastore down")}
reg := NewRegistry(store, ecr.NewMemoryMetaStore(), testAccount)
reg := NewRegistry(store, newTestMetaStore(t), testAccount)
w := do(reg, http.MethodGet, "/v2/myrepo/tags/list", nil, nil)
assert.Equal(t, http.StatusInternalServerError, w.Code)
@@ -40,7 +39,7 @@ func TestRegistry_EnsureBucketFailureReturns500(t *testing.T) {
func TestRegistry_EnsureBucketCachedAfterSuccess(t *testing.T) {
store := &countingStore{MemoryObjectStore: objectstore.NewMemoryObjectStore()}
reg := NewRegistry(store, ecr.NewMemoryMetaStore(), testAccount)
reg := NewRegistry(store, newTestMetaStore(t), testAccount)
do(reg, http.MethodGet, "/v2/myrepo/tags/list", nil, nil)
do(reg, http.MethodGet, "/v2/other/tags/list", nil, nil)
@@ -44,7 +44,7 @@ func storeLayersManifest(t *testing.T, reg *Registry, repo, tag string, layers .
}
func TestDeleteImage_ReclaimsBlobsKeepsShared(t *testing.T) {
reg := newTestRegistry()
reg := newTestRegistry(t)
repo := "team/app"
shared := pushBlob(t, reg, repo, []byte("shared-layer-bytes"))
exclusive := pushBlob(t, reg, repo, []byte("exclusive-layer-bytes"))
@@ -85,7 +85,7 @@ func seedImage(t *testing.T, reg *Registry, repo, tag string) ([]byte, string) {
}
func TestStoreAndGetManifest_RoundTrip(t *testing.T) {
reg := newTestRegistry()
reg := newTestRegistry(t)
manifest, digest := seedImage(t, reg, "team/app", "v1")
// Fetch by tag.
@@ -114,7 +114,7 @@ func TestStoreAndGetManifest_RoundTrip(t *testing.T) {
}
func TestListImages_TaggedAndUntagged(t *testing.T) {
reg := newTestRegistry()
reg := newTestRegistry(t)
repo := "team/app"
seedImage(t, reg, repo, "v1")
seedImage(t, reg, repo, "v2")
@@ -176,7 +176,7 @@ func TestListImages_KVDigestForm(t *testing.T) {
}
func TestDeleteImage_ByTagAndByDigest(t *testing.T) {
reg := newTestRegistry()
reg := newTestRegistry(t)
repo := "team/app"
_, digest := seedImage(t, reg, repo, "v1")
seedImage(t, reg, repo, "v2") // shares nothing; distinct digest
@@ -205,7 +205,7 @@ func TestDeleteImage_ByTagAndByDigest(t *testing.T) {
}
func TestDeleteImage_ByDigestClearsTags(t *testing.T) {
reg := newTestRegistry()
reg := newTestRegistry(t)
repo := "team/app"
_, digest := seedImage(t, reg, repo, "v1")
// Add a second tag pointing at the same digest.
@@ -221,7 +221,7 @@ func TestDeleteImage_ByDigestClearsTags(t *testing.T) {
}
func TestStoreManifest_ImmutableTag(t *testing.T) {
reg := newTestRegistry()
reg := newTestRegistry(t)
repo := "team/app"
manA := fmt.Appendf(nil, `{"schemaVersion":2,"mediaType":"%s","layers":[{"digest":"%s"}]}`,
mediaTypeDockerManifest, pushBlob(t, reg, repo, []byte("layer-a")))
@@ -254,7 +254,7 @@ func TestStoreManifest_ImmutableTag(t *testing.T) {
}
func TestStoreManifest_BadDigestReference(t *testing.T) {
reg := newTestRegistry()
reg := newTestRegistry(t)
repo := "team/app"
cfgDg := pushBlob(t, reg, repo, []byte("cfg"))
manifest := fmt.Appendf(nil,
@@ -38,7 +38,7 @@ func storedMeta(t *testing.T, reg *Registry, body string) (subject, artifactType
}
func TestPutManifest_SubjectRecordedAndAdvertised(t *testing.T) {
reg := newTestRegistry()
reg := newTestRegistry(t)
cfg := pushBlob(t, reg, referrerRepo, []byte("cfg"))
tests := []struct {
@@ -65,7 +65,7 @@ func TestPutManifest_SubjectRecordedAndAdvertised(t *testing.T) {
}
func TestPutManifest_IndexSubject(t *testing.T) {
reg := newTestRegistry()
reg := newTestRegistry(t)
seedRepo(t, reg, referrerRepo)
body := fmt.Sprintf(`{"schemaVersion":2,"mediaType":%q,"manifests":[],"subject":{"mediaType":%q,"digest":%q,"size":10}}`,
@@ -81,7 +81,7 @@ func TestPutManifest_IndexSubject(t *testing.T) {
}
func TestPutManifest_NoSubjectLeavesFieldsEmpty(t *testing.T) {
reg := newTestRegistry()
reg := newTestRegistry(t)
cfg := pushBlob(t, reg, referrerRepo, []byte("cfg"))
body := fmt.Sprintf(`{"schemaVersion":2,"mediaType":%q,"artifactType":%q,"config":{"mediaType":%q,"digest":%q,"size":3},"layers":[]}`,
mediaTypeOCIManifest, sigArtifactType, sigConfigType, cfg)
@@ -97,7 +97,7 @@ func TestPutManifest_NoSubjectLeavesFieldsEmpty(t *testing.T) {
}
func TestPutManifest_MalformedSubjectDigestRejected(t *testing.T) {
reg := newTestRegistry()
reg := newTestRegistry(t)
cfg := pushBlob(t, reg, referrerRepo, []byte("cfg"))
body := imageWithSubject(cfg, "sha256:nothex", "")
@@ -111,7 +111,7 @@ func TestPutManifest_MalformedSubjectDigestRejected(t *testing.T) {
}
func TestPutManifest_IndexWithContentTypeParameterGetsNoConfigArtifactType(t *testing.T) {
reg := newTestRegistry()
reg := newTestRegistry(t)
seedRepo(t, reg, referrerRepo)
body := fmt.Sprintf(`{"schemaVersion":2,"mediaType":%q,"manifests":[],"subject":{"mediaType":%q,"digest":%q,"size":10}}`,
@@ -128,7 +128,7 @@ func TestPutManifest_IndexWithContentTypeParameterGetsNoConfigArtifactType(t *te
}
func TestDeleteReferrerManifest_RemovesItFromTheSubjectListing(t *testing.T) {
reg := newTestRegistry()
reg := newTestRegistry(t)
cfg := pushBlob(t, reg, referrerRepo, []byte("cfg"))
body := imageWithSubject(cfg, absentSubjectDgt, sigArtifactType)
dg := digestOf([]byte(body))
@@ -183,7 +183,7 @@ func getReferrersBody(t *testing.T, reg *Registry, path string) (*httptest.Respo
}
func TestGetReferrers_ListsReferrersAsAnOCIIndex(t *testing.T) {
reg := newTestRegistry()
reg := newTestRegistry(t)
dgA := pushReferrer(t, reg, referrerRepo, absentSubjectDgt, sigArtifactType, "cfgA")
dgB := pushReferrer(t, reg, referrerRepo, absentSubjectDgt, "application/vnd.example.sbom.v1", "cfgB")
@@ -202,7 +202,7 @@ func TestGetReferrers_ListsReferrersAsAnOCIIndex(t *testing.T) {
}
func TestGetReferrers_NoReferrersIsAnEmptyIndexNotAnError(t *testing.T) {
reg := newTestRegistry()
reg := newTestRegistry(t)
seedRepo(t, reg, referrerRepo)
w := do(reg, http.MethodGet, "/v2/"+referrerRepo+"/referrers/"+absentSubjectDgt, nil, nil)
@@ -212,7 +212,7 @@ func TestGetReferrers_NoReferrersIsAnEmptyIndexNotAnError(t *testing.T) {
}
func TestGetReferrers_ArtifactTypeFilter(t *testing.T) {
reg := newTestRegistry()
reg := newTestRegistry(t)
dgSig := pushReferrer(t, reg, referrerRepo, absentSubjectDgt, sigArtifactType, "cfgA")
pushReferrer(t, reg, referrerRepo, absentSubjectDgt, "application/vnd.example.sbom.v1", "cfgB")
@@ -230,7 +230,7 @@ func TestGetReferrers_ArtifactTypeFilter(t *testing.T) {
}
func TestGetReferrers_MalformedDigest(t *testing.T) {
reg := newTestRegistry()
reg := newTestRegistry(t)
seedRepo(t, reg, referrerRepo)
w := do(reg, http.MethodGet, "/v2/"+referrerRepo+"/referrers/not-a-digest", nil, nil)
@@ -239,14 +239,14 @@ func TestGetReferrers_MalformedDigest(t *testing.T) {
}
func TestGetReferrers_UnknownRepository(t *testing.T) {
reg := newTestRegistry()
reg := newTestRegistry(t)
w := do(reg, http.MethodGet, "/v2/team/ghost/referrers/"+absentSubjectDgt, nil, nil)
assert.Equal(t, http.StatusNotFound, w.Code)
assertCode(t, w, "NAME_UNKNOWN")
}
func TestGetReferrers_DoesNotCrossRepositories(t *testing.T) {
reg := newTestRegistry()
reg := newTestRegistry(t)
pushReferrer(t, reg, referrerRepo, absentSubjectDgt, sigArtifactType, "cfgA")
seedRepo(t, reg, "team/other")
@@ -256,7 +256,7 @@ func TestGetReferrers_DoesNotCrossRepositories(t *testing.T) {
}
func TestReferrersRoute_OnlyGET(t *testing.T) {
reg := newTestRegistry()
reg := newTestRegistry(t)
seedRepo(t, reg, referrerRepo)
for _, method := range []string{http.MethodPut, http.MethodDelete, http.MethodPost, http.MethodPatch} {
w := do(reg, method, "/v2/"+referrerRepo+"/referrers/"+absentSubjectDgt, []byte("{}"), nil)
@@ -272,7 +272,7 @@ func (failingReferrerStore) ListReferrers(context.Context, string, string, strin
}
func TestGetReferrers_MetadataFailureIsA5xxNotA404(t *testing.T) {
reg := newTestRegistry()
reg := newTestRegistry(t)
seedRepo(t, reg, referrerRepo)
reg.Meta = failingReferrerStore{reg.Meta}
@@ -310,7 +310,7 @@ func referrerDigests(t *testing.T, reg *Registry, repo, subject string) []string
}
func TestDeleteManifest_SubjectDeleteRemovesItsReferrers(t *testing.T) {
reg := newTestRegistry()
reg := newTestRegistry(t)
subject := pushSubjectImage(t, reg, referrerRepo, "subject")
sig := pushReferrer(t, reg, referrerRepo, subject, sigArtifactType, "sig")
sbom := pushReferrer(t, reg, referrerRepo, subject, "application/vnd.example.sbom.v1", "sbom")
@@ -326,7 +326,7 @@ func TestDeleteManifest_SubjectDeleteRemovesItsReferrers(t *testing.T) {
}
func TestDeleteManifest_NestedReferrersAreRemoved(t *testing.T) {
reg := newTestRegistry()
reg := newTestRegistry(t)
subject := pushSubjectImage(t, reg, referrerRepo, "subject")
sig := pushReferrer(t, reg, referrerRepo, subject, sigArtifactType, "sig")
sigOfSig := pushReferrer(t, reg, referrerRepo, sig, sigArtifactType, "sig-of-sig")
@@ -341,7 +341,7 @@ func TestDeleteManifest_NestedReferrersAreRemoved(t *testing.T) {
}
func TestDeleteManifest_ReferrerDeleteLeavesTheSubject(t *testing.T) {
reg := newTestRegistry()
reg := newTestRegistry(t)
subject := pushSubjectImage(t, reg, referrerRepo, "subject")
sig := pushReferrer(t, reg, referrerRepo, subject, sigArtifactType, "sig")
other := pushReferrer(t, reg, referrerRepo, subject, "application/vnd.example.sbom.v1", "sbom")
@@ -355,7 +355,7 @@ func TestDeleteManifest_ReferrerDeleteLeavesTheSubject(t *testing.T) {
}
func TestDeleteManifest_TagDeleteDoesNotCascade(t *testing.T) {
reg := newTestRegistry()
reg := newTestRegistry(t)
subject := pushSubjectImage(t, reg, referrerRepo, "subject")
sig := pushReferrer(t, reg, referrerRepo, subject, sigArtifactType, "sig")
require.NoError(t, reg.Meta.PutTag(context.Background(), testAccount, referrerRepo, "v1", subject))
@@ -368,7 +368,7 @@ func TestDeleteManifest_TagDeleteDoesNotCascade(t *testing.T) {
}
func TestDeleteManifest_CascadeStaysInsideTheRepository(t *testing.T) {
reg := newTestRegistry()
reg := newTestRegistry(t)
subject := pushSubjectImage(t, reg, referrerRepo, "subject")
pushReferrer(t, reg, referrerRepo, subject, sigArtifactType, "sig")
elsewhere := pushReferrer(t, reg, "team/other", subject, sigArtifactType, "sig-other")
@@ -394,7 +394,7 @@ func (s failingDeleteStore) DeleteManifestMeta(ctx context.Context, account, rep
}
func TestDeleteManifest_ReferrerFailureDoesNotFailTheSubjectDelete(t *testing.T) {
reg := newTestRegistry()
reg := newTestRegistry(t)
subject := pushSubjectImage(t, reg, referrerRepo, "subject")
stuck := pushReferrer(t, reg, referrerRepo, subject, sigArtifactType, "stuck")
fine := pushReferrer(t, reg, referrerRepo, subject, "application/vnd.example.sbom.v1", "fine")
+36 -28
View File
@@ -2,6 +2,7 @@ package ecrregistry
import (
"context"
"github.com/mulgadc/spinifex/internal/testkit"
"crypto/sha256"
"encoding/hex"
@@ -21,8 +22,15 @@ import (
const testAccount = "000000000000"
func newTestRegistry() *Registry {
return NewRegistry(objectstore.NewMemoryObjectStore(), ecr.NewMemoryMetaStore(), testAccount)
func newTestRegistry(t *testing.T) *Registry {
t.Helper()
return NewRegistry(objectstore.NewMemoryObjectStore(), newTestMetaStore(t), testAccount)
}
func newTestMetaStore(t *testing.T) *ecr.KVMetaStore {
t.Helper()
_, _, js := testutil.StartTestJetStream(t)
return ecr.NewKVMetaStore(js)
}
func digestOf(b []byte) string {
@@ -69,7 +77,7 @@ func pushBlob(t *testing.T, reg *Registry, repo string, data []byte) string {
}
func TestBlobUpload_Monolithic_And_Head_Get(t *testing.T) {
reg := newTestRegistry()
reg := newTestRegistry(t)
data := []byte("hello layer bytes")
dg := pushBlob(t, reg, "team/app", data)
@@ -84,20 +92,20 @@ func TestBlobUpload_Monolithic_And_Head_Get(t *testing.T) {
}
func TestBlobHead_NotFound(t *testing.T) {
reg := newTestRegistry()
reg := newTestRegistry(t)
w := do(reg, http.MethodHead, "/v2/team/app/blobs/"+digestOf([]byte("absent")), nil, nil)
assert.Equal(t, http.StatusNotFound, w.Code)
}
func TestBlobGet_MalformedDigest(t *testing.T) {
reg := newTestRegistry()
reg := newTestRegistry(t)
w := do(reg, http.MethodGet, "/v2/team/app/blobs/sha256:zzz", nil, nil)
assert.Equal(t, http.StatusBadRequest, w.Code)
assertCode(t, w, "DIGEST_INVALID")
}
func TestBlobUpload_Chunked(t *testing.T) {
reg := newTestRegistry()
reg := newTestRegistry(t)
seedRepo(t, reg, "r/repo")
c1 := []byte("first-chunk-")
c2 := []byte("second-chunk")
@@ -124,7 +132,7 @@ func TestBlobUpload_Chunked(t *testing.T) {
}
func TestBlobUpload_DigestMismatch(t *testing.T) {
reg := newTestRegistry()
reg := newTestRegistry(t)
seedRepo(t, reg, "r/repo")
w := do(reg, http.MethodPost, "/v2/r/repo/blobs/uploads/", nil, nil)
loc := w.Header().Get("Location")
@@ -134,7 +142,7 @@ func TestBlobUpload_DigestMismatch(t *testing.T) {
}
func TestBlobUpload_Cancel(t *testing.T) {
reg := newTestRegistry()
reg := newTestRegistry(t)
seedRepo(t, reg, "r/repo")
w := do(reg, http.MethodPost, "/v2/r/repo/blobs/uploads/", nil, nil)
loc := w.Header().Get("Location")
@@ -161,7 +169,7 @@ func putManifestReferencing(t *testing.T, reg *Registry, repo, digest string) {
}
func TestBlobMount_HitAndMiss(t *testing.T) {
reg := newTestRegistry()
reg := newTestRegistry(t)
data := []byte("shared-layer")
dg := pushBlob(t, reg, "team/source", data)
putManifestReferencing(t, reg, "team/source", dg)
@@ -185,7 +193,7 @@ func TestBlobMount_HitAndMiss(t *testing.T) {
// as a plain upload start, matching the authorization middleware's
// classification of the same request.
func TestBlobMount_MissingFromFallsThroughToUpload(t *testing.T) {
reg := newTestRegistry()
reg := newTestRegistry(t)
data := []byte("shared-layer")
dg := pushBlob(t, reg, "team/source", data)
putManifestReferencing(t, reg, "team/source", dg)
@@ -200,7 +208,7 @@ func TestBlobMount_MissingFromFallsThroughToUpload(t *testing.T) {
// named source repository is rejected as a mount — proving digest existence
// alone is not enough to mount cross-repo.
func TestBlobMount_UnreferencedDigestFallsThrough(t *testing.T) {
reg := newTestRegistry()
reg := newTestRegistry(t)
unreferenced := pushBlob(t, reg, "team/other", []byte("not-in-source"))
seedRepo(t, reg, "team/source")
seedRepo(t, reg, "team/dest")
@@ -214,7 +222,7 @@ func TestBlobMount_UnreferencedDigestFallsThrough(t *testing.T) {
// repository that does not exist never mounts, and never distinguishes
// itself from an unreferenced-digest miss (both fall through identically).
func TestBlobMount_NonexistentSourceFallsThrough(t *testing.T) {
reg := newTestRegistry()
reg := newTestRegistry(t)
dg := pushBlob(t, reg, "team/elsewhere", []byte("some-bytes"))
seedRepo(t, reg, "team/dest")
@@ -224,7 +232,7 @@ func TestBlobMount_NonexistentSourceFallsThrough(t *testing.T) {
}
func TestManifest_PutGetHead_ImageManifest(t *testing.T) {
reg := newTestRegistry()
reg := newTestRegistry(t)
repo := "team/app"
cfg := []byte(`{"config":true}`)
@@ -255,7 +263,7 @@ func TestManifest_PutGetHead_ImageManifest(t *testing.T) {
}
func TestManifest_Put_MissingBlob(t *testing.T) {
reg := newTestRegistry()
reg := newTestRegistry(t)
repo := "team/app"
seedRepo(t, reg, repo)
manifest := fmt.Appendf(nil,
@@ -268,7 +276,7 @@ func TestManifest_Put_MissingBlob(t *testing.T) {
}
func TestManifest_Get_AcceptMismatch(t *testing.T) {
reg := newTestRegistry()
reg := newTestRegistry(t)
repo := "team/app"
cfg := pushBlob(t, reg, repo, []byte("c"))
manifest := fmt.Appendf(nil,
@@ -285,7 +293,7 @@ func TestManifest_Get_AcceptMismatch(t *testing.T) {
// acceptable manifest media type as its own Accept header line rather than one
// comma-joined value. Negotiation must consider every line, not just the first.
func TestManifest_Get_MultipleAcceptHeaders(t *testing.T) {
reg := newTestRegistry()
reg := newTestRegistry(t)
repo := "team/app"
cfg := pushBlob(t, reg, repo, []byte("c"))
manifest := fmt.Appendf(nil,
@@ -305,7 +313,7 @@ func TestManifest_Get_MultipleAcceptHeaders(t *testing.T) {
}
func TestManifest_Get_Unknown(t *testing.T) {
reg := newTestRegistry()
reg := newTestRegistry(t)
w := do(reg, http.MethodGet, "/v2/team/app/manifests/missing", nil, nil)
assert.Equal(t, http.StatusNotFound, w.Code)
assertCode(t, w, "MANIFEST_UNKNOWN")
@@ -314,7 +322,7 @@ func TestManifest_Get_Unknown(t *testing.T) {
// TestStartUpload_RepoNotCreated asserts a blob upload to a repository that was
// never created is rejected up front — ECR does not auto-create on push.
func TestStartUpload_RepoNotCreated(t *testing.T) {
reg := newTestRegistry()
reg := newTestRegistry(t)
w := do(reg, http.MethodPost, "/v2/team/ghost/blobs/uploads/", nil, nil)
assert.Equal(t, http.StatusNotFound, w.Code)
assertCode(t, w, "NAME_UNKNOWN")
@@ -323,7 +331,7 @@ func TestStartUpload_RepoNotCreated(t *testing.T) {
// TestPutManifest_RepoNotCreated asserts a manifest PUT to an uncreated
// repository is rejected with NAME_UNKNOWN.
func TestPutManifest_RepoNotCreated(t *testing.T) {
reg := newTestRegistry()
reg := newTestRegistry(t)
manifest := fmt.Appendf(nil, `{"mediaType":"%s","layers":[]}`, mediaTypeDockerManifest)
w := do(reg, http.MethodPut, "/v2/team/ghost/manifests/v1", manifest,
map[string]string{"Content-Type": mediaTypeDockerManifest})
@@ -332,7 +340,7 @@ func TestPutManifest_RepoNotCreated(t *testing.T) {
}
func TestManifest_ImageIndex_ChildValidation(t *testing.T) {
reg := newTestRegistry()
reg := newTestRegistry(t)
repo := "team/app"
// Push a child image manifest first.
@@ -372,7 +380,7 @@ func TestManifest_ImageIndex_ChildValidation(t *testing.T) {
}
func TestManifest_Delete_Tag(t *testing.T) {
reg := newTestRegistry()
reg := newTestRegistry(t)
repo := "team/app"
cfg := pushBlob(t, reg, repo, []byte("c"))
manifest := fmt.Appendf(nil,
@@ -390,7 +398,7 @@ func TestManifest_Delete_Tag(t *testing.T) {
// TestManifest_Delete_ByDigest proves delete-by-digest is no longer a no-op: it
// removes the manifest record + object, and a subsequent GET is a 404.
func TestManifest_Delete_ByDigest(t *testing.T) {
reg := newTestRegistry()
reg := newTestRegistry(t)
repo := "team/app"
cfg := pushBlob(t, reg, repo, []byte("cfg-bytes"))
manifest := fmt.Appendf(nil,
@@ -412,7 +420,7 @@ func TestManifest_Delete_ByDigest(t *testing.T) {
}
func TestTagsList(t *testing.T) {
reg := newTestRegistry()
reg := newTestRegistry(t)
repo := "team/app"
cfg := pushBlob(t, reg, repo, []byte("c"))
manifest := fmt.Appendf(nil,
@@ -434,13 +442,13 @@ func TestTagsList(t *testing.T) {
}
func TestTagsList_UnknownRepo(t *testing.T) {
reg := newTestRegistry()
reg := newTestRegistry(t)
w := do(reg, http.MethodGet, "/v2/team/ghost/tags/list", nil, nil)
assert.Equal(t, http.StatusNotFound, w.Code)
}
func TestCatalog(t *testing.T) {
reg := newTestRegistry()
reg := newTestRegistry(t)
pushBlob(t, reg, "team/a", []byte("a"))
pushBlob(t, reg, "team/b", []byte("b"))
@@ -454,21 +462,21 @@ func TestCatalog(t *testing.T) {
}
func TestDispatch_InvalidRepoName(t *testing.T) {
reg := newTestRegistry()
reg := newTestRegistry(t)
w := do(reg, http.MethodGet, "/v2/Team/App/tags/list", nil, nil)
assert.Equal(t, http.StatusBadRequest, w.Code)
assertCode(t, w, "NAME_INVALID")
}
func TestDispatch_RepeatedSeparatorRepoName(t *testing.T) {
reg := newTestRegistry()
reg := newTestRegistry(t)
dg := pushBlob(t, reg, "team/a__b--c", []byte("layer"))
w := do(reg, http.MethodHead, "/v2/team/a__b--c/blobs/"+dg, nil, nil)
assert.Equal(t, http.StatusOK, w.Code)
}
func TestDispatch_UnknownPath(t *testing.T) {
reg := newTestRegistry()
reg := newTestRegistry(t)
w := do(reg, http.MethodGet, "/v2/team/app/frobnicate", nil, nil)
assert.Equal(t, http.StatusNotFound, w.Code)
}
@@ -35,7 +35,7 @@ func (s *blobPutFailStore) PutObject(ctx context.Context, input *s3.PutObjectInp
func TestFinishUpload_StoreFailure_CleansUp(t *testing.T) {
mem := objectstore.NewMemoryObjectStore()
store := &blobPutFailStore{MemoryObjectStore: mem}
meta := ecr.NewMemoryMetaStore()
meta := newTestMetaStore(t)
reg := NewRegistry(store, meta, testAccount)
seedRepo(t, reg, "team/app")
@@ -72,7 +72,7 @@ func awsString(s string) *string { return &s }
// updates issued from the same observed revision cannot both commit. One wins,
// the other gets ErrConflict — so a racing PATCH is rejected, never merged.
func TestUploadCAS_SameRevisionSingleWinner(t *testing.T) {
meta := ecr.NewMemoryMetaStore()
meta := newTestMetaStore(t)
rev, err := meta.PutUpload(context.Background(), testAccount, "u-race", ecr.UploadState{RepoName: "r"})
require.NoError(t, err)
@@ -116,7 +116,7 @@ func TestUploadCAS_SameRevisionSingleWinner(t *testing.T) {
// hash state and the recorded byte object never desynchronise.
func TestPatchUpload_ConcurrentNoCorruption(t *testing.T) {
for iter := range 25 {
reg := newTestRegistry()
reg := newTestRegistry(t)
repo := "team/race"
seedRepo(t, reg, repo)
+1 -2
View File
@@ -16,8 +16,7 @@ type MetaServiceImpl struct {
var _ MetaService = (*MetaServiceImpl)(nil)
// NewMetaServiceImpl builds a MetaService over an explicit MetaStore (used by
// daemon-side tests with a MemoryMetaStore).
// NewMetaServiceImpl builds a MetaService over an explicit MetaStore.
func NewMetaServiceImpl(store MetaStore) *MetaServiceImpl {
return &MetaServiceImpl{store: store}
}
+1 -1
View File
@@ -21,7 +21,7 @@ const svcTestAccount = "000000000000"
// ErrNotFound/ErrConflict into the response flags rather than transport errors,
// so they survive the AWS-error-code-only NATS reply.
func TestMetaServiceImpl_FlagMapping(t *testing.T) {
svc := NewMetaServiceImpl(NewMemoryMetaStore())
svc := NewMetaServiceImpl(newTestKVMetaStore(t))
// Absent repo -> Found:false, nil error.
dr, err := svc.RepoDescribe(context.Background(), &RepoDescribeRequest{Repo: "ghost"}, svcTestAccount)
+50 -11
View File
@@ -23,42 +23,81 @@ func notFound(region, accountID, name string) error {
arn.FormatLambdaFunction(region, accountID, name))
}
// getFunction implements GetFunction for the $LATEST version.
func (d Deps) getFunction(ctx context.Context, c *call) (reply, error) {
// lookup resolves the requested $LATEST function's record; query is the
// Qualifier query parameter, empty for an action that has none.
func (d Deps) lookup(ctx context.Context, c *call, query string) (function.Record, uint64, error) {
name, q, err := resolveFunctionName(c.Region, c.AccountID, c.Params[0])
if err != nil {
return reply{}, err
return function.Record{}, 0, err
}
if query := c.Query.Get("Qualifier"); query != "" {
if query != "" {
if q != "" && q != query {
return reply{}, invalid("The derived qualifier from the function name does not match the specified qualifier.")
return function.Record{}, 0, invalid("The derived qualifier from the function name does not match the specified qualifier.")
}
q = query
}
if q != "" && q != "$LATEST" {
return reply{}, notFound(c.Region, c.AccountID, name+":"+q)
return function.Record{}, 0, notFound(c.Region, c.AccountID, name+":"+q)
}
rec, revision, err := d.Meta.Get(ctx, c.AccountID, c.Region, name)
if errors.Is(err, function.ErrNotFound) {
return reply{}, notFound(c.Region, c.AccountID, name)
return function.Record{}, 0, notFound(c.Region, c.AccountID, name)
}
return rec, revision, err
}
// loadConfiguration resolves the requested $LATEST function and renders its
// configuration; it is a metadata read and never touches the code store.
func (d Deps) loadConfiguration(ctx context.Context, c *call) (function.Record, *lambda.FunctionConfiguration, error) {
rec, revision, err := d.lookup(ctx, c, c.Query.Get("Qualifier"))
if err != nil {
return reply{}, err
return function.Record{}, nil, err
}
var env map[string]string
if rec.EnvCipher != "" {
if d.AEAD == nil {
return reply{}, serviceError("environment decryption is unavailable")
return function.Record{}, nil, serviceError("environment decryption is unavailable")
}
if env, err = function.OpenEnv(d.AEAD, rec.AccountID, rec.FunctionID, rec.EnvCipher); err != nil {
slog.ErrorContext(ctx, "Lambda: open environment failed", "function_id", rec.FunctionID, "err", err)
return reply{}, serviceError("could not read the function environment")
return function.Record{}, nil, serviceError("could not read the function environment")
}
}
cfg, err := configuration(rec, revision, env)
if err != nil {
return reply{}, serviceError("could not build the function configuration")
return function.Record{}, nil, serviceError("could not build the function configuration")
}
return rec, cfg, nil
}
// getFunctionConfiguration implements GetFunctionConfiguration for $LATEST.
func (d Deps) getFunctionConfiguration(ctx context.Context, c *call) (reply, error) {
_, cfg, err := d.loadConfiguration(ctx, c)
if err != nil {
return reply{}, err
}
return reply{Status: http.StatusOK, Body: cfg}, nil
}
// noCodeSigning is AWS's body for a function without a code signing config:
// both members present and null, which the SDK shapes cannot encode.
var noCodeSigning = rawJSON(`{"CodeSigningConfigArn":null,"FunctionName":null}`)
// getFunctionCodeSigningConfig implements GetFunctionCodeSigningConfig; code
// signing is unsupported, so it only proves the function exists.
func (d Deps) getFunctionCodeSigningConfig(ctx context.Context, c *call) (reply, error) {
if _, _, err := d.lookup(ctx, c, ""); err != nil {
return reply{}, err
}
return reply{Status: http.StatusOK, Body: noCodeSigning}, nil
}
// getFunction implements GetFunction for the $LATEST version.
func (d Deps) getFunction(ctx context.Context, c *call) (reply, error) {
rec, cfg, err := d.loadConfiguration(ctx, c)
if err != nil {
return reply{}, err
}
out := &lambda.GetFunctionOutput{
Configuration: cfg,
+204 -1
View File
@@ -662,8 +662,211 @@ func TestAuthorization(t *testing.T) {
func TestRegistration_ResolvesActionsAndInventory(t *testing.T) {
reg := awsapi.NewRegistration(awsapi.Deps{})
assert.Equal(t, []string{"CreateFunction", "GetFunction"}, reg.Inventory.Registered)
assert.Equal(t, []string{"CreateFunction", "GetFunction", "GetFunctionCodeSigningConfig", "GetFunctionConfiguration"}, reg.Inventory.Registered)
assert.Equal(t, "GetFunctionCodeSigningConfig", reg.ResolveAction(httptest.NewRequest(http.MethodGet, "/2020-06-30/functions/x/code-signing-config", nil)))
assert.Equal(t, "CreateFunction", reg.ResolveAction(httptest.NewRequest(http.MethodPost, "/2015-03-31/functions", nil)))
assert.Equal(t, "GetFunction", reg.ResolveAction(httptest.NewRequest(http.MethodGet, "/2015-03-31/functions/x", nil)))
assert.Equal(t, "GetFunctionConfiguration", reg.ResolveAction(httptest.NewRequest(http.MethodGet, "/2015-03-31/functions/x/configuration", nil)))
assert.Empty(t, reg.ResolveAction(httptest.NewRequest(http.MethodDelete, "/2015-03-31/functions/x", nil)))
}
func TestGetFunctionConfiguration_ReturnsTheSameConfigurationAsGetFunction(t *testing.T) {
e := newEnv(t)
_, err := e.do(http.MethodPost, "/2015-03-31/functions", createBody("hello", func(b map[string]any) {
b["Environment"] = map[string]any{"Variables": map[string]string{"MODE": "prod"}}
}))
require.NoError(t, err)
rec, err := e.do(http.MethodGet, "/2015-03-31/functions/hello", nil)
require.NoError(t, err)
var full lambda.GetFunctionOutput
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &full))
rec, err = e.do(http.MethodGet, "/2015-03-31/functions/hello/configuration", nil)
require.NoError(t, err)
require.Equal(t, http.StatusOK, rec.Code)
assert.NotContains(t, rec.Body.String(), `"Code"`, "the configuration is the top-level body, not GetFunction's wrapper")
var cfg lambda.FunctionConfiguration
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &cfg))
assert.Equal(t, *full.Configuration, cfg)
assert.Equal(t, "prod", *cfg.Environment.Variables["MODE"])
arn := "arn%3Aaws%3Alambda%3A" + gwtest.Region + "%3A" + gwtest.AccountID + "%3Afunction%3Ahello"
for _, path := range []string{
"/2015-03-31/functions/hello/configuration?Qualifier=%24LATEST",
"/2015-03-31/functions/" + arn + "/configuration",
"/2015-03-31/functions/" + arn + "%3A%24LATEST/configuration",
} {
rec, err := e.do(http.MethodGet, path, nil)
require.NoError(t, err, path)
assert.Equal(t, http.StatusOK, rec.Code, path)
}
}
func TestGetFunctionConfiguration_MissingAndUnsupportedMatchGetFunction(t *testing.T) {
e := newEnv(t)
_, err := e.do(http.MethodPost, "/2015-03-31/functions", createBody("hello", nil))
require.NoError(t, err)
for label, tc := range map[string]struct{ suffix, code string }{
"missing": {"ghost", awserrors.ErrorResourceNotFoundException},
"version": {"hello?Qualifier=3", awserrors.ErrorResourceNotFoundException},
"other account": {"arn%3Aaws%3Alambda%3A" + gwtest.Region + "%3A999999999999%3Afunction%3Ahello", awserrors.ErrorResourceNotFoundException},
"qualifier clash": {"hello%3A%24LATEST?Qualifier=3", awserrors.ErrorInvalidParameterValueException},
"not lambda": {"arn%3Aaws%3As3%3A%3A%3Abucket", awserrors.ErrorInvalidParameterValueException},
} {
t.Run(label, func(t *testing.T) {
path, query, _ := strings.Cut(tc.suffix, "?")
if query != "" {
query = "?" + query
}
_, getErr := e.do(http.MethodGet, "/2015-03-31/functions/"+path+query, nil)
requireCode(t, getErr, tc.code)
_, err := e.do(http.MethodGet, "/2015-03-31/functions/"+path+"/configuration"+query, nil)
requireCode(t, err, tc.code)
assert.Equal(t, getErr.Error(), err.Error())
})
}
e.h.Region = "us-west-2"
_, err = e.do(http.MethodGet, "/2015-03-31/functions/hello/configuration", nil)
requireCode(t, err, awserrors.ErrorResourceNotFoundException)
}
func TestGetFunctionConfiguration_DoesNotNeedTheCodeStore(t *testing.T) {
for name, codes := range map[string]awsapi.CodeStoreFunc{
"store outage": func(context.Context) (awsapi.CodeStore, error) { return nil, errors.New("code store down") },
"no store": nil,
} {
t.Run(name, func(t *testing.T) {
e := newEnv(t)
_, err := e.do(http.MethodPost, "/2015-03-31/functions", createBody("hello", nil))
require.NoError(t, err)
e.deps.Codes = codes
rec, err := e.do(http.MethodGet, "/2015-03-31/functions/hello/configuration", nil)
require.NoError(t, err)
require.Equal(t, http.StatusOK, rec.Code)
var cfg lambda.FunctionConfiguration
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &cfg))
assert.Equal(t, "hello", *cfg.FunctionName)
_, err = e.do(http.MethodGet, "/2015-03-31/functions/hello", nil)
requireCode(t, err, awserrors.ErrorServiceException)
})
}
}
func TestGetFunctionConfiguration_Authorization(t *testing.T) {
arn := "arn:aws:lambda:" + gwtest.Region + ":" + gwtest.AccountID + ":function:hello"
setup := func(t *testing.T, statements ...iamdomain.Statement) *env {
e := newEnv(t, statements...)
_, err := e.meta.Create(t.Context(), gwtest.AccountID, gwtest.Region, function.Record{
FunctionID: "f1", FunctionName: "hello", Role: roleARN, Runtime: "provided.al2023", Handler: "h",
Timeout: 3, MemorySize: 128, Code: function.Code{RevisionID: "r1"},
})
require.NoError(t, err)
return e
}
get := func(e *env) error {
_, err := e.do(http.MethodGet, "/2015-03-31/functions/hello/configuration", nil)
return err
}
t.Run("lambda:GetFunction alone is not enough", func(t *testing.T) {
gwtest.AssertDenied(t, get(setup(t, gwtest.Statement("Allow", "lambda:GetFunction", "*"))))
})
t.Run("a deny on the function ARN applies", func(t *testing.T) {
gwtest.AssertDenied(t, get(setup(t,
gwtest.Statement("Allow", "lambda:*", "*"),
gwtest.Statement("Deny", "lambda:GetFunctionConfiguration", arn))))
})
t.Run("an allow on another function ARN does not apply", func(t *testing.T) {
gwtest.AssertDenied(t, get(setup(t, gwtest.Statement("Allow", "lambda:GetFunctionConfiguration", arn+"-other"))))
})
t.Run("an allow on the function ARN passes", func(t *testing.T) {
require.NoError(t, get(setup(t, gwtest.Statement("Allow", "lambda:GetFunctionConfiguration", arn))))
})
}
func TestGetFunctionCodeSigningConfig_ExistingFunctionHasExplicitNulls(t *testing.T) {
e := newEnv(t)
_, err := e.do(http.MethodPost, "/2015-03-31/functions", createBody("hello", nil))
require.NoError(t, err)
arn := "arn%3Aaws%3Alambda%3A" + gwtest.Region + "%3A" + gwtest.AccountID + "%3Afunction%3Ahello"
for _, name := range []string{"hello", arn, gwtest.AccountID + "%3Afunction%3Ahello"} {
rec, err := e.do(http.MethodGet, "/2020-06-30/functions/"+name+"/code-signing-config", nil)
require.NoError(t, err, name)
require.Equal(t, http.StatusOK, rec.Code, name)
assert.Equal(t, "application/json", rec.Header().Get("Content-Type"))
assert.JSONEq(t, `{"CodeSigningConfigArn":null,"FunctionName":null}`, rec.Body.String(), name)
}
}
func TestGetFunctionCodeSigningConfig_MissingFunctionIsNotFound(t *testing.T) {
e := newEnv(t)
_, err := e.do(http.MethodPost, "/2015-03-31/functions", createBody("hello", nil))
require.NoError(t, err)
_, err = e.do(http.MethodGet, "/2020-06-30/functions/ghost/code-signing-config", nil)
requireCode(t, err, awserrors.ErrorResourceNotFoundException)
_, getErr := e.do(http.MethodGet, "/2015-03-31/functions/ghost", nil)
assert.Equal(t, getErr.Error(), err.Error())
e.h.Region = "us-west-2"
_, err = e.do(http.MethodGet, "/2020-06-30/functions/hello/code-signing-config", nil)
requireCode(t, err, awserrors.ErrorResourceNotFoundException)
}
func TestGetFunctionCodeSigningConfig_NeedsNeitherCodeStoreNorDecryption(t *testing.T) {
e := newEnv(t)
_, err := e.do(http.MethodPost, "/2015-03-31/functions", createBody("hello", func(b map[string]any) {
b["Environment"] = map[string]any{"Variables": map[string]string{"MODE": "prod"}}
}))
require.NoError(t, err)
e.deps.Codes = func(context.Context) (awsapi.CodeStore, error) { return nil, errors.New("code store down") }
e.deps.AEAD = nil
rec, err := e.do(http.MethodGet, "/2020-06-30/functions/hello/code-signing-config", nil)
require.NoError(t, err)
assert.JSONEq(t, `{"CodeSigningConfigArn":null,"FunctionName":null}`, rec.Body.String())
_, err = e.do(http.MethodGet, "/2015-03-31/functions/hello/configuration", nil)
requireCode(t, err, awserrors.ErrorServiceException)
e.deps.Codes = nil
_, err = e.do(http.MethodGet, "/2020-06-30/functions/hello/code-signing-config", nil)
require.NoError(t, err)
}
func TestGetFunctionCodeSigningConfig_Authorization(t *testing.T) {
arn := "arn:aws:lambda:" + gwtest.Region + ":" + gwtest.AccountID + ":function:hello"
setup := func(t *testing.T, statements ...iamdomain.Statement) *env {
e := newEnv(t, statements...)
_, err := e.meta.Create(t.Context(), gwtest.AccountID, gwtest.Region, function.Record{
FunctionID: "f1", FunctionName: "hello", Role: roleARN, Runtime: "provided.al2023", Handler: "h",
Timeout: 3, MemorySize: 128, Code: function.Code{RevisionID: "r1"},
})
require.NoError(t, err)
return e
}
get := func(e *env) error {
_, err := e.do(http.MethodGet, "/2020-06-30/functions/hello/code-signing-config", nil)
return err
}
t.Run("lambda:GetFunction alone is not enough", func(t *testing.T) {
gwtest.AssertDenied(t, get(setup(t, gwtest.Statement("Allow", "lambda:GetFunction", "*"))))
})
t.Run("a deny on the function ARN applies", func(t *testing.T) {
gwtest.AssertDenied(t, get(setup(t,
gwtest.Statement("Allow", "lambda:*", "*"),
gwtest.Statement("Deny", "lambda:GetFunctionCodeSigningConfig", arn))))
})
t.Run("an allow on another function ARN does not apply", func(t *testing.T) {
gwtest.AssertDenied(t, get(setup(t, gwtest.Statement("Allow", "lambda:GetFunctionCodeSigningConfig", arn+"-other"))))
})
t.Run("an allow on the function ARN passes", func(t *testing.T) {
require.NoError(t, get(setup(t, gwtest.Statement("Allow", "lambda:GetFunctionCodeSigningConfig", arn))))
})
}
+11 -3
View File
@@ -117,7 +117,7 @@ func (d Deps) serve(w http.ResponseWriter, inv dispatch.Invocation) error {
if decodeErr == nil {
resource = functionARN(inv.Region, inv.AccountID, aws.StringValue(c.Create.FunctionName))
}
case "GetFunction":
case "GetFunction", "GetFunctionConfiguration", "GetFunctionCodeSigningConfig":
if name, _, err := resolveFunctionName(inv.Region, inv.AccountID, params[0]); err == nil {
resource = functionARN(inv.Region, inv.AccountID, name)
}
@@ -133,7 +133,9 @@ func (d Deps) serve(w http.ResponseWriter, inv dispatch.Invocation) error {
return err
}
}
if d.Meta == nil || d.Codes == nil {
// Only GetFunction presigns code; the other reads are metadata only.
needsCodes := action != "GetFunctionConfiguration" && action != "GetFunctionCodeSigningConfig"
if d.Meta == nil || (d.Codes == nil && needsCodes) {
return errors.New(awserrors.ErrorServiceException)
}
@@ -180,7 +182,13 @@ func (d Deps) checkPassRole(inv dispatch.Invocation, roleARN string) error {
}
func writeJSON(w http.ResponseWriter, out reply) {
body, err := jsonutil.BuildJSON(out.Body)
var body []byte
var err error
if raw, ok := out.Body.(rawJSON); ok {
body = raw
} else {
body, err = jsonutil.BuildJSON(out.Body)
}
if err != nil {
slog.Error("Failed to marshal Lambda response", "err", err)
w.Header().Set("Content-Type", "application/json")
+12
View File
@@ -21,6 +21,10 @@ type call struct {
Create *lambda.CreateFunctionInput
}
// rawJSON is a response body written as is, for a shape the SDK encoder
// cannot produce.
type rawJSON []byte
// reply is a handler's success: the HTTP status and the SDK shape to encode.
type reply struct {
Status int
@@ -41,6 +45,14 @@ var routes = []route{
Handler: func(ctx context.Context, d Deps, c *call) (reply, error) {
return d.getFunction(ctx, c)
}},
{Method: "GET", Pattern: "/2015-03-31/functions/{FunctionName}/configuration", Action: "GetFunctionConfiguration",
Handler: func(ctx context.Context, d Deps, c *call) (reply, error) {
return d.getFunctionConfiguration(ctx, c)
}},
{Method: "GET", Pattern: "/2020-06-30/functions/{FunctionName}/code-signing-config", Action: "GetFunctionCodeSigningConfig",
Handler: func(ctx context.Context, d Deps, c *call) (reply, error) {
return d.getFunctionCodeSigningConfig(ctx, c)
}},
}
// actionNames returns the distinct actions in routes in stable order.
+3 -2
View File
@@ -31,7 +31,7 @@ type VolumeService interface {
// Publisher is the provider pair used to reach the new volume's blocks.
type Publisher interface {
PublishVolume(context.Context, ebsprovider.PublishVolumeRequest) (*ebsprovider.PublishedVolume, error)
UnpublishVolume(context.Context, ebsprovider.UnpublishVolumeRequest) error
UnpublishVolume(context.Context, ebsprovider.UnpublishVolumeRequest) (*ebsprovider.UnpublishedVolume, error)
}
// Request names the volume to create.
@@ -204,9 +204,10 @@ func (p *Preparer) fill(ctx context.Context, volumeID, image string) error {
func (p *Preparer) unpublish(ctx context.Context, volumeID string) error {
cctx, cancel := p.cleanupContext(ctx)
defer cancel()
return p.Provider.UnpublishVolume(cctx, ebsprovider.UnpublishVolumeRequest{
_, err := p.Provider.UnpublishVolume(cctx, ebsprovider.UnpublishVolumeRequest{
Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID, NodeID: p.NodeID,
})
return err
}
// fail discards the volume when its export is known closed and otherwise
@@ -105,14 +105,17 @@ func (h *harness) PublishVolume(_ context.Context, req ebsprovider.PublishVolume
return &ebsprovider.PublishedVolume{VolumeID: req.VolumeID, NodeID: req.NodeID, Export: export}, nil
}
func (h *harness) UnpublishVolume(ctx context.Context, req ebsprovider.UnpublishVolumeRequest) error {
func (h *harness) UnpublishVolume(ctx context.Context, req ebsprovider.UnpublishVolumeRequest) (*ebsprovider.UnpublishedVolume, error) {
h.record("unpublish " + req.VolumeID + " on " + req.NodeID)
h.unpublishCtxOK = ctx.Err() == nil
if h.unpublishBlocks {
<-ctx.Done()
return ctx.Err()
return nil, ctx.Err()
}
return h.unpublishErr
if h.unpublishErr != nil {
return nil, h.unpublishErr
}
return &ebsprovider.UnpublishedVolume{}, nil
}
func (h *harness) preparer(t *testing.T) *Preparer {
+9 -3
View File
@@ -35,6 +35,12 @@ type Engine struct {
// TERMINATING and before the first stage. It records evidence and gates nothing.
OnQuiesced func(ctx context.Context, quiescence operatorv1.IAMQuiescence)
// Cleanup runs the EC2 platform cleanup. Only Exercise uses it.
Cleanup PlatformCleaner
// OnExercised receives what a non-finalizing run did after quiescence.
OnExercised func(ctx context.Context, result operatorv1.ExerciseResult)
// ec2Inventory is EC2's read-only platform inventory. Only DryRun consults
// it, and only to log a comparison; no teardown or Inventory call does.
ec2Inventory ec2PlatformInventory
@@ -129,7 +135,7 @@ func (e *Engine) Teardown(ctx context.Context, req Request) (*Result, error) {
return nil, fmt.Errorf("quiesce account: %w", err)
}
if e.OnQuiesced != nil {
e.OnQuiesced(ctx, e.evaluateQuiescence(ctx, req, generation))
e.OnQuiesced(ctx, e.evaluateQuiescence(ctx, req, generation, false))
}
e.logger().InfoContext(ctx, "Account teardown started",
"accountID", account.AccountID, "accountName", account.AccountName, "force", req.Force)
@@ -338,9 +344,9 @@ func SortReapers(reapers []Reaper) {
// evaluateQuiescence reads back what the quiesce wrote and, only if the fence
// is confirmed, asks EC2 for its platform inventory within a short budget.
func (e *Engine) evaluateQuiescence(ctx context.Context, req Request, generation uint64) operatorv1.IAMQuiescence {
func (e *Engine) evaluateQuiescence(ctx context.Context, req Request, generation uint64, exercise bool) operatorv1.IAMQuiescence {
ev := QuiesceEvidence{
OperationID: req.OperationID, Fence: req.Fence, Generation: generation, EvaluatedAt: time.Now(),
OperationID: req.OperationID, Fence: req.Fence, Generation: generation, EvaluatedAt: time.Now(), Exercise: exercise,
}
ev.Account, ev.ReadbackErr = e.Accounts.GetAccount(req.AccountID)
if _, confirmed := fenceReadback(ev); confirmed && e.ec2Inventory != nil {
@@ -0,0 +1,207 @@
package accountlifecycle
import (
"context"
"errors"
"fmt"
"time"
"github.com/aws/aws-sdk-go/service/ec2"
ec2v1 "github.com/mulgadc/spinifex/contracts/ec2/v1"
operatorv1 "github.com/mulgadc/spinifex/contracts/operator/v1"
"github.com/mulgadc/spinifex/spinifex/domains/ec2/accountcleanup"
ec2instanceapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi/instance"
"github.com/nats-io/nats.go"
)
// PlatformCleaner is the EC2 platform cleanup the exercise runs.
type PlatformCleaner = accountcleanup.PlatformCleaner
// Exercise quiesces the account, cleans the EC2 platform kinds the evaluation
// recorded as quiesced, then re-reads the owner inventory and holds it against
// the cleanup. No legacy stage runs and the account record is never removed.
func (e *Engine) Exercise(ctx context.Context, req Request) (*operatorv1.ExerciseResult, error) {
if _, err := e.checkDeletable(req.AccountID, req.AccountName); err != nil {
return nil, err
}
if req.Force {
return nil, errors.New("a non-finalizing run refuses force")
}
if e.Cleanup == nil || e.ec2Inventory == nil {
return nil, errors.New("exercise needs the platform cleanup and inventory")
}
generation, err := e.Accounts.QuiesceAccount(req.AccountID, req.OperationID, req.Fence)
if err != nil {
return nil, fmt.Errorf("quiesce account: %w", err)
}
quiescence := e.evaluateQuiescence(ctx, req, generation, true)
if e.OnQuiesced != nil {
e.OnQuiesced(ctx, quiescence)
}
result := &operatorv1.ExerciseResult{}
if len(quiescence.QuiescedScope) == 0 {
result.Skipped = operatorv1.ExerciseSkippedNoQuiescedKind
} else {
cleaned, err := e.Cleanup.CleanupPlatform(ctx, &accountcleanup.Request{
AccountID: req.AccountID, CorrelationRef: req.OperationID, Kinds: quiescence.QuiescedScope,
})
if err != nil {
return nil, fmt.Errorf("platform cleanup: %w", err)
}
result.Cleanup = platformCleanup(cleaned)
result.Accounting = e.accountForRemainder(ctx, req, quiescence.QuiescedScope, result.Cleanup)
}
if e.OnExercised != nil {
e.OnExercised(ctx, *result)
}
return result, nil
}
// accountForRemainder re-reads the owner inventory after cleanup and holds it
// against the cleanup result, per quiesced kind.
func (e *Engine) accountForRemainder(ctx context.Context, req Request, kinds []string, cleanup *operatorv1.PlatformCleanup) []operatorv1.KindAccounting {
budget := ec2PlatformShadowBudget
if e.ec2PlatformShadowBudget > 0 {
budget = e.ec2PlatformShadowBudget
}
rereadCtx, cancel := context.WithTimeout(ctx, budget)
defer cancel()
reply := e.ec2Inventory.PlatformInventory(rereadCtx, req.AccountID, req.OperationID)
return AccountForRemainder(kinds, &reply, cleanup)
}
// AccountForRemainder marks a kind accounted only when its re-read was complete
// and every record still there is named in the cleanup result with a reason.
// A record the cleanup reported deleted that is back is unnamed.
func AccountForRemainder(kinds []string, reread *ec2v1.PlatformInventoryReply, cleanup *operatorv1.PlatformCleanup) []operatorv1.KindAccounting {
out := make([]operatorv1.KindAccounting, 0, len(kinds))
for _, kind := range kinds {
entry := operatorv1.KindAccounting{Kind: kind, Status: operatorv1.AccountingAccounted}
status, complete := kindInventory(reread, kind)
if !complete {
entry.Status, entry.Reason = operatorv1.AccountingUnaccounted, operatorv1.AccountingReasonRereadIncomplete
out = append(out, entry)
continue
}
if status == ec2v1.PlatformStatusRecordsFound {
unnamed := false
for _, item := range reread.Items {
if item.Kind != kind {
continue
}
entry.Remaining++
unnamed = unnamed || !namedItem(cleanup, item.ResourceID, kind)
}
for _, count := range reread.Counts {
if count.Kind != kind {
continue
}
entry.Remaining += count.Count
unnamed = unnamed || !namedCount(cleanup, count)
}
if unnamed {
entry.Status, entry.Reason = operatorv1.AccountingUnaccounted, operatorv1.AccountingReasonUnnamedRemainder
}
}
out = append(out, entry)
}
return out
}
func namedItem(cleanup *operatorv1.PlatformCleanup, id, kind string) bool {
if cleanup == nil {
return false
}
for _, item := range cleanup.Items {
if item.ResourceID == id && item.Kind == kind && item.Disposition != accountcleanup.DispositionDeleted && item.Reason != "" {
return true
}
}
return false
}
func namedCount(cleanup *operatorv1.PlatformCleanup, want ec2v1.PlatformRecordCount) bool {
if cleanup == nil {
return false
}
for _, count := range cleanup.Counts {
if count.Kind == want.Kind && count.Class == want.Class && count.Disposition != accountcleanup.DispositionDeleted &&
count.Reason != "" && count.Count >= want.Count {
return true
}
}
return false
}
func platformCleanup(res *accountcleanup.Result) *operatorv1.PlatformCleanup {
out := &operatorv1.PlatformCleanup{Kinds: []operatorv1.CleanupKind{}}
for _, kind := range res.Kinds {
out.Kinds = append(out.Kinds, operatorv1.CleanupKind{Kind: kind.Kind, Status: kind.Status, IncompleteReasons: kind.IncompleteReasons})
}
for _, item := range res.Items {
out.Items = append(out.Items, operatorv1.CleanupItem{
ResourceID: item.ResourceID, Kind: item.Kind, Disposition: item.Disposition, Reason: item.Reason, MemberCount: item.MemberCount,
})
}
for _, count := range res.Counts {
out.Counts = append(out.Counts, operatorv1.CleanupCount{
Kind: count.Kind, Class: count.Class, Disposition: count.Disposition, Reason: count.Reason, Count: count.Count,
})
}
return out
}
// InstanceReferenceReader reads the key-pair and placement-group names the
// account's non-terminated instances carry. It fails closed: a listing that is
// not complete from every node is an error, never a partial answer.
type InstanceReferenceReader struct {
nc *nats.Conn
nodeIDs []string
budget time.Duration
}
var _ accountcleanup.InstanceReferenceReader = (*InstanceReferenceReader)(nil)
// NewInstanceReferenceReader builds the reader over the configured node set.
func NewInstanceReferenceReader(nc *nats.Conn, nodeIDs []string) *InstanceReferenceReader {
return &InstanceReferenceReader{nc: nc, nodeIDs: nodeIDs, budget: ec2PlatformShadowBudget}
}
func (r *InstanceReferenceReader) AccountInstanceReferences(ctx context.Context, accountID string) (*accountcleanup.References, error) {
ctx, cancel := context.WithTimeout(ctx, r.budget)
defer cancel()
out, err := ec2instanceapi.DescribeAccountInstancesComplete(ctx, r.nc, r.nodeIDs, accountID)
if err != nil {
return nil, err
}
return instanceReferences(out), nil
}
func instanceReferences(out *ec2.DescribeInstancesOutput) *accountcleanup.References {
refs := &accountcleanup.References{
KeyNames: map[string]bool{}, PlacementGroupNames: map[string]bool{}, PlacementGroupIDs: map[string]bool{},
}
for _, reservation := range out.Reservations {
for _, instance := range reservation.Instances {
if instance == nil {
continue
}
if instance.State != nil && instance.State.Name != nil && terminalInstanceStates[*instance.State.Name] {
continue
}
if instance.KeyName != nil && *instance.KeyName != "" {
refs.KeyNames[*instance.KeyName] = true
}
if instance.Placement != nil {
if instance.Placement.GroupName != nil && *instance.Placement.GroupName != "" {
refs.PlacementGroupNames[*instance.Placement.GroupName] = true
}
if instance.Placement.GroupId != nil && *instance.Placement.GroupId != "" {
refs.PlacementGroupIDs[*instance.Placement.GroupId] = true
}
}
}
}
return refs
}
@@ -0,0 +1,288 @@
package accountlifecycle
//test:in-package — the exercise's inventory and cleanup collaborators are
// unexported seams, and the S7 invariants drive the engine directly.
import (
"context"
"errors"
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
ec2v1 "github.com/mulgadc/spinifex/contracts/ec2/v1"
operatorv1 "github.com/mulgadc/spinifex/contracts/operator/v1"
"github.com/mulgadc/spinifex/spinifex/domains/ec2/accountcleanup"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type stubCleaner struct {
calls []accountcleanup.Request
result *accountcleanup.Result
err error
// onCall runs inside the cleanup, so a test can observe what was recorded first.
onCall func()
}
func (s *stubCleaner) CleanupPlatform(_ context.Context, req *accountcleanup.Request) (*accountcleanup.Result, error) {
s.calls = append(s.calls, *req)
if s.onCall != nil {
s.onCall()
}
return s.result, s.err
}
// sequencedInventory answers the quiescence evaluation first, then the re-read.
type sequencedInventory struct {
replies []ec2v1.PlatformInventoryReply
calls int
}
func (s *sequencedInventory) PlatformInventory(context.Context, string, string) ec2v1.PlatformInventoryReply {
reply := s.replies[min(s.calls, len(s.replies)-1)]
s.calls++
return reply
}
func exerciseEngine(accounts AccountStore, cleaner PlatformCleaner, inventory ec2PlatformInventory) *Engine {
engine := quietEngine(accounts)
engine.Cleanup = cleaner
engine.ec2Inventory = inventory
return engine
}
func exerciseRequest() Request {
req := request()
req.OperationID, req.Fence = "op-1", 1
return req
}
func cleanResult() *accountcleanup.Result {
res := &accountcleanup.Result{AccountID: testAccountID}
for _, kind := range ec2v1.PlatformKinds() {
res.Kinds = append(res.Kinds, accountcleanup.KindReport{Kind: kind, Status: ec2v1.PlatformStatusObservedClean})
}
return res
}
func accountingFor(result *operatorv1.ExerciseResult, kind string) operatorv1.KindAccounting {
for _, entry := range result.Accounting {
if entry.Kind == kind {
return entry
}
}
return operatorv1.KindAccounting{}
}
// The exercise records the evaluation before it cleans, cleans only the kinds that
// evaluation recorded as quiesced, and never removes the account record.
func TestExerciseCleansTheQuiescedKindsAfterRecordingAndKeepsTheAccount(t *testing.T) {
accounts := newFakeAccounts()
var recorded *operatorv1.IAMQuiescence
cleaner := &stubCleaner{result: cleanResult()}
cleaner.onCall = func() { assert.NotNil(t, recorded, "quiescence must be recorded before any cleanup") }
inventory := &sequencedInventory{replies: []ec2v1.PlatformInventoryReply{*completeInventory()}}
engine := exerciseEngine(accounts, cleaner, inventory)
engine.OnQuiesced = func(_ context.Context, q operatorv1.IAMQuiescence) { recorded = &q }
result, err := engine.Exercise(context.Background(), exerciseRequest())
require.NoError(t, err)
require.NotNil(t, recorded)
assert.ElementsMatch(t, ec2PlatformKinds, recorded.QuiescedScope)
require.Len(t, cleaner.calls, 1)
assert.ElementsMatch(t, recorded.QuiescedScope, cleaner.calls[0].Kinds)
assert.Equal(t, "op-1", cleaner.calls[0].CorrelationRef)
assert.False(t, accounts.deleted, "an exercise never removes the account record")
assert.Equal(t, AccountStatusTerminating, accounts.account.Status)
assert.Equal(t, 2, inventory.calls, "one evaluation read and one final re-read")
for _, kind := range recorded.QuiescedScope {
assert.Equal(t, operatorv1.AccountingAccounted, accountingFor(result, kind).Status, kind)
}
}
// The final re-read is held against the cleanup result and the quiescence record
// is not rewritten from it.
func TestExerciseAccountsForARemainderOnlyWhenTheCleanupNamedIt(t *testing.T) {
cases := map[string]struct {
cleanup *accountcleanup.Result
reread ec2v1.PlatformInventoryReply
want string
reason string
}{
"late write the cleanup never saw": {
cleanup: cleanResult(),
reread: rereadWithKeyPair("key-late"),
want: operatorv1.AccountingUnaccounted, reason: operatorv1.AccountingReasonUnnamedRemainder,
},
"cleanup deleted it but it is back": {
cleanup: withItem(cleanResult(), accountcleanup.Item{ResourceID: "key-1", Kind: ec2v1.PlatformKindKeyPair, Disposition: accountcleanup.DispositionDeleted}),
reread: rereadWithKeyPair("key-1"),
want: operatorv1.AccountingUnaccounted, reason: operatorv1.AccountingReasonUnnamedRemainder,
},
"cleanup blocked it with a reason": {
cleanup: withItem(cleanResult(), accountcleanup.Item{ResourceID: "key-1", Kind: ec2v1.PlatformKindKeyPair, Disposition: accountcleanup.DispositionBlocked, Reason: accountcleanup.ReasonInstanceReference}),
reread: rereadWithKeyPair("key-1"),
want: operatorv1.AccountingAccounted,
},
"re-read incomplete": {
cleanup: cleanResult(),
reread: *incompleteKeyPairs(),
want: operatorv1.AccountingUnaccounted, reason: operatorv1.AccountingReasonRereadIncomplete,
},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
accounts := newFakeAccounts()
inventory := &sequencedInventory{replies: []ec2v1.PlatformInventoryReply{*completeInventory(), tc.reread}}
engine := exerciseEngine(accounts, &stubCleaner{result: tc.cleanup}, inventory)
var recorded operatorv1.IAMQuiescence
engine.OnQuiesced = func(_ context.Context, q operatorv1.IAMQuiescence) { recorded = q }
result, err := engine.Exercise(context.Background(), exerciseRequest())
require.NoError(t, err)
entry := accountingFor(result, ec2v1.PlatformKindKeyPair)
assert.Equal(t, tc.want, entry.Status)
assert.Equal(t, tc.reason, entry.Reason)
assert.Contains(t, recorded.QuiescedScope, ec2v1.PlatformKindKeyPair, "quiescence is not rewritten from the re-read")
})
}
}
func rereadWithKeyPair(id string) ec2v1.PlatformInventoryReply {
reply := *completeInventory()
reply.Kinds[0].Status = ec2v1.PlatformStatusRecordsFound
reply.Items = []ec2v1.PlatformItem{{ResourceID: id, Kind: ec2v1.PlatformKindKeyPair, Disposition: accountcleanup.DispositionBlocked}}
return reply
}
func incompleteKeyPairs() *ec2v1.PlatformInventoryReply {
reply := completeInventory()
reply.Kinds[0].Status = ec2v1.PlatformStatusScanIncomplete
return reply
}
func withItem(res *accountcleanup.Result, item accountcleanup.Item) *accountcleanup.Result {
res.Items = append(res.Items, item)
return res
}
// With no kind quiesced the exercise runs no cleanup and records why.
func TestExerciseWithNoQuiescedKindRunsNoCleanup(t *testing.T) {
accounts := newFakeAccounts()
cleaner := &stubCleaner{result: cleanResult()}
incomplete := ec2v1.IncompletePlatformInventory(testAccountID, "op-1", ec2v1.PlatformReasonTimeout)
engine := exerciseEngine(accounts, cleaner, &sequencedInventory{replies: []ec2v1.PlatformInventoryReply{incomplete}})
result, err := engine.Exercise(context.Background(), exerciseRequest())
require.NoError(t, err)
assert.Empty(t, cleaner.calls)
assert.Equal(t, operatorv1.ExerciseSkippedNoQuiescedKind, result.Skipped)
assert.Nil(t, result.Cleanup)
}
func TestExerciseRefusesForceAndAMissingCollaborator(t *testing.T) {
accounts := newFakeAccounts()
req := exerciseRequest()
req.Force = true
_, err := exerciseEngine(accounts, &stubCleaner{}, &stubInventory{}).Exercise(context.Background(), req)
require.Error(t, err)
_, err = exerciseEngine(accounts, nil, &stubInventory{}).Exercise(context.Background(), exerciseRequest())
require.Error(t, err)
assert.Empty(t, accounts.quiesces, "a refused exercise quiesces nothing")
}
// ADR-0003:S7: the exercise is an automated actor that deletes customer-visible
// records, so it acts only on kinds whose quiescence was recorded under a confirmed
// fence, and a failed cleanup leaves the account where it is.
func TestS7_ExerciseCleansNothingUnlessItsQuiescenceWasRecordedUnderAConfirmedFence(t *testing.T) {
cases := map[string]func(*Engine, *fakeAccounts) *sequencedInventory{
"read-back fails": func(e *Engine, a *fakeAccounts) *sequencedInventory {
e.Accounts = &readbackFails{fakeAccounts: a}
return &sequencedInventory{replies: []ec2v1.PlatformInventoryReply{*completeInventory()}}
},
"inventory incomplete": func(*Engine, *fakeAccounts) *sequencedInventory {
return &sequencedInventory{replies: []ec2v1.PlatformInventoryReply{
ec2v1.IncompletePlatformInventory(testAccountID, "op-1", ec2v1.PlatformReasonNoResponder),
}}
},
}
for name, setup := range cases {
t.Run(name, func(t *testing.T) {
accounts := newFakeAccounts()
cleaner := &stubCleaner{result: cleanResult()}
engine := exerciseEngine(accounts, cleaner, nil)
engine.ec2Inventory = setup(engine, accounts)
_, err := engine.Exercise(context.Background(), exerciseRequest())
require.NoError(t, err)
assert.Empty(t, cleaner.calls, "no kind may be cleaned without a recorded quiescence")
assert.False(t, accounts.deleted)
})
}
t.Run("only the kinds with a complete inventory are cleaned", func(t *testing.T) {
accounts := newFakeAccounts()
cleaner := &stubCleaner{result: cleanResult()}
partial := completeInventory()
partial.Kinds[1].Status = ec2v1.PlatformStatusScanIncomplete
engine := exerciseEngine(accounts, cleaner, &sequencedInventory{replies: []ec2v1.PlatformInventoryReply{*partial}})
_, err := engine.Exercise(context.Background(), exerciseRequest())
require.NoError(t, err)
require.Len(t, cleaner.calls, 1)
assert.ElementsMatch(t, []string{ec2v1.PlatformKindKeyPair, ec2v1.PlatformKindLaunchTemplate}, cleaner.calls[0].Kinds)
})
t.Run("a failed cleanup returns an error and removes nothing", func(t *testing.T) {
accounts := newFakeAccounts()
engine := exerciseEngine(accounts, &stubCleaner{err: errors.New("owner down")}, &sequencedInventory{replies: []ec2v1.PlatformInventoryReply{*completeInventory()}})
_, err := engine.Exercise(context.Background(), exerciseRequest())
require.Error(t, err)
assert.False(t, accounts.deleted)
})
}
// ADR-0003:S7: an ordinary teardown never runs the platform cleanup and never
// records a quiesced scope, whatever its inventory says.
func TestS7_OrdinaryTeardownNeverCleansOrRecordsAScope(t *testing.T) {
accounts := newFakeAccounts()
cleaner := &stubCleaner{result: cleanResult()}
engine := exerciseEngine(accounts, cleaner, &sequencedInventory{replies: []ec2v1.PlatformInventoryReply{*completeInventory()}})
var recorded operatorv1.IAMQuiescence
engine.OnQuiesced = func(_ context.Context, q operatorv1.IAMQuiescence) { recorded = q }
_, err := engine.Teardown(context.Background(), exerciseRequest())
require.NoError(t, err)
assert.Empty(t, cleaner.calls)
assert.Empty(t, recorded.QuiescedScope)
}
func TestInstanceReferencesSkipTerminalInstancesAndNameKeysAndGroups(t *testing.T) {
refs := instanceReferences(&ec2.DescribeInstancesOutput{Reservations: []*ec2.Reservation{{Instances: []*ec2.Instance{
instanceWith("running", "kp-a", "pg-a", "pg-1"),
instanceWith("stopped", "kp-b", "", ""),
instanceWith("terminated", "kp-gone", "pg-gone", "pg-9"),
}}}})
assert.Equal(t, map[string]bool{"kp-a": true, "kp-b": true}, refs.KeyNames)
assert.Equal(t, map[string]bool{"pg-a": true}, refs.PlacementGroupNames)
assert.Equal(t, map[string]bool{"pg-1": true}, refs.PlacementGroupIDs)
}
func instanceWith(state, key, groupName, groupID string) *ec2.Instance {
return &ec2.Instance{
State: &ec2.InstanceState{Name: aws.String(state)},
KeyName: aws.String(key),
Placement: &ec2.Placement{GroupName: aws.String(groupName), GroupId: aws.String(groupID)},
}
}
@@ -7,17 +7,17 @@ import (
operatorv1 "github.com/mulgadc/spinifex/contracts/operator/v1"
)
// finalInventoryRereadWired says whether anything re-reads the owner inventory
// before the account record goes. Until it does, a write landing after the
// reaper stage is orphaned, so no kind may be recorded as quiesced.
var finalInventoryRereadWired = false
// QuiesceEvidence is what the engine read after QuiesceAccount returned.
type QuiesceEvidence struct {
OperationID string
Fence uint64
Generation uint64
// Exercise is set only for a non-finalizing run, which re-reads the owner
// inventory after its cleanup and removes no account record. An ordinary
// delete re-reads nothing before the record goes, so it records no scope.
Exercise bool
// Account is the IAM record read back after the quiesce.
Account *Account
ReadbackErr error
@@ -79,7 +79,8 @@ var otherScopes = []unresolvedEntry{
// EvaluateQuiescence applies ADR-0008:S9's three conditions to the evidence and
// records what holds. A fence the read-back does not confirm stops everything
// else; otherwise every scope is listed, and none is reported quiesced yet.
// else; otherwise every scope is listed. A kind is quiesced only for an
// exercise whose owner inventory for it was complete.
func EvaluateQuiescence(ev QuiesceEvidence) operatorv1.IAMQuiescence {
out := operatorv1.IAMQuiescence{
OperationID: ev.OperationID,
@@ -109,7 +110,7 @@ func EvaluateQuiescence(ev QuiesceEvidence) operatorv1.IAMQuiescence {
switch {
case !complete:
out.Unresolved = append(out.Unresolved, writerEntry("ec2-"+kind, operatorv1.ReasonOwnerInventoryIncomplete))
case !finalInventoryRereadWired:
case !ev.Exercise:
out.Unresolved = append(out.Unresolved, writerEntry("ec2-"+kind, operatorv1.ReasonFinalInventoryRereadNotWired))
default:
out.QuiescedScope = append(out.QuiescedScope, kind)
@@ -72,12 +72,10 @@ func TestEvaluateQuiescenceRecordsEvidenceButQuiescesNothingWithoutAFinalReread(
assert.Equal(t, "2026-11-02T00:00:00Z", q.EvaluatedAt)
}
// The wiring step flips this switch; the complete kinds are then, and only then, named.
// Only an exercise, which re-reads the inventory after cleanup, names the complete kinds.
func TestEvaluateQuiescenceNamesCompleteKindsOnceTheRereadIsWired(t *testing.T) {
finalInventoryRereadWired = true
t.Cleanup(func() { finalInventoryRereadWired = false })
ev := confirmedEvidence()
ev.Exercise = true
ev.Inventory.Kinds[1].Status = ec2v1.PlatformStatusScanIncomplete
q := EvaluateQuiescence(ev)
@@ -133,9 +131,8 @@ func TestEvaluateQuiescenceRecordsNothingWhenTheFenceIsNotConfirmed(t *testing.T
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
finalInventoryRereadWired = true
t.Cleanup(func() { finalInventoryRereadWired = false })
ev := confirmedEvidence()
ev.Exercise = true
tc.mutate(&ev)
q := EvaluateQuiescence(ev)
@@ -172,11 +169,11 @@ func TestEveryOtherScopeIsRecordedUnresolvedByName(t *testing.T) {
// Predastore has no accepted enforcement contract, so the scopes it admits writes
// for stay unresolved and it is never cited as a consumer, even once the
// re-read is wired and every L09 kind is complete.
// run is an exercise and every L09 kind is complete.
func TestPredastoreIsNeverCitedAndItsScopesNeverQuiesce(t *testing.T) {
finalInventoryRereadWired = true
t.Cleanup(func() { finalInventoryRereadWired = false })
q := EvaluateQuiescence(confirmedEvidence())
ev := confirmedEvidence()
ev.Exercise = true
q := EvaluateQuiescence(ev)
for _, scope := range []string{"s3", "ecr"} {
got := unresolvedFor(q, scope)
+50 -10
View File
@@ -66,7 +66,10 @@ type accountDeletionJob struct {
ClientToken string `json:"clientToken"`
State string `json:"state"`
Force bool `json:"force"`
StartedAt string `json:"startedAt"`
// NonFinalizing marks an exercise: it quiesces and cleans the quiesced EC2
// platform kinds, then ends EXERCISED with the account still TERMINATING.
NonFinalizing bool `json:"nonFinalizing,omitempty"`
StartedAt string `json:"startedAt"`
// UpdatedAt is the running job's heartbeat. Its absence for long enough is
// what lets another gateway take over after a restart.
UpdatedAt string `json:"updatedAt"`
@@ -88,6 +91,8 @@ type accountDeletionJob struct {
// IAM is the quiescence evaluation made under the current fence. A takeover
// starts without one and the new holder records its own.
IAM *operatorv1.IAMQuiescence `json:"iam,omitempty"`
// Exercise is what a non-finalizing run did after quiescence.
Exercise *operatorv1.ExerciseResult `json:"exercise,omitempty"`
}
// deletionTakeover records one change of holder.
@@ -99,8 +104,9 @@ type deletionTakeover struct {
}
const (
takeoverReasonLeaseExpired = "lease_expired"
takeoverReasonFailed = "failed"
takeoverReasonLeaseExpired = "lease_expired"
takeoverReasonFailed = "failed"
takeoverReasonExerciseFinished = "exercise_finished"
)
// deleteAccount starts a teardown, or answers a dry run inline.
@@ -118,6 +124,11 @@ func (d Deps) deleteAccount(ctx context.Context, body []byte) (any, error) {
return nil, errors.New(awserrors.ErrorInternalError)
}
if req.NonFinalizing && d.PlatformCleaner == nil {
slog.Error("DeleteAccount: platform cleanup not available")
return nil, errors.New(awserrors.ErrorServiceUnavailable)
}
engine, err := accountteardown.NewClusterEngine(ctx, d.NATS, accountteardown.ClusterNodes{Expected: d.ExpectedNodes, DaemonIDs: d.NodeIDs, GatewayIDs: d.GatewayNodeIDs}, d.Termination, d.BucketStore)
if err != nil {
slog.Error("DeleteAccount: failed to build teardown engine", "err", err)
@@ -262,6 +273,10 @@ func (d Deps) runAccountDeletion(
tracker := &deletionTracker{jobs: jobs, job: job, revision: revision, cancel: cancel}
engine.OnStage = tracker.stageFinished
engine.OnQuiesced = tracker.quiesced
if job.NonFinalizing {
engine.Cleanup = d.PlatformCleaner
engine.OnExercised = tracker.exercised
}
go func() {
defer cancel()
@@ -269,13 +284,19 @@ func (d Deps) runAccountDeletion(
stop := tracker.startHeartbeat(ctx)
defer stop()
result, err := engine.Teardown(ctx, accountteardown.Request{
request := accountteardown.Request{
AccountID: job.AccountID,
AccountName: job.AccountName,
Force: job.Force,
OperationID: job.OperationID,
Fence: job.Fence,
})
}
if job.NonFinalizing {
_, err := engine.Exercise(ctx, request)
tracker.finished(ctx, nil, err)
return
}
result, err := engine.Teardown(ctx, request)
tracker.finished(ctx, result, err)
}()
}
@@ -308,6 +329,13 @@ func (t *deletionTracker) quiesced(ctx context.Context, quiescence operatorv1.IA
t.persist(ctx)
}
func (t *deletionTracker) exercised(ctx context.Context, result operatorv1.ExerciseResult) {
t.mu.Lock()
t.job.Exercise = &result
t.mu.Unlock()
t.persist(ctx)
}
func (t *deletionTracker) finished(ctx context.Context, result *accountteardown.Result, err error) {
t.mu.Lock()
if result != nil {
@@ -316,6 +344,8 @@ func (t *deletionTracker) finished(ctx context.Context, result *accountteardown.
if err != nil {
t.job.State = operatorv1.DeletionStateFailed
t.job.Error = err.Error()
} else if t.job.NonFinalizing {
t.job.State = operatorv1.DeletionStateExercised
} else {
t.job.State = operatorv1.DeletionStateCompleted
}
@@ -445,8 +475,10 @@ func claimDeletionJob(
StartedAt: now.Format(time.RFC3339),
UpdatedAt: now.Format(time.RFC3339),
Fence: 1,
Holder: holder,
LeaseUntil: now.Add(accountDeletionStale).Format(time.RFC3339),
NonFinalizing: req.NonFinalizing,
Holder: holder,
LeaseUntil: now.Add(accountDeletionStale).Format(time.RFC3339),
}
record, err := json.Marshal(job)
if err != nil {
@@ -478,6 +510,9 @@ func claimDeletionJob(
}
reason := takeoverReasonFailed
if existing.State == operatorv1.DeletionStateExercised {
reason = takeoverReasonExerciseFinished
}
if existing.State == operatorv1.DeletionStateRunning {
if !deletionLeaseExpired(existing, now) {
return nil, 0, nil, errors.New(awserrors.ErrorOperationInProgress)
@@ -512,8 +547,10 @@ func takeOverDeletionJob(
Force: req.Force,
StartedAt: now.Format(time.RFC3339),
UpdatedAt: now.Format(time.RFC3339),
Holder: holder,
LeaseUntil: now.Add(accountDeletionStale).Format(time.RFC3339),
NonFinalizing: req.NonFinalizing,
Holder: holder,
LeaseUntil: now.Add(accountDeletionStale).Format(time.RFC3339),
}
if existing.OperationID != "" {
job.OperationID = existing.OperationID
@@ -557,7 +594,7 @@ func replayDeletionJob(
return nil, err
}
if existing.ClientToken == req.ClientToken {
if existing.AccountName != req.AccountName {
if existing.AccountName != req.AccountName || existing.NonFinalizing != req.NonFinalizing {
return nil, errors.New(awserrors.ErrorIdempotentParameterMismatch)
}
// A RUNNING job whose lease has lapsed has no holder to follow, so the
@@ -703,6 +740,9 @@ func validateDeleteAccountRequest(req *operatorv1.DeleteAccountRequest) error {
if !accountIDRE.MatchString(req.AccountID) {
return errors.New(awserrors.ErrorInvalidParameterValue)
}
if req.NonFinalizing && (req.Force || req.DryRun) {
return errors.New(awserrors.ErrorInvalidParameterCombination)
}
if req.DryRun {
// A dry run deletes nothing, so it needs neither the name confirmation
// nor a token to make it idempotent.
@@ -0,0 +1,133 @@
package admin
//test:in-package — the exercise runs through the job record, the takeover and
// the resumer, all of which are unexported.
import (
"context"
"encoding/json"
"testing"
"time"
operatorv1 "github.com/mulgadc/spinifex/contracts/operator/v1"
"github.com/mulgadc/spinifex/spinifex/domains/ec2/accountcleanup"
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const exerciseToken = "1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d"
type panicCleaner struct{ calls int }
func (p *panicCleaner) CleanupPlatform(context.Context, *accountcleanup.Request) (*accountcleanup.Result, error) {
p.calls++
return &accountcleanup.Result{}, nil
}
func exerciseBody(t *testing.T, accountID, name, token string, mutate func(*operatorv1.DeleteAccountRequest)) []byte {
t.Helper()
req := operatorv1.DeleteAccountRequest{AccountID: accountID, AccountName: name, ClientToken: token, NonFinalizing: true}
if mutate != nil {
mutate(&req)
}
body, err := json.Marshal(req)
require.NoError(t, err)
return body
}
func awaitState(t *testing.T, gw Deps, accountID, state string) *accountDeletionJob {
t.Helper()
require.Eventually(t, func() bool {
return describeDeletion(t, gw, accountID).State == state
}, 30*time.Second, 100*time.Millisecond)
return describeDeletion(t, gw, accountID)
}
// An exercise ends EXERCISED with the account still TERMINATING, and a later
// ordinary delete takes the same operation over to finish it.
func TestNonFinalizingDeleteEndsExercisedAndALaterDeleteTakesItOver(t *testing.T) {
gw := newDeleteAccountGateway(t)
gw.PlatformCleaner = &panicCleaner{}
account := newTenantAccount(t, gw, "tenant@example.com")
_, err := gw.deleteAccount(t.Context(), exerciseBody(t, account.AccountID, "tenant@example.com", exerciseToken, nil))
require.NoError(t, err)
exercised := awaitState(t, gw, account.AccountID, operatorv1.DeletionStateExercised)
require.NotNil(t, exercised.IAM)
require.NotNil(t, exercised.Exercise)
assert.True(t, exercised.NonFinalizing)
assert.NotEmpty(t, exercised.FinishedAt)
assert.Empty(t, exercised.Stages, "no legacy stage runs")
stored, err := gw.IAM.GetAccount(account.AccountID)
require.NoError(t, err)
assert.Equal(t, "TERMINATING", stored.Status)
_, err = gw.deleteAccount(t.Context(), deleteAccountBody(t, account.AccountID, "tenant@example.com", deleteClientToken))
require.NoError(t, err)
done := awaitState(t, gw, account.AccountID, operatorv1.DeletionStateCompleted)
assert.Equal(t, exercised.OperationID, done.OperationID)
assert.Equal(t, uint64(2), done.Fence)
require.Len(t, done.Takeovers, 1)
assert.Equal(t, takeoverReasonExerciseFinished, done.Takeovers[0].Reason)
assert.Nil(t, done.Exercise, "the takeover starts a fresh run")
assert.Equal(t, uint64(2), done.IAM.Fence)
assert.Empty(t, done.IAM.QuiescedScope, "an ordinary delete records no scope")
}
func TestNonFinalizingIsPartOfTheIdempotencyKeyAndRefusesForce(t *testing.T) {
gw := newDeleteAccountGateway(t)
gw.PlatformCleaner = &panicCleaner{}
account := newTenantAccount(t, gw, "tenant@example.com")
_, err := gw.deleteAccount(t.Context(), exerciseBody(t, account.AccountID, "tenant@example.com", exerciseToken,
func(r *operatorv1.DeleteAccountRequest) { r.Force = true }))
require.EqualError(t, err, awserrors.ErrorInvalidParameterCombination)
_, err = gw.deleteAccount(t.Context(), exerciseBody(t, account.AccountID, "tenant@example.com", exerciseToken, nil))
require.NoError(t, err)
awaitState(t, gw, account.AccountID, operatorv1.DeletionStateExercised)
_, err = gw.deleteAccount(t.Context(), deleteAccountBody(t, account.AccountID, "tenant@example.com", exerciseToken))
require.EqualError(t, err, awserrors.ErrorIdempotentParameterMismatch, "the same token cannot turn an exercise into a delete")
again, err := gw.deleteAccount(t.Context(), exerciseBody(t, account.AccountID, "tenant@example.com", exerciseToken, nil))
require.NoError(t, err)
assert.Equal(t, operatorv1.DeletionStateExercised, again.(*DeleteAccountResponse).State, "a repeated exercise is answered from the record")
}
func TestNonFinalizingWithoutACleanupIsRefusedBeforeAnyJobExists(t *testing.T) {
gw := newDeleteAccountGateway(t)
account := newTenantAccount(t, gw, "tenant@example.com")
_, err := gw.deleteAccount(t.Context(), exerciseBody(t, account.AccountID, "tenant@example.com", exerciseToken, nil))
require.EqualError(t, err, awserrors.ErrorServiceUnavailable)
jobs, err := gw.accountDeletionStore(t.Context())
require.NoError(t, err)
stored, err := readDeletionJob(t.Context(), jobs, account.AccountID)
require.NoError(t, err)
assert.Nil(t, stored)
}
// A resumed exercise stays an exercise: the resumer must not turn a recorded
// non-finalizing decision into a deletion.
func TestResumerKeepsAnAbandonedExerciseNonFinalizing(t *testing.T) {
gw := newDeleteAccountGateway(t)
gw.PlatformCleaner = &panicCleaner{}
account := newTenantAccount(t, gw, "tenant@example.com")
job := abandonedJob(account.AccountID, "tenant@example.com")
job.NonFinalizing = true
storeDeletionJob(t, gw, job)
require.Equal(t, 1, gw.resumeDeletions(t.Context(), time.Now().UTC()))
done := awaitState(t, gw, account.AccountID, operatorv1.DeletionStateExercised)
assert.Equal(t, uint64(2), done.Fence)
assert.True(t, done.NonFinalizing)
stored, err := gw.IAM.GetAccount(account.AccountID)
require.NoError(t, err)
assert.Equal(t, "TERMINATING", stored.Status, "the account record is never removed")
}
@@ -110,6 +110,8 @@ func (d Deps) resumeDeletion(
AccountName: existing.AccountName,
ClientToken: existing.ClientToken,
Force: existing.Force,
NonFinalizing: existing.NonFinalizing,
}
job, newRevision, _, err := takeOverDeletionJob(ctx, jobs, req, d.holderID(), existing, revision, takeoverReasonLeaseExpired, now)
if err != nil {
@@ -45,6 +45,7 @@ func TestS7_DeletionResumerActsOnlyOnAnAcceptedOperationWithoutALiveLease(t *tes
},
"failed job": func(j *accountDeletionJob) { j.State = operatorv1.DeletionStateFailed },
"completed job": func(j *accountDeletionJob) { j.State = operatorv1.DeletionStateCompleted },
"exercised job": func(j *accountDeletionJob) { j.State = operatorv1.DeletionStateExercised },
"legacy record": func(j *accountDeletionJob) { j.OperationID, j.Fence, j.LeaseUntil = "", 0, "" },
}
for name, mutate := range cases {
+1 -1
View File
@@ -76,7 +76,7 @@ func ImportImage(ctx context.Context, provider ebsprovider.EBSProvider, opts Imp
// Unpublish regardless: a volume left published cannot be snapshotted or
// attached, and on the failure path it would strand the export too.
if err := provider.UnpublishVolume(ctx, ebsprovider.UnpublishVolumeRequest{
if _, err := provider.UnpublishVolume(ctx, ebsprovider.UnpublishVolumeRequest{
Versioned: ebsprovider.NewVersioned(),
VolumeID: opts.VolumeID,
NodeID: opts.NodeID,
+1 -1
View File
@@ -49,7 +49,7 @@ func (p *recordingProvider) PublishVolume(ctx context.Context, req ebsprovider.P
return &ebsprovider.PublishedVolume{VolumeID: req.VolumeID, NodeID: req.NodeID, Export: export}, nil
}
func (p *recordingProvider) UnpublishVolume(ctx context.Context, req ebsprovider.UnpublishVolumeRequest) error {
func (p *recordingProvider) UnpublishVolume(ctx context.Context, req ebsprovider.UnpublishVolumeRequest) (*ebsprovider.UnpublishedVolume, error) {
p.calls = append(p.calls, "unpublish")
return p.MemoryProvider.UnpublishVolume(ctx, req)
}
+4 -1
View File
@@ -58,7 +58,10 @@ type Deps struct {
IAM iamdomain.IAMService
// Termination is the concrete IAM service, the only holder of the capability
// to quiesce and remove an account. DeleteAccount needs it.
Termination accountteardown.IAMBackend
Termination accountteardown.IAMBackend
// PlatformCleaner is the in-process EC2 platform cleanup a non-finalizing
// DeleteAccount runs. Nil refuses that request.
PlatformCleaner accountteardown.PlatformCleaner
Quota *quota.Service
ExpectedNodes int
NodeIDs []string
@@ -61,6 +61,8 @@ func init() {
accountCmd.AddCommand(accountDeleteCmd)
accountDeleteCmd.Flags().Bool("dry-run", false, "Report what would be deleted and change nothing")
accountDeleteCmd.Flags().Bool("non-finalizing", false, "Quiesce and clean the EC2 platform kinds, then stop with the account still TERMINATING")
_ = accountDeleteCmd.Flags().MarkHidden("non-finalizing")
accountDeleteCmd.Flags().Bool("force", false, "Escalate past state guards for resources that will not delete")
accountDeleteCmd.Flags().Bool("yes", false, "Skip the interactive confirmation (requires --name)")
accountDeleteCmd.Flags().String("name", "", "Account name, which must match the stored record")
@@ -24,6 +24,7 @@ var accountDeletePollInterval = 10 * time.Second
func runAccountDeleteRemote(cmd *cobra.Command, accountID string) error {
dryRun, _ := cmd.Flags().GetBool("dry-run")
force, _ := cmd.Flags().GetBool("force")
nonFinalizing, _ := cmd.Flags().GetBool("non-finalizing")
assumeYes, _ := cmd.Flags().GetBool("yes")
name, _ := cmd.Flags().GetString("name")
clientToken, _ := cmd.Flags().GetString("client-token")
@@ -66,6 +67,7 @@ func runAccountDeleteRemote(cmd *cobra.Command, accountID string) error {
started, err := deleteAccountRemote(ctx, target, operatorv1.DeleteAccountRequest{
AccountID: accountID, AccountName: name, ClientToken: clientToken, Force: force,
NonFinalizing: nonFinalizing,
})
if err != nil {
var adminErr *adminError
@@ -101,6 +103,9 @@ func followAccountDeletion(ctx context.Context, target adminTarget, accountID st
case operatorv1.DeletionStateCompleted:
fmt.Printf("\nAccount %s deleted.\n", accountID)
return nil
case operatorv1.DeletionStateExercised:
fmt.Printf("\nAccount %s exercised and left TERMINATING; a normal delete finishes it.\n", accountID)
return nil
case operatorv1.DeletionStateFailed:
fmt.Fprintf(os.Stderr, "\nThe account is still TERMINATING so its leftovers keep an owner.\n")
return fmt.Errorf("teardown failed: %s", job.Error)
+28
View File
@@ -1748,3 +1748,31 @@ func TestApplyNetworkConfig_PropagatesPoolDNSServersWithoutSelf(t *testing.T) {
require.Len(t, settings.Pools, 1)
assert.Equal(t, []string{"1.1.1.1", "10.0.0.6", "8.8.8.8"}, settings.Pools[0].DNSServers)
}
// A generated config writes no [nodes.<peer>.ebs] stanza, so a node switched to
// a non-default provider must still load: an absent peer provider is unknown.
func TestSpinifexTomlTemplate_NonDefaultProviderLoadsBesidePeers(t *testing.T) {
allNodes := map[string]formation.NodeInfo{
"node1": {Name: "node1", BindIP: "10.0.0.1", AdvertiseIP: "192.168.1.31"},
"node2": {Name: "node2", BindIP: "10.0.0.2", AdvertiseIP: "192.168.1.32"},
"node3": {Name: "node3", BindIP: "10.0.0.3", AdvertiseIP: "192.168.1.33"},
}
settings := multinodeNorthstarSettings("/etc/spinifex/northstar/northstar.toml")
settings.RemoteNodes = buildRemoteNodes(allNodes, "node1", settings.NorthstarConfigPath)
require.Len(t, settings.RemoteNodes, 2)
path := filepath.Join(t.TempDir(), "spinifex.toml")
require.NoError(t, admin.GenerateConfigFile(path, spinifexTomlTemplate, settings))
rendered, err := os.ReadFile(path)
require.NoError(t, err)
const defaultLine = "provider = \"viperblockd\"\n"
require.Equal(t, 1, strings.Count(string(rendered), defaultLine), "the template must set the provider for its own node only")
switched := strings.Replace(string(rendered), defaultLine, "provider = \"qemunbd\"\n", 1)
require.NoError(t, os.WriteFile(path, []byte(switched), 0o600))
cfg, err := config.LoadConfig(path)
require.NoError(t, err)
assert.Equal(t, config.EBSProviderQEMUNBD, cfg.Nodes["node1"].EBS.ResolvedProvider())
assert.Contains(t, cfg.Nodes, "node2")
assert.Contains(t, cfg.Nodes, "node3")
}
@@ -136,6 +136,9 @@ encryption_key_file = "{{.EncryptionKeyFile}}"
{{- end}}
[nodes.{{.Node}}.ebs]
# provider: every node in a cluster must name the same one, and the daemon
# refuses to start on a provider other than the one the cluster recorded.
# Changing it needs the cluster wiped and reformed.
provider = "viperblockd"
# Per-volume export tunables. Both are applied per nbdkit process and there is
# one of those per volume, so the host cost of each is the value times the
+1
View File
@@ -21,6 +21,7 @@ Single source of truth for the current schema version of every config file Spini
|---|---|---|
| `spinifex-instance-state` | `5` | `daemon.InstanceStateBucketVersion` |
| `spinifex-cluster-state` | `1` | `daemon.ClusterStateBucketVersion` |
| `spinifex-cluster-config` | `1` | `daemon.ClusterConfigBucketVersion` |
| `spinifex-terminated-instances` | `3` | `daemon.TerminatedInstanceBucketVersion` |
## Registered migrations
+1 -1
View File
@@ -137,7 +137,7 @@ func RunBenchSuite(b *testing.B, provider ebsprovider.EBSProvider, cfg BenchConf
}); err != nil {
b.Fatal(err)
}
if err := provider.UnpublishVolume(context.Background(), ebsprovider.UnpublishVolumeRequest{
if _, err := provider.UnpublishVolume(context.Background(), ebsprovider.UnpublishVolumeRequest{
Versioned: ebsprovider.NewVersioned(),
VolumeID: volumeID,
NodeID: cfg.node(),
+231 -21
View File
@@ -6,6 +6,9 @@ package conformance
import (
"bytes"
"context"
"errors"
"fmt"
"slices"
"strings"
"sync"
"testing"
@@ -34,18 +37,24 @@ var ReferenceCapabilities = ebsprovider.Capabilities{
// capabilitiesOf reads what a provider advertises, so the suite can branch on
// it the way the contract tells callers to.
func capabilitiesOf(t *testing.T, provider ebsprovider.EBSProvider) ebsprovider.Capabilities {
func capabilitiesOf(t *testing.T, provider ebsprovider.EBSProvider) *ebsprovider.GetCapabilitiesResponse {
t.Helper()
resp, err := provider.GetCapabilities(context.Background(), ebsprovider.GetCapabilitiesRequest{Versioned: ebsprovider.NewVersioned()})
require.NoError(t, err)
require.NotNil(t, resp)
return resp.Capabilities
return resp
}
// maxListPages bounds a drain so a provider whose token fails to advance
// fails the test instead of hanging it.
const maxListPages = 100
// unpublish is UnpublishVolume for a case that asserts only on the error.
func unpublish(ctx context.Context, provider ebsprovider.EBSProvider, req ebsprovider.UnpublishVolumeRequest) error {
_, err := provider.UnpublishVolume(ctx, req)
return err
}
// volumeIDs projects one page of refs down to its IDs.
func volumeIDs(refs []ebsprovider.VolumeRef) []string {
ids := make([]string, 0, len(refs))
@@ -131,6 +140,11 @@ type SuiteConfig struct {
// also be published elsewhere. Empty skips that subtest, for a caller with
// only one node to offer.
OtherNodeID string
// KillExport ends the process serving a published export without telling
// the provider, standing in for a crash. Nil skips the dead-export case,
// for a provider whose export is not a separate process.
KillExport func(t *testing.T, published *ebsprovider.PublishedVolume)
}
func (c SuiteConfig) node() string {
@@ -176,7 +190,8 @@ func RunSuiteWithConfig(t *testing.T, newRawProvider func(t *testing.T) ebsprovi
return newTrackingProvider(t, newRawProvider(t))
}
capabilities := capabilitiesOf(t, newProvider(t))
advertised := capabilitiesOf(t, newProvider(t))
capabilities, contractVersions := advertised.Capabilities, advertised.ContractVersions
t.Run("GetCapabilities", func(t *testing.T) {
provider := newProvider(t)
@@ -193,6 +208,44 @@ func RunSuiteWithConfig(t *testing.T, newRawProvider func(t *testing.T) ebsprovi
assert.Equal(t, resp.Capabilities, again.Capabilities, "GetCapabilities must be stable across calls")
})
t.Run("ContractVersions", func(t *testing.T) {
t.Run("listed versions are ascending and include the legacy version", func(t *testing.T) {
require.NotEmpty(t, contractVersions, "a caller cannot choose a version for a provider that lists none")
assert.Contains(t, contractVersions, ebsprovider.LegacySchemaVersion,
"dropping the legacy version strands every caller still in an N/N-1 rollout")
for i := 1; i < len(contractVersions); i++ {
assert.Lessf(t, contractVersions[i-1], contractVersions[i], "contract versions %v are not strictly ascending", contractVersions)
}
})
t.Run("every listed version is answered in that version", func(t *testing.T) {
provider := newProvider(t)
for _, version := range contractVersions {
resp, err := provider.GetCapabilities(context.Background(), ebsprovider.GetCapabilitiesRequest{Versioned: ebsprovider.Versioned{SchemaVersion: version}})
require.NoErrorf(t, err, "version %d is listed as served", version)
assert.Equalf(t, version, resp.SchemaVersion, "a reply in another version than the request's is one an older caller may not parse")
}
})
t.Run("an unlisted version is refused without changing state", func(t *testing.T) {
provider := newProvider(t)
ctx := context.Background()
unknown := ebsprovider.Versioned{SchemaVersion: ebsprovider.SchemaVersion + 1}
_, err := provider.GetCapabilities(ctx, ebsprovider.GetCapabilitiesRequest{Versioned: unknown})
require.ErrorIs(t, err, ebsprovider.ErrUnsupportedVersion)
volumeID := cfg.id("vol-ver-unknown")
_, err = provider.CreateVolume(ctx, ebsprovider.CreateVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID, CapacityRange: ebsprovider.CapacityRange{RequiredBytes: 1 << 30}})
require.NoError(t, err)
_, err = provider.PublishVolume(ctx, ebsprovider.PublishVolumeRequest{Versioned: unknown, VolumeID: volumeID, NodeID: cfg.node()})
require.ErrorIs(t, err, ebsprovider.ErrUnsupportedVersion)
// A refused publish that still exported the volume would leave it
// published, and a published volume cannot be deleted.
require.NoError(t, provider.DeleteVolume(ctx, ebsprovider.DeleteVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID}),
"a publish refused for its version must not have published the volume")
})
})
t.Run("CreateVolume", func(t *testing.T) {
t.Run("success", func(t *testing.T) {
provider := newProvider(t)
@@ -385,8 +438,6 @@ func RunSuiteWithConfig(t *testing.T, newRawProvider func(t *testing.T) ebsprovi
})
t.Run("ListVolumes", func(t *testing.T) {
capabilities := capabilitiesOf(t, newProvider(t))
t.Run("unsupported_capability when enumeration is not advertised", func(t *testing.T) {
if capabilities.VolumeEnumeration {
t.Skip("provider advertises volume enumeration")
@@ -694,8 +745,6 @@ func RunSuiteWithConfig(t *testing.T, newRawProvider func(t *testing.T) ebsprovi
})
t.Run("ListSnapshots", func(t *testing.T) {
capabilities := capabilitiesOf(t, newProvider(t))
t.Run("unsupported_capability when enumeration is not advertised", func(t *testing.T) {
if capabilities.SnapshotEnumeration {
t.Skip("provider advertises snapshot enumeration")
@@ -798,16 +847,23 @@ func RunSuiteWithConfig(t *testing.T, newRawProvider func(t *testing.T) ebsprovi
}
})
// A republish succeeds at every listed version, so a caller at any of
// them gets the running export back rather than a refusal.
t.Run("idempotent republish to the same node", func(t *testing.T) {
provider := newProvider(t)
ctx := context.Background()
_, err := provider.CreateVolume(ctx, ebsprovider.CreateVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: cfg.id("vol-pub-idem"), CapacityRange: ebsprovider.CapacityRange{RequiredBytes: 1 << 30}})
require.NoError(t, err)
first, err := provider.PublishVolume(ctx, ebsprovider.PublishVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: cfg.id("vol-pub-idem"), NodeID: cfg.node()})
require.NoError(t, err)
second, err := provider.PublishVolume(ctx, ebsprovider.PublishVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: cfg.id("vol-pub-idem"), NodeID: cfg.node()})
require.NoError(t, err)
assert.Equal(t, first, second)
for _, version := range contractVersions {
t.Run(fmt.Sprintf("v%d", version), func(t *testing.T) {
provider := newProvider(t)
ctx := context.Background()
volumeID := cfg.id(fmt.Sprintf("vol-pub-idem-v%d", version))
_, err := provider.CreateVolume(ctx, ebsprovider.CreateVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID, CapacityRange: ebsprovider.CapacityRange{RequiredBytes: 1 << 30}})
require.NoError(t, err)
first, err := provider.PublishVolume(ctx, ebsprovider.PublishVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID, NodeID: cfg.node()})
require.NoError(t, err)
second, err := provider.PublishVolume(ctx, ebsprovider.PublishVolumeRequest{Versioned: ebsprovider.Versioned{SchemaVersion: version}, VolumeID: volumeID, NodeID: cfg.node()})
require.NoError(t, err)
assert.Equal(t, first, second)
})
}
})
t.Run("volume_in_use when published to a different node", func(t *testing.T) {
@@ -824,6 +880,76 @@ func RunSuiteWithConfig(t *testing.T, newRawProvider func(t *testing.T) ebsprovi
require.ErrorIs(t, err, ebsprovider.ErrVolumeInUse)
})
t.Run("held elsewhere is reported only from the version that defines it", func(t *testing.T) {
if cfg.otherNode() == "" {
t.Skip("no second node configured")
}
for _, version := range contractVersions {
t.Run(fmt.Sprintf("v%d", version), func(t *testing.T) {
provider := newProvider(t)
ctx := context.Background()
volumeID := cfg.id(fmt.Sprintf("vol-pub-held-v%d", version))
_, err := provider.CreateVolume(ctx, ebsprovider.CreateVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID, CapacityRange: ebsprovider.CapacityRange{RequiredBytes: 1 << 30}})
require.NoError(t, err)
_, err = provider.PublishVolume(ctx, ebsprovider.PublishVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID, NodeID: cfg.node()})
require.NoError(t, err)
_, err = provider.PublishVolume(ctx, ebsprovider.PublishVolumeRequest{Versioned: ebsprovider.Versioned{SchemaVersion: version}, VolumeID: volumeID, NodeID: cfg.otherNode()})
require.ErrorIs(t, err, ebsprovider.ErrVolumeInUse, "every version must still read the refusal as in use")
if version >= ebsprovider.HeldElsewhereSchemaVersion {
assert.ErrorIs(t, err, ebsprovider.ErrVolumeHeldElsewhere)
} else {
assert.NotErrorIs(t, err, ebsprovider.ErrVolumeHeldElsewhere,
"a version %d caller predates held_elsewhere and must get the volume_in_use it has always got", version)
}
})
}
})
t.Run("a republish in the other access mode is in use, not held elsewhere", func(t *testing.T) {
if !capabilities.ReadOnlyPublish {
t.Skip("read-only publish is not advertised")
}
for _, version := range contractVersions {
t.Run(fmt.Sprintf("v%d", version), func(t *testing.T) {
provider := newProvider(t)
ctx := context.Background()
volumeID := cfg.id(fmt.Sprintf("vol-pub-mode-v%d", version))
_, err := provider.CreateVolume(ctx, ebsprovider.CreateVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID, CapacityRange: ebsprovider.CapacityRange{RequiredBytes: 1 << 30}})
require.NoError(t, err)
_, err = provider.PublishVolume(ctx, ebsprovider.PublishVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID, NodeID: cfg.node()})
require.NoError(t, err)
_, err = provider.PublishVolume(ctx, ebsprovider.PublishVolumeRequest{Versioned: ebsprovider.Versioned{SchemaVersion: version}, VolumeID: volumeID, NodeID: cfg.node(), ReadOnly: true})
require.ErrorIs(t, err, ebsprovider.ErrVolumeInUse)
assert.NotErrorIs(t, err, ebsprovider.ErrVolumeHeldElsewhere,
"this node holds the volume, so a caller told it is held elsewhere would wait for a node that does not exist")
})
}
})
t.Run("a republish after the export died returns a serving export", func(t *testing.T) {
if cfg.KillExport == nil {
t.Skip("no way to kill this provider's export")
}
RequireNBDTools(t)
provider := newProvider(t)
ctx := context.Background()
volumeID := cfg.id("vol-pub-dead")
_, err := provider.CreateVolume(ctx, ebsprovider.CreateVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID, CapacityRange: ebsprovider.CapacityRange{RequiredBytes: 1 << 30}})
require.NoError(t, err)
first, err := provider.PublishVolume(ctx, ebsprovider.PublishVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID, NodeID: cfg.node()})
require.NoError(t, err)
firstURI := dialURI(t, publishedNBDURI(t, first))
cfg.KillExport(t, first)
require.False(t, nbdReachable(firstURI), "the export still answers, so the kill did not take and this case proves nothing")
second, err := provider.PublishVolume(ctx, ebsprovider.PublishVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID, NodeID: cfg.node()})
require.NoError(t, err, "a dead export must not make its volume unpublishable")
nbdInfoRetry(t, dialURI(t, publishedNBDURI(t, second)))
})
t.Run("not_found on absent volume", func(t *testing.T) {
provider := newProvider(t)
_, err := provider.PublishVolume(context.Background(), ebsprovider.PublishVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: cfg.id("vol-never-existed"), NodeID: cfg.node()})
@@ -845,21 +971,105 @@ func RunSuiteWithConfig(t *testing.T, newRawProvider func(t *testing.T) ebsprovi
require.NoError(t, err)
_, err = provider.PublishVolume(ctx, ebsprovider.PublishVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: cfg.id("vol-unpub-ok"), NodeID: cfg.node()})
require.NoError(t, err)
require.NoError(t, provider.UnpublishVolume(ctx, ebsprovider.UnpublishVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: cfg.id("vol-unpub-ok"), NodeID: cfg.node()}))
require.NoError(t, unpublish(ctx, provider, ebsprovider.UnpublishVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: cfg.id("vol-unpub-ok"), NodeID: cfg.node()}))
})
t.Run("absent target is idempotent", func(t *testing.T) {
provider := newProvider(t)
require.NoError(t, provider.UnpublishVolume(context.Background(), ebsprovider.UnpublishVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: cfg.id("vol-never-existed"), NodeID: cfg.node()}))
require.NoError(t, unpublish(context.Background(), provider, ebsprovider.UnpublishVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: cfg.id("vol-never-existed"), NodeID: cfg.node()}))
})
t.Run("torn down reports whether an export was removed", func(t *testing.T) {
if !slices.Contains(contractVersions, ebsprovider.NoSealSchemaVersion) {
t.Skipf("torn_down is defined from version %d, which the provider does not list", ebsprovider.NoSealSchemaVersion)
}
provider := newProvider(t)
ctx := context.Background()
volumeID := cfg.id("vol-unpub-torn")
v3 := ebsprovider.Versioned{SchemaVersion: ebsprovider.NoSealSchemaVersion}
_, err := provider.CreateVolume(ctx, ebsprovider.CreateVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID, CapacityRange: ebsprovider.CapacityRange{RequiredBytes: 1 << 30}})
require.NoError(t, err)
_, err = provider.PublishVolume(ctx, ebsprovider.PublishVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID, NodeID: cfg.node()})
require.NoError(t, err)
first, err := provider.UnpublishVolume(ctx, ebsprovider.UnpublishVolumeRequest{Versioned: v3, VolumeID: volumeID, NodeID: cfg.node()})
require.NoError(t, err)
assert.True(t, first.TornDown, "the volume was published, so this unpublish removed an export")
again, err := provider.UnpublishVolume(ctx, ebsprovider.UnpublishVolumeRequest{Versioned: v3, VolumeID: volumeID, NodeID: cfg.node()})
require.NoError(t, err)
assert.False(t, again.TornDown, "nothing was left to remove, so a retry must not claim it removed something")
})
t.Run("no_seal below the version that defines it is refused", func(t *testing.T) {
for _, version := range contractVersions {
if version >= ebsprovider.NoSealSchemaVersion {
continue
}
t.Run(fmt.Sprintf("v%d", version), func(t *testing.T) {
provider := newProvider(t)
ctx := context.Background()
volumeID := cfg.id(fmt.Sprintf("vol-unpub-noseal-v%d", version))
_, err := provider.CreateVolume(ctx, ebsprovider.CreateVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID, CapacityRange: ebsprovider.CapacityRange{RequiredBytes: 1 << 30}})
require.NoError(t, err)
_, err = provider.PublishVolume(ctx, ebsprovider.PublishVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID, NodeID: cfg.node()})
require.NoError(t, err)
err = unpublish(ctx, provider, ebsprovider.UnpublishVolumeRequest{Versioned: ebsprovider.Versioned{SchemaVersion: version}, VolumeID: volumeID, NodeID: cfg.node(), NoSeal: true})
require.ErrorIs(t, err, ebsprovider.ErrInvalidArgument,
"a version %d server would ignore no_seal and seal, so the flag must be refused rather than dropped", version)
err = provider.DeleteVolume(ctx, ebsprovider.DeleteVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID})
require.ErrorIs(t, err, ebsprovider.ErrVolumeInUse, "the refused unpublish must have left the volume published")
})
}
})
t.Run("no_seal is honoured or refused as unsupported", func(t *testing.T) {
provider := newProvider(t)
ctx := context.Background()
volumeID := cfg.id("vol-unpub-noseal")
v3 := ebsprovider.Versioned{SchemaVersion: ebsprovider.NoSealSchemaVersion}
_, err := provider.CreateVolume(ctx, ebsprovider.CreateVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID, CapacityRange: ebsprovider.CapacityRange{RequiredBytes: 1 << 30}})
require.NoError(t, err)
_, err = provider.PublishVolume(ctx, ebsprovider.PublishVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID, NodeID: cfg.node()})
require.NoError(t, err)
result, err := provider.UnpublishVolume(ctx, ebsprovider.UnpublishVolumeRequest{Versioned: v3, VolumeID: volumeID, NodeID: cfg.node(), NoSeal: true})
if errors.Is(err, ebsprovider.ErrUnsupportedVersion) {
return
}
require.NoError(t, err)
assert.True(t, result.TornDown, "the volume was published, so a no_seal unpublish removed an export")
again, err := provider.UnpublishVolume(ctx, ebsprovider.UnpublishVolumeRequest{Versioned: v3, VolumeID: volumeID, NodeID: cfg.node(), NoSeal: true})
require.NoError(t, err)
assert.False(t, again.TornDown)
_, err = provider.PublishVolume(ctx, ebsprovider.PublishVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID, NodeID: cfg.node()})
require.NoError(t, err, "a volume dropped without sealing must still be publishable")
})
t.Run("invalid_argument on missing ids", func(t *testing.T) {
provider := newProvider(t)
err := provider.UnpublishVolume(context.Background(), ebsprovider.UnpublishVolumeRequest{Versioned: ebsprovider.NewVersioned()})
err := unpublish(context.Background(), provider, ebsprovider.UnpublishVolumeRequest{Versioned: ebsprovider.NewVersioned()})
require.ErrorIs(t, err, ebsprovider.ErrInvalidArgument)
})
})
t.Run("HoldsUnsealedWrites", func(t *testing.T) {
t.Run("answers with a subset of the volumes asked about", func(t *testing.T) {
provider := newProvider(t)
ctx := context.Background()
volumeID := cfg.id("vol-unsealed")
_, err := provider.CreateVolume(ctx, ebsprovider.CreateVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID, CapacityRange: ebsprovider.CapacityRange{RequiredBytes: 1 << 30}})
require.NoError(t, err)
_, err = provider.PublishVolume(ctx, ebsprovider.PublishVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID, NodeID: cfg.node()})
require.NoError(t, err)
asked := []string{volumeID, cfg.id("vol-unsealed-never")}
held, err := provider.HoldsUnsealedWrites(ctx, cfg.node(), asked)
require.NoError(t, err, "an error reads as cannot say, which blocks every start elsewhere")
assert.Subset(t, asked, held, "a volume nobody asked about is not an answer to the question")
})
})
t.Run("Exclusion", func(t *testing.T) {
exclusion := capabilities.Exclusion
@@ -907,7 +1117,7 @@ func RunSuiteWithConfig(t *testing.T, newRawProvider func(t *testing.T) ebsprovi
// Releasing the claim must make the volume publishable again, or
// the guarantee is indistinguishable from a permanent lock.
require.NoError(t, provider.UnpublishVolume(ctx, ebsprovider.UnpublishVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID, NodeID: cfg.node()}))
require.NoError(t, unpublish(ctx, provider, ebsprovider.UnpublishVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID, NodeID: cfg.node()}))
_, err = provider.PublishVolume(ctx, ebsprovider.PublishVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID, NodeID: cfg.otherNode()})
require.NoError(t, err, "a released claim must be reclaimable, or an unpublish strands the volume")
})
@@ -968,7 +1178,7 @@ func RunSuiteWithConfig(t *testing.T, newRawProvider func(t *testing.T) ebsprovi
require.ErrorIs(t, err, ebsprovider.ErrVolumeInUse,
"deleting a published volume must be refused; the export is still serving reads and writes against it")
require.NoError(t, provider.UnpublishVolume(ctx, ebsprovider.UnpublishVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID, NodeID: cfg.node()}))
require.NoError(t, unpublish(ctx, provider, ebsprovider.UnpublishVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID, NodeID: cfg.node()}))
require.NoError(t, provider.DeleteVolume(ctx, ebsprovider.DeleteVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID}),
"once unpublished the delete must go through, or a volume can never be removed after being used")
})
@@ -217,7 +217,7 @@ func cleanupVolume(t *testing.T, provider ebsprovider.EBSProvider, cfg NBDClient
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
if err := provider.UnpublishVolume(ctx, ebsprovider.UnpublishVolumeRequest{
if _, err := provider.UnpublishVolume(ctx, ebsprovider.UnpublishVolumeRequest{
Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID, NodeID: cfg.NodeID,
}); err != nil {
t.Logf("cleanup: unpublish %s: %v", volumeID, err)
@@ -310,7 +310,7 @@ func RunNBDClientSuiteWithConfig(t *testing.T, newProvider func(t *testing.T) eb
// holes to a server that really tracks allocation.
t.Run("sparse extent reporting matches the advertised capability", func(t *testing.T) {
provider := newProvider(t)
capabilities := capabilitiesOf(t, provider)
capabilities := capabilitiesOf(t, provider).Capabilities
nbdURI := publishForNBD(t, provider, cfg, cfg.VolumePrefix+"-extents", false)
holes := slices.ContainsFunc(nbdMap(t, dialURI(t, nbdURI)), func(extent nbdExtent) bool {
@@ -323,7 +323,7 @@ func RunNBDClientSuiteWithConfig(t *testing.T, newProvider func(t *testing.T) eb
t.Run("read-only publish matches the advertised capability", func(t *testing.T) {
provider := newProvider(t)
capabilities := capabilitiesOf(t, provider)
capabilities := capabilitiesOf(t, provider).Capabilities
if !capabilities.ReadOnlyPublish {
// Refusing is the contract for a provider that cannot honour it;
@@ -356,11 +356,12 @@ func RunNBDClientSuiteWithConfig(t *testing.T, newProvider func(t *testing.T) eb
uri := dialURI(t, nbdURI)
require.True(t, nbdReachable(uri), "export should be reachable before unpublish")
require.NoError(t, provider.UnpublishVolume(context.Background(), ebsprovider.UnpublishVolumeRequest{
_, err := provider.UnpublishVolume(context.Background(), ebsprovider.UnpublishVolumeRequest{
Versioned: ebsprovider.NewVersioned(),
VolumeID: volumeID,
NodeID: cfg.NodeID,
}))
})
require.NoError(t, err)
assert.Eventually(t, func() bool { return !nbdReachable(uri) }, 30*time.Second, 250*time.Millisecond,
"export still answers after UnpublishVolume")
@@ -84,12 +84,12 @@ func (p *trackingProvider) PublishVolume(ctx context.Context, req ebsprovider.Pu
return pub, err
}
func (p *trackingProvider) UnpublishVolume(ctx context.Context, req ebsprovider.UnpublishVolumeRequest) error {
err := p.EBSProvider.UnpublishVolume(ctx, req)
func (p *trackingProvider) UnpublishVolume(ctx context.Context, req ebsprovider.UnpublishVolumeRequest) (*ebsprovider.UnpublishedVolume, error) {
result, err := p.EBSProvider.UnpublishVolume(ctx, req)
if err == nil {
delete(p.published, req.VolumeID)
}
return err
return result, err
}
// cleanup removes what the subtest left behind, in dependency order: a
@@ -100,7 +100,7 @@ func (p *trackingProvider) cleanup() {
ctx := context.Background()
for volumeID, nodeID := range p.published {
if err := p.EBSProvider.UnpublishVolume(ctx, ebsprovider.UnpublishVolumeRequest{
if _, err := p.EBSProvider.UnpublishVolume(ctx, ebsprovider.UnpublishVolumeRequest{
Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID, Handle: p.volumes[volumeID], NodeID: nodeID,
}); err != nil {
p.t.Logf("conformance cleanup: unpublish %s: %v", volumeID, err)
+37 -9
View File
@@ -12,6 +12,11 @@ var (
ErrUnsupportedVersion = errors.New("unsupported EBS provider schema version")
ErrVolumeInUse = errors.New("volume is in use")
// ErrVolumeHeldElsewhere is a publish refused because another node holds
// the volume's claim. It wraps ErrVolumeInUse, so a caller that only asks
// whether the volume is in use still matches it.
ErrVolumeHeldElsewhere = fmt.Errorf("volume is held by another node: %w", ErrVolumeInUse)
// ErrUnsupportedCapability is returned when a request asks for optional
// behaviour this provider does not advertise in Capabilities. It is
// distinct from ErrInvalidArgument: the request is well formed.
@@ -28,19 +33,35 @@ var (
ErrUnavailable = errors.New("provider is temporarily unable to answer")
)
// HeldElsewhereSchemaVersion is the first version a held-elsewhere refusal is
// reported as such. Earlier requests get volume_in_use, as v1.23.0 sends it.
const HeldElsewhereSchemaVersion uint16 = 3
// HeldElsewhereError is the sentinel a held-elsewhere refusal of a request at
// version wraps: ErrVolumeHeldElsewhere from HeldElsewhereSchemaVersion on.
func HeldElsewhereError(version uint16) error {
if version >= HeldElsewhereSchemaVersion {
return ErrVolumeHeldElsewhere
}
return ErrVolumeInUse
}
// ErrorCode is the wire form of a provider error in ProviderError. Unwrap maps
// every code but internal back to the matching Err sentinel.
type ErrorCode string
const (
ErrorCodeAlreadyExists ErrorCode = "already_exists"
ErrorCodeInvalidArgument ErrorCode = "invalid_argument"
ErrorCodeNotFound ErrorCode = "not_found"
ErrorCodeUnsupportedVersion ErrorCode = "unsupported_version"
ErrorCodeVolumeInUse ErrorCode = "volume_in_use"
ErrorCodeUnsupportedCap ErrorCode = "unsupported_capability"
ErrorCodeUnavailable ErrorCode = "unavailable"
ErrorCodeInternal ErrorCode = "internal"
ErrorCodeAlreadyExists ErrorCode = "already_exists"
ErrorCodeInvalidArgument ErrorCode = "invalid_argument"
ErrorCodeNotFound ErrorCode = "not_found"
ErrorCodeUnsupportedVersion ErrorCode = "unsupported_version"
ErrorCodeVolumeInUse ErrorCode = "volume_in_use"
ErrorCodeVolumeHeldElsewhere ErrorCode = "volume_held_elsewhere"
ErrorCodeUnsupportedCap ErrorCode = "unsupported_capability"
// ErrorCodeUnavailable is the retry-later code: among others, a mount
// against a backing store that is not ready yet returns it.
ErrorCodeUnavailable ErrorCode = "unavailable"
ErrorCodeInternal ErrorCode = "internal"
)
// Retryable reports whether repeating the identical request could succeed.
@@ -78,6 +99,8 @@ func (e *ProviderError) Unwrap() error {
return ErrUnsupportedVersion
case ErrorCodeVolumeInUse:
return ErrVolumeInUse
case ErrorCodeVolumeHeldElsewhere:
return ErrVolumeHeldElsewhere
case ErrorCodeUnsupportedCap:
return ErrUnsupportedCapability
case ErrorCodeUnavailable:
@@ -89,7 +112,12 @@ func (e *ProviderError) Unwrap() error {
func checkVersion(version uint16) error {
if !SupportsSchemaVersion(version) {
return fmt.Errorf("%w: got %d, supported versions are %d and %d", ErrUnsupportedVersion, version, LegacySchemaVersion, SchemaVersion)
return fmt.Errorf("%w: got %d, %s", ErrUnsupportedVersion, version, SupportedVersionsText())
}
return nil
}
// SupportedVersionsText names the served version range for error messages.
func SupportedVersionsText() string {
return fmt.Sprintf("supported versions are %d to %d", LegacySchemaVersion, SchemaVersion)
}
+1 -1
View File
@@ -105,7 +105,7 @@ func soakOneVolume(ctx context.Context, provider ebsprovider.EBSProvider, volume
if _, err := provider.PublishVolume(ctx, ebsprovider.PublishVolumeRequest{
Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID, NodeID: "node-1",
}); err == nil {
_ = provider.UnpublishVolume(ctx, ebsprovider.UnpublishVolumeRequest{
_, _ = provider.UnpublishVolume(ctx, ebsprovider.UnpublishVolumeRequest{
Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID, NodeID: "node-1",
})
}
+22 -3
View File
@@ -21,6 +21,7 @@ const (
verbSnapshotDelete = "snapshot.delete"
verbSnapshotCopy = "snapshot.copy"
verbSnapshotList = "snapshot.list"
verbUnsealedWrites = "volume.unsealed"
)
// lieFlavours are the ways a successful answer can be wrong. Each one is a
@@ -240,10 +241,28 @@ func (p *Provider) PublishVolume(ctx context.Context, req ebsprovider.PublishVol
return published, nil
}
func (p *Provider) UnpublishVolume(ctx context.Context, req ebsprovider.UnpublishVolumeRequest) error {
func (p *Provider) UnpublishVolume(ctx context.Context, req ebsprovider.UnpublishVolumeRequest) (*ebsprovider.UnpublishedVolume, error) {
injection := p.draw(verbVolumeUnpublish, req.VolumeID)
if stop, err := p.apply(ctx, injection); stop || err != nil {
return err
return nil, err
}
return p.after(injection, p.inner.UnpublishVolume(ctx, req))
result, err := p.inner.UnpublishVolume(ctx, req)
if err := p.after(injection, err); err != nil {
return nil, err
}
return result, nil
}
// HoldsUnsealedWrites is never made to lie: a false "none held" lets an
// instance start elsewhere over writes only this node has.
func (p *Provider) HoldsUnsealedWrites(ctx context.Context, nodeID string, volumeIDs []string) ([]string, error) {
injection := p.drawTruthful(verbUnsealedWrites, nodeID)
if stop, err := p.apply(ctx, injection); stop || err != nil {
return nil, err
}
held, err := p.inner.HoldsUnsealedWrites(ctx, nodeID, volumeIDs)
if err := p.after(injection, err); err != nil {
return nil, err
}
return held, nil
}
+36 -10
View File
@@ -27,6 +27,7 @@ type memoryVolume struct {
parameters []byte
seed []byte
published *PublishedVolume
readOnly bool
}
var _ EBSProvider = (*MemoryProvider)(nil)
@@ -53,7 +54,11 @@ func (m *MemoryProvider) GetCapabilities(_ context.Context, req GetCapabilitiesR
if err := checkVersion(req.SchemaVersion); err != nil {
return nil, err
}
return &GetCapabilitiesResponse{Versioned: NewVersioned(), Capabilities: m.capabilities}, nil
return &GetCapabilitiesResponse{
Versioned: ReplyVersioned(req.SchemaVersion),
Capabilities: m.capabilities,
ContractVersions: SupportedSchemaVersions(),
}, nil
}
func (m *MemoryProvider) CreateVolume(_ context.Context, req CreateVolumeRequest) (*Volume, error) {
@@ -167,7 +172,7 @@ func (m *MemoryProvider) ListVolumes(_ context.Context, req ListVolumesRequest)
defer m.mu.RUnlock()
ids := slices.Sorted(maps.Keys(m.volumes))
response := &ListVolumesResponse{Versioned: NewVersioned()}
response := &ListVolumesResponse{Versioned: ReplyVersioned(req.SchemaVersion)}
response.Volumes, response.NextToken = Page(ids, req.StartingToken, int(req.PageSize()),
func(id string) VolumeRef {
return VolumeRef{ID: id, Handle: m.volumes[id].volume.Handle}
@@ -338,7 +343,7 @@ func (m *MemoryProvider) ListSnapshots(_ context.Context, req ListSnapshotsReque
defer m.mu.RUnlock()
ids := slices.Sorted(maps.Keys(m.snapshots))
response := &ListSnapshotsResponse{Versioned: NewVersioned()}
response := &ListSnapshotsResponse{Versioned: ReplyVersioned(req.SchemaVersion)}
response.Snapshots, response.NextToken = Page(ids, req.StartingToken, int(req.PageSize()),
func(id string) SnapshotRef {
return SnapshotRef{
@@ -368,7 +373,10 @@ func (m *MemoryProvider) PublishVolume(_ context.Context, req PublishVolumeReque
}
if volume.published != nil {
if volume.published.NodeID != req.NodeID {
return nil, fmt.Errorf("%w: volume %s is published to %s", ErrVolumeInUse, req.VolumeID, volume.published.NodeID)
return nil, fmt.Errorf("%w: volume %s is published to %s", HeldElsewhereError(req.SchemaVersion), req.VolumeID, volume.published.NodeID)
}
if volume.readOnly != req.ReadOnly {
return nil, fmt.Errorf("%w: volume %s is already published read_only=%t", ErrVolumeInUse, req.VolumeID, volume.readOnly)
}
return clonePublished(volume.published), nil
}
@@ -377,26 +385,44 @@ func (m *MemoryProvider) PublishVolume(_ context.Context, req PublishVolumeReque
NodeID: req.NodeID,
Export: NewNBDExport("nbd+unix:///?socket=/memory/" + req.VolumeID + ".sock"),
}
volume.readOnly = req.ReadOnly
volume.volume.State = VolumeStateInUse
return clonePublished(volume.published), nil
}
func (m *MemoryProvider) UnpublishVolume(_ context.Context, req UnpublishVolumeRequest) error {
if err := checkVersion(req.SchemaVersion); err != nil {
return err
// UnpublishVolume honours NoSeal trivially: the volume's bytes live in this
// process, so there is no local copy to seal or to drop.
func (m *MemoryProvider) UnpublishVolume(_ context.Context, req UnpublishVolumeRequest) (*UnpublishedVolume, error) {
if err := CheckUnpublishVersion(req); err != nil {
return nil, err
}
if req.VolumeID == "" || req.NodeID == "" {
return fmt.Errorf("%w: volume and node IDs are required", ErrInvalidArgument)
return nil, fmt.Errorf("%w: volume and node IDs are required", ErrInvalidArgument)
}
m.mu.Lock()
defer m.mu.Unlock()
result := &UnpublishedVolume{}
volume := m.volumes[req.VolumeID]
if volume == nil || volume.published == nil || volume.published.NodeID != req.NodeID {
return nil
return result, nil
}
volume.published = nil
volume.readOnly = false
volume.volume.State = VolumeStateAvailable
return nil
result.TornDown = req.SchemaVersion >= NoSealSchemaVersion
return result, nil
}
// HoldsUnsealedWrites answers that none are held: every write lands in this
// process's map, which is the backend, before it is acknowledged.
func (m *MemoryProvider) HoldsUnsealedWrites(_ context.Context, nodeID string, volumeIDs []string) ([]string, error) {
if nodeID == "" {
return nil, fmt.Errorf("%w: node ID is required", ErrInvalidArgument)
}
if slices.Contains(volumeIDs, "") {
return nil, fmt.Errorf("%w: empty volume ID", ErrInvalidArgument)
}
return nil, nil
}
func cloneVolume(volume *Volume) *Volume {
+4 -3
View File
@@ -56,9 +56,10 @@ func TestMemoryProviderLifecycleAndIdempotency(t *testing.T) {
Versioned: NewVersioned(), VolumeID: volume.ID, Handle: volume.Handle,
}), ErrVolumeInUse)
require.NoError(t, provider.UnpublishVolume(ctx, UnpublishVolumeRequest{
_, err = provider.UnpublishVolume(ctx, UnpublishVolumeRequest{
Versioned: NewVersioned(), VolumeID: volume.ID, Handle: volume.Handle, NodeID: "node-1",
}))
})
require.NoError(t, err)
expanded, err := provider.ExpandVolume(ctx, ExpandVolumeRequest{
Versioned: NewVersioned(), VolumeID: volume.ID, Handle: volume.Handle,
@@ -141,7 +142,7 @@ func TestMemoryProviderChecksVersionOnEveryMethod(t *testing.T) {
_, err = provider.PublishVolume(ctx, PublishVolumeRequest{VolumeID: "vol-1", NodeID: "node-1"})
require.ErrorIs(t, err, ErrUnsupportedVersion)
err = provider.UnpublishVolume(ctx, UnpublishVolumeRequest{VolumeID: "vol-1", NodeID: "node-1"})
_, err = provider.UnpublishVolume(ctx, UnpublishVolumeRequest{VolumeID: "vol-1", NodeID: "node-1"})
require.ErrorIs(t, err, ErrUnsupportedVersion)
}
+85 -52
View File
@@ -39,20 +39,22 @@ const defaultRequestTimeout = 30 * time.Second
// codebase's existing budget for an EBS operation the caller cannot bound.
const dataMovementTimeout = 180 * time.Second
// dataMovementSubjects names every subject that references or moves real data.
// Everything absent answers from state already held and keeps the caller's
// timeout, so a subject added without a deliberate decision stays fast.
var dataMovementSubjects = map[string]struct{}{
CreateVolumeSubject: {},
ExpandVolumeSubject: {},
CopySnapshotSubject: {},
DeleteVolumeSubject: {},
DeleteSnapshotSubject: {},
// dataMovementVerbs names every operation that references or moves real data,
// however its subject addresses it. Everything absent answers from state
// already held and keeps the caller's timeout, so a new verb stays fast.
var dataMovementVerbs = map[string]bool{
verbVolumeCreate: true,
verbVolumeExpand: true,
verbVolumeDelete: true,
verbSnapshotCreate: true,
verbSnapshotCopy: true,
verbSnapshotDelete: true,
// Unpublish seals local state back to the backend, which takes as long as
// the volume has unsealed writes.
verbVolumeUnpublish: true,
}
// timeoutFor sizes a request to the work its subject names. The per-volume
// snapshot create and the owner-routed variants carry no constant, so they are
// matched by prefix and by verb.
// timeoutFor sizes a request to the work its subject names.
func (p *NATSProvider) timeoutFor(subject string) time.Duration {
if movesData(subject) {
return max(p.requestTimeout, dataMovementTimeout)
@@ -60,28 +62,8 @@ func (p *NATSProvider) timeoutFor(subject string) time.Duration {
return p.requestTimeout
}
// movesData answers for the named subjects, for the per-volume snapshot create
// prefix, and for the owner-routed variants, none of which carry a constant.
func movesData(subject string) bool {
if _, ok := dataMovementSubjects[subject]; ok {
return true
}
if strings.HasPrefix(subject, SnapshotCreateSubjectPrefix) {
return true
}
_, verb, ok := ParseOwnerSubject(subject)
return ok && isDataMovementVerb(verb)
}
// isDataMovementVerb answers for the owner-routed subjects. volume.describe is
// the one owner verb that reads state rather than moving data.
func isDataMovementVerb(verb string) bool {
switch verb {
case verbSnapshotCreate, verbSnapshotCopy, verbVolumeExpand:
return true
default:
return false
}
return dataMovementVerbs[parseSubject(subject).verb]
}
// SnapshotSubject addresses a create by source volume so the owning node can
@@ -104,32 +86,65 @@ func SnapshotCompletionSubject(snapshotID string) (string, error) {
return "ebs.provider.v1.snapshot.response." + snapshotID, nil
}
// PublishSubject returns nodeID's per-node mount subject
// (ebs.provider.v1.<node>.mount), which serves PublishVolume on that node.
func PublishSubject(nodeID string) (string, error) {
// Last tokens of the per-node subjects: ebs.provider.v1.<node>.<verb>.
const (
publishVerb = "mount"
unpublishVerb = "unmount"
unsealedVerb = "unsealed"
nodeCapabilitiesVerb = "capabilities"
fenceNoticeVerb = "fenced"
)
// nodeSubjectWildcard is the subject matching verb on every node, for a server
// that stands in for every node's provider.
func nodeSubjectWildcard(verb string) string { return subjectPrefix + "*." + verb }
// Wildcards over every node for each per-node subject.
var (
PublishSubjectWildcard = nodeSubjectWildcard(publishVerb)
UnpublishSubjectWildcard = nodeSubjectWildcard(unpublishVerb)
UnsealedWritesSubjectWildcard = nodeSubjectWildcard(unsealedVerb)
NodeCapabilitiesSubjectWildcard = nodeSubjectWildcard(nodeCapabilitiesVerb)
)
func nodeSubject(nodeID, verb string) (string, error) {
if err := validateSubjectToken(nodeID); err != nil {
return "", err
}
return subjectPrefix + nodeID + ".mount", nil
return subjectPrefix + nodeID + "." + verb, nil
}
// PublishSubject returns nodeID's per-node mount subject
// (ebs.provider.v1.<node>.mount), which serves PublishVolume on that node.
func PublishSubject(nodeID string) (string, error) {
return nodeSubject(nodeID, publishVerb)
}
// UnpublishSubject returns nodeID's per-node unmount subject
// (ebs.provider.v1.<node>.unmount), which serves UnpublishVolume on that node.
func UnpublishSubject(nodeID string) (string, error) {
if err := validateSubjectToken(nodeID); err != nil {
return "", err
}
return subjectPrefix + nodeID + ".unmount", nil
return nodeSubject(nodeID, unpublishVerb)
}
// UnsealedWritesSubject returns nodeID's per-node subject
// (ebs.provider.v1.<node>.unsealed), which answers whether that node holds
// unsealed writes for a set of volumes.
func UnsealedWritesSubject(nodeID string) (string, error) {
if err := validateSubjectToken(nodeID); err != nil {
return "", err
}
return subjectPrefix + nodeID + ".unsealed", nil
return nodeSubject(nodeID, unsealedVerb)
}
// NodeCapabilitiesSubject returns nodeID's per-node GetCapabilities subject
// (ebs.provider.v1.<node>.capabilities). Unlike CapabilitiesSubject, which
// any node's provider answers, only nodeID's provider answers it.
func NodeCapabilitiesSubject(nodeID string) (string, error) {
return nodeSubject(nodeID, nodeCapabilitiesVerb)
}
// FenceNoticeSubject returns nodeID's fence-notice subject
// (ebs.provider.v1.<node>.fenced), on which a provider that revoked a live
// export there sends a FenceNotice for that node's daemon to acknowledge.
func FenceNoticeSubject(nodeID string) (string, error) {
return nodeSubject(nodeID, fenceNoticeVerb)
}
// ownerSubjectPrefix is common to every owner-routed subject OwnerSubject
@@ -224,11 +239,26 @@ func NewNATSProvider(conn *nats.Conn, requestTimeout time.Duration) *NATSProvide
}
func (p *NATSProvider) GetCapabilities(ctx context.Context, req GetCapabilitiesRequest) (*GetCapabilitiesResponse, error) {
return p.getCapabilities(ctx, CapabilitiesSubject, req)
}
// GetNodeCapabilities asks nodeID's own provider what it supports, over
// NodeCapabilitiesSubject. A provider that predates that subject gets no
// responder, which a caller must count as no evidence.
func (p *NATSProvider) GetNodeCapabilities(ctx context.Context, nodeID string, req GetCapabilitiesRequest) (*GetCapabilitiesResponse, error) {
subject, err := NodeCapabilitiesSubject(nodeID)
if err != nil {
return nil, err
}
return p.getCapabilities(ctx, subject, req)
}
func (p *NATSProvider) getCapabilities(ctx context.Context, subject string, req GetCapabilitiesRequest) (*GetCapabilitiesResponse, error) {
if err := checkVersion(req.SchemaVersion); err != nil {
return nil, err
}
var response GetCapabilitiesResponse
if err := p.request(ctx, CapabilitiesSubject, req, &response); err != nil {
if err := p.request(ctx, subject, req, &response); err != nil {
return nil, err
}
if err := responseError(response.SchemaVersion, response.Error); err != nil {
@@ -490,19 +520,22 @@ func (p *NATSProvider) PublishVolume(ctx context.Context, req PublishVolumeReque
return response.Published, nil
}
func (p *NATSProvider) UnpublishVolume(ctx context.Context, req UnpublishVolumeRequest) error {
if err := checkVersion(req.SchemaVersion); err != nil {
return err
func (p *NATSProvider) UnpublishVolume(ctx context.Context, req UnpublishVolumeRequest) (*UnpublishedVolume, error) {
if err := CheckUnpublishVersion(req); err != nil {
return nil, err
}
subject, err := UnpublishSubject(req.NodeID)
if err != nil {
return err
return nil, err
}
var response UnpublishVolumeResponse
if err := p.request(ctx, subject, req, &response); err != nil {
return err
return nil, err
}
return responseError(response.SchemaVersion, response.Error)
if err := responseError(response.SchemaVersion, response.Error); err != nil {
return nil, err
}
return &UnpublishedVolume{TornDown: response.TornDown}, nil
}
// HoldsUnsealedWrites returns the subset of volumeIDs for which nodeID's
+14 -3
View File
@@ -250,7 +250,7 @@ func TestNATSProviderChecksVersionBeforeTransport(t *testing.T) {
require.ErrorIs(t, err, ErrUnsupportedVersion)
})
t.Run("UnpublishVolume", func(t *testing.T) {
err := provider.UnpublishVolume(ctx, UnpublishVolumeRequest{VolumeID: "vol-1", NodeID: "node-1"})
_, err := provider.UnpublishVolume(ctx, UnpublishVolumeRequest{VolumeID: "vol-1", NodeID: "node-1"})
require.ErrorIs(t, err, ErrUnsupportedVersion)
})
}
@@ -563,7 +563,7 @@ func TestNATSProviderPropagatesProviderErrors(t *testing.T) {
respondWith(t, conn, subject, UnpublishVolumeResponse{
Versioned: NewVersioned(), Error: &ProviderError{Code: ErrorCodeNotFound, Message: "no such mount"},
})
err = NewNATSProvider(conn, time.Second).UnpublishVolume(t.Context(), UnpublishVolumeRequest{
_, err = NewNATSProvider(conn, time.Second).UnpublishVolume(t.Context(), UnpublishVolumeRequest{
Versioned: NewVersioned(), VolumeID: "vol-1", NodeID: "node-err",
})
require.ErrorIs(t, err, ErrNotFound)
@@ -601,7 +601,7 @@ func TestNATSProviderValidatesSubjectTokensBeforeTransport(t *testing.T) {
require.ErrorIs(t, err, ErrInvalidArgument)
})
t.Run("UnpublishVolume", func(t *testing.T) {
err := provider.UnpublishVolume(ctx, UnpublishVolumeRequest{Versioned: NewVersioned(), VolumeID: "vol-1", NodeID: "node.*"})
_, err := provider.UnpublishVolume(ctx, UnpublishVolumeRequest{Versioned: NewVersioned(), VolumeID: "vol-1", NodeID: "node.*"})
require.ErrorIs(t, err, ErrInvalidArgument)
})
}
@@ -712,6 +712,14 @@ func TestTimeoutForSizesEachSubjectToItsWork(t *testing.T) {
require.NoError(t, err)
ownerDescribe, err := GetVolumeOwnerSubject("vol-1")
require.NoError(t, err)
unpublish, err := UnpublishSubject("node-1")
require.NoError(t, err)
publish, err := PublishSubject("node-1")
require.NoError(t, err)
unsealed, err := UnsealedWritesSubject("node-1")
require.NoError(t, err)
nodeCapabilities, err := NodeCapabilitiesSubject("node-1")
require.NoError(t, err)
// A delete costs one round trip per object, so its duration is a function
// of the volume. On prod a 100 GiB volume's delete took 52 s against a 30 s
@@ -721,6 +729,8 @@ func TestTimeoutForSizesEachSubjectToItsWork(t *testing.T) {
CreateVolumeSubject, ExpandVolumeSubject, CopySnapshotSubject,
DeleteVolumeSubject, DeleteSnapshotSubject,
snapshotCreate, ownerCopy, ownerExpand, ownerCreate,
// Unpublish seals the volume's unsealed writes back to the backend.
unpublish,
}
for _, subject := range slow {
assert.Equal(t, dataMovementTimeout, p.timeoutFor(subject),
@@ -730,6 +740,7 @@ func TestTimeoutForSizesEachSubjectToItsWork(t *testing.T) {
fast := []string{
CapabilitiesSubject, GetVolumeSubject, ListVolumesSubject,
ListSnapshotsSubject, ownerDescribe,
publish, unsealed, nodeCapabilities,
}
for _, subject := range fast {
assert.Equal(t, callerTimeout, p.timeoutFor(subject),
@@ -23,6 +23,7 @@ func TestClassifyError(t *testing.T) {
{ebsprovider.ErrVolumeInUse, ebsprovider.ErrorCodeVolumeInUse},
{ebsprovider.ErrUnsupportedCapability, ebsprovider.ErrorCodeUnsupportedCap},
{ebsprovider.ErrUnavailable, ebsprovider.ErrorCodeUnavailable},
{ebsprovider.ErrVolumeHeldElsewhere, ebsprovider.ErrorCodeVolumeHeldElsewhere},
}
for _, tt := range tests {
@@ -96,8 +96,12 @@ func (p *failingProvider) PublishVolume(context.Context, ebsprovider.PublishVolu
return nil, p.err
}
func (p *failingProvider) UnpublishVolume(context.Context, ebsprovider.UnpublishVolumeRequest) error {
return p.err
func (p *failingProvider) UnpublishVolume(context.Context, ebsprovider.UnpublishVolumeRequest) (*ebsprovider.UnpublishedVolume, error) {
return nil, p.err
}
func (p *failingProvider) HoldsUnsealedWrites(context.Context, string, []string) ([]string, error) {
return nil, p.err
}
// wireVerb names one subject Serve subscribes, a valid request body for it,
@@ -120,6 +124,10 @@ func wireVerbs(t *testing.T) []wireVerb {
require.NoError(t, err)
unmountSubject, err := ebsprovider.UnpublishSubject("node-1")
require.NoError(t, err)
unsealedSubject, err := ebsprovider.UnsealedWritesSubject("node-1")
require.NoError(t, err)
nodeCapabilitiesSubject, err := ebsprovider.NodeCapabilitiesSubject("node-1")
require.NoError(t, err)
return []wireVerb{
{name: "capabilities", subject: ebsprovider.CapabilitiesSubject,
@@ -146,9 +154,122 @@ func wireVerbs(t *testing.T) []wireVerb {
request: ebsprovider.PublishVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: "vol-1", NodeID: "node-1"}},
{name: "unmount", subject: unmountSubject,
request: ebsprovider.UnpublishVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: "vol-1", NodeID: "node-1"}},
{name: "unsealed", subject: unsealedSubject,
request: ebsprovider.HoldsUnsealedWritesRequest{Versioned: ebsprovider.NewVersioned(), NodeID: "node-1", VolumeIDs: []string{"vol-1"}}},
{name: "node.capabilities", subject: nodeCapabilitiesSubject,
request: ebsprovider.GetCapabilitiesRequest{Versioned: ebsprovider.NewVersioned()}},
}
}
// withVersion re-encodes a wireVerb request at version, so one table drives
// every served version without a copy of each request per version.
func withVersion(t *testing.T, request any, version uint16) []byte {
t.Helper()
payload, err := json.Marshal(request)
require.NoError(t, err)
var body map[string]any
require.NoError(t, json.Unmarshal(payload, &body))
body["schema_version"] = version
payload, err = json.Marshal(body)
require.NoError(t, err)
return payload
}
// TestHandlersReplyInRequestVersion is what lets a version 3 server serve a
// v1.23.0 caller: every reply, success or provider failure, carries the
// version the request was sent at, never the server's newest.
func TestHandlersReplyInRequestVersion(t *testing.T) {
failing := serveProvider(t, &failingProvider{err: fmt.Errorf("vol-1: %w", ebsprovider.ErrUnavailable)}, natsserve.Options{NoQueueGroup: true})
for _, version := range ebsprovider.SupportedSchemaVersions() {
for _, verb := range wireVerbs(t) {
// Each success case gets its own seeded provider, so none relies on
// an earlier case having created or deleted something.
t.Run(fmt.Sprintf("success/v%d/%s", version, verb.name), func(t *testing.T) {
conn := serveProvider(t, seededProvider(t), natsserve.Options{NoQueueGroup: true})
for _, envelope := range requestAt(t, conn, verb, version) {
require.Nil(t, envelope.Error, "the case must succeed for it to check a success reply")
assert.Equal(t, version, envelope.SchemaVersion)
}
})
t.Run(fmt.Sprintf("failure/v%d/%s", version, verb.name), func(t *testing.T) {
envelopes := requestAt(t, failing, verb, version)
// A snapshot create is accepted first; the failure is in its completion.
require.NotNil(t, envelopes[len(envelopes)-1].Error, "the case must fail for it to check a failure reply")
for _, envelope := range envelopes {
assert.Equal(t, version, envelope.SchemaVersion)
}
})
}
}
}
// seededProvider holds what every wireVerbs request needs to succeed: vol-1
// and snap-1 taken from it, with enumeration advertised so listing succeeds.
func seededProvider(t *testing.T) ebsprovider.EBSProvider {
t.Helper()
provider := ebsprovider.NewMemoryProvider(ebsprovider.Capabilities{VolumeEnumeration: true, SnapshotEnumeration: true})
ctx := context.Background()
_, err := provider.CreateVolume(ctx, ebsprovider.CreateVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: "vol-1", CapacityRange: ebsprovider.CapacityRange{RequiredBytes: 1 << 30}})
require.NoError(t, err)
_, err = provider.CreateSnapshot(ctx, ebsprovider.CreateSnapshotRequest{Versioned: ebsprovider.NewVersioned(), SnapshotID: "snap-1", VolumeID: "vol-1"})
require.NoError(t, err)
return provider
}
// requestAt sends verb at version and returns the reply envelope, followed by
// the completion's for a verb that publishes one.
func requestAt(t *testing.T, conn *nats.Conn, verb wireVerb, version uint16) []wireEnvelope {
t.Helper()
var completionSub *nats.Subscription
if verb.completionSubject != "" {
var err error
completionSub, err = conn.SubscribeSync(verb.completionSubject)
require.NoError(t, err)
t.Cleanup(func() { _ = completionSub.Unsubscribe() })
require.NoError(t, conn.Flush())
}
msg, err := conn.Request(verb.subject, withVersion(t, verb.request, version), requestTimeout)
require.NoError(t, err)
envelopes := []wireEnvelope{decodeEnvelope(t, msg.Data)}
if completionSub != nil {
msg, err = completionSub.NextMsg(requestTimeout)
require.NoError(t, err)
envelopes = append(envelopes, decodeEnvelope(t, msg.Data))
}
return envelopes
}
// TestUnpublishNoSealOnTheWire pins the server half of no_seal: refused below
// version 3 without reaching the provider, and reported as torn down at 3.
func TestUnpublishNoSealOnTheWire(t *testing.T) {
provider := ebsprovider.NewMemoryProvider(ebsprovider.Capabilities{})
conn := serveProvider(t, provider, natsserve.Options{NoQueueGroup: true})
ctx := context.Background()
_, err := provider.CreateVolume(ctx, ebsprovider.CreateVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: "vol-1", CapacityRange: ebsprovider.CapacityRange{RequiredBytes: 1 << 30}})
require.NoError(t, err)
_, err = provider.PublishVolume(ctx, ebsprovider.PublishVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: "vol-1", NodeID: "node-1"})
require.NoError(t, err)
subject, err := ebsprovider.UnpublishSubject("node-1")
require.NoError(t, err)
request := ebsprovider.UnpublishVolumeRequest{VolumeID: "vol-1", NodeID: "node-1", NoSeal: true}
msg, err := conn.Request(subject, withVersion(t, request, ebsprovider.DescriptorSchemaVersion), requestTimeout)
require.NoError(t, err)
envelope := decodeEnvelope(t, msg.Data)
require.NotNil(t, envelope.Error)
assert.Equal(t, ebsprovider.ErrorCodeInvalidArgument, envelope.Error.Code)
err = provider.DeleteVolume(ctx, ebsprovider.DeleteVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: "vol-1"})
require.ErrorIs(t, err, ebsprovider.ErrVolumeInUse, "the refused unpublish must not have reached the provider")
msg, err = conn.Request(subject, withVersion(t, request, ebsprovider.NoSealSchemaVersion), requestTimeout)
require.NoError(t, err)
var resp ebsprovider.UnpublishVolumeResponse
require.NoError(t, json.Unmarshal(msg.Data, &resp))
require.Nil(t, resp.Error)
assert.True(t, resp.TornDown)
}
// TestHandlersRejectMalformedRequests pins the caller-fault arm of every
// subject: a body that is not JSON must come back as a versioned
// invalid_argument reply, not as a dropped message the caller waits out.
@@ -184,7 +305,8 @@ func TestHandlersRejectVersionSkew(t *testing.T) {
assert.Equal(t, ebsprovider.LegacySchemaVersion, envelope.SchemaVersion, "the refusal itself must be versioned")
require.NotNil(t, envelope.Error)
assert.Equal(t, ebsprovider.ErrorCodeUnsupportedVersion, envelope.Error.Code)
assert.Equal(t, fmt.Sprintf("unsupported schema version 99; supported versions are %d and %d", ebsprovider.LegacySchemaVersion, ebsprovider.SchemaVersion), envelope.Error.Message)
assert.Contains(t, envelope.Error.Message, "99", "the refusal must name the version it refused")
assert.Contains(t, envelope.Error.Message, ebsprovider.SupportedVersionsText(), "the refusal must name the versions served")
})
}
}
@@ -282,9 +404,10 @@ func TestServedProviderRoundTrip(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, ebsprovider.VolumeStateInUse, inUse.State)
require.NoError(t, client.UnpublishVolume(ctx, ebsprovider.UnpublishVolumeRequest{
_, err = client.UnpublishVolume(ctx, ebsprovider.UnpublishVolumeRequest{
Versioned: ebsprovider.NewVersioned(), VolumeID: "vol-roundtrip", NodeID: "node-1",
}))
})
require.NoError(t, err)
available, err := client.GetVolume(ctx, ebsprovider.GetVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: "vol-roundtrip"})
require.NoError(t, err)
assert.Equal(t, ebsprovider.VolumeStateAvailable, available.State)
@@ -614,6 +737,22 @@ func TestServeNodeIDScopesMountSubjects(t *testing.T) {
require.ErrorIs(t, err, nats.ErrNoResponders, "another node's mount subject must go unanswered")
}
// TestGetNodeCapabilitiesIsAnsweredOnlyByThatNode is why the per-node subject
// exists: the shared one is answered by whichever node's provider is fastest,
// which says nothing about the provider on the node a caller will mount on.
func TestGetNodeCapabilitiesIsAnsweredOnlyByThatNode(t *testing.T) {
conn := serveProvider(t, ebsprovider.NewMemoryProvider(ebsprovider.Capabilities{}), natsserve.Options{NodeID: "node-1"})
client := ebsprovider.NewNATSProvider(conn, requestTimeout)
request := ebsprovider.GetCapabilitiesRequest{Versioned: ebsprovider.NewVersioned()}
resp, err := client.GetNodeCapabilities(t.Context(), "node-1", request)
require.NoError(t, err)
assert.Equal(t, ebsprovider.SupportedSchemaVersions(), resp.ContractVersions)
_, err = client.GetNodeCapabilities(t.Context(), "node-2", request)
require.ErrorIs(t, err, nats.ErrNoResponders, "another node's capabilities subject must go unanswered")
}
// TestServeRejectsUnroutableNodeID covers the subject-build failure path: an
// unsubscribable node ID must fail Serve outright rather than leave a server
// running with the shared subjects subscribed and no mount subject at all.
@@ -622,7 +761,7 @@ func TestServeRejectsUnroutableNodeID(t *testing.T) {
stop, err := natsserve.Serve(t.Context(), conn, ebsprovider.NewMemoryProvider(ebsprovider.Capabilities{}), natsserve.Options{NodeID: "node.*"})
require.ErrorIs(t, err, ebsprovider.ErrInvalidArgument)
assert.Contains(t, err.Error(), "build publish subject")
assert.Contains(t, err.Error(), "build per-node subject for "+ebsprovider.PublishSubjectWildcard)
assert.Nil(t, stop)
_, err = conn.Request(ebsprovider.CapabilitiesSubject, []byte(fmt.Sprintf(`{"schema_version":%d}`, ebsprovider.SchemaVersion)), 200*time.Millisecond)
+152 -96
View File
@@ -36,9 +36,9 @@ type Options struct {
// across workers does not apply.
NoQueueGroup bool
// NodeID scopes PublishVolume/UnpublishVolume to this node's own
// subjects (ebsprovider.PublishSubject / UnpublishSubject), matching a
// production daemon that can only mount volumes on its own node. Left empty (the default), Serve instead subscribes the wildcard "ebs.provider.v1.*.mount"/"unmount" subjects, answering requests for any node.
// NodeID scopes the per-node subjects (publish, unpublish, unsealed writes
// and per-node capabilities) to this node, as a production daemon serves
// them. Left empty, Serve subscribes their wildcards and answers for any node.
NodeID string
}
@@ -127,31 +127,27 @@ func Serve(ctx context.Context, nc *nats.Conn, provider ebsprovider.EBSProvider,
}
}
if opts.NodeID == "" {
if err := subscribePlain("ebs.provider.v1.*.mount", handlePublishVolume(provider)); err != nil {
cleanup()
return nil, err
nodeSteps := []struct {
wildcard string
build func(string) (string, error)
handler msgHandler
}{
{ebsprovider.PublishSubjectWildcard, ebsprovider.PublishSubject, handlePublishVolume(provider)},
{ebsprovider.UnpublishSubjectWildcard, ebsprovider.UnpublishSubject, handleUnpublishVolume(provider)},
{ebsprovider.UnsealedWritesSubjectWildcard, ebsprovider.UnsealedWritesSubject, handleHoldsUnsealedWrites(provider)},
{ebsprovider.NodeCapabilitiesSubjectWildcard, ebsprovider.NodeCapabilitiesSubject, handleCapabilities(provider)},
}
for _, step := range nodeSteps {
subject := step.wildcard
if opts.NodeID != "" {
built, err := step.build(opts.NodeID)
if err != nil {
cleanup()
return nil, fmt.Errorf("build per-node subject for %s: %w", step.wildcard, err)
}
subject = built
}
if err := subscribePlain("ebs.provider.v1.*.unmount", handleUnpublishVolume(provider)); err != nil {
cleanup()
return nil, err
}
} else {
publishSubject, err := ebsprovider.PublishSubject(opts.NodeID)
if err != nil {
cleanup()
return nil, fmt.Errorf("build publish subject: %w", err)
}
if err := subscribePlain(publishSubject, handlePublishVolume(provider)); err != nil {
cleanup()
return nil, err
}
unpublishSubject, err := ebsprovider.UnpublishSubject(opts.NodeID)
if err != nil {
cleanup()
return nil, fmt.Errorf("build unpublish subject: %w", err)
}
if err := subscribePlain(unpublishSubject, handleUnpublishVolume(provider)); err != nil {
if err := subscribePlain(subject, step.handler); err != nil {
cleanup()
return nil, err
}
@@ -188,7 +184,7 @@ func badRequestError(err error) *ebsprovider.ProviderError {
}
func versionError(got uint16) *ebsprovider.ProviderError {
return &ebsprovider.ProviderError{Code: ebsprovider.ErrorCodeUnsupportedVersion, Message: fmt.Sprintf("unsupported schema version %d; supported versions are %d and %d", got, ebsprovider.LegacySchemaVersion, ebsprovider.SchemaVersion)}
return &ebsprovider.ProviderError{Code: ebsprovider.ErrorCodeUnsupportedVersion, Message: fmt.Sprintf("unsupported schema version %d; %s", got, ebsprovider.SupportedVersionsText())}
}
func invalidArgumentError(format string, args ...any) *ebsprovider.ProviderError {
@@ -202,6 +198,7 @@ func internalError(format string, args ...any) *ebsprovider.ProviderError {
// classifyError maps a provider error to the wire ProviderError shape,
// using only the sentinel errors and ErrorCode values errors.go exports:
// it has no forward (error -> ProviderError) mapper of its own to call.
// The provider picks the sentinel for the request's version (HeldElsewhereError).
func classifyError(err error) *ebsprovider.ProviderError {
switch {
case errors.Is(err, ebsprovider.ErrAlreadyExists):
@@ -212,6 +209,8 @@ func classifyError(err error) *ebsprovider.ProviderError {
return &ebsprovider.ProviderError{Code: ebsprovider.ErrorCodeNotFound, Message: err.Error()}
case errors.Is(err, ebsprovider.ErrUnsupportedVersion):
return &ebsprovider.ProviderError{Code: ebsprovider.ErrorCodeUnsupportedVersion, Message: err.Error()}
case errors.Is(err, ebsprovider.ErrVolumeHeldElsewhere):
return &ebsprovider.ProviderError{Code: ebsprovider.ErrorCodeVolumeHeldElsewhere, Message: err.Error()}
case errors.Is(err, ebsprovider.ErrVolumeInUse):
return &ebsprovider.ProviderError{Code: ebsprovider.ErrorCodeVolumeInUse, Message: err.Error()}
case errors.Is(err, ebsprovider.ErrUnsupportedCapability):
@@ -226,178 +225,202 @@ func classifyError(err error) *ebsprovider.ProviderError {
func handleCapabilities(provider ebsprovider.EBSProvider) msgHandler {
return func(ctx context.Context, msg *nats.Msg) {
var req ebsprovider.GetCapabilitiesRequest
if err := json.Unmarshal(msg.Data, &req); err != nil {
respond(ctx, msg, ebsprovider.GetCapabilitiesResponse{Versioned: ebsprovider.NewVersioned(), Error: badRequestError(err)})
err := json.Unmarshal(msg.Data, &req)
v := ebsprovider.ReplyVersioned(req.SchemaVersion)
if err != nil {
respond(ctx, msg, ebsprovider.GetCapabilitiesResponse{Versioned: v, Error: badRequestError(err)})
return
}
if !ebsprovider.SupportsSchemaVersion(req.SchemaVersion) {
respond(ctx, msg, ebsprovider.GetCapabilitiesResponse{Versioned: ebsprovider.NewVersioned(), Error: versionError(req.SchemaVersion)})
respond(ctx, msg, ebsprovider.GetCapabilitiesResponse{Versioned: v, Error: versionError(req.SchemaVersion)})
return
}
resp, err := provider.GetCapabilities(ctx, req)
if err != nil {
respond(ctx, msg, ebsprovider.GetCapabilitiesResponse{Versioned: ebsprovider.NewVersioned(), Error: classifyError(err)})
respond(ctx, msg, ebsprovider.GetCapabilitiesResponse{Versioned: v, Error: classifyError(err)})
return
}
respond(ctx, msg, *resp)
reply := *resp
reply.Versioned = v
respond(ctx, msg, reply)
}
}
func handleCreateVolume(provider ebsprovider.EBSProvider) msgHandler {
return func(ctx context.Context, msg *nats.Msg) {
var req ebsprovider.CreateVolumeRequest
if err := json.Unmarshal(msg.Data, &req); err != nil {
respond(ctx, msg, ebsprovider.CreateVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: badRequestError(err)})
err := json.Unmarshal(msg.Data, &req)
v := ebsprovider.ReplyVersioned(req.SchemaVersion)
if err != nil {
respond(ctx, msg, ebsprovider.CreateVolumeResponse{Versioned: v, Error: badRequestError(err)})
return
}
if !ebsprovider.SupportsSchemaVersion(req.SchemaVersion) {
respond(ctx, msg, ebsprovider.CreateVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: versionError(req.SchemaVersion)})
respond(ctx, msg, ebsprovider.CreateVolumeResponse{Versioned: v, Error: versionError(req.SchemaVersion)})
return
}
vol, err := provider.CreateVolume(ctx, req)
if err != nil {
respond(ctx, msg, ebsprovider.CreateVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: classifyError(err)})
respond(ctx, msg, ebsprovider.CreateVolumeResponse{Versioned: v, Error: classifyError(err)})
return
}
respond(ctx, msg, ebsprovider.CreateVolumeResponse{Versioned: ebsprovider.NewVersioned(), Volume: vol})
respond(ctx, msg, ebsprovider.CreateVolumeResponse{Versioned: v, Volume: vol})
}
}
func handleGetVolume(provider ebsprovider.EBSProvider) msgHandler {
return func(ctx context.Context, msg *nats.Msg) {
var req ebsprovider.GetVolumeRequest
if err := json.Unmarshal(msg.Data, &req); err != nil {
respond(ctx, msg, ebsprovider.GetVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: badRequestError(err)})
err := json.Unmarshal(msg.Data, &req)
v := ebsprovider.ReplyVersioned(req.SchemaVersion)
if err != nil {
respond(ctx, msg, ebsprovider.GetVolumeResponse{Versioned: v, Error: badRequestError(err)})
return
}
if !ebsprovider.SupportsSchemaVersion(req.SchemaVersion) {
respond(ctx, msg, ebsprovider.GetVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: versionError(req.SchemaVersion)})
respond(ctx, msg, ebsprovider.GetVolumeResponse{Versioned: v, Error: versionError(req.SchemaVersion)})
return
}
vol, err := provider.GetVolume(ctx, req)
if err != nil {
respond(ctx, msg, ebsprovider.GetVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: classifyError(err)})
respond(ctx, msg, ebsprovider.GetVolumeResponse{Versioned: v, Error: classifyError(err)})
return
}
respond(ctx, msg, ebsprovider.GetVolumeResponse{Versioned: ebsprovider.NewVersioned(), Volume: vol})
respond(ctx, msg, ebsprovider.GetVolumeResponse{Versioned: v, Volume: vol})
}
}
func handleListVolumes(provider ebsprovider.EBSProvider) msgHandler {
return func(ctx context.Context, msg *nats.Msg) {
var req ebsprovider.ListVolumesRequest
if err := json.Unmarshal(msg.Data, &req); err != nil {
respond(ctx, msg, ebsprovider.ListVolumesResponse{Versioned: ebsprovider.NewVersioned(), Error: badRequestError(err)})
err := json.Unmarshal(msg.Data, &req)
v := ebsprovider.ReplyVersioned(req.SchemaVersion)
if err != nil {
respond(ctx, msg, ebsprovider.ListVolumesResponse{Versioned: v, Error: badRequestError(err)})
return
}
if !ebsprovider.SupportsSchemaVersion(req.SchemaVersion) {
respond(ctx, msg, ebsprovider.ListVolumesResponse{Versioned: ebsprovider.NewVersioned(), Error: versionError(req.SchemaVersion)})
respond(ctx, msg, ebsprovider.ListVolumesResponse{Versioned: v, Error: versionError(req.SchemaVersion)})
return
}
resp, err := provider.ListVolumes(ctx, req)
if err != nil {
respond(ctx, msg, ebsprovider.ListVolumesResponse{Versioned: ebsprovider.NewVersioned(), Error: classifyError(err)})
respond(ctx, msg, ebsprovider.ListVolumesResponse{Versioned: v, Error: classifyError(err)})
return
}
respond(ctx, msg, *resp)
reply := *resp
reply.Versioned = v
respond(ctx, msg, reply)
}
}
func handleListSnapshots(provider ebsprovider.EBSProvider) msgHandler {
return func(ctx context.Context, msg *nats.Msg) {
var req ebsprovider.ListSnapshotsRequest
if err := json.Unmarshal(msg.Data, &req); err != nil {
respond(ctx, msg, ebsprovider.ListSnapshotsResponse{Versioned: ebsprovider.NewVersioned(), Error: badRequestError(err)})
err := json.Unmarshal(msg.Data, &req)
v := ebsprovider.ReplyVersioned(req.SchemaVersion)
if err != nil {
respond(ctx, msg, ebsprovider.ListSnapshotsResponse{Versioned: v, Error: badRequestError(err)})
return
}
if !ebsprovider.SupportsSchemaVersion(req.SchemaVersion) {
respond(ctx, msg, ebsprovider.ListSnapshotsResponse{Versioned: ebsprovider.NewVersioned(), Error: versionError(req.SchemaVersion)})
respond(ctx, msg, ebsprovider.ListSnapshotsResponse{Versioned: v, Error: versionError(req.SchemaVersion)})
return
}
resp, err := provider.ListSnapshots(ctx, req)
if err != nil {
respond(ctx, msg, ebsprovider.ListSnapshotsResponse{Versioned: ebsprovider.NewVersioned(), Error: classifyError(err)})
respond(ctx, msg, ebsprovider.ListSnapshotsResponse{Versioned: v, Error: classifyError(err)})
return
}
respond(ctx, msg, *resp)
reply := *resp
reply.Versioned = v
respond(ctx, msg, reply)
}
}
func handleExpandVolume(provider ebsprovider.EBSProvider) msgHandler {
return func(ctx context.Context, msg *nats.Msg) {
var req ebsprovider.ExpandVolumeRequest
if err := json.Unmarshal(msg.Data, &req); err != nil {
respond(ctx, msg, ebsprovider.ExpandVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: badRequestError(err)})
err := json.Unmarshal(msg.Data, &req)
v := ebsprovider.ReplyVersioned(req.SchemaVersion)
if err != nil {
respond(ctx, msg, ebsprovider.ExpandVolumeResponse{Versioned: v, Error: badRequestError(err)})
return
}
if !ebsprovider.SupportsSchemaVersion(req.SchemaVersion) {
respond(ctx, msg, ebsprovider.ExpandVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: versionError(req.SchemaVersion)})
respond(ctx, msg, ebsprovider.ExpandVolumeResponse{Versioned: v, Error: versionError(req.SchemaVersion)})
return
}
vol, err := provider.ExpandVolume(ctx, req)
if err != nil {
respond(ctx, msg, ebsprovider.ExpandVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: classifyError(err)})
respond(ctx, msg, ebsprovider.ExpandVolumeResponse{Versioned: v, Error: classifyError(err)})
return
}
respond(ctx, msg, ebsprovider.ExpandVolumeResponse{Versioned: ebsprovider.NewVersioned(), Volume: vol})
respond(ctx, msg, ebsprovider.ExpandVolumeResponse{Versioned: v, Volume: vol})
}
}
func handleDeleteVolume(provider ebsprovider.EBSProvider) msgHandler {
return func(ctx context.Context, msg *nats.Msg) {
var req ebsprovider.DeleteVolumeRequest
if err := json.Unmarshal(msg.Data, &req); err != nil {
respond(ctx, msg, ebsprovider.DeleteVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: badRequestError(err)})
err := json.Unmarshal(msg.Data, &req)
v := ebsprovider.ReplyVersioned(req.SchemaVersion)
if err != nil {
respond(ctx, msg, ebsprovider.DeleteVolumeResponse{Versioned: v, Error: badRequestError(err)})
return
}
if !ebsprovider.SupportsSchemaVersion(req.SchemaVersion) {
respond(ctx, msg, ebsprovider.DeleteVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: versionError(req.SchemaVersion)})
respond(ctx, msg, ebsprovider.DeleteVolumeResponse{Versioned: v, Error: versionError(req.SchemaVersion)})
return
}
if err := provider.DeleteVolume(ctx, req); err != nil {
respond(ctx, msg, ebsprovider.DeleteVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: classifyError(err)})
respond(ctx, msg, ebsprovider.DeleteVolumeResponse{Versioned: v, Error: classifyError(err)})
return
}
respond(ctx, msg, ebsprovider.DeleteVolumeResponse{Versioned: ebsprovider.NewVersioned()})
respond(ctx, msg, ebsprovider.DeleteVolumeResponse{Versioned: v})
}
}
func handleDeleteSnapshot(provider ebsprovider.EBSProvider) msgHandler {
return func(ctx context.Context, msg *nats.Msg) {
var req ebsprovider.DeleteSnapshotRequest
if err := json.Unmarshal(msg.Data, &req); err != nil {
respond(ctx, msg, ebsprovider.DeleteSnapshotResponse{Versioned: ebsprovider.NewVersioned(), Error: badRequestError(err)})
err := json.Unmarshal(msg.Data, &req)
v := ebsprovider.ReplyVersioned(req.SchemaVersion)
if err != nil {
respond(ctx, msg, ebsprovider.DeleteSnapshotResponse{Versioned: v, Error: badRequestError(err)})
return
}
if !ebsprovider.SupportsSchemaVersion(req.SchemaVersion) {
respond(ctx, msg, ebsprovider.DeleteSnapshotResponse{Versioned: ebsprovider.NewVersioned(), Error: versionError(req.SchemaVersion)})
respond(ctx, msg, ebsprovider.DeleteSnapshotResponse{Versioned: v, Error: versionError(req.SchemaVersion)})
return
}
if err := provider.DeleteSnapshot(ctx, req); err != nil {
respond(ctx, msg, ebsprovider.DeleteSnapshotResponse{Versioned: ebsprovider.NewVersioned(), Error: classifyError(err)})
respond(ctx, msg, ebsprovider.DeleteSnapshotResponse{Versioned: v, Error: classifyError(err)})
return
}
respond(ctx, msg, ebsprovider.DeleteSnapshotResponse{Versioned: ebsprovider.NewVersioned()})
respond(ctx, msg, ebsprovider.DeleteSnapshotResponse{Versioned: v})
}
}
func handleCopySnapshot(provider ebsprovider.EBSProvider) msgHandler {
return func(ctx context.Context, msg *nats.Msg) {
var req ebsprovider.CopySnapshotRequest
if err := json.Unmarshal(msg.Data, &req); err != nil {
respond(ctx, msg, ebsprovider.CopySnapshotResponse{Versioned: ebsprovider.NewVersioned(), Error: badRequestError(err)})
err := json.Unmarshal(msg.Data, &req)
v := ebsprovider.ReplyVersioned(req.SchemaVersion)
if err != nil {
respond(ctx, msg, ebsprovider.CopySnapshotResponse{Versioned: v, Error: badRequestError(err)})
return
}
if !ebsprovider.SupportsSchemaVersion(req.SchemaVersion) {
respond(ctx, msg, ebsprovider.CopySnapshotResponse{Versioned: ebsprovider.NewVersioned(), Error: versionError(req.SchemaVersion)})
respond(ctx, msg, ebsprovider.CopySnapshotResponse{Versioned: v, Error: versionError(req.SchemaVersion)})
return
}
snap, err := provider.CopySnapshot(ctx, req)
if err != nil {
respond(ctx, msg, ebsprovider.CopySnapshotResponse{Versioned: ebsprovider.NewVersioned(), Error: classifyError(err)})
respond(ctx, msg, ebsprovider.CopySnapshotResponse{Versioned: v, Error: classifyError(err)})
return
}
respond(ctx, msg, ebsprovider.CopySnapshotResponse{Versioned: ebsprovider.NewVersioned(), Snapshot: snap})
respond(ctx, msg, ebsprovider.CopySnapshotResponse{Versioned: v, Snapshot: snap})
}
}
@@ -407,12 +430,14 @@ func handleCopySnapshot(provider ebsprovider.EBSProvider) msgHandler {
func handleCreateSnapshot(nc *nats.Conn, provider ebsprovider.EBSProvider) msgHandler {
return func(ctx context.Context, msg *nats.Msg) {
var req ebsprovider.CreateSnapshotRequest
if err := json.Unmarshal(msg.Data, &req); err != nil {
respond(ctx, msg, ebsprovider.CreateSnapshotResponse{Versioned: ebsprovider.NewVersioned(), Error: badRequestError(err)})
err := json.Unmarshal(msg.Data, &req)
v := ebsprovider.ReplyVersioned(req.SchemaVersion)
if err != nil {
respond(ctx, msg, ebsprovider.CreateSnapshotResponse{Versioned: v, Error: badRequestError(err)})
return
}
if !ebsprovider.SupportsSchemaVersion(req.SchemaVersion) {
respond(ctx, msg, ebsprovider.CreateSnapshotResponse{Versioned: ebsprovider.NewVersioned(), Error: versionError(req.SchemaVersion)})
respond(ctx, msg, ebsprovider.CreateSnapshotResponse{Versioned: v, Error: versionError(req.SchemaVersion)})
return
}
@@ -420,23 +445,23 @@ func handleCreateSnapshot(nc *nats.Conn, provider ebsprovider.EBSProvider) msgHa
// token (SnapshotCreateSubjectPrefix + volumeID); a mismatch with
// the request body means the two disagree about which volume this create is for.
if subjectVolumeID, ok := strings.CutPrefix(msg.Subject, ebsprovider.SnapshotCreateSubjectPrefix); ok && subjectVolumeID != req.VolumeID {
respond(ctx, msg, ebsprovider.CreateSnapshotResponse{Versioned: ebsprovider.NewVersioned(), Error: invalidArgumentError("volume id %q in subject does not match request volume id %q", subjectVolumeID, req.VolumeID)})
respond(ctx, msg, ebsprovider.CreateSnapshotResponse{Versioned: v, Error: invalidArgumentError("volume id %q in subject does not match request volume id %q", subjectVolumeID, req.VolumeID)})
return
}
completionSubject, err := ebsprovider.SnapshotCompletionSubject(req.SnapshotID)
if err != nil {
respond(ctx, msg, ebsprovider.CreateSnapshotResponse{Versioned: ebsprovider.NewVersioned(), Error: classifyError(err)})
respond(ctx, msg, ebsprovider.CreateSnapshotResponse{Versioned: v, Error: classifyError(err)})
return
}
operationID, err := newOperationID()
if err != nil {
respond(ctx, msg, ebsprovider.CreateSnapshotResponse{Versioned: ebsprovider.NewVersioned(), Error: internalError("generate operation id: %v", err)})
respond(ctx, msg, ebsprovider.CreateSnapshotResponse{Versioned: v, Error: internalError("generate operation id: %v", err)})
return
}
respond(ctx, msg, ebsprovider.CreateSnapshotResponse{
Versioned: ebsprovider.NewVersioned(),
Versioned: v,
OperationID: operationID,
CompletionSubject: completionSubject,
Snapshot: &ebsprovider.Snapshot{
@@ -452,7 +477,7 @@ func handleCreateSnapshot(nc *nats.Conn, provider ebsprovider.EBSProvider) msgHa
func completeCreateSnapshot(ctx context.Context, nc *nats.Conn, provider ebsprovider.EBSProvider, req ebsprovider.CreateSnapshotRequest, operationID, completionSubject string) {
completion := ebsprovider.CreateSnapshotResponse{
Versioned: ebsprovider.NewVersioned(),
Versioned: ebsprovider.ReplyVersioned(req.SchemaVersion),
OperationID: operationID,
CompletionSubject: completionSubject,
}
@@ -488,27 +513,29 @@ func newOperationID() (string, error) {
func handlePublishVolume(provider ebsprovider.EBSProvider) msgHandler {
return func(ctx context.Context, msg *nats.Msg) {
var req ebsprovider.PublishVolumeRequest
if err := json.Unmarshal(msg.Data, &req); err != nil {
respond(ctx, msg, ebsprovider.PublishVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: badRequestError(err)})
err := json.Unmarshal(msg.Data, &req)
v := ebsprovider.ReplyVersioned(req.SchemaVersion)
if err != nil {
respond(ctx, msg, ebsprovider.PublishVolumeResponse{Versioned: v, Error: badRequestError(err)})
return
}
if !ebsprovider.SupportsSchemaVersion(req.SchemaVersion) {
respond(ctx, msg, ebsprovider.PublishVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: versionError(req.SchemaVersion)})
respond(ctx, msg, ebsprovider.PublishVolumeResponse{Versioned: v, Error: versionError(req.SchemaVersion)})
return
}
pub, err := provider.PublishVolume(ctx, req)
if err != nil {
respond(ctx, msg, ebsprovider.PublishVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: classifyError(err)})
respond(ctx, msg, ebsprovider.PublishVolumeResponse{Versioned: v, Error: classifyError(err)})
return
}
if pub == nil {
respond(ctx, msg, ebsprovider.PublishVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: internalError("provider returned no published volume for %s", req.VolumeID)})
respond(ctx, msg, ebsprovider.PublishVolumeResponse{Versioned: v, Error: internalError("provider returned no published volume for %s", req.VolumeID)})
return
}
// An unattachable export is the provider's fault, so it is reported as
// internal rather than relayed for the client to reject.
if err := pub.Export.Validate(); err != nil {
respond(ctx, msg, ebsprovider.PublishVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: internalError("provider returned an invalid export for %s: %v", req.VolumeID, err)})
respond(ctx, msg, ebsprovider.PublishVolumeResponse{Versioned: v, Error: internalError("provider returned an invalid export for %s: %v", req.VolumeID, err)})
return
}
if req.SchemaVersion == ebsprovider.LegacySchemaVersion {
@@ -517,36 +544,65 @@ func handlePublishVolume(provider ebsprovider.EBSProvider) msgHandler {
// A v1 caller cannot safely consume a block export. Do not emit an
// empty legacy URI and leave the old client to misattach it.
respond(ctx, msg, ebsprovider.PublishVolumeResponseV1{
Versioned: ebsprovider.NewVersioned(),
Versioned: v,
Error: &ebsprovider.ProviderError{Code: ebsprovider.ErrorCodeUnsupportedVersion, Message: err.Error()},
})
return
}
respond(ctx, msg, ebsprovider.PublishVolumeResponseV1{
Versioned: ebsprovider.NewVersioned(),
Versioned: v,
Published: legacy,
})
return
}
respond(ctx, msg, ebsprovider.PublishVolumeResponse{Versioned: ebsprovider.NewLatestVersioned(), Published: pub})
respond(ctx, msg, ebsprovider.PublishVolumeResponse{Versioned: v, Published: pub})
}
}
func handleUnpublishVolume(provider ebsprovider.EBSProvider) msgHandler {
return func(ctx context.Context, msg *nats.Msg) {
var req ebsprovider.UnpublishVolumeRequest
if err := json.Unmarshal(msg.Data, &req); err != nil {
respond(ctx, msg, ebsprovider.UnpublishVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: badRequestError(err)})
err := json.Unmarshal(msg.Data, &req)
v := ebsprovider.ReplyVersioned(req.SchemaVersion)
if err != nil {
respond(ctx, msg, ebsprovider.UnpublishVolumeResponse{Versioned: v, Error: badRequestError(err)})
return
}
if err := ebsprovider.CheckUnpublishVersion(req); err != nil {
respond(ctx, msg, ebsprovider.UnpublishVolumeResponse{Versioned: v, Error: classifyError(err)})
return
}
result, err := provider.UnpublishVolume(ctx, req)
if err != nil {
respond(ctx, msg, ebsprovider.UnpublishVolumeResponse{Versioned: v, Error: classifyError(err)})
return
}
response := ebsprovider.UnpublishVolumeResponse{Versioned: v}
if result != nil {
response.TornDown = result.TornDown
}
respond(ctx, msg, response)
}
}
func handleHoldsUnsealedWrites(provider ebsprovider.EBSProvider) msgHandler {
return func(ctx context.Context, msg *nats.Msg) {
var req ebsprovider.HoldsUnsealedWritesRequest
err := json.Unmarshal(msg.Data, &req)
v := ebsprovider.ReplyVersioned(req.SchemaVersion)
if err != nil {
respond(ctx, msg, ebsprovider.HoldsUnsealedWritesResponse{Versioned: v, Error: badRequestError(err)})
return
}
if !ebsprovider.SupportsSchemaVersion(req.SchemaVersion) {
respond(ctx, msg, ebsprovider.UnpublishVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: versionError(req.SchemaVersion)})
respond(ctx, msg, ebsprovider.HoldsUnsealedWritesResponse{Versioned: v, Error: versionError(req.SchemaVersion)})
return
}
if err := provider.UnpublishVolume(ctx, req); err != nil {
respond(ctx, msg, ebsprovider.UnpublishVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: classifyError(err)})
held, err := provider.HoldsUnsealedWrites(ctx, req.NodeID, req.VolumeIDs)
if err != nil {
respond(ctx, msg, ebsprovider.HoldsUnsealedWritesResponse{Versioned: v, Error: classifyError(err)})
return
}
respond(ctx, msg, ebsprovider.UnpublishVolumeResponse{Versioned: ebsprovider.NewVersioned()})
respond(ctx, msg, ebsprovider.HoldsUnsealedWritesResponse{Versioned: v, Held: held})
}
}
+99 -10
View File
@@ -19,10 +19,20 @@ const (
// evidence that the peer supports a newer representation.
LegacySchemaVersion uint16 = 1
// DescriptorSchemaVersion is the first version whose PublishVolume reply
// carries a VolumeExport descriptor, and so the first that can describe a
// block export.
DescriptorSchemaVersion uint16 = 2
// NoSealSchemaVersion is the first version that carries
// UnpublishVolumeRequest.NoSeal. A provider that does not serve it refuses
// the request as unsupported rather than dropping the flag and sealing.
NoSealSchemaVersion uint16 = 3
// SchemaVersion is the newest wire-contract version understood by this
// build. Every request and response carries a version so an unsupported
// representation fails at the boundary rather than silently decoding it.
SchemaVersion uint16 = 2
SchemaVersion uint16 = NoSealSchemaVersion
)
// Versioned is embedded in every request and response sent over NATS.
@@ -41,14 +51,39 @@ func NewVersioned() Versioned { return Versioned{SchemaVersion: LegacySchemaVers
func NewLatestVersioned() Versioned { return Versioned{SchemaVersion: SchemaVersion} }
// SupportsSchemaVersion reports whether this build can safely decode version.
// Every version from LegacySchemaVersion to SchemaVersion is served.
func SupportsSchemaVersion(version uint16) bool {
return version == LegacySchemaVersion || version == SchemaVersion
return version >= LegacySchemaVersion && version <= SchemaVersion
}
// SupportedSchemaVersions lists, in ascending order, every contract version
// this build serves. GetCapabilitiesResponse.ContractVersions reports it.
func SupportedSchemaVersions() []uint16 {
versions := make([]uint16, 0, SchemaVersion-LegacySchemaVersion+1)
for v := LegacySchemaVersion; v <= SchemaVersion; v++ {
versions = append(versions, v)
}
return versions
}
// ReplyVersioned stamps a reply with its request's version, so a body is never
// labelled newer than its shape. A version this build cannot serve is answered
// at LegacySchemaVersion, the one every peer decodes.
func ReplyVersioned(requested uint16) Versioned {
if !SupportsSchemaVersion(requested) {
return NewVersioned()
}
return Versioned{SchemaVersion: requested}
}
// EBSProvider is the block-storage controller contract consumed by Spinifex.
// Its operation and idempotency shape follows the CSI controller service, but
// the transport remains NATS request/reply rather than CSI's gRPC transport.
type EBSProvider interface {
// Required of every provider: a caller counts no answer as held, which
// would keep an instance from ever starting on another node.
UnsealedWritesChecker
GetCapabilities(context.Context, GetCapabilitiesRequest) (*GetCapabilitiesResponse, error)
CreateVolume(context.Context, CreateVolumeRequest) (*Volume, error)
GetVolume(context.Context, GetVolumeRequest) (*Volume, error)
@@ -60,7 +95,7 @@ type EBSProvider interface {
CopySnapshot(context.Context, CopySnapshotRequest) (*Snapshot, error)
ListSnapshots(context.Context, ListSnapshotsRequest) (*ListSnapshotsResponse, error)
PublishVolume(context.Context, PublishVolumeRequest) (*PublishedVolume, error)
UnpublishVolume(context.Context, UnpublishVolumeRequest) error
UnpublishVolume(context.Context, UnpublishVolumeRequest) (*UnpublishedVolume, error)
}
// Provider is retained as a concise alias for implementation code. New
@@ -164,8 +199,14 @@ type GetCapabilitiesRequest struct{ Versioned }
type GetCapabilitiesResponse struct {
Versioned
Capabilities Capabilities `json:"capabilities"`
Error *ProviderError `json:"error,omitempty"`
Capabilities Capabilities `json:"capabilities"`
// ContractVersions lists every schema version the answering provider
// serves, ascending. A provider that predates the field leaves it empty,
// which a caller must read as no evidence, not as version 1 only.
ContractVersions []uint16 `json:"contract_versions,omitempty"`
Error *ProviderError `json:"error,omitempty"`
}
// CapacityRange mirrors CSI's capacity-range semantics. RequiredBytes is the
@@ -563,7 +604,7 @@ func (p *PublishedVolume) AsV1() (*PublishedVolumeV1, error) {
return nil, err
}
if p.Export.Kind != ExportKindNBD {
return nil, fmt.Errorf("%s export requires schema version %d", p.Export.Kind, SchemaVersion)
return nil, fmt.Errorf("%s export requires schema version %d", p.Export.Kind, DescriptorSchemaVersion)
}
return &PublishedVolumeV1{
VolumeID: p.VolumeID,
@@ -617,6 +658,31 @@ type UnpublishVolumeRequest struct {
VolumeID string `json:"volume_id"`
Handle string `json:"handle,omitempty"`
NodeID string `json:"node_id"`
// NoSeal drops this node's copy unsealed, because a newer owner's supersedes
// it; unlike AWS DetachVolume Force, which leaves a copy that must seal.
// Carried only at NoSealSchemaVersion or later (CheckUnpublishVersion).
NoSeal bool `json:"no_seal,omitempty"`
}
// CheckUnpublishVersion refuses a request this build cannot serve, and a
// NoSeal sent below NoSealSchemaVersion: a provider at that version would
// drop the flag and seal, so no provider may honour it there either.
func CheckUnpublishVersion(req UnpublishVolumeRequest) error {
if err := checkVersion(req.SchemaVersion); err != nil {
return err
}
if req.NoSeal && req.SchemaVersion < NoSealSchemaVersion {
return fmt.Errorf("%w: no_seal requires schema version %d, got %d", ErrInvalidArgument, NoSealSchemaVersion, req.SchemaVersion)
}
return nil
}
// UnpublishedVolume is the outcome of an UnpublishVolume that succeeded.
// TornDown reports whether an export existed and was removed; it is reported
// only at NoSealSchemaVersion or later, and is false at earlier versions.
type UnpublishedVolume struct {
TornDown bool
}
// UnpublishVolumeResponse is the reply to UnpublishVolumeRequest. A nil Error
@@ -624,13 +690,14 @@ type UnpublishVolumeRequest struct {
type UnpublishVolumeResponse struct {
Versioned
Error *ProviderError `json:"error,omitempty"`
// TornDown mirrors UnpublishedVolume.TornDown.
TornDown bool `json:"torn_down,omitempty"`
Error *ProviderError `json:"error,omitempty"`
}
// UnsealedWritesChecker answers, for one node, which of a set of volumes it
// still holds writes for that the backend may not have. It is separate from
// EBSProvider because only the instance start path asks, and only of the node
// that last ran the instance.
// still holds writes for that the backend may not have. A write-through
// provider answers with none. Consumers that only ask this depend on it alone.
type UnsealedWritesChecker interface {
HoldsUnsealedWrites(ctx context.Context, nodeID string, volumeIDs []string) ([]string, error)
}
@@ -652,3 +719,25 @@ type HoldsUnsealedWritesResponse struct {
Held []string `json:"held,omitempty"`
Error *ProviderError `json:"error,omitempty"`
}
// FenceNotice announces that a provider revoked a live export on NodeID
// because another node now holds the volume, so that node's daemon can stop
// the guest using it. Sent over FenceNoticeSubject until acknowledged.
type FenceNotice struct {
Versioned
VolumeID string `json:"volume_id"`
NodeID string `json:"node_id"`
Winner string `json:"winner,omitempty"`
Reason string `json:"reason,omitempty"`
}
// FenceNoticeAck is the daemon's answer to a FenceNotice. Any answer ends the
// sender's retries; InstanceID names the guest stopped, if one was, and Error
// says why the daemon could not act on the notice.
type FenceNoticeAck struct {
Versioned
InstanceID string `json:"instance_id,omitempty"`
Error *ProviderError `json:"error,omitempty"`
}
+10 -2
View File
@@ -46,6 +46,8 @@ const (
verbVolumeUnpublish = "volume.unpublish"
verbSnapshotDelete = "snapshot.delete"
verbSnapshotList = "snapshot.list"
verbUnsealedWrites = "volume.unsealed"
verbFenceNotice = "volume.fenced"
)
// subjectVerbs maps every fixed subject to its verb. The dynamic subject
@@ -107,10 +109,16 @@ func parseSubject(subject string) subjectTarget {
return subjectTarget{verb: VerbUnknown}
}
switch suffix {
case "mount":
case publishVerb:
return subjectTarget{verb: verbVolumePublish, nodeID: nodeID}
case "unmount":
case unpublishVerb:
return subjectTarget{verb: verbVolumeUnpublish, nodeID: nodeID}
case unsealedVerb:
return subjectTarget{verb: verbUnsealedWrites, nodeID: nodeID}
case nodeCapabilitiesVerb:
return subjectTarget{verb: verbCapabilities, nodeID: nodeID}
case fenceNoticeVerb:
return subjectTarget{verb: verbFenceNotice, nodeID: nodeID}
default:
return subjectTarget{verb: VerbUnknown}
}
+9
View File
@@ -30,6 +30,12 @@ func TestSubjectVerb_StripsIDsFromEveryDynamicSubject(t *testing.T) {
require.NoError(t, err)
unpublish, err := UnpublishSubject(nodeID)
require.NoError(t, err)
unsealed, err := UnsealedWritesSubject(nodeID)
require.NoError(t, err)
nodeCapabilities, err := NodeCapabilitiesSubject(nodeID)
require.NoError(t, err)
fenced, err := FenceNoticeSubject(nodeID)
require.NoError(t, err)
cases := map[string]string{
CapabilitiesSubject: verbCapabilities,
@@ -47,6 +53,9 @@ func TestSubjectVerb_StripsIDsFromEveryDynamicSubject(t *testing.T) {
snapshotCreate: verbSnapshotCreate,
publish: verbVolumePublish,
unpublish: verbVolumeUnpublish,
unsealed: verbUnsealedWrites,
nodeCapabilities: verbCapabilities,
fenced: verbFenceNotice,
}
for subject, want := range cases {
@@ -15,6 +15,7 @@ import (
clusterv1 "github.com/mulgadc/spinifex/contracts/cluster/v1"
"github.com/mulgadc/spinifex/spinifex/providers/ebs"
"github.com/mulgadc/spinifex/spinifex/providers/ebs/conformance"
hostprocess "github.com/mulgadc/spinifex/spinifex/runtime/host/process"
testpredastore "github.com/mulgadc/spinifex/tests/fixtures/predastore"
"github.com/nats-io/nats.go/jetstream"
"github.com/stretchr/testify/require"
@@ -61,7 +62,8 @@ func requireExportTools(t *testing.T) string {
// would go to a subject nobody serves and time out instead of being refused.
// Both share the NATS the volume leases live in and the bucket the volumes
// live in, so a second opener is refused by the lease, not by the harness.
func inProcessProvider(t *testing.T) (func(t *testing.T) ebsprovider.EBSProvider, string, string) {
// The last return kills a published export's nbdkit behind the handlers' back.
func inProcessProvider(t *testing.T) (func(t *testing.T) ebsprovider.EBSProvider, string, string, func(*testing.T, *ebsprovider.PublishedVolume)) {
t.Helper()
pluginPath := requireExportTools(t)
@@ -98,10 +100,21 @@ func inProcessProvider(t *testing.T) (func(t *testing.T) ebsprovider.EBSProvider
_, err = js.CreateKeyValue(t.Context(), jetstream.KeyValueConfig{Bucket: clusterv1.ClusterStateBucket})
require.NoError(t, err)
killExport := func(t *testing.T, published *ebsprovider.PublishedVolume) {
t.Helper()
for _, node := range []*Config{first, second} {
if mv, ok := findMountedVolume(node, published.VolumeID); ok && node.NodeName == published.NodeID {
require.NoError(t, hostprocess.ForceKillProcess(mv.PID, 5*time.Second))
return
}
}
t.Fatalf("no mounted entry for %s on %s", published.VolumeID, published.NodeID)
}
return func(t *testing.T) ebsprovider.EBSProvider {
t.Helper()
return ebsprovider.NewNATSProvider(nc, 60*time.Second)
}, first.NodeName, second.NodeName
}, first.NodeName, second.NodeName, killExport
}
// runPrefix names one run's volumes so it cannot meet an earlier run's
@@ -113,11 +126,12 @@ func runPrefix() string {
// TestViperblockdConformance judges the provider handlers by the same suite
// MemoryProvider and qemunbdd answer to.
func TestViperblockdConformance(t *testing.T) {
newProvider, nodeID, otherNodeID := inProcessProvider(t)
newProvider, nodeID, otherNodeID, killExport := inProcessProvider(t)
conformance.RunSuiteWithConfig(t, newProvider, conformance.SuiteConfig{
NamePrefix: runPrefix(),
NodeID: nodeID,
OtherNodeID: otherNodeID,
KillExport: killExport,
})
}
@@ -125,7 +139,7 @@ func TestViperblockdConformance(t *testing.T) {
// cannot: whether the nbdkit export viperblockd publishes is usable by libnbd,
// which knows nothing about this codebase.
func TestViperblockdNBDClient(t *testing.T) {
newProvider, nodeID, _ := inProcessProvider(t)
newProvider, nodeID, _, _ := inProcessProvider(t)
conformance.RunNBDClientSuiteWithConfig(t, newProvider, conformance.NBDClientConfig{
NodeID: nodeID,
VolumePrefix: "vol-" + runPrefix() + "nbd",
@@ -0,0 +1,339 @@
package viperblock
import (
"context"
"encoding/json"
"errors"
"fmt"
"maps"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/mulgadc/spinifex/internal/testkit/deadpid"
"github.com/mulgadc/spinifex/spinifex/providers/ebs"
"github.com/mulgadc/spinifex/spinifex/runtime/compute/nbd"
"github.com/mulgadc/viperblock/viperblock"
"github.com/nats-io/nats.go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestProviderHandlers_RepliesInRequestVersion holds every subject to the
// rule a mixed-version rollout depends on: a caller at version N must get a
// reply at N, success or error, or it may not be able to parse it.
func TestProviderHandlers_RepliesInRequestVersion(t *testing.T) {
prevProbe := ownerProbeTimeout
ownerProbeTimeout = 100 * time.Millisecond
t.Cleanup(func() { ownerProbeTimeout = prevProbe })
_, natsURL := setupEmbeddedNATS(t)
cfg := setupTestConfig(t, natsURL)
nc := startProviderSubjects(t, cfg, natsURL)
for _, version := range ebsprovider.SupportedSchemaVersions() {
for _, tc := range providerSubjectCases() {
t.Run(fmt.Sprintf("v%d/%s", version, tc.name), func(t *testing.T) {
body := map[string]any{"schema_version": version}
maps.Copy(body, tc.body)
msg := requestProvider(t, nc, tc.subject, marshalRequest(t, body))
var resp versionedErrorResponse
require.NoError(t, json.Unmarshal(msg.Data, &resp))
assert.Equal(t, version, resp.SchemaVersion)
})
}
}
}
// TestProviderHandlers_UnpublishNoSealNeverSeals is the reason no_seal exists:
// a node whose copy is behind a newer owner's must drop it, and a seal would
// write that stale copy over the owner's block map.
func TestProviderHandlers_UnpublishNoSealNeverSeals(t *testing.T) {
_, natsURL := setupEmbeddedNATS(t)
const volumeName = "vol-unpubnoseal01"
sealed := false
cfg := fencedConfig(t, natsURL, "test-node", volumeName)
withLocalState(t, cfg, volumeName)
cfg.sealVolume = func(context.Context, string) error { sealed = true; return nil }
nc := startProviderSubjects(t, cfg, natsURL)
subject, err := ebsprovider.UnpublishSubject(cfg.NodeName)
require.NoError(t, err)
body := marshalRequest(t, map[string]any{
"schema_version": ebsprovider.NoSealSchemaVersion,
"volume_id": volumeName,
"node_id": cfg.NodeName,
"no_seal": true,
})
msg := requestProvider(t, nc, subject, body)
var resp ebsprovider.UnpublishVolumeResponse
require.NoError(t, json.Unmarshal(msg.Data, &resp))
require.Nil(t, resp.Error)
assert.True(t, resp.TornDown, "the volume was mounted, so the export was torn down")
assert.False(t, sealed, "a no_seal unpublish must never seal")
_, mounted := findMountedVolume(cfg, volumeName)
assert.False(t, mounted, "the entry must go with the export")
}
// TestProviderHandlers_UnpublishNoSealBelowV3Refused covers the other half: a
// caller below version 3 cannot ask for no_seal, so the flag is refused rather
// than ignored, and the export is left alone.
func TestProviderHandlers_UnpublishNoSealBelowV3Refused(t *testing.T) {
_, natsURL := setupEmbeddedNATS(t)
const volumeName = "vol-unpubnosealv2"
sealed := false
cfg := fencedConfig(t, natsURL, "test-node", volumeName)
cfg.sealVolume = func(context.Context, string) error { sealed = true; return nil }
nc := startProviderSubjects(t, cfg, natsURL)
subject, err := ebsprovider.UnpublishSubject(cfg.NodeName)
require.NoError(t, err)
body := marshalRequest(t, map[string]any{
"schema_version": ebsprovider.DescriptorSchemaVersion,
"volume_id": volumeName,
"node_id": cfg.NodeName,
"no_seal": true,
})
msg := requestProvider(t, nc, subject, body)
var resp ebsprovider.UnpublishVolumeResponse
require.NoError(t, json.Unmarshal(msg.Data, &resp))
require.NotNil(t, resp.Error)
assert.Equal(t, ebsprovider.ErrorCodeInvalidArgument, resp.Error.Code)
assert.False(t, sealed)
_, mounted := findMountedVolume(cfg, volumeName)
assert.True(t, mounted, "a refused unpublish must leave the export running")
}
// TestProviderHandlers_PublishVolume_DeadEntryIsNotHandedBack pins the removed
// early return: an entry whose nbdkit has died must be dropped and remounted,
// never answered with a URI nothing serves.
func TestProviderHandlers_PublishVolume_DeadEntryIsNotHandedBack(t *testing.T) {
_, natsURL := setupEmbeddedNATS(t)
cfg := setupTestConfig(t, natsURL)
nc := startProviderSubjects(t, cfg, natsURL)
const volumeName = "vol-pubdeadentry1"
const deadURI = "nbd://127.0.0.1:1"
cfg.MountedVolumes = append(cfg.MountedVolumes, MountedVolume{
Name: volumeName, VB: createTestVBWithState(t, volumeName), PID: deadpid.PID, NBDURI: deadURI,
})
subject, err := ebsprovider.PublishSubject(cfg.NodeName)
require.NoError(t, err)
body := marshalRequest(t, map[string]any{
"schema_version": ebsprovider.SchemaVersion,
"volume_id": volumeName,
"node_id": cfg.NodeName,
})
msg := requestProvider(t, nc, subject, body)
var resp ebsprovider.PublishVolumeResponse
require.NoError(t, json.Unmarshal(msg.Data, &resp))
assert.True(t, resp.Error != nil || resp.Published != nil, "the reply must be a refusal or an export")
assert.NotContains(t, string(msg.Data), deadURI, "the dead entry's URI was handed back")
mv, _ := findMountedVolume(cfg, volumeName)
assert.NotEqual(t, deadURI, mv.NBDURI, "the dead entry must not survive the republish")
}
// TestProviderHandlers_PublishVolume_RepublishRepliesInRequestVersion checks a
// successful publish reply, not only refusals, at every served version: a
// republish of a live export is the success path that needs no nbdkit.
func TestProviderHandlers_PublishVolume_RepublishRepliesInRequestVersion(t *testing.T) {
_, natsURL := setupEmbeddedNATS(t)
cfg := setupTestConfig(t, natsURL)
nc := startProviderSubjects(t, cfg, natsURL)
const volumeName = "vol-republishver1"
socket := filepath.Join(t.TempDir(), "nbd.sock")
require.NoError(t, os.WriteFile(socket, nil, 0o600))
liveURI := nbd.FormatNBDSocketURI(socket)
cfg.MountedVolumes = append(cfg.MountedVolumes, MountedVolume{
Name: volumeName, VB: createTestVBWithState(t, volumeName), PID: os.Getpid(), Socket: socket, NBDURI: liveURI,
})
subject, err := ebsprovider.PublishSubject(cfg.NodeName)
require.NoError(t, err)
for _, version := range ebsprovider.SupportedSchemaVersions() {
t.Run(fmt.Sprintf("v%d", version), func(t *testing.T) {
msg := requestProvider(t, nc, subject, marshalRequest(t, map[string]any{
"schema_version": version,
"volume_id": volumeName,
"node_id": cfg.NodeName,
}))
var resp versionedErrorResponse
require.NoError(t, json.Unmarshal(msg.Data, &resp))
require.Nil(t, resp.Error, "the republish must succeed for this to check a success reply")
assert.Equal(t, version, resp.SchemaVersion)
assert.Contains(t, string(msg.Data), socket, "the live export is handed back")
})
}
}
// TestProviderHandlers_NodeCapabilitiesListsServedVersions pins the per-node
// subject to the same contract versions this build serves.
func TestProviderHandlers_NodeCapabilitiesListsServedVersions(t *testing.T) {
_, natsURL := setupEmbeddedNATS(t)
cfg := setupTestConfig(t, natsURL)
nc := startProviderSubjects(t, cfg, natsURL)
subject, err := ebsprovider.NodeCapabilitiesSubject(cfg.NodeName)
require.NoError(t, err)
msg := requestProvider(t, nc, subject, marshalRequest(t, map[string]any{"schema_version": ebsprovider.SchemaVersion}))
var resp ebsprovider.GetCapabilitiesResponse
require.NoError(t, json.Unmarshal(msg.Data, &resp))
require.Nil(t, resp.Error)
assert.Equal(t, ebsprovider.SupportedSchemaVersions(), resp.ContractVersions)
}
// TestProviderHandlers_UnpublishNoSealAboveServedVersionRefused covers a caller
// newer than this build: a no_seal it cannot vouch for is refused, and the
// export is neither torn down nor sealed.
func TestProviderHandlers_UnpublishNoSealAboveServedVersionRefused(t *testing.T) {
_, natsURL := setupEmbeddedNATS(t)
const volumeName = "vol-unpubnosealv4"
sealed := false
cfg := fencedConfig(t, natsURL, "test-node", volumeName)
withLocalState(t, cfg, volumeName)
cfg.sealVolume = func(context.Context, string) error { sealed = true; return nil }
nc := startProviderSubjects(t, cfg, natsURL)
subject, err := ebsprovider.UnpublishSubject(cfg.NodeName)
require.NoError(t, err)
msg := requestProvider(t, nc, subject, marshalRequest(t, map[string]any{
"schema_version": ebsprovider.SchemaVersion + 1,
"volume_id": volumeName,
"node_id": cfg.NodeName,
"no_seal": true,
}))
var resp ebsprovider.UnpublishVolumeResponse
require.NoError(t, json.Unmarshal(msg.Data, &resp))
require.NotNil(t, resp.Error)
assert.Equal(t, ebsprovider.ErrorCodeUnsupportedVersion, resp.Error.Code)
assert.False(t, resp.TornDown)
assert.False(t, sealed, "a refused unpublish must not seal")
_, mounted := findMountedVolume(cfg, volumeName)
assert.True(t, mounted, "a refused unpublish must leave the export running")
}
// TestPublishError_Codes pins the code each mount failure reaches a caller as.
// held_elsewhere is gated on the request version because v1.23.0 callers only
// know volume_in_use, and a mode mismatch is never held_elsewhere.
func TestPublishError_Codes(t *testing.T) {
leaseHeld := fmt.Errorf("acquire: %w", errVolumeLeaseHeld)
cases := []struct {
name string
version uint16
err error
want ebsprovider.ErrorCode
}{
{"absent volume", ebsprovider.SchemaVersion, fmt.Errorf("load: %w", viperblock.ErrStateNotFound), ebsprovider.ErrorCodeNotFound},
{"lease held at v1", ebsprovider.LegacySchemaVersion, leaseHeld, ebsprovider.ErrorCodeVolumeInUse},
{"lease held at v2", ebsprovider.DescriptorSchemaVersion, leaseHeld, ebsprovider.ErrorCodeVolumeInUse},
{"lease held at v3", ebsprovider.NoSealSchemaVersion, leaseHeld, ebsprovider.ErrorCodeVolumeHeldElsewhere},
{"mode mismatch at v3", ebsprovider.NoSealSchemaVersion, fmt.Errorf("mounted: %w", errReadOnlyMismatch), ebsprovider.ErrorCodeVolumeInUse},
{"backing store not ready", ebsprovider.SchemaVersion, fmt.Errorf("load: %w", viperblock.ErrStateBackendUnavailable), ebsprovider.ErrorCodeUnavailable},
{"lease store not ready", ebsprovider.SchemaVersion, fmt.Errorf("open: %w", ErrLeaseStoreUnavailable), ebsprovider.ErrorCodeUnavailable},
{"anything else", ebsprovider.SchemaVersion, errors.New("nbdkit exited"), ebsprovider.ErrorCodeInternal},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := publishError(tc.version, "vol-publisherror01", tc.err)
require.NotNil(t, got)
assert.Equal(t, tc.want, got.Code)
})
}
assert.True(t, ebsprovider.ErrorCodeUnavailable.Retryable(), "a backing store that is not ready must read as retry-later")
}
// TestProviderHandlers_PublishVolume_StoreNotReadyIsUnavailable carries a mount
// refused because a store is not ready yet to the wire as retry-later, so a
// caller waits for the store instead of giving up on the volume.
func TestProviderHandlers_PublishVolume_StoreNotReadyIsUnavailable(t *testing.T) {
for _, cause := range []error{ErrLeaseStoreUnavailable, viperblock.ErrStateBackendUnavailable} {
t.Run(cause.Error(), func(t *testing.T) {
_, natsURL := setupEmbeddedNATS(t)
cfg := setupTestConfig(t, natsURL)
cfg.constructVB = func(context.Context, string) (*viperblock.VB, int, error) {
return nil, 0, fmt.Errorf("open: %w", cause)
}
nc := startProviderSubjects(t, cfg, natsURL)
subject, err := ebsprovider.PublishSubject(cfg.NodeName)
require.NoError(t, err)
msg := requestProvider(t, nc, subject, marshalRequest(t, map[string]any{
"schema_version": ebsprovider.SchemaVersion,
"volume_id": "vol-storenotready",
"node_id": cfg.NodeName,
}))
var resp ebsprovider.PublishVolumeResponse
require.NoError(t, json.Unmarshal(msg.Data, &resp))
require.NotNil(t, resp.Error)
assert.Equal(t, ebsprovider.ErrorCodeUnavailable, resp.Error.Code)
assert.Nil(t, resp.Published)
})
}
}
// TestProviderHandlers_CreateSnapshot_QueueRefusesWhileLeaseHeldElsewhere is
// what makes a snapshot from a non-owner safe: whichever node answers the queue
// subject must not snapshot a checkpoint the holder may still be writing.
func TestProviderHandlers_CreateSnapshot_QueueRefusesWhileLeaseHeldElsewhere(t *testing.T) {
const volumeName = "vol-snapqueueheld1"
const snapshotID = "snap-queueheld00001"
// The volume really exists in the backend, so only the lease stands
// between this request and a snapshot written beside the holder's writes.
key := testMasterKey(t)
backend := newRecordingS3(t, "/"+volumeName+"/config.json", seedVolumeConfig(t, volumeName, key))
_, natsURL := setupEmbeddedNATS(t)
cfg := setupTestConfig(t, natsURL)
cfg.S3Host = backend.URL
cfg.masterKey = key
nc := startProviderSubjects(t, cfg, natsURL)
holder := newTestLeases(t, natsURL, "node-other")
_, err := holder.acquire(t.Context(), volumeName)
require.NoError(t, err)
completionSubject, err := ebsprovider.SnapshotCompletionSubject(snapshotID)
require.NoError(t, err)
completions := make(chan *nats.Msg, 1)
sub, err := nc.ChanSubscribe(completionSubject, completions)
require.NoError(t, err)
t.Cleanup(func() { _ = sub.Unsubscribe() })
require.NoError(t, nc.Flush())
body := marshalRequest(t, map[string]any{
"schema_version": ebsprovider.SchemaVersion,
"volume_id": volumeName,
"snapshot_id": snapshotID,
})
accepted := requestProvider(t, nc, ebsprovider.SnapshotCreateSubjectPrefix+volumeName, body)
var acceptance ebsprovider.CreateSnapshotResponse
require.NoError(t, json.Unmarshal(accepted.Data, &acceptance))
require.Nil(t, acceptance.Error)
select {
case msg := <-completions:
var completion ebsprovider.CreateSnapshotResponse
require.NoError(t, json.Unmarshal(msg.Data, &completion))
require.NotNil(t, completion.Error, "a snapshot under another node's lease must be refused")
assert.Equal(t, ebsprovider.ErrorCodeUnavailable, completion.Error.Code)
assert.Nil(t, completion.Snapshot)
case <-time.After(10 * time.Second):
t.Fatal("no completion published")
}
assert.Empty(t, backend.matching(func(r string) bool { return strings.HasPrefix(r, "PUT ") && strings.Contains(r, snapshotID) }),
"no snapshot may be written while another node holds the volume")
}
@@ -149,6 +149,16 @@ func RegisterProviderSubjects(cfg *Config, nc *nats.Conn) error {
return fmt.Errorf("subscribe to %s: %w", unsealedSubject, err)
}
// The queue subject is answered by any node's provider; this one only by
// this node's, so a daemon can learn what its own provider serves.
nodeCapabilitiesSubject, err := ebsprovider.NodeCapabilitiesSubject(cfg.NodeName)
if err != nil {
return fmt.Errorf("build node capabilities subject: %w", err)
}
if _, err := nc.Subscribe(nodeCapabilitiesSubject, tracedProviderHandler(handleProviderCapabilities)); err != nil {
return fmt.Errorf("subscribe to %s: %w", nodeCapabilitiesSubject, err)
}
return nil
}
@@ -188,7 +198,7 @@ func badRequestError(err error) *ebsprovider.ProviderError {
}
func unsupportedVersionError(got uint16) *ebsprovider.ProviderError {
return &ebsprovider.ProviderError{Code: ebsprovider.ErrorCodeUnsupportedVersion, Message: fmt.Sprintf("unsupported schema version %d; supported versions are %d and %d", got, ebsprovider.LegacySchemaVersion, ebsprovider.SchemaVersion)}
return &ebsprovider.ProviderError{Code: ebsprovider.ErrorCodeUnsupportedVersion, Message: fmt.Sprintf("unsupported schema version %d; %s", got, ebsprovider.SupportedVersionsText())}
}
// respondPublishedVolume selects the exact publish response a caller asked
@@ -199,19 +209,19 @@ func respondPublishedVolume(ctx context.Context, msg *nats.Msg, schemaVersion ui
legacy, err := published.AsV1()
if err != nil {
respondProvider(ctx, msg, ebsprovider.PublishVolumeResponseV1{
Versioned: ebsprovider.NewVersioned(),
Versioned: ebsprovider.ReplyVersioned(schemaVersion),
Error: &ebsprovider.ProviderError{Code: ebsprovider.ErrorCodeUnsupportedVersion, Message: err.Error()},
})
return
}
respondProvider(ctx, msg, ebsprovider.PublishVolumeResponseV1{
Versioned: ebsprovider.NewVersioned(),
Versioned: ebsprovider.ReplyVersioned(schemaVersion),
Published: legacy,
})
return
}
respondProvider(ctx, msg, ebsprovider.PublishVolumeResponse{
Versioned: ebsprovider.NewLatestVersioned(),
Versioned: ebsprovider.ReplyVersioned(schemaVersion),
Published: published,
})
}
@@ -232,6 +242,14 @@ func volumeInUseError(format string, args ...any) *ebsprovider.ProviderError {
return &ebsprovider.ProviderError{Code: ebsprovider.ErrorCodeVolumeInUse, Message: fmt.Sprintf(format, args...)}
}
func heldElsewhereError(format string, args ...any) *ebsprovider.ProviderError {
return &ebsprovider.ProviderError{Code: ebsprovider.ErrorCodeVolumeHeldElsewhere, Message: fmt.Sprintf(format, args...)}
}
// errReadOnlyMismatch is a remount asking for the other access mode than the
// running export's, which is fixed when nbdkit starts.
var errReadOnlyMismatch = errors.New("already mounted with the other access mode")
func internalError(format string, args ...any) *ebsprovider.ProviderError {
return &ebsprovider.ProviderError{Code: ebsprovider.ErrorCodeInternal, Message: fmt.Sprintf(format, args...)}
}
@@ -276,18 +294,21 @@ func findMountedVolume(cfg *Config, volumeID string) (MountedVolume, bool) {
func handleProviderCapabilities(ctx context.Context, msg *nats.Msg) {
var req ebsprovider.GetCapabilitiesRequest
if err := json.Unmarshal(msg.Data, &req); err != nil {
err := json.Unmarshal(msg.Data, &req)
v := ebsprovider.ReplyVersioned(req.SchemaVersion)
if err != nil {
slog.Error("ebs.provider.capabilities: bad request", "err", err)
respondProvider(ctx, msg, ebsprovider.GetCapabilitiesResponse{Versioned: ebsprovider.NewVersioned(), Error: badRequestError(err)})
respondProvider(ctx, msg, ebsprovider.GetCapabilitiesResponse{Versioned: v, Error: badRequestError(err)})
return
}
if !ebsprovider.SupportsSchemaVersion(req.SchemaVersion) {
slog.Error("ebs.provider.capabilities: unsupported schema version", "version", req.SchemaVersion)
respondProvider(ctx, msg, ebsprovider.GetCapabilitiesResponse{Versioned: ebsprovider.NewVersioned(), Error: unsupportedVersionError(req.SchemaVersion)})
respondProvider(ctx, msg, ebsprovider.GetCapabilitiesResponse{Versioned: v, Error: unsupportedVersionError(req.SchemaVersion)})
return
}
respondProvider(ctx, msg, ebsprovider.GetCapabilitiesResponse{
Versioned: ebsprovider.NewVersioned(),
Versioned: v,
ContractVersions: ebsprovider.SupportedSchemaVersions(),
Capabilities: ebsprovider.Capabilities{
// Every snapshot freezes the live checkpoint rather than
// depending on a guest-coordinated quiesce.
@@ -387,24 +408,26 @@ func listTopLevelPrefixes(ctx context.Context, store objectstore.ObjectStore, bu
func handleListVolumes(ctx context.Context, cfg *Config, msg *nats.Msg) {
var req ebsprovider.ListVolumesRequest
if err := json.Unmarshal(msg.Data, &req); err != nil {
err := json.Unmarshal(msg.Data, &req)
v := ebsprovider.ReplyVersioned(req.SchemaVersion)
if err != nil {
slog.Error("ebs.provider.volume.list: bad request", "err", err)
respondProvider(ctx, msg, ebsprovider.ListVolumesResponse{Versioned: ebsprovider.NewVersioned(), Error: badRequestError(err)})
respondProvider(ctx, msg, ebsprovider.ListVolumesResponse{Versioned: v, Error: badRequestError(err)})
return
}
if !ebsprovider.SupportsSchemaVersion(req.SchemaVersion) {
respondProvider(ctx, msg, ebsprovider.ListVolumesResponse{Versioned: ebsprovider.NewVersioned(), Error: unsupportedVersionError(req.SchemaVersion)})
respondProvider(ctx, msg, ebsprovider.ListVolumesResponse{Versioned: v, Error: unsupportedVersionError(req.SchemaVersion)})
return
}
ids, err := listVolumePrefixes(ctx, providerObjectStoreFactory(cfg), cfg.Bucket)
if err != nil {
slog.Error("ebs.provider.volume.list: failed to enumerate volumes", "err", err)
respondProvider(ctx, msg, ebsprovider.ListVolumesResponse{Versioned: ebsprovider.NewVersioned(), Error: internalError("enumerate volumes: %v", err)})
respondProvider(ctx, msg, ebsprovider.ListVolumesResponse{Versioned: v, Error: internalError("enumerate volumes: %v", err)})
return
}
response := ebsprovider.ListVolumesResponse{Versioned: ebsprovider.NewVersioned()}
response := ebsprovider.ListVolumesResponse{Versioned: v}
response.Volumes, response.NextToken = ebsprovider.Page(ids, req.StartingToken, int(req.PageSize()),
func(id string) ebsprovider.VolumeRef {
return ebsprovider.VolumeRef{ID: id, Handle: volumeHandle(id)}
@@ -417,24 +440,26 @@ func handleListVolumes(ctx context.Context, cfg *Config, msg *nats.Msg) {
// encrypted state, and reading it would mean a fetch per snapshot listed.
func handleListSnapshots(ctx context.Context, cfg *Config, msg *nats.Msg) {
var req ebsprovider.ListSnapshotsRequest
if err := json.Unmarshal(msg.Data, &req); err != nil {
err := json.Unmarshal(msg.Data, &req)
v := ebsprovider.ReplyVersioned(req.SchemaVersion)
if err != nil {
slog.Error("ebs.provider.snapshot.list: bad request", "err", err)
respondProvider(ctx, msg, ebsprovider.ListSnapshotsResponse{Versioned: ebsprovider.NewVersioned(), Error: badRequestError(err)})
respondProvider(ctx, msg, ebsprovider.ListSnapshotsResponse{Versioned: v, Error: badRequestError(err)})
return
}
if !ebsprovider.SupportsSchemaVersion(req.SchemaVersion) {
respondProvider(ctx, msg, ebsprovider.ListSnapshotsResponse{Versioned: ebsprovider.NewVersioned(), Error: unsupportedVersionError(req.SchemaVersion)})
respondProvider(ctx, msg, ebsprovider.ListSnapshotsResponse{Versioned: v, Error: unsupportedVersionError(req.SchemaVersion)})
return
}
ids, err := listSnapshotPrefixes(ctx, providerObjectStoreFactory(cfg), cfg.Bucket)
if err != nil {
slog.Error("ebs.provider.snapshot.list: failed to enumerate snapshots", "err", err)
respondProvider(ctx, msg, ebsprovider.ListSnapshotsResponse{Versioned: ebsprovider.NewVersioned(), Error: internalError("enumerate snapshots: %v", err)})
respondProvider(ctx, msg, ebsprovider.ListSnapshotsResponse{Versioned: v, Error: internalError("enumerate snapshots: %v", err)})
return
}
response := ebsprovider.ListSnapshotsResponse{Versioned: ebsprovider.NewVersioned()}
response := ebsprovider.ListSnapshotsResponse{Versioned: v}
response.Snapshots, response.NextToken = ebsprovider.Page(ids, req.StartingToken, int(req.PageSize()),
func(id string) ebsprovider.SnapshotRef {
return ebsprovider.SnapshotRef{ID: id, Handle: snapshotHandle(id)}
@@ -474,40 +499,42 @@ func buildProviderVBConfig(cfg *Config, volumeID string, volumeSizeBytes uint64,
func handleCreateVolume(ctx context.Context, cfg *Config, msg *nats.Msg) {
var req ebsprovider.CreateVolumeRequest
if err := json.Unmarshal(msg.Data, &req); err != nil {
err := json.Unmarshal(msg.Data, &req)
v := ebsprovider.ReplyVersioned(req.SchemaVersion)
if err != nil {
slog.Error("ebs.provider.volume.create: bad request", "err", err)
respondProvider(ctx, msg, ebsprovider.CreateVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: badRequestError(err)})
respondProvider(ctx, msg, ebsprovider.CreateVolumeResponse{Versioned: v, Error: badRequestError(err)})
return
}
if !ebsprovider.SupportsSchemaVersion(req.SchemaVersion) {
slog.Error("ebs.provider.volume.create: unsupported schema version", "version", req.SchemaVersion)
respondProvider(ctx, msg, ebsprovider.CreateVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: unsupportedVersionError(req.SchemaVersion)})
respondProvider(ctx, msg, ebsprovider.CreateVolumeResponse{Versioned: v, Error: unsupportedVersionError(req.SchemaVersion)})
return
}
if !validVolumeName(req.VolumeID) {
slog.Error("ebs.provider.volume.create: invalid volume id", "volume", req.VolumeID)
respondProvider(ctx, msg, ebsprovider.CreateVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: invalidArgumentError("invalid volume id %q", req.VolumeID)})
respondProvider(ctx, msg, ebsprovider.CreateVolumeResponse{Versioned: v, Error: invalidArgumentError("invalid volume id %q", req.VolumeID)})
return
}
if req.CapacityRange.RequiredBytes <= 0 || (req.CapacityRange.LimitBytes > 0 && req.CapacityRange.RequiredBytes > req.CapacityRange.LimitBytes) {
respondProvider(ctx, msg, ebsprovider.CreateVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: invalidArgumentError("invalid capacity range")})
respondProvider(ctx, msg, ebsprovider.CreateVolumeResponse{Versioned: v, Error: invalidArgumentError("invalid capacity range")})
return
}
if req.SourceSnapshotID != "" && req.SourceSnapshotVolumeID == "" {
respondProvider(ctx, msg, ebsprovider.CreateVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: invalidArgumentError("source_snapshot_volume_id is required with source_snapshot_id")})
respondProvider(ctx, msg, ebsprovider.CreateVolumeResponse{Versioned: v, Error: invalidArgumentError("source_snapshot_volume_id is required with source_snapshot_id")})
return
}
if err := ebsprovider.ValidateSeedData(req.SeedData); err != nil {
slog.Error("ebs.provider.volume.create: seed data rejected", "volume", req.VolumeID, "err", err)
respondProvider(ctx, msg, ebsprovider.CreateVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: invalidArgumentError("%v", err)})
respondProvider(ctx, msg, ebsprovider.CreateVolumeResponse{Versioned: v, Error: invalidArgumentError("%v", err)})
return
}
if int64(len(req.SeedData)) > req.CapacityRange.RequiredBytes {
respondProvider(ctx, msg, ebsprovider.CreateVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: invalidArgumentError("seed data is larger than the requested capacity")})
respondProvider(ctx, msg, ebsprovider.CreateVolumeResponse{Versioned: v, Error: invalidArgumentError("seed data is larger than the requested capacity")})
return
}
if len(req.SeedData) > 0 && req.SourceSnapshotID != "" {
respondProvider(ctx, msg, ebsprovider.CreateVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: invalidArgumentError("seed data cannot be combined with a source snapshot")})
respondProvider(ctx, msg, ebsprovider.CreateVolumeResponse{Versioned: v, Error: invalidArgumentError("seed data cannot be combined with a source snapshot")})
return
}
@@ -516,16 +543,16 @@ func handleCreateVolume(ctx context.Context, cfg *Config, msg *nats.Msg) {
case err == nil:
if existing.CapacityBytes != req.CapacityRange.RequiredBytes {
slog.Error("ebs.provider.volume.create: volume exists with different capacity", "volume", req.VolumeID)
respondProvider(ctx, msg, ebsprovider.CreateVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: alreadyExistsError("volume %s already exists with a different capacity", req.VolumeID)})
respondProvider(ctx, msg, ebsprovider.CreateVolumeResponse{Versioned: v, Error: alreadyExistsError("volume %s already exists with a different capacity", req.VolumeID)})
return
}
respondProvider(ctx, msg, ebsprovider.CreateVolumeResponse{Versioned: ebsprovider.NewVersioned(), Volume: existing})
respondProvider(ctx, msg, ebsprovider.CreateVolumeResponse{Versioned: v, Volume: existing})
return
case errors.Is(err, viperblock.ErrStateNotFound):
// Volume does not exist yet: fall through and create it.
default:
slog.Error("ebs.provider.volume.create: existence check failed", "volume", req.VolumeID, "err", err)
respondProvider(ctx, msg, ebsprovider.CreateVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: internalError("check existing volume: %v", err)})
respondProvider(ctx, msg, ebsprovider.CreateVolumeResponse{Versioned: v, Error: internalError("check existing volume: %v", err)})
return
}
@@ -534,7 +561,7 @@ func handleCreateVolume(ctx context.Context, cfg *Config, msg *nats.Msg) {
vb, err := viperblock.New(vbconfig, "s3", s3cfg)
if err != nil {
slog.Error("ebs.provider.volume.create: new viperblock failed", "volume", req.VolumeID, "err", err)
respondProvider(ctx, msg, ebsprovider.CreateVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: internalError("new viperblock: %v", err)})
respondProvider(ctx, msg, ebsprovider.CreateVolumeResponse{Versioned: v, Error: internalError("new viperblock: %v", err)})
return
}
defer vb.Detach()
@@ -542,26 +569,26 @@ func handleCreateVolume(ctx context.Context, cfg *Config, msg *nats.Msg) {
if err := vb.Backend.InitCtx(ctx); err != nil {
slog.Error("ebs.provider.volume.create: backend init failed", "volume", req.VolumeID, "err", err)
respondProvider(ctx, msg, ebsprovider.CreateVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: internalError("backend init: %v", err)})
respondProvider(ctx, msg, ebsprovider.CreateVolumeResponse{Versioned: v, Error: internalError("backend init: %v", err)})
return
}
if req.SourceSnapshotID != "" {
if perr := verifySourceSnapshot(vb, req.SourceSnapshotID, req.SourceSnapshotVolumeID); perr != nil {
slog.Error("ebs.provider.volume.create: source snapshot rejected", "volume", req.VolumeID, "snapshot", req.SourceSnapshotID, "err", perr.Message)
respondProvider(ctx, msg, ebsprovider.CreateVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: perr})
respondProvider(ctx, msg, ebsprovider.CreateVolumeResponse{Versioned: v, Error: perr})
return
}
}
if len(req.SeedData) > 0 {
if err := seedVolume(ctx, vb, req.SeedData); err != nil {
slog.Error("ebs.provider.volume.create: seed failed", "volume", req.VolumeID, "bytes", len(req.SeedData), "err", err)
respondProvider(ctx, msg, ebsprovider.CreateVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: internalError("seed volume: %v", err)})
respondProvider(ctx, msg, ebsprovider.CreateVolumeResponse{Versioned: v, Error: internalError("seed volume: %v", err)})
return
}
} else {
if err := vb.SaveStateCtx(ctx); err != nil {
slog.Error("ebs.provider.volume.create: save state failed", "volume", req.VolumeID, "err", err)
respondProvider(ctx, msg, ebsprovider.CreateVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: internalError("save state: %v", err)})
respondProvider(ctx, msg, ebsprovider.CreateVolumeResponse{Versioned: v, Error: internalError("save state: %v", err)})
return
}
// A state read prefers a local copy over the backend, so state left on
@@ -574,7 +601,7 @@ func handleCreateVolume(ctx context.Context, cfg *Config, msg *nats.Msg) {
slog.Info("ebs.provider.volume.create: created", "volume", req.VolumeID, "capacityBytes", req.CapacityRange.RequiredBytes, "seedBytes", len(req.SeedData))
respondProvider(ctx, msg, ebsprovider.CreateVolumeResponse{
Versioned: ebsprovider.NewVersioned(),
Versioned: v,
Volume: &ebsprovider.Volume{
ID: req.VolumeID,
CapacityBytes: req.CapacityRange.RequiredBytes,
@@ -667,72 +694,76 @@ func describeVolumeEngine(ctx context.Context, cfg *Config, volumeID string) (*e
func handleGetVolume(ctx context.Context, cfg *Config, msg *nats.Msg) {
var req ebsprovider.GetVolumeRequest
if err := json.Unmarshal(msg.Data, &req); err != nil {
err := json.Unmarshal(msg.Data, &req)
v := ebsprovider.ReplyVersioned(req.SchemaVersion)
if err != nil {
slog.Error("ebs.provider.volume.describe: bad request", "err", err)
respondProvider(ctx, msg, ebsprovider.GetVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: badRequestError(err)})
respondProvider(ctx, msg, ebsprovider.GetVolumeResponse{Versioned: v, Error: badRequestError(err)})
return
}
if !ebsprovider.SupportsSchemaVersion(req.SchemaVersion) {
respondProvider(ctx, msg, ebsprovider.GetVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: unsupportedVersionError(req.SchemaVersion)})
respondProvider(ctx, msg, ebsprovider.GetVolumeResponse{Versioned: v, Error: unsupportedVersionError(req.SchemaVersion)})
return
}
if !validVolumeName(req.VolumeID) {
respondProvider(ctx, msg, ebsprovider.GetVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: invalidArgumentError("invalid volume id %q", req.VolumeID)})
respondProvider(ctx, msg, ebsprovider.GetVolumeResponse{Versioned: v, Error: invalidArgumentError("invalid volume id %q", req.VolumeID)})
return
}
volume, err := describeVolumeEngine(ctx, cfg, req.VolumeID)
if err != nil {
if errors.Is(err, viperblock.ErrStateNotFound) {
respondProvider(ctx, msg, ebsprovider.GetVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: notFoundError("volume %s not found", req.VolumeID)})
respondProvider(ctx, msg, ebsprovider.GetVolumeResponse{Versioned: v, Error: notFoundError("volume %s not found", req.VolumeID)})
return
}
slog.Error("ebs.provider.volume.describe: failed", "volume", req.VolumeID, "err", err)
respondProvider(ctx, msg, ebsprovider.GetVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: internalError("describe volume: %v", err)})
respondProvider(ctx, msg, ebsprovider.GetVolumeResponse{Versioned: v, Error: internalError("describe volume: %v", err)})
return
}
respondProvider(ctx, msg, ebsprovider.GetVolumeResponse{Versioned: ebsprovider.NewVersioned(), Volume: volume})
respondProvider(ctx, msg, ebsprovider.GetVolumeResponse{Versioned: v, Volume: volume})
}
func handleExpandVolume(ctx context.Context, cfg *Config, msg *nats.Msg) {
var req ebsprovider.ExpandVolumeRequest
if err := json.Unmarshal(msg.Data, &req); err != nil {
err := json.Unmarshal(msg.Data, &req)
v := ebsprovider.ReplyVersioned(req.SchemaVersion)
if err != nil {
slog.Error("ebs.provider.volume.expand: bad request", "err", err)
respondProvider(ctx, msg, ebsprovider.ExpandVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: badRequestError(err)})
respondProvider(ctx, msg, ebsprovider.ExpandVolumeResponse{Versioned: v, Error: badRequestError(err)})
return
}
if !ebsprovider.SupportsSchemaVersion(req.SchemaVersion) {
respondProvider(ctx, msg, ebsprovider.ExpandVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: unsupportedVersionError(req.SchemaVersion)})
respondProvider(ctx, msg, ebsprovider.ExpandVolumeResponse{Versioned: v, Error: unsupportedVersionError(req.SchemaVersion)})
return
}
if !validVolumeName(req.VolumeID) {
respondProvider(ctx, msg, ebsprovider.ExpandVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: invalidArgumentError("invalid volume id %q", req.VolumeID)})
respondProvider(ctx, msg, ebsprovider.ExpandVolumeResponse{Versioned: v, Error: invalidArgumentError("invalid volume id %q", req.VolumeID)})
return
}
if req.CapacityRange.RequiredBytes <= 0 {
respondProvider(ctx, msg, ebsprovider.ExpandVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: invalidArgumentError("invalid capacity range")})
respondProvider(ctx, msg, ebsprovider.ExpandVolumeResponse{Versioned: v, Error: invalidArgumentError("invalid capacity range")})
return
}
if mv, ok := findMountedVolume(cfg, req.VolumeID); ok && mv.VB != nil {
slog.Error("ebs.provider.volume.expand: volume is mounted", "volume", req.VolumeID)
respondProvider(ctx, msg, ebsprovider.ExpandVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: volumeInUseError("volume %s is mounted; detach before expanding", req.VolumeID)})
respondProvider(ctx, msg, ebsprovider.ExpandVolumeResponse{Versioned: v, Error: volumeInUseError("volume %s is mounted; detach before expanding", req.VolumeID)})
return
}
vb, lease, err := openVolumeVB(ctx, cfg, req.VolumeID)
if err != nil {
if errors.Is(err, viperblock.ErrStateNotFound) {
respondProvider(ctx, msg, ebsprovider.ExpandVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: notFoundError("volume %s not found", req.VolumeID)})
respondProvider(ctx, msg, ebsprovider.ExpandVolumeResponse{Versioned: v, Error: notFoundError("volume %s not found", req.VolumeID)})
return
}
if errors.Is(err, errVolumeLeaseHeld) {
slog.Error("ebs.provider.volume.expand: volume is leased elsewhere", "volume", req.VolumeID, "err", err)
respondProvider(ctx, msg, ebsprovider.ExpandVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: volumeInUseError("volume %s is open elsewhere: %v", req.VolumeID, err)})
respondProvider(ctx, msg, ebsprovider.ExpandVolumeResponse{Versioned: v, Error: volumeInUseError("volume %s is open elsewhere: %v", req.VolumeID, err)})
return
}
slog.Error("ebs.provider.volume.expand: open volume failed", "volume", req.VolumeID, "err", err)
respondProvider(ctx, msg, ebsprovider.ExpandVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: internalError("open volume: %v", err)})
respondProvider(ctx, msg, ebsprovider.ExpandVolumeResponse{Versioned: v, Error: internalError("open volume: %v", err)})
return
}
defer func() {
@@ -742,7 +773,7 @@ func handleExpandVolume(ctx context.Context, cfg *Config, msg *nats.Msg) {
currentBytes := safecast.Uint64ToInt64(vb.GetVolumeSize())
if req.CapacityRange.RequiredBytes < currentBytes {
respondProvider(ctx, msg, ebsprovider.ExpandVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: invalidArgumentError("volume expansion is grow-only")})
respondProvider(ctx, msg, ebsprovider.ExpandVolumeResponse{Versioned: v, Error: invalidArgumentError("volume expansion is grow-only")})
return
}
@@ -751,18 +782,18 @@ func handleExpandVolume(ctx context.Context, cfg *Config, msg *nats.Msg) {
rawConfig, err := json.Marshal(vc)
if err != nil {
slog.Error("ebs.provider.volume.expand: marshal volume config failed", "volume", req.VolumeID, "err", err)
respondProvider(ctx, msg, ebsprovider.ExpandVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: internalError("marshal volume config: %v", err)})
respondProvider(ctx, msg, ebsprovider.ExpandVolumeResponse{Versioned: v, Error: internalError("marshal volume config: %v", err)})
return
}
if err := applyConfigUpdate(ctx, vb, rawConfig); err != nil {
slog.Error("ebs.provider.volume.expand: apply config update failed", "volume", req.VolumeID, "err", err)
respondProvider(ctx, msg, ebsprovider.ExpandVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: internalError("apply config update: %v", err)})
respondProvider(ctx, msg, ebsprovider.ExpandVolumeResponse{Versioned: v, Error: internalError("apply config update: %v", err)})
return
}
slog.Info("ebs.provider.volume.expand: expanded", "volume", req.VolumeID, "capacityBytes", req.CapacityRange.RequiredBytes)
respondProvider(ctx, msg, ebsprovider.ExpandVolumeResponse{
Versioned: ebsprovider.NewVersioned(),
Versioned: v,
Volume: &ebsprovider.Volume{
ID: req.VolumeID,
CapacityBytes: safecast.Uint64ToInt64(vb.GetVolumeSize()),
@@ -856,17 +887,19 @@ func deleteObjectPrefix(ctx context.Context, store objectstore.ObjectStore, buck
func handleDeleteVolume(ctx context.Context, cfg *Config, nc *nats.Conn, msg *nats.Msg) {
var req ebsprovider.DeleteVolumeRequest
if err := json.Unmarshal(msg.Data, &req); err != nil {
err := json.Unmarshal(msg.Data, &req)
v := ebsprovider.ReplyVersioned(req.SchemaVersion)
if err != nil {
slog.Error("ebs.provider.volume.delete: bad request", "err", err)
respondProvider(ctx, msg, ebsprovider.DeleteVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: badRequestError(err)})
respondProvider(ctx, msg, ebsprovider.DeleteVolumeResponse{Versioned: v, Error: badRequestError(err)})
return
}
if !ebsprovider.SupportsSchemaVersion(req.SchemaVersion) {
respondProvider(ctx, msg, ebsprovider.DeleteVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: unsupportedVersionError(req.SchemaVersion)})
respondProvider(ctx, msg, ebsprovider.DeleteVolumeResponse{Versioned: v, Error: unsupportedVersionError(req.SchemaVersion)})
return
}
if !validVolumeName(req.VolumeID) {
respondProvider(ctx, msg, ebsprovider.DeleteVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: invalidArgumentError("invalid volume id %q", req.VolumeID)})
respondProvider(ctx, msg, ebsprovider.DeleteVolumeResponse{Versioned: v, Error: invalidArgumentError("invalid volume id %q", req.VolumeID)})
return
}
@@ -880,7 +913,7 @@ func handleDeleteVolume(ctx context.Context, cfg *Config, nc *nats.Conn, msg *na
}
if mountedHere || ownership == ownershipMounted {
slog.Error("ebs.provider.volume.delete: volume is published", "volume", req.VolumeID)
respondProvider(ctx, msg, ebsprovider.DeleteVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: volumeInUseError("volume %s is published; unpublish it before deleting", req.VolumeID)})
respondProvider(ctx, msg, ebsprovider.DeleteVolumeResponse{Versioned: v, Error: volumeInUseError("volume %s is published; unpublish it before deleting", req.VolumeID)})
return
}
// Ownership could not be established. Deleting is irreversible, so an
@@ -888,7 +921,7 @@ func handleDeleteVolume(ctx context.Context, cfg *Config, nc *nats.Conn, msg *na
// retry once the owner is reachable.
if ownership == ownershipUnknown {
slog.Error("ebs.provider.volume.delete: could not establish whether the volume is published", "volume", req.VolumeID)
respondProvider(ctx, msg, ebsprovider.DeleteVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: unavailableError("volume %s: could not establish whether it is published; retry when its owner is reachable", req.VolumeID)})
respondProvider(ctx, msg, ebsprovider.DeleteVolumeResponse{Versioned: v, Error: unavailableError("volume %s: could not establish whether it is published; retry when its owner is reachable", req.VolumeID)})
return
}
@@ -908,44 +941,46 @@ func handleDeleteVolume(ctx context.Context, cfg *Config, nc *nats.Conn, msg *na
store := providerObjectStoreFactory(cfg)
if err := deleteObjectPrefix(ctx, store, cfg.Bucket, req.VolumeID+"-efi/"); err != nil {
slog.Error("ebs.provider.volume.delete: failed to delete aux objects", "volume", req.VolumeID, "err", err)
respondProvider(ctx, msg, ebsprovider.DeleteVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: internalError("delete aux objects: %v", err)})
respondProvider(ctx, msg, ebsprovider.DeleteVolumeResponse{Versioned: v, Error: internalError("delete aux objects: %v", err)})
return
}
if err := deleteObjectPrefix(ctx, store, cfg.Bucket, req.VolumeID+"/"); err != nil {
slog.Error("ebs.provider.volume.delete: failed to delete volume objects", "volume", req.VolumeID, "err", err)
respondProvider(ctx, msg, ebsprovider.DeleteVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: internalError("delete volume objects: %v", err)})
respondProvider(ctx, msg, ebsprovider.DeleteVolumeResponse{Versioned: v, Error: internalError("delete volume objects: %v", err)})
return
}
slog.Info("ebs.provider.volume.delete: deleted", "volume", req.VolumeID)
respondProvider(ctx, msg, ebsprovider.DeleteVolumeResponse{Versioned: ebsprovider.NewVersioned()})
respondProvider(ctx, msg, ebsprovider.DeleteVolumeResponse{Versioned: v})
}
func handleDeleteSnapshot(ctx context.Context, cfg *Config, msg *nats.Msg) {
var req ebsprovider.DeleteSnapshotRequest
if err := json.Unmarshal(msg.Data, &req); err != nil {
err := json.Unmarshal(msg.Data, &req)
v := ebsprovider.ReplyVersioned(req.SchemaVersion)
if err != nil {
slog.Error("ebs.provider.snapshot.delete: bad request", "err", err)
respondProvider(ctx, msg, ebsprovider.DeleteSnapshotResponse{Versioned: ebsprovider.NewVersioned(), Error: badRequestError(err)})
respondProvider(ctx, msg, ebsprovider.DeleteSnapshotResponse{Versioned: v, Error: badRequestError(err)})
return
}
if !ebsprovider.SupportsSchemaVersion(req.SchemaVersion) {
respondProvider(ctx, msg, ebsprovider.DeleteSnapshotResponse{Versioned: ebsprovider.NewVersioned(), Error: unsupportedVersionError(req.SchemaVersion)})
respondProvider(ctx, msg, ebsprovider.DeleteSnapshotResponse{Versioned: v, Error: unsupportedVersionError(req.SchemaVersion)})
return
}
if !validVolumeName(req.SnapshotID) {
respondProvider(ctx, msg, ebsprovider.DeleteSnapshotResponse{Versioned: ebsprovider.NewVersioned(), Error: invalidArgumentError("invalid snapshot id %q", req.SnapshotID)})
respondProvider(ctx, msg, ebsprovider.DeleteSnapshotResponse{Versioned: v, Error: invalidArgumentError("invalid snapshot id %q", req.SnapshotID)})
return
}
store := providerObjectStoreFactory(cfg)
if err := deleteObjectPrefix(ctx, store, cfg.Bucket, req.SnapshotID+"/"); err != nil {
slog.Error("ebs.provider.snapshot.delete: failed", "snapshot", req.SnapshotID, "err", err)
respondProvider(ctx, msg, ebsprovider.DeleteSnapshotResponse{Versioned: ebsprovider.NewVersioned(), Error: internalError("delete snapshot objects: %v", err)})
respondProvider(ctx, msg, ebsprovider.DeleteSnapshotResponse{Versioned: v, Error: internalError("delete snapshot objects: %v", err)})
return
}
slog.Info("ebs.provider.snapshot.delete: deleted", "snapshot", req.SnapshotID)
respondProvider(ctx, msg, ebsprovider.DeleteSnapshotResponse{Versioned: ebsprovider.NewVersioned()})
respondProvider(ctx, msg, ebsprovider.DeleteSnapshotResponse{Versioned: v})
}
// handleCopySnapshot serves ebs.provider.v1.snapshot.copy. Unlike
@@ -955,21 +990,23 @@ func handleDeleteSnapshot(ctx context.Context, cfg *Config, msg *nats.Msg) {
// pattern.
func handleCopySnapshot(ctx context.Context, cfg *Config, msg *nats.Msg) {
var req ebsprovider.CopySnapshotRequest
if err := json.Unmarshal(msg.Data, &req); err != nil {
err := json.Unmarshal(msg.Data, &req)
v := ebsprovider.ReplyVersioned(req.SchemaVersion)
if err != nil {
slog.Error("ebs.provider.snapshot.copy: bad request", "err", err)
respondProvider(ctx, msg, ebsprovider.CopySnapshotResponse{Versioned: ebsprovider.NewVersioned(), Error: badRequestError(err)})
respondProvider(ctx, msg, ebsprovider.CopySnapshotResponse{Versioned: v, Error: badRequestError(err)})
return
}
if !ebsprovider.SupportsSchemaVersion(req.SchemaVersion) {
respondProvider(ctx, msg, ebsprovider.CopySnapshotResponse{Versioned: ebsprovider.NewVersioned(), Error: unsupportedVersionError(req.SchemaVersion)})
respondProvider(ctx, msg, ebsprovider.CopySnapshotResponse{Versioned: v, Error: unsupportedVersionError(req.SchemaVersion)})
return
}
if !validVolumeName(req.SourceSnapshotID) || !validVolumeName(req.DestinationSnapshotID) || !validVolumeName(req.VolumeID) {
respondProvider(ctx, msg, ebsprovider.CopySnapshotResponse{Versioned: ebsprovider.NewVersioned(), Error: invalidArgumentError("invalid source snapshot, destination snapshot, or volume id")})
respondProvider(ctx, msg, ebsprovider.CopySnapshotResponse{Versioned: v, Error: invalidArgumentError("invalid source snapshot, destination snapshot, or volume id")})
return
}
if req.SourceSnapshotID == req.DestinationSnapshotID {
respondProvider(ctx, msg, ebsprovider.CopySnapshotResponse{Versioned: ebsprovider.NewVersioned(), Error: invalidArgumentError("destination snapshot %q must differ from the source", req.DestinationSnapshotID)})
respondProvider(ctx, msg, ebsprovider.CopySnapshotResponse{Versioned: v, Error: invalidArgumentError("destination snapshot %q must differ from the source", req.DestinationSnapshotID)})
return
}
@@ -977,20 +1014,20 @@ func handleCopySnapshot(ctx context.Context, cfg *Config, msg *nats.Msg) {
if err != nil {
switch {
case errors.Is(err, errSnapshotDestinationExists):
respondProvider(ctx, msg, ebsprovider.CopySnapshotResponse{Versioned: ebsprovider.NewVersioned(), Error: alreadyExistsError("snapshot %s already exists", req.DestinationSnapshotID)})
respondProvider(ctx, msg, ebsprovider.CopySnapshotResponse{Versioned: v, Error: alreadyExistsError("snapshot %s already exists", req.DestinationSnapshotID)})
case errors.Is(err, viperblock.ErrSnapshotVolumeMismatch):
respondProvider(ctx, msg, ebsprovider.CopySnapshotResponse{Versioned: ebsprovider.NewVersioned(), Error: invalidArgumentError("%v", err)})
respondProvider(ctx, msg, ebsprovider.CopySnapshotResponse{Versioned: v, Error: invalidArgumentError("%v", err)})
case errors.Is(err, viperblock.ErrStateNotFound), errors.Is(err, os.ErrNotExist):
respondProvider(ctx, msg, ebsprovider.CopySnapshotResponse{Versioned: ebsprovider.NewVersioned(), Error: notFoundError("source snapshot %s not found on volume %s", req.SourceSnapshotID, req.VolumeID)})
respondProvider(ctx, msg, ebsprovider.CopySnapshotResponse{Versioned: v, Error: notFoundError("source snapshot %s not found on volume %s", req.SourceSnapshotID, req.VolumeID)})
default:
slog.Error("ebs.provider.snapshot.copy: failed", "volume", req.VolumeID, "source", req.SourceSnapshotID, "destination", req.DestinationSnapshotID, "err", err)
respondProvider(ctx, msg, ebsprovider.CopySnapshotResponse{Versioned: ebsprovider.NewVersioned(), Error: internalError("copy snapshot: %v", err)})
respondProvider(ctx, msg, ebsprovider.CopySnapshotResponse{Versioned: v, Error: internalError("copy snapshot: %v", err)})
}
return
}
slog.Info("ebs.provider.snapshot.copy: copied", "volume", req.VolumeID, "source", req.SourceSnapshotID, "destination", req.DestinationSnapshotID)
respondProvider(ctx, msg, ebsprovider.CopySnapshotResponse{Versioned: ebsprovider.NewVersioned(), Snapshot: snapshot})
respondProvider(ctx, msg, ebsprovider.CopySnapshotResponse{Versioned: v, Snapshot: snapshot})
}
// snapshotConfigExists probes whether snapshotID's config object is already
@@ -1055,13 +1092,15 @@ func copySnapshotMetaWithVB(vb *viperblock.VB, volumeID, srcSnapshotID, dstSnaps
func handleCreateSnapshot(ctx context.Context, cfg *Config, nc *nats.Conn, msg *nats.Msg) {
var req ebsprovider.CreateSnapshotRequest
if err := json.Unmarshal(msg.Data, &req); err != nil {
err := json.Unmarshal(msg.Data, &req)
v := ebsprovider.ReplyVersioned(req.SchemaVersion)
if err != nil {
slog.Error("ebs.provider.snapshot.create: bad request", "err", err)
respondProvider(ctx, msg, ebsprovider.CreateSnapshotResponse{Versioned: ebsprovider.NewVersioned(), Error: badRequestError(err)})
respondProvider(ctx, msg, ebsprovider.CreateSnapshotResponse{Versioned: v, Error: badRequestError(err)})
return
}
if !ebsprovider.SupportsSchemaVersion(req.SchemaVersion) {
respondProvider(ctx, msg, ebsprovider.CreateSnapshotResponse{Versioned: ebsprovider.NewVersioned(), Error: unsupportedVersionError(req.SchemaVersion)})
respondProvider(ctx, msg, ebsprovider.CreateSnapshotResponse{Versioned: v, Error: unsupportedVersionError(req.SchemaVersion)})
return
}
@@ -1076,23 +1115,23 @@ func handleCreateSnapshot(ctx context.Context, cfg *Config, nc *nats.Conn, msg *
}
if !validVolumeName(subjectVolumeID) || subjectVolumeID != req.VolumeID {
slog.Error("ebs.provider.snapshot.create: subject/volume mismatch", "subject", msg.Subject, "volume", req.VolumeID)
respondProvider(ctx, msg, ebsprovider.CreateSnapshotResponse{Versioned: ebsprovider.NewVersioned(), Error: invalidArgumentError("volume id %q in subject does not match request volume id %q", subjectVolumeID, req.VolumeID)})
respondProvider(ctx, msg, ebsprovider.CreateSnapshotResponse{Versioned: v, Error: invalidArgumentError("volume id %q in subject does not match request volume id %q", subjectVolumeID, req.VolumeID)})
return
}
if !validVolumeName(req.SnapshotID) {
respondProvider(ctx, msg, ebsprovider.CreateSnapshotResponse{Versioned: ebsprovider.NewVersioned(), Error: invalidArgumentError("invalid snapshot id %q", req.SnapshotID)})
respondProvider(ctx, msg, ebsprovider.CreateSnapshotResponse{Versioned: v, Error: invalidArgumentError("invalid snapshot id %q", req.SnapshotID)})
return
}
completionSubject, err := ebsprovider.SnapshotCompletionSubject(req.SnapshotID)
if err != nil {
respondProvider(ctx, msg, ebsprovider.CreateSnapshotResponse{Versioned: ebsprovider.NewVersioned(), Error: invalidArgumentError("%s", err.Error())})
respondProvider(ctx, msg, ebsprovider.CreateSnapshotResponse{Versioned: v, Error: invalidArgumentError("%s", err.Error())})
return
}
operationID := awsidentifiers.GenerateResourceID("op")
respondProvider(ctx, msg, ebsprovider.CreateSnapshotResponse{
Versioned: ebsprovider.NewVersioned(),
Versioned: v,
OperationID: operationID,
CompletionSubject: completionSubject,
Snapshot: &ebsprovider.Snapshot{
@@ -1117,7 +1156,7 @@ func handleCreateSnapshot(ctx context.Context, cfg *Config, nc *nats.Conn, msg *
// contract NATSProvider.CreateSnapshot waits on.
func completeCreateSnapshot(ctx context.Context, cfg *Config, nc *nats.Conn, req ebsprovider.CreateSnapshotRequest, operationID, completionSubject string) {
completion := ebsprovider.CreateSnapshotResponse{
Versioned: ebsprovider.NewVersioned(),
Versioned: ebsprovider.ReplyVersioned(req.SchemaVersion),
OperationID: operationID,
CompletionSubject: completionSubject,
}
@@ -1396,19 +1435,20 @@ func mountVolume(ctx context.Context, cfg *Config, nc *nats.Conn, volumeName str
// ebs.mount subject is the route production actually takes: a retried
// attach racing a fresh one would otherwise start two real exports.
if mv, ok := findMountedVolume(cfg, volumeName); ok {
// An entry whose socket is gone describes an export that no longer
// exists, and returning its URI makes the volume unstartable forever:
// every caller waits for a socket nothing will create. Dropping it and
// mounting again is safe because an absent socket cannot be serving.
// Returning a dead entry's URI makes the volume unstartable forever:
// every caller waits for a socket nothing will create. The entry is
// dropped and the volume mounted again once its nbdkit is gone.
if stale, why := mountEntryIsStale(mv); stale {
slog.WarnContext(ctx, "ebs.mount: dropping stale mount entry and remounting",
"volume", volumeName, "uri", mv.NBDURI, "reason", why)
releaseStaleMount(ctx, cfg, volumeName)
if err := releaseStaleMount(ctx, cfg, volumeName); err != nil {
return viperblocklegacyv1.EBSMountResponse{Error: err.Error()}, err
}
} else {
// Access mode is fixed when nbdkit starts, so a remount asking for
// the other mode cannot be answered with the running export.
if mv.ReadOnly != readOnly {
err := fmt.Errorf("volume %s is already mounted read_only=%t on this node", volumeName, mv.ReadOnly)
err := fmt.Errorf("volume %s is already mounted read_only=%t on this node: %w", volumeName, mv.ReadOnly, errReadOnlyMismatch)
return viperblocklegacyv1.EBSMountResponse{Error: err.Error()}, err
}
slog.InfoContext(ctx, "ebs.mount: already mounted, returning existing export", "volume", volumeName, "uri", mv.NBDURI)
@@ -1675,13 +1715,13 @@ func unmountVolume(ctx context.Context, cfg *Config, volumeName string) (viperbl
// drains to the backend and leaves a receipt. SIGKILLing into that
// discards the drain and leaves the WAL for the fallback seal below to
// replay, which costs far more than the caller's deadline allows.
hostprocess.TerminateProcess(matched.PID, pluginSealGrace)
hostprocess.TerminateProcess(matched.PID, cfg.exportSealGrace())
// The seal below rewrites the directory nbdkit writes, so a kill that
// did not take makes it a concurrent write to that directory. Fail the
// unmount instead: the entry stays mounted and a retry re-attempts
// both, which is what a failed seal already does.
if err := hostprocess.ForceKillProcess(matched.PID, fenceKillTimeout); err != nil {
if err := hostprocess.ForceKillProcess(matched.PID, cfg.exportKillWait()); err != nil {
slog.ErrorContext(ctx, "ebs.unmount: nbdkit did not exit, refusing to seal underneath a live writer",
"volume", matched.Name, "pid", matched.PID, "err", err)
ebsResponse.Error = fmt.Sprintf("kill nbdkit for %s: %v", matched.Name, err)
@@ -1777,54 +1817,27 @@ func unmountVolume(ctx context.Context, cfg *Config, volumeName string) (viperbl
// unlike the legacy handler there is no queue-group fallback to reason about.
func handlePublishVolume(ctx context.Context, cfg *Config, nc *nats.Conn, msg *nats.Msg) {
var req ebsprovider.PublishVolumeRequest
if err := json.Unmarshal(msg.Data, &req); err != nil {
err := json.Unmarshal(msg.Data, &req)
v := ebsprovider.ReplyVersioned(req.SchemaVersion)
if err != nil {
slog.Error("ebs.provider.volume.publish: bad request", "err", err)
respondProvider(ctx, msg, ebsprovider.PublishVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: badRequestError(err)})
respondProvider(ctx, msg, ebsprovider.PublishVolumeResponse{Versioned: v, Error: badRequestError(err)})
return
}
if !ebsprovider.SupportsSchemaVersion(req.SchemaVersion) {
slog.Error("ebs.provider.volume.publish: unsupported schema version", "version", req.SchemaVersion)
respondProvider(ctx, msg, ebsprovider.PublishVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: unsupportedVersionError(req.SchemaVersion)})
respondProvider(ctx, msg, ebsprovider.PublishVolumeResponse{Versioned: v, Error: unsupportedVersionError(req.SchemaVersion)})
return
}
if !validVolumeName(req.VolumeID) {
respondProvider(ctx, msg, ebsprovider.PublishVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: invalidArgumentError("invalid volume id %q", req.VolumeID)})
respondProvider(ctx, msg, ebsprovider.PublishVolumeResponse{Versioned: v, Error: invalidArgumentError("invalid volume id %q", req.VolumeID)})
return
}
// Idempotent republish: a volume this node already has mounted must not
// start a second nbdkit against it (the double-writer hazard this
// provider boundary exists to prevent). Match memory.go's behaviour.
if mv, ok := findMountedVolume(cfg, req.VolumeID); ok {
// Access mode is fixed when nbdkit starts, so a republish asking for
// the other mode cannot be answered with the running export.
if mv.ReadOnly != req.ReadOnly {
respondProvider(ctx, msg, ebsprovider.PublishVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: volumeInUseError("volume %s is already published read_only=%t on this node", req.VolumeID, mv.ReadOnly)})
return
}
slog.Info("ebs.provider.volume.publish: already published, returning existing attachment", "volume", req.VolumeID)
respondPublishedVolume(ctx, msg, req.SchemaVersion, &ebsprovider.PublishedVolume{
VolumeID: req.VolumeID,
NodeID: cfg.NodeName,
Export: ebsprovider.NewNBDExport(mv.NBDURI),
})
return
}
// mountVolume returns a live entry's export, and drops and remounts one
// whose export has died, so a republish never hands back a dead URI.
mountResp, err := mountVolume(ctx, cfg, nc, req.VolumeID, req.ReadOnly)
if err != nil {
if errors.Is(err, viperblock.ErrStateNotFound) {
respondProvider(ctx, msg, ebsprovider.PublishVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: notFoundError("volume %s not found", req.VolumeID)})
return
}
// Another node holds the volume's lease. This is the exclusion working,
// not a failure: reporting it as internal tells the caller something
// broke when the answer is that someone else has the volume.
if errors.Is(err, errVolumeLeaseHeld) {
respondProvider(ctx, msg, ebsprovider.PublishVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: volumeInUseError("volume %s is held by another node: %v", req.VolumeID, err)})
return
}
slog.Error("ebs.provider.volume.publish: mount failed", "volume", req.VolumeID, "err", err)
respondProvider(ctx, msg, ebsprovider.PublishVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: internalError("mount volume: %v", err)})
respondProvider(ctx, msg, ebsprovider.PublishVolumeResponse{Versioned: v, Error: publishError(req.SchemaVersion, req.VolumeID, err)})
return
}
@@ -1836,59 +1849,110 @@ func handlePublishVolume(ctx context.Context, cfg *Config, nc *nats.Conn, msg *n
})
}
// publishError classifies a failed mount for the contract reply. A lease held
// by another node is the exclusion working, and a backing store that is not
// ready yet is worth retrying, so neither is reported as internal.
func publishError(version uint16, volumeID string, err error) *ebsprovider.ProviderError {
switch {
case errors.Is(err, viperblock.ErrStateNotFound):
return notFoundError("volume %s not found", volumeID)
case errors.Is(err, errVolumeLeaseHeld) && version >= ebsprovider.HeldElsewhereSchemaVersion:
return heldElsewhereError("volume %s is held by another node: %v", volumeID, err)
case errors.Is(err, errVolumeLeaseHeld):
// Below version 3 the refusal keeps the code v1.23.0 sends.
return volumeInUseError("volume %s is held by another node: %v", volumeID, err)
case errors.Is(err, errReadOnlyMismatch):
return volumeInUseError("%v", err)
case mountErrRetryable(err), errors.Is(err, ebsprovider.ErrUnavailable):
return unavailableError("mount volume %s: %v", volumeID, err)
default:
slog.Error("ebs.provider.volume.publish: mount failed", "volume", volumeID, "err", err)
return internalError("mount volume: %v", err)
}
}
// handleUnpublishVolume serves ebs.provider.v1.<node>.unmount, fronting the
// same seal-decision path ebs.unmount uses. A failed seal is reported as an
// error while leaving the volume in cfg.MountedVolumes (see unmountVolume),
// never as success.
func handleUnpublishVolume(ctx context.Context, cfg *Config, msg *nats.Msg) {
var req ebsprovider.UnpublishVolumeRequest
if err := json.Unmarshal(msg.Data, &req); err != nil {
err := json.Unmarshal(msg.Data, &req)
v := ebsprovider.ReplyVersioned(req.SchemaVersion)
if err != nil {
slog.Error("ebs.provider.volume.unpublish: bad request", "err", err)
respondProvider(ctx, msg, ebsprovider.UnpublishVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: badRequestError(err)})
respondProvider(ctx, msg, ebsprovider.UnpublishVolumeResponse{Versioned: v, Error: badRequestError(err)})
return
}
if !ebsprovider.SupportsSchemaVersion(req.SchemaVersion) {
slog.Error("ebs.provider.volume.unpublish: unsupported schema version", "version", req.SchemaVersion)
respondProvider(ctx, msg, ebsprovider.UnpublishVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: unsupportedVersionError(req.SchemaVersion)})
if err := ebsprovider.CheckUnpublishVersion(req); err != nil {
slog.Error("ebs.provider.volume.unpublish: request refused", "version", req.SchemaVersion, "no_seal", req.NoSeal, "err", err)
respondProvider(ctx, msg, ebsprovider.UnpublishVolumeResponse{Versioned: v, Error: unpublishRequestError(req.SchemaVersion, err)})
return
}
if !validVolumeName(req.VolumeID) {
respondProvider(ctx, msg, ebsprovider.UnpublishVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: invalidArgumentError("invalid volume id %q", req.VolumeID)})
respondProvider(ctx, msg, ebsprovider.UnpublishVolumeResponse{Versioned: v, Error: invalidArgumentError("invalid volume id %q", req.VolumeID)})
return
}
tornDown, err := unpublishVolume(ctx, cfg, req)
if err != nil {
slog.Error("ebs.provider.volume.unpublish: unmount failed", "volume", req.VolumeID, "no_seal", req.NoSeal, "err", err)
respondProvider(ctx, msg, ebsprovider.UnpublishVolumeResponse{Versioned: v, Error: internalError("unmount volume: %v", err)})
return
}
slog.Info("ebs.provider.volume.unpublish: unpublished", "volume", req.VolumeID, "node", cfg.NodeName, "no_seal", req.NoSeal, "torn_down", tornDown)
respondProvider(ctx, msg, ebsprovider.UnpublishVolumeResponse{Versioned: v, TornDown: tornDown})
}
// unpublishRequestError classifies a CheckUnpublishVersion refusal.
func unpublishRequestError(version uint16, err error) *ebsprovider.ProviderError {
if errors.Is(err, ebsprovider.ErrUnsupportedVersion) {
return unsupportedVersionError(version)
}
return invalidArgumentError("%v", err)
}
// unpublishVolume tears down this node's export and reports whether there was
// one, which is only reported from NoSealSchemaVersion on. NoSeal drops it
// unsealed, as an abandon does; otherwise it seals first, as ebs.unmount does.
func unpublishVolume(ctx context.Context, cfg *Config, req ebsprovider.UnpublishVolumeRequest) (bool, error) {
tornDown, err := tearDownPublication(ctx, cfg, req)
return tornDown && req.SchemaVersion >= ebsprovider.NoSealSchemaVersion, err
}
func tearDownPublication(ctx context.Context, cfg *Config, req ebsprovider.UnpublishVolumeRequest) (bool, error) {
if req.NoSeal {
return cfg.abandonVolume(ctx, req.VolumeID, "unpublish with no_seal")
}
unmountResp, err := unmountVolume(ctx, cfg, req.VolumeID)
if err != nil {
// Nothing mounted here is already the state unpublish asks for, so a
// retry after a request that completed server-side converges rather
// than erroring, matching the legacy path's unmountResponseError.
if unmountResp.NotFound {
slog.Info("ebs.provider.volume.unpublish: already unpublished", "volume", req.VolumeID, "node", cfg.NodeName)
respondProvider(ctx, msg, ebsprovider.UnpublishVolumeResponse{Versioned: ebsprovider.NewVersioned()})
return
return unmountResp.Reaped, nil
}
slog.Error("ebs.provider.volume.unpublish: unmount failed", "volume", req.VolumeID, "err", err)
respondProvider(ctx, msg, ebsprovider.UnpublishVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: internalError("unmount volume: %v", err)})
return
return false, err
}
slog.Info("ebs.provider.volume.unpublish: unpublished", "volume", req.VolumeID, "node", cfg.NodeName)
respondProvider(ctx, msg, ebsprovider.UnpublishVolumeResponse{Versioned: ebsprovider.NewVersioned()})
return true, nil
}
func handleHoldsUnsealedWrites(ctx context.Context, cfg *Config, msg *nats.Msg) {
var req ebsprovider.HoldsUnsealedWritesRequest
if err := json.Unmarshal(msg.Data, &req); err != nil {
respondProvider(ctx, msg, ebsprovider.HoldsUnsealedWritesResponse{Versioned: ebsprovider.NewVersioned(), Error: badRequestError(err)})
err := json.Unmarshal(msg.Data, &req)
v := ebsprovider.ReplyVersioned(req.SchemaVersion)
if err != nil {
respondProvider(ctx, msg, ebsprovider.HoldsUnsealedWritesResponse{Versioned: v, Error: badRequestError(err)})
return
}
if !ebsprovider.SupportsSchemaVersion(req.SchemaVersion) {
respondProvider(ctx, msg, ebsprovider.HoldsUnsealedWritesResponse{Versioned: ebsprovider.NewVersioned(), Error: unsupportedVersionError(req.SchemaVersion)})
respondProvider(ctx, msg, ebsprovider.HoldsUnsealedWritesResponse{Versioned: v, Error: unsupportedVersionError(req.SchemaVersion)})
return
}
for _, volume := range req.VolumeIDs {
if !validVolumeName(volume) {
respondProvider(ctx, msg, ebsprovider.HoldsUnsealedWritesResponse{Versioned: ebsprovider.NewVersioned(), Error: invalidArgumentError("invalid volume id %q", volume)})
respondProvider(ctx, msg, ebsprovider.HoldsUnsealedWritesResponse{Versioned: v, Error: invalidArgumentError("invalid volume id %q", volume)})
return
}
}
@@ -1896,19 +1960,18 @@ func handleHoldsUnsealedWrites(ctx context.Context, cfg *Config, msg *nats.Msg)
held, err := cfg.unsealedWritesHeld(ctx, req.VolumeIDs)
if err != nil {
slog.Error("ebs.provider.unsealed: could not read dirty markers", "node", cfg.NodeName, "err", err)
respondProvider(ctx, msg, ebsprovider.HoldsUnsealedWritesResponse{Versioned: ebsprovider.NewVersioned(), Error: internalError("read dirty markers: %v", err)})
respondProvider(ctx, msg, ebsprovider.HoldsUnsealedWritesResponse{Versioned: v, Error: internalError("read dirty markers: %v", err)})
return
}
respondProvider(ctx, msg, ebsprovider.HoldsUnsealedWritesResponse{Versioned: ebsprovider.NewVersioned(), Held: held})
respondProvider(ctx, msg, ebsprovider.HoldsUnsealedWritesResponse{Versioned: v, Held: held})
}
// mountEntryIsStale reports whether a MountedVolume describes an export that is
// no longer running, and why.
// mountEntryIsStale reports whether a MountedVolume describes an export that no
// new client can reach, and why.
//
// Only positive evidence of death counts. The entry guards against a second
// nbdkit for one volume, so treating a live export as stale would create the
// double-writer hazard it exists to prevent — an absent socket or a dead pid
// are the two conditions under which no export can be serving.
// Only positive evidence counts: an absent socket or a dead pid. An absent
// socket does not mean the nbdkit has exited, since a client that connected
// before it went keeps writing through it, so releaseStaleMount stops it.
func mountEntryIsStale(mv MountedVolume) (bool, string) {
if mv.Socket != "" {
if _, err := os.Stat(mv.Socket); errors.Is(err, os.ErrNotExist) {
@@ -1923,7 +1986,18 @@ func mountEntryIsStale(mv MountedVolume) (bool, string) {
// releaseStaleMount drops a dead export's entry and everything it held, so the
// remount that follows starts from the same state a clean unmount would leave.
func releaseStaleMount(ctx context.Context, cfg *Config, volumeName string) {
//
// An nbdkit still running behind a lost socket is a writer, so it goes through
// unmountVolume, which stops it and seals its copy. If it will not stop, the
// entry stays and the error wraps ErrUnavailable: a remount would be a second writer.
func releaseStaleMount(ctx context.Context, cfg *Config, volumeName string) error {
if mv, ok := findMountedVolume(cfg, volumeName); ok && hostprocess.ProcessAlive(mv.PID) {
if _, err := unmountVolume(ctx, cfg, volumeName); err != nil {
return fmt.Errorf("%w: stop nbdkit %d behind the lost socket of %s: %w", ebsprovider.ErrUnavailable, mv.PID, volumeName, err)
}
return nil
}
cfg.mu.Lock()
var matched MountedVolume
found := false
@@ -1936,7 +2010,7 @@ func releaseStaleMount(ctx context.Context, cfg *Config, volumeName string) {
}
cfg.mu.Unlock()
if !found {
return
return nil
}
if matched.ConfigSub != nil {
@@ -1961,4 +2035,5 @@ func releaseStaleMount(ctx context.Context, cfg *Config, volumeName string) {
}
}
cfg.releaseVolumeLease(ctx, matched.Lease)
return nil
}
@@ -150,6 +150,11 @@ func providerSubjectCases() []providerSubjectCase {
"volume_id": "vol-testunpublish01",
"node_id": "test-node",
}},
{"holds_unsealed_writes", "ebs.provider.v1.test-node.unsealed", map[string]any{
"node_id": "test-node",
"volume_ids": []string{"vol-testunsealed01"},
}},
{"node_capabilities", "ebs.provider.v1.test-node.capabilities", map[string]any{}},
}
}
@@ -867,7 +872,7 @@ func TestProviderHandlers_PublishVolume_IdempotentRepublish(t *testing.T) {
cfg.MountedVolumes = append(cfg.MountedVolumes, MountedVolume{
Name: volumeName,
VB: vb,
PID: 424242,
PID: os.Getpid(),
NBDURI: existingURI,
})
@@ -893,7 +898,7 @@ func TestProviderHandlers_PublishVolume_IdempotentRepublish(t *testing.T) {
pid := cfg.MountedVolumes[0].PID
cfg.mu.Unlock()
assert.Equal(t, 1, count, "republish of an already-mounted volume must not add a second entry")
assert.Equal(t, 424242, pid, "republish must not spawn a second nbdkit (PID must be unchanged)")
assert.Equal(t, os.Getpid(), pid, "republish must not spawn a second nbdkit (PID must be unchanged)")
}
func TestProviderHandlers_PublishVolume_LegacyV1Wire(t *testing.T) {
@@ -905,7 +910,7 @@ func TestProviderHandlers_PublishVolume_LegacyV1Wire(t *testing.T) {
const existingURI = "nbd:unix:/fake/vol-publegacyv1.sock"
vb := createTestVBWithState(t, volumeName)
cfg.MountedVolumes = append(cfg.MountedVolumes, MountedVolume{
Name: volumeName, VB: vb, PID: 424242, NBDURI: existingURI,
Name: volumeName, VB: vb, PID: os.Getpid(), NBDURI: existingURI,
})
publishSubject, err := ebsprovider.PublishSubject(cfg.NodeName)
@@ -939,7 +944,7 @@ func TestProviderHandlers_PublishVolume_ReadOnlyModeMismatchRefused(t *testing.T
cfg.MountedVolumes = append(cfg.MountedVolumes, MountedVolume{
Name: volumeName,
VB: vb,
PID: 424243,
PID: os.Getpid(),
NBDURI: "nbd:unix:/fake/vol-readonlypub001.sock",
ReadOnly: false,
})
@@ -1038,8 +1043,8 @@ func TestProviderHandlers_PublishUnpublish_RequireNodeName(t *testing.T) {
cfgNamed := setupTestConfig(t, natsURL2)
ncNamed := startProviderSubjects(t, cfgNamed, natsURL2)
assert.Equal(t, ncNamed.NumSubscriptions()-3, ncNoNode.NumSubscriptions(),
"an empty NodeName must skip exactly the publish, unpublish and unsealed-writes subscriptions")
assert.Equal(t, ncNamed.NumSubscriptions()-4, ncNoNode.NumSubscriptions(),
"an empty NodeName must skip exactly the publish, unpublish, unsealed-writes and node capabilities subscriptions")
// The other provider subjects must still work without a node name.
body := marshalRequest(t, map[string]any{"schema_version": ebsprovider.SchemaVersion})
@@ -4,12 +4,20 @@ package viperblock
//exporting them would widen the package API for a test rather than for a caller.
import (
"context"
"errors"
"os"
"os/exec"
"path/filepath"
"testing"
"time"
"github.com/mulgadc/spinifex/spinifex/providers/ebs"
hostprocess "github.com/mulgadc/spinifex/spinifex/runtime/host/process"
"github.com/mulgadc/viperblock/viperblock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/sys/unix"
)
// A mount entry pointing at a socket that no longer exists describes an export
@@ -47,11 +55,11 @@ func TestMountEntryIsStaleWhenProcessIsGone(t *testing.T) {
func TestReleaseStaleMountDropsTheEntry(t *testing.T) {
cfg := &Config{MountedVolumes: []MountedVolume{{Name: "vol-1"}, {Name: "vol-2"}}}
releaseStaleMount(t.Context(), cfg, "vol-1")
require.NoError(t, releaseStaleMount(t.Context(), cfg, "vol-1"))
require.Len(t, cfg.MountedVolumes, 1)
assert.Equal(t, "vol-2", cfg.MountedVolumes[0].Name)
releaseStaleMount(t.Context(), cfg, "vol-absent")
require.NoError(t, releaseStaleMount(t.Context(), cfg, "vol-absent"))
assert.Len(t, cfg.MountedVolumes, 1, "releasing an unmounted volume must be a no-op")
}
@@ -64,3 +72,76 @@ func deadPID(t *testing.T) int {
require.NoError(t, err)
return state.Pid()
}
// orphanedConfig is fencedConfig with the entry's socket gone and local state
// under BaseDir, so a stop through the unmount path has a copy to seal.
func orphanedConfig(t *testing.T, volumeName string, pid int) *Config {
t.Helper()
_, natsURL := setupEmbeddedNATS(t)
cfg := fencedConfig(t, natsURL, "node-a", volumeName)
withLocalState(t, cfg, volumeName)
cfg.MountedVolumes[0].Socket = filepath.Join(t.TempDir(), "gone.sock")
if pid != 0 {
cfg.MountedVolumes[0].PID = pid
}
return cfg
}
// An nbdkit whose socket is gone still serves a client connected before it
// went, so a remount beside it is a second writer. It has to be stopped, and
// its copy sealed as any unmount would, before the volume is opened again.
func TestMountVolume_SocketGoneProcessAliveIsStoppedBeforeRemount(t *testing.T) {
const volumeName = "vol-orphanalive1"
cfg := orphanedConfig(t, volumeName, 0)
pid := cfg.MountedVolumes[0].PID
require.True(t, hostprocess.ProcessAlive(pid))
sealed := false
cfg.sealVolume = func(context.Context, string) error { sealed = true; return nil }
errNoRemount := errors.New("remount stops here")
var aliveAtRemount []bool
cfg.constructVB = func(context.Context, string) (*viperblock.VB, int, error) {
aliveAtRemount = append(aliveAtRemount, hostprocess.ProcessAlive(pid))
return nil, 0, errNoRemount
}
_, err := mountVolume(t.Context(), cfg, nil, volumeName, false)
require.ErrorIs(t, err, errNoRemount, "the remount must be reached once the old nbdkit is stopped")
assert.Equal(t, []bool{false}, aliveAtRemount, "the old nbdkit must be gone before the volume is opened again")
assert.True(t, sealed, "the old export's copy is sealed as a normal unmount would seal it")
}
// An nbdkit that will not stop is still a writer, so the publish is refused as
// retry-later and the entry kept for the next attempt to retry the stop.
func TestMountVolume_SocketGoneProcessUnstoppableIsUnavailable(t *testing.T) {
const volumeName = "vol-orphanstuck1"
cfg := orphanedConfig(t, volumeName, zombiePID(t))
cfg.exportStopWait = 50 * time.Millisecond
sealed := false
cfg.sealVolume = func(context.Context, string) error { sealed = true; return nil }
cfg.constructVB = func(context.Context, string) (*viperblock.VB, int, error) {
t.Error("a volume must not be opened again beside an nbdkit that would not stop")
return nil, 0, errors.New("unreachable")
}
_, err := mountVolume(t.Context(), cfg, nil, volumeName, false)
require.ErrorIs(t, err, ebsprovider.ErrUnavailable)
assert.Equal(t, ebsprovider.ErrorCodeUnavailable, publishError(ebsprovider.SchemaVersion, volumeName, err).Code)
_, mounted := findMountedVolume(cfg, volumeName)
assert.True(t, mounted, "the entry must stay while its nbdkit may still write")
assert.False(t, sealed, "nothing may seal underneath a writer that is still running")
}
// zombiePID returns an exited, unreaped child: kill(pid,0) still reaches it and
// no signal makes it go, so it stands in for an nbdkit that will not stop.
func zombiePID(t *testing.T) int {
t.Helper()
hostprocess.ShortenProcessExitPollForTest(t, 5*time.Millisecond)
cmd := exec.Command("sleep", "300")
require.NoError(t, cmd.Start())
require.NoError(t, cmd.Process.Kill())
var info unix.Siginfo
require.NoError(t, unix.Waitid(unix.P_PID, cmd.Process.Pid, &info, unix.WEXITED|unix.WNOWAIT, nil))
t.Cleanup(func() { _ = cmd.Wait() })
return cmd.Process.Pid
}
@@ -214,6 +214,10 @@ type Config struct {
fenceWatchEvery time.Duration
processAlive func(pid int) bool
// exportStopWait overrides both waits unmountVolume gives nbdkit to exit,
// pluginSealGrace and fenceKillTimeout. Zero means those.
exportStopWait time.Duration
// fenceNoticeEvery overrides the wait between fence notice attempts. Zero
// means defaultFenceNoticeRetry.
fenceNoticeEvery time.Duration
@@ -245,6 +249,20 @@ type Config struct {
mu sync.Mutex
}
func (cfg *Config) exportSealGrace() time.Duration {
if cfg.exportStopWait > 0 {
return cfg.exportStopWait
}
return pluginSealGrace
}
func (cfg *Config) exportKillWait() time.Duration {
if cfg.exportStopWait > 0 {
return cfg.exportStopWait
}
return fenceKillTimeout
}
// seal persists volumeName's block map to predastore, honouring a test's
// injected seal if there is one.
func (cfg *Config) seal(ctx context.Context, volumeName string) error {
@@ -63,6 +63,7 @@ func TestVolumeAbandon_DoesNotSeal(t *testing.T) {
sealed := false
cfg := fencedConfig(t, natsURL, "node-a", volumeName)
withLocalState(t, cfg, volumeName)
cfg.sealVolume = func(context.Context, string) error { sealed = true; return nil }
_, err := cfg.abandonVolume(t.Context(), volumeName, "the instance moved")
@@ -5,7 +5,9 @@ package viperblock
import (
"context"
"os"
"os/exec"
"path/filepath"
"sync/atomic"
"testing"
"time"
@@ -70,6 +72,15 @@ func fencedConfig(t *testing.T, natsURL, owner, volumeName string) *Config {
return cfg
}
// withLocalState gives cfg a BaseDir holding local state for volumeName, so a
// teardown that seals has a copy to seal and a test that no seal ran means it.
func withLocalState(t *testing.T, cfg *Config, volumeName string) {
t.Helper()
cfg.BaseDir = t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(cfg.BaseDir, volumeName), 0o750))
require.True(t, volumeNeedsSeal(volumeName, cfg.BaseDir), "a seal must be reachable for the test to mean anything")
}
// TestVolumeFence_TearsDownWhenAnotherNodeHoldsTheLease is the property the
// fence exists for. A node that has lost its lease is a second writer, and the
// only way to stop being one without a backend that can refuse is to give up
+42
View File
@@ -1971,6 +1971,17 @@ func (d *Daemon) startCluster() error {
return fmt.Errorf("initialize JetStream: %w", err)
}
// Before any service is built: a node on a different provider from the
// cluster's would address existing volumes through the wrong implementation.
// A refusal exits so the unit shows failed rather than a healthy idle daemon.
if err := d.claimEBSProvider(); err != nil {
if errors.Is(err, errStartupPermanent) {
slog.Error("Refusing to start: EBS provider lock", "err", err)
d.exitFunc(1)
}
return fmt.Errorf("EBS provider lock: %w", err)
}
// Remove the obsolete spinifex-dhcp-leases bucket (idempotent).
if js, jsErr := jetstream.New(d.natsConn); jsErr == nil {
if err := kvutil.DeleteBucketIfExists(d.ctx, js, "spinifex-dhcp-leases"); err != nil {
@@ -2738,6 +2749,10 @@ func (d *Daemon) initJetStream() error {
err = d.jsManager.InitTerminatedInstanceBucket()
}
if err == nil {
err = d.jsManager.InitClusterConfigBucket()
}
if err == nil {
d.jsManager.SetSyncObserver(d)
// Replicas cannot be undeclared here: bucket creation refuses
@@ -2786,6 +2801,29 @@ func (d *Daemon) upgradeJetStreamReplicas() {
}
}
// claimEBSProvider records this node's EBS provider as the cluster's, or
// refuses when the cluster already records a different one.
func (d *Daemon) claimEBSProvider() error {
provider := d.config.EBS.ResolvedProvider()
rev, err := initServiceWithRetry("EBS provider lock", func() (uint64, error) {
ctx, cancel := context.WithTimeout(context.Background(), ebsProviderClaimTimeout)
defer cancel()
return d.jsManager.ClaimEBSProvider(ctx, d.node, provider)
})
if err != nil {
return err
}
slog.Info("EBS provider matches the cluster's", "provider", provider, "revision", rev)
return nil
}
// ebsProviderClaimTimeout bounds one attempt at the provider lock's KV calls.
const ebsProviderClaimTimeout = 10 * time.Second
// errStartupPermanent marks an error that a startup retry loop cannot clear,
// so the loop returns it at once rather than waiting out its budget.
var errStartupPermanent = errors.New("permanent startup refusal")
// initRetrySleep is the sleep seam used by initServiceWithRetry; tests override it.
var initRetrySleep = time.Sleep
@@ -2806,6 +2844,10 @@ func initServiceWithRetry[T any](name string, initFn func() (T, error)) (T, erro
}
return result, nil
}
if errors.Is(err, errStartupPermanent) {
var zero T
return zero, err
}
elapsed := time.Since(start)
if elapsed >= maxWait {
@@ -0,0 +1,103 @@
package daemon
import (
"context"
"encoding/json"
"errors"
"fmt"
"github.com/mulgadc/spinifex/spinifex/bootstrap/config"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
"github.com/nats-io/nats.go/jetstream"
)
// EBSProviderKey is the ClusterConfigBucket key recording the one EBS
// provider the cluster runs.
const EBSProviderKey = "ebs-provider"
// EBSProviderRecord is the value at EBSProviderKey.
type EBSProviderRecord struct {
Provider string `json:"provider"`
// RecordedBy names the node whose Create won, for the refusal message.
RecordedBy string `json:"recorded_by"`
}
// EBSProviderMismatchError is the refusal a daemon returns when its configured
// provider is not the one the cluster recorded. Retrying cannot clear it.
type EBSProviderMismatchError struct {
Node string
Configured string
Recorded EBSProviderRecord
}
func (e *EBSProviderMismatchError) Error() string {
return fmt.Sprintf("EBS provider mismatch: node %s is configured with provider %q, but this cluster runs %q (recorded by node %s in KV bucket %s, key %s); "+
"set [nodes.%s.ebs] provider = %q to join it, or change the cluster's provider by wiping and reforming the cluster, since volumes record no provider to migrate by",
e.Node, e.Configured, e.Recorded.Provider, e.Recorded.RecordedBy, ClusterConfigBucket, EBSProviderKey, e.Node, e.Recorded.Provider)
}
func (e *EBSProviderMismatchError) Unwrap() error { return errStartupPermanent }
// EBSProviderRecordInvalidError reports a recorded value that does not decode
// or names no supported provider, so there is nothing to compare against.
type EBSProviderRecordInvalidError struct {
Raw []byte
Reason string
}
func (e *EBSProviderRecordInvalidError) Error() string {
return fmt.Sprintf("EBS provider lock unreadable: KV bucket %s, key %s holds %q (%s); "+
"correct the value to {\"provider\":\"<provider>\"} naming the provider the cluster's volumes were created on, or delete the key so the next daemon records its own",
ClusterConfigBucket, EBSProviderKey, e.Raw, e.Reason)
}
func (e *EBSProviderRecordInvalidError) Unwrap() error { return errStartupPermanent }
// ClaimEBSProvider records provider as the cluster's EBS provider if none is
// recorded, and otherwise checks it against the recorded one. Create decides
// a race, so one provider is ever recorded; it returns that record's revision.
func (m *JetStreamManager) ClaimEBSProvider(ctx context.Context, node, provider string) (uint64, error) {
if m.clusterConfig == nil {
return 0, errors.New("cluster config KV not initialized")
}
claim := &EBSProviderRecord{Provider: provider, RecordedBy: node}
rev, err := kvstore.On[EBSProviderRecord](m.clusterConfig).Create(ctx, EBSProviderKey, claim)
if err == nil {
return rev, nil
}
if !errors.Is(err, kvstore.ErrExists) {
return 0, fmt.Errorf("record EBS provider: %w", err)
}
recorded, rev, err := m.readEBSProvider(ctx)
if err != nil {
return 0, err
}
if recorded.Provider != provider {
return 0, &EBSProviderMismatchError{Node: node, Configured: provider, Recorded: *recorded}
}
return rev, nil
}
// readEBSProvider reads the raw value rather than through the store's codec,
// so a malformed one can be quoted in the refusal.
func (m *JetStreamManager) readEBSProvider(ctx context.Context) (*EBSProviderRecord, uint64, error) {
kv, err := m.clusterConfig.KV(ctx)
if err != nil {
return nil, 0, err
}
entry, err := kv.Get(ctx, EBSProviderKey)
if err != nil {
if errors.Is(err, jetstream.ErrKeyNotFound) {
return nil, 0, fmt.Errorf("recorded EBS provider vanished after Create lost: %w", err)
}
return nil, 0, fmt.Errorf("read recorded EBS provider: %w", err)
}
var rec EBSProviderRecord
if err := json.Unmarshal(entry.Value(), &rec); err != nil {
return nil, 0, &EBSProviderRecordInvalidError{Raw: entry.Value(), Reason: "not a JSON record: " + err.Error()}
}
if !config.SupportedEBSProvider(rec.Provider) {
return nil, 0, &EBSProviderRecordInvalidError{Raw: entry.Value(), Reason: fmt.Sprintf("provider %q is not a supported provider", rec.Provider)}
}
return &rec, entry.Revision(), nil
}
@@ -0,0 +1,272 @@
package daemon
import (
"context"
"encoding/json"
"fmt"
"slices"
"strconv"
"sync"
"testing"
"time"
testutil "github.com/mulgadc/spinifex/internal/testkit"
"github.com/mulgadc/spinifex/spinifex/bootstrap/config"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// newLockManager opens the cluster-config bucket over its own connection, as
// a separate daemon would.
func newLockManager(t *testing.T, url string) *JetStreamManager {
t.Helper()
nc, err := nats.Connect(url)
require.NoError(t, err)
t.Cleanup(nc.Close)
m, err := NewJetStreamManager(nc)
require.NoError(t, err)
require.NoError(t, m.InitClusterConfigBucket())
return m
}
func recordedEBSProvider(t *testing.T, js jetstream.JetStream) (EBSProviderRecord, uint64) {
t.Helper()
kv, err := js.KeyValue(t.Context(), ClusterConfigBucket)
require.NoError(t, err)
entry, err := kv.Get(t.Context(), EBSProviderKey)
require.NoError(t, err)
var rec EBSProviderRecord
require.NoError(t, json.Unmarshal(entry.Value(), &rec))
return rec, entry.Revision()
}
func TestClusterConfigBucket_NoTTLAndSchemaVersion(t *testing.T) {
ns, _, js := testutil.StartTestJetStream(t)
newLockManager(t, ns.ClientURL())
kv, err := js.KeyValue(t.Context(), ClusterConfigBucket)
require.NoError(t, err)
status, err := kv.Status(t.Context())
require.NoError(t, err)
assert.Equal(t, int64(1), status.History())
assert.Zero(t, status.TTL(), "a TTL would expire the lock and let a second provider in")
v, err := kvutil.ReadVersion(t.Context(), kv)
require.NoError(t, err)
assert.Equal(t, 1, v)
}
func TestClaimEBSProvider_FirstClaimRecordsAndMatchingNodesJoin(t *testing.T) {
ns, _, js := testutil.StartTestJetStream(t)
first := newLockManager(t, ns.ClientURL())
second := newLockManager(t, ns.ClientURL())
rev, err := first.ClaimEBSProvider(t.Context(), "node1", config.EBSProviderQEMUNBD)
require.NoError(t, err)
joined, err := second.ClaimEBSProvider(t.Context(), "node2", config.EBSProviderQEMUNBD)
require.NoError(t, err)
assert.Equal(t, rev, joined)
restarted, err := first.ClaimEBSProvider(t.Context(), "node1", config.EBSProviderQEMUNBD)
require.NoError(t, err, "a restart on the same provider must start")
assert.Equal(t, rev, restarted)
rec, revAfter := recordedEBSProvider(t, js)
assert.Equal(t, EBSProviderRecord{Provider: config.EBSProviderQEMUNBD, RecordedBy: "node1"}, rec)
assert.Equal(t, rev, revAfter, "a matching claim must not rewrite the key")
}
func TestClaimEBSProvider_DifferentProviderRefused(t *testing.T) {
ns, _, js := testutil.StartTestJetStream(t)
first := newLockManager(t, ns.ClientURL())
second := newLockManager(t, ns.ClientURL())
_, err := first.ClaimEBSProvider(t.Context(), "node1", config.EBSProviderViperblockd)
require.NoError(t, err)
_, err = second.ClaimEBSProvider(t.Context(), "node2", config.EBSProviderQEMUNBD)
var mismatch *EBSProviderMismatchError
require.ErrorAs(t, err, &mismatch)
assert.ErrorIs(t, err, errStartupPermanent)
assert.Equal(t, "node2", mismatch.Node)
assert.Equal(t, config.EBSProviderQEMUNBD, mismatch.Configured)
assert.Equal(t, EBSProviderRecord{Provider: config.EBSProviderViperblockd, RecordedBy: "node1"}, mismatch.Recorded)
for _, want := range []string{`"qemunbd"`, `"viperblockd"`, "node1", ClusterConfigBucket, EBSProviderKey, `[nodes.node2.ebs] provider = "viperblockd"`, "wiping and reforming"} {
assert.Contains(t, err.Error(), want)
}
rec, _ := recordedEBSProvider(t, js)
assert.Equal(t, config.EBSProviderViperblockd, rec.Provider, "a refused claim must leave the recorded provider in place")
}
// A value that does not decode, or names no supported provider, is refused as
// unreadable rather than as a mismatch, and quotes what the key holds.
func TestClaimEBSProvider_InvalidRecordRefused(t *testing.T) {
cases := []struct {
name string
raw string
reason string
}{
{"not JSON", "qemunbd", "not a JSON record"},
{"empty provider", `{"provider":"","recorded_by":"node1"}`, `provider "" is not a supported provider`},
{"unknown provider", `{"provider":"cephd","recorded_by":"node1"}`, `provider "cephd" is not a supported provider`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
ns, _, js := testutil.StartTestJetStream(t)
m := newLockManager(t, ns.ClientURL())
kv, err := js.KeyValue(t.Context(), ClusterConfigBucket)
require.NoError(t, err)
_, err = kv.Put(t.Context(), EBSProviderKey, []byte(tc.raw))
require.NoError(t, err)
_, err = m.ClaimEBSProvider(t.Context(), "node2", config.EBSProviderViperblockd)
var invalid *EBSProviderRecordInvalidError
require.ErrorAs(t, err, &invalid)
assert.ErrorIs(t, err, errStartupPermanent)
assert.NotErrorAs(t, err, new(*EBSProviderMismatchError))
for _, want := range []string{ClusterConfigBucket, EBSProviderKey, strconv.Quote(tc.raw), tc.reason} {
assert.Contains(t, err.Error(), want)
}
})
}
}
// Two daemons on different providers starting together: whichever Create wins
// is recorded, and the other is refused.
func TestClaimEBSProvider_ConcurrentDifferentProvidersLeaveOneKeyAndOneRefusal(t *testing.T) {
for range 20 {
ns, _, js := testutil.StartTestJetStream(t)
managers := []*JetStreamManager{newLockManager(t, ns.ClientURL()), newLockManager(t, ns.ClientURL())}
providers := []string{config.EBSProviderViperblockd, config.EBSProviderQEMUNBD}
nodes := []string{"node1", "node2"}
errs := make([]error, 2)
revs := make([]uint64, 2)
start := make(chan struct{})
var wg sync.WaitGroup
for i := range managers {
wg.Go(func() {
<-start
revs[i], errs[i] = managers[i].ClaimEBSProvider(context.Background(), nodes[i], providers[i])
})
}
close(start)
wg.Wait()
rec, rev := recordedEBSProvider(t, js)
winner := slices.Index(providers, rec.Provider)
require.GreaterOrEqual(t, winner, 0, "recorded provider %q is neither claimant's", rec.Provider)
assert.Equal(t, nodes[winner], rec.RecordedBy)
require.NoError(t, errs[winner])
assert.Equal(t, revs[winner], rev, "the key must still be the winner's Create")
var mismatch *EBSProviderMismatchError
require.ErrorAs(t, errs[1-winner], &mismatch, "loser must be refused")
assert.Equal(t, rec.Provider, mismatch.Recorded.Provider)
}
}
// A permanent refusal returns on the first attempt rather than being retried
// for the startup budget.
func TestInitServiceWithRetry_PermanentErrorNotRetried(t *testing.T) {
stubInitRetrySleep(t)
calls := 0
_, err := initServiceWithRetry("svc", func() (int, error) {
calls++
if calls > 1 {
t.Fatal("a permanent error was retried")
}
return 0, fmt.Errorf("refused: %w", errStartupPermanent)
})
require.ErrorIs(t, err, errStartupPermanent)
assert.Equal(t, 1, calls)
}
// An unset provider resolves to viperblockd for the check, and the daemon
// refuses on the first mismatch: a retry would need a sleep first.
func TestDaemonClaimEBSProvider_MismatchRefusesWithoutRetry(t *testing.T) {
ns, _, _ := testutil.StartTestJetStream(t)
prev := initRetrySleep
initRetrySleep = func(time.Duration) { t.Fatal("a mismatch was retried") }
t.Cleanup(func() { initRetrySleep = prev })
first := &Daemon{node: "node1", config: &config.Config{EBS: config.EBSConfig{Provider: config.EBSProviderQEMUNBD}}, jsManager: newLockManager(t, ns.ClientURL())}
require.NoError(t, first.claimEBSProvider())
second := &Daemon{node: "node2", config: &config.Config{}, jsManager: newLockManager(t, ns.ClientURL())}
err := second.claimEBSProvider()
var mismatch *EBSProviderMismatchError
require.ErrorAs(t, err, &mismatch)
assert.Equal(t, config.EBSProviderViperblockd, mismatch.Configured)
}
// A transient KV failure is retried until the bucket answers.
func TestDaemonClaimEBSProvider_RetriesUntilBucketOpens(t *testing.T) {
ns, _, js := testutil.StartTestJetStream(t)
m := newLockManager(t, ns.ClientURL())
bucket := m.clusterConfig
m.clusterConfig = nil
sleeps := 0
prev := initRetrySleep
initRetrySleep = func(time.Duration) {
sleeps++
m.clusterConfig = bucket
}
t.Cleanup(func() { initRetrySleep = prev })
d := &Daemon{node: "node1", config: &config.Config{}, jsManager: m}
require.NoError(t, d.claimEBSProvider())
assert.Equal(t, 1, sleeps)
rec, _ := recordedEBSProvider(t, js)
assert.Equal(t, config.EBSProviderViperblockd, rec.Provider)
}
// A daemon whose provider differs from the recorded one exits 1 before any
// cluster service is built or any instance restored.
func TestStartCluster_EBSProviderMismatchExits(t *testing.T) {
t.Setenv("SPINIFEX_REQUIRE_NATS", "")
ns, _, js := testutil.StartTestJetStream(t)
seed := newLockManager(t, ns.ClientURL())
_, err := seed.ClaimEBSProvider(t.Context(), "node-2", config.EBSProviderQEMUNBD)
require.NoError(t, err)
tmpDir := t.TempDir()
cfg := &config.ClusterConfig{
Node: "node-1",
Nodes: map[string]config.Config{
"node-1": {BaseDir: tmpDir, DataDir: tmpDir, NATS: config.NATSConfig{Host: ns.ClientURL()}},
},
}
d, err := NewDaemon(cfg)
require.NoError(t, err)
t.Cleanup(d.cancel)
exitCh := make(chan int, 1)
d.exitFunc = func(code int) { exitCh <- code }
err = d.startCluster()
var mismatch *EBSProviderMismatchError
require.ErrorAs(t, err, &mismatch)
assert.Equal(t, config.EBSProviderViperblockd, mismatch.Configured)
select {
case code := <-exitCh:
assert.Equal(t, 1, code)
default:
t.Fatal("a provider mismatch must request exit(1)")
}
assert.Nil(t, d.instanceService, "no cluster service may be built after the refusal")
assert.Nil(t, d.imageService)
assert.Nil(t, d.snapshotService)
assert.Nil(t, d.volumeService)
assert.Nil(t, d.vpcService)
assert.Nil(t, d.eksService)
assert.Zero(t, d.vmMgr.Count(), "no instance may be restored after the refusal")
rec, _ := recordedEBSProvider(t, js)
assert.Equal(t, config.EBSProviderQEMUNBD, rec.Provider)
}
+33 -5
View File
@@ -25,6 +25,9 @@ const (
InstanceStateBucket = "spinifex-instance-state"
// ClusterStateBucket is the name of the KV bucket for cluster state (heartbeats, shutdown markers, service maps).
ClusterStateBucket = clusterv1.ClusterStateBucket
// ClusterConfigBucket holds settings fixed when a cluster forms. Unlike
// ClusterStateBucket it has no TTL, so a key written once stays written.
ClusterConfigBucket = "spinifex-cluster-config"
// InstanceStatePrefix is the key prefix for the per-node running-set blobs
// the record space replaces. Nothing writes it after the cutover; the
// migration reads it and a rolled-back node still finds what it left.
@@ -53,6 +56,7 @@ const (
// on a bucket stamped 5 rather than reading the keys it no longer owns.
InstanceStateBucketVersion = 5
ClusterStateBucketVersion = 1
ClusterConfigBucketVersion = 1
TerminatedInstanceBucketVersion = 3
)
@@ -80,11 +84,12 @@ type JetStreamManager struct {
// The per-resource key space the three views above are moving onto. Each
// is a third view over a bucket one of them already holds, so the two
// spaces share a handle and a recovery driven through either repairs both.
records *kvstore.Store[vm.InstanceRecord] // i.<id>, instance-state bucket
termRecords *kvstore.Store[vm.InstanceRecord] // i.<id>, terminated bucket
clusterKV jetstream.KeyValue // spinifex-cluster-state
obs KVSyncObserver
running runningSetState
records *kvstore.Store[vm.InstanceRecord] // i.<id>, instance-state bucket
termRecords *kvstore.Store[vm.InstanceRecord] // i.<id>, terminated bucket
clusterKV jetstream.KeyValue // spinifex-cluster-state
clusterConfig *kvstore.Bucket // spinifex-cluster-config
obs KVSyncObserver
running runningSetState
}
// checkNodeStateVersion reports whether a node record read from KV is one this
@@ -204,6 +209,29 @@ func (m *JetStreamManager) InitClusterStateBucket() error {
return nil
}
// clusterConfigConfig describes the cluster-config bucket. RecreateIfMissing
// stays false: a lost lock is not reconstructible by recovery, only by the
// next daemon start, which claims it again.
func clusterConfigConfig() kvstore.Config {
return kvstore.Config{
Name: ClusterConfigBucket,
Description: "Spinifex cluster configuration fixed at formation (EBS provider)",
History: 1,
OnOpen: func(ctx context.Context, kv jetstream.KeyValue) error {
return migrate.DefaultRegistry.RunKV(ctx, ClusterConfigBucket, kv, ClusterConfigBucketVersion)
},
Missing: "cluster config KV bucket not initialized",
}
}
// InitClusterConfigBucket initializes the cluster-config KV bucket, creating it
// if it doesn't exist.
func (m *JetStreamManager) InitClusterConfigBucket() error {
m.clusterConfig = kvstore.NewBucket(m.js, clusterConfigConfig())
_, err := m.clusterConfig.KV(context.Background())
return err
}
// InitTerminatedInstanceBucket initializes the terminated-instances KV bucket with a 1-hour TTL.
// JetStream automatically purges keys after 1 hour, matching AWS behavior for terminated instances.
func (m *JetStreamManager) InitTerminatedInstanceBucket() error {
+3
View File
@@ -725,6 +725,9 @@ func launchService(config *config.ClusterConfig) error {
SQSWorkerPolling: sqsRelay.WorkerPresent,
SQSTenantAPIRegistered: func() bool { return sqsServed(composedServices) },
})
operatorAdminDeps = withPlatformCleaner(operatorAdminDeps, func() (accountlifecycle.PlatformCleaner, error) {
return platformCleaner(janitorCtx, &nodeConfig, natsConn, js, operatorAdminDeps.NodeIDs)
})
operatorControlPlane := operatoradmin.NewRegistration(operatorAdminDeps)
// Every gateway scans for account deletions whose holder died; the fenced
// takeover makes any number of them safe.
@@ -1,12 +1,16 @@
package awsgw
import (
"context"
"errors"
"go/ast"
"go/parser"
"go/token"
"testing"
"github.com/mulgadc/spinifex/spinifex/bootstrap/config"
"github.com/mulgadc/spinifex/spinifex/domains/ec2/accountcleanup"
"github.com/mulgadc/spinifex/spinifex/operator/accountlifecycle"
operatoradmin "github.com/mulgadc/spinifex/spinifex/operator/admin"
awsapi "github.com/mulgadc/spinifex/spinifex/domains/ecr/awsapi"
@@ -88,3 +92,40 @@ func TestAwsgwStartsTheDeletionResumer(t *testing.T) {
})
assert.True(t, started, "awsgw.go must call Deps.StartDeletionResumer")
}
type fixedCleaner struct{}
func (fixedCleaner) CleanupPlatform(context.Context, *accountcleanup.Request) (*accountcleanup.Result, error) {
return &accountcleanup.Result{}, nil
}
// A non-finalizing DeleteAccount cleans through the in-process owners awsgw
// binds. A cleanup that cannot be built must leave the field nil, which refuses
// the request, rather than bind something half-working.
func TestWithPlatformCleaner_BindsTheCleanupOrLeavesItNil(t *testing.T) {
bound := withPlatformCleaner(operatoradmin.Deps{}, func() (accountlifecycle.PlatformCleaner, error) { return fixedCleaner{}, nil })
assert.NotNil(t, bound.PlatformCleaner)
unbound := withPlatformCleaner(operatoradmin.Deps{}, func() (accountlifecycle.PlatformCleaner, error) {
return nil, errors.New("owner store unavailable")
})
assert.Nil(t, unbound.PlatformCleaner)
}
// The cleanup is bound where the operator deps are composed, or the exercise is
// refused on every gateway.
func TestAwsgwBindsThePlatformCleanup(t *testing.T) {
file, err := parser.ParseFile(token.NewFileSet(), "awsgw.go", nil, 0)
require.NoError(t, err)
var bound bool
ast.Inspect(file, func(n ast.Node) bool {
if call, ok := n.(*ast.CallExpr); ok {
if ident, ok := call.Fun.(*ast.Ident); ok && ident.Name == "withPlatformCleaner" {
bound = true
}
}
return true
})
assert.True(t, bound, "awsgw.go must call withPlatformCleaner")
}
@@ -0,0 +1,66 @@
package awsgw
import (
"context"
"fmt"
"log/slog"
"github.com/mulgadc/spinifex/spinifex/bootstrap/config"
"github.com/mulgadc/spinifex/spinifex/domains/ec2/accountcleanup"
ec2key "github.com/mulgadc/spinifex/spinifex/domains/ec2/key"
ec2launchtemplate "github.com/mulgadc/spinifex/spinifex/domains/ec2/launchtemplate"
ec2placementgroup "github.com/mulgadc/spinifex/spinifex/domains/ec2/placementgroup"
ec2spotinstance "github.com/mulgadc/spinifex/spinifex/domains/ec2/spotinstance"
ec2tags "github.com/mulgadc/spinifex/spinifex/domains/ec2/tags"
"github.com/mulgadc/spinifex/spinifex/operator/accountlifecycle"
operatoradmin "github.com/mulgadc/spinifex/spinifex/operator/admin"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
)
// platformCleaner builds the EC2 platform cleanup over the owners' own services
// in this process. It has no NATS subject, so the only caller is the operator
// admin DeleteAccount that holds it. Deleting a key pair, placement group or
// launch template through these owners clears its tag-index entry too.
func platformCleaner(ctx context.Context, node *config.Config, nc *nats.Conn, js jetstream.JetStream, nodeIDs []string) (accountlifecycle.PlatformCleaner, error) {
tagsKV, err := ec2tags.GetOrCreateTagsBucket(ctx, js)
if err != nil {
return nil, fmt.Errorf("tags bucket: %w", err)
}
tags := ec2tags.NewTagsServiceImpl(node, tagsKV)
keys := ec2key.NewKeyServiceImpl(node)
groups, err := ec2placementgroup.NewPlacementGroupServiceImplWithNATS(ctx, node, nc)
if err != nil {
return nil, fmt.Errorf("placement group service: %w", err)
}
templates, err := ec2launchtemplate.NewLaunchTemplateServiceImplWithNATS(ctx, node, nc)
if err != nil {
return nil, fmt.Errorf("launch template service: %w", err)
}
spot, err := ec2spotinstance.NewSpotInstanceServiceImplWithNATS(ctx, node, nc)
if err != nil {
return nil, fmt.Errorf("spot instance service: %w", err)
}
keys.SetCentralTagStore(tags)
groups.SetCentralTagStore(tags)
templates.SetCentralTagStore(tags)
return accountcleanup.New(keys, groups, templates, tags, accountcleanup.ReferenceReaders{
Instances: accountlifecycle.NewInstanceReferenceReader(nc, nodeIDs),
Spot: spot,
Templates: templates,
}), nil
}
// withPlatformCleaner binds the cleanup a non-finalizing DeleteAccount runs. When
// it cannot be built the field stays nil, which refuses that request instead of
// half-running it.
func withPlatformCleaner(d operatoradmin.Deps, build func() (accountlifecycle.PlatformCleaner, error)) operatoradmin.Deps {
cleaner, err := build()
if err != nil {
slog.Error("awsgw: EC2 platform cleanup unavailable", "err", err)
return d
}
d.PlatformCleaner = cleaner
return d
}
@@ -7,6 +7,8 @@ package qemunbdd_test
import (
"os"
"os/exec"
"strconv"
"strings"
"testing"
"time"
@@ -14,6 +16,8 @@ import (
"github.com/mulgadc/spinifex/spinifex/providers/ebs"
"github.com/mulgadc/spinifex/spinifex/providers/ebs/conformance"
"github.com/mulgadc/spinifex/spinifex/providers/ebs/natsserve"
"github.com/mulgadc/spinifex/spinifex/runtime/compute/nbd"
hostprocess "github.com/mulgadc/spinifex/spinifex/runtime/host/process"
"github.com/mulgadc/spinifex/spinifex/runtime/roles/qemunbdd"
"github.com/stretchr/testify/require"
)
@@ -58,9 +62,23 @@ func newQEMUProviderTB(t testing.TB) ebsprovider.EBSProvider {
return provider
}
// killQEMUExport stops the qemu-nbd serving an export behind the provider's
// back, through the pid file it writes beside the socket, as a crash would.
func killQEMUExport(t *testing.T, published *ebsprovider.PublishedVolume) {
t.Helper()
serverType, socketPath, _, _, err := nbd.ParseNBDURI(published.Export.NBD.NBDURI)
require.NoError(t, err)
require.Equal(t, "unix", serverType)
data, err := os.ReadFile(strings.TrimSuffix(socketPath, ".sock") + ".pid")
require.NoError(t, err)
pid, err := strconv.Atoi(strings.TrimSpace(string(data)))
require.NoError(t, err)
require.NoError(t, hostprocess.ForceKillProcess(pid, 5*time.Second))
}
func TestQEMUNBDProviderConformance(t *testing.T) {
requireQEMUTools(t)
conformance.RunSuite(t, newQEMUProvider)
conformance.RunSuiteWithConfig(t, newQEMUProvider, conformance.SuiteConfig{KillExport: killQEMUExport})
}
// TestQEMUNBDExternalNBDClient checks the half of the boundary our own Go
@@ -76,12 +94,12 @@ func TestQEMUNBDExternalNBDClient(t *testing.T) {
// in it, and a client that cannot tell the difference.
func TestQEMUNBDProviderNATSConformance(t *testing.T) {
requireQEMUTools(t)
conformance.RunSuite(t, func(t *testing.T) ebsprovider.EBSProvider {
conformance.RunSuiteWithConfig(t, func(t *testing.T) ebsprovider.EBSProvider {
t.Helper()
_, conn := testutil.StartTestNATS(t)
stop, err := natsserve.Serve(t.Context(), conn, newQEMUProvider(t), natsserve.Options{})
require.NoError(t, err)
t.Cleanup(stop)
return ebsprovider.NewNATSProvider(conn, 15*time.Second)
})
}, conformance.SuiteConfig{KillExport: killQEMUExport})
}
@@ -76,13 +76,15 @@ func TestLive_QEMUNBDLifecycle(t *testing.T) {
cleanupCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cleanupBestEffort(t, "unpublish restored volume", func() error {
return provider.UnpublishVolume(cleanupCtx, ebsprovider.UnpublishVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: restoredVolumeID, NodeID: nodeID})
_, err := provider.UnpublishVolume(cleanupCtx, ebsprovider.UnpublishVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: restoredVolumeID, NodeID: nodeID})
return err
})
cleanupBestEffort(t, "delete restored volume", func() error {
return provider.DeleteVolume(cleanupCtx, ebsprovider.DeleteVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: restoredVolumeID})
})
cleanupBestEffort(t, "unpublish volume", func() error {
return provider.UnpublishVolume(cleanupCtx, ebsprovider.UnpublishVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID, NodeID: nodeID})
_, err := provider.UnpublishVolume(cleanupCtx, ebsprovider.UnpublishVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID, NodeID: nodeID})
return err
})
cleanupBestEffort(t, "delete snapshot", func() error {
return provider.DeleteSnapshot(cleanupCtx, ebsprovider.DeleteSnapshotRequest{Versioned: ebsprovider.NewVersioned(), SnapshotID: snapshotID})
@@ -110,7 +112,8 @@ func TestLive_QEMUNBDLifecycle(t *testing.T) {
writePattern(t, sockPath, pattern, liveVolumeBytes)
readAndVerifyPattern(t, sockPath, pattern, liveVolumeBytes)
require.NoError(t, provider.UnpublishVolume(ctx, ebsprovider.UnpublishVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID, NodeID: nodeID}), "unpublish volume")
_, err = provider.UnpublishVolume(ctx, ebsprovider.UnpublishVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID, NodeID: nodeID})
require.NoError(t, err, "unpublish volume")
t.Logf("unpublished volume %s", volumeID)
snap, err := provider.CreateSnapshot(ctx, ebsprovider.CreateSnapshotRequest{Versioned: ebsprovider.NewVersioned(), SnapshotID: snapshotID, VolumeID: volumeID})
@@ -136,7 +139,8 @@ func TestLive_QEMUNBDLifecycle(t *testing.T) {
// snapshot and the restore into an independent volume.
readAndVerifyPattern(t, restoredSockPath, pattern, liveVolumeBytes)
require.NoError(t, provider.UnpublishVolume(ctx, ebsprovider.UnpublishVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: restoredVolumeID, NodeID: nodeID}), "unpublish restored volume")
_, err = provider.UnpublishVolume(ctx, ebsprovider.UnpublishVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: restoredVolumeID, NodeID: nodeID})
require.NoError(t, err, "unpublish restored volume")
t.Logf("unpublished restored volume %s", restoredVolumeID)
}
+87 -14
View File
@@ -14,9 +14,11 @@ import (
"strings"
"sync"
"syscall"
"time"
"github.com/mulgadc/spinifex/spinifex/providers/ebs"
"github.com/mulgadc/spinifex/spinifex/runtime/compute/nbd"
hostprocess "github.com/mulgadc/spinifex/spinifex/runtime/host/process"
)
// nbdSharedClients bounds how many simultaneous NBD connections qemu-nbd
@@ -69,8 +71,27 @@ type publication struct {
nodeID string
socketPath string
pid int
readOnly bool
}
// dead reports positive evidence that the export is no longer serving: its
// qemu-nbd has exited or its socket is gone. Without a PID nothing is known.
// A qemu-nbd whose socket is gone can still be writing for a connected client.
func (p *publication) dead() bool {
if p.pid <= 0 {
return false
}
if !hostprocess.ProcessAlive(p.pid) {
return true
}
_, err := os.Stat(p.socketPath)
return errors.Is(err, os.ErrNotExist)
}
// defaultStopWait bounds the wait for a qemu-nbd to exit after SIGTERM
// before a republish over its dead socket is refused.
const defaultStopWait = 10 * time.Second
// Provider is an EBSProvider backed by qcow2 files and qemu-nbd. One volume
// is one qcow2 file; a snapshot is an independent copy, not a chunk
// reference, so DeleteVolume never has to consult a snapshot the way viperblock does.
@@ -78,6 +99,9 @@ type Provider struct {
baseDir string
run runner
// stopWait overrides defaultStopWait. Zero means the default.
stopWait time.Duration
mu sync.Mutex
published map[string]*publication
volumeMeta map[string]volumeMeta
@@ -131,11 +155,11 @@ func (p *Provider) pidFilePath(volumeID string) string {
}
// checkVersion mirrors ebsprovider's supported schema window. qemunbdd's
// normal operations have the same representation in both versions; the NATS
// normal operations have the same representation in every version; the NATS
// server owns the version-specific PublishVolume wire conversion.
func checkVersion(version uint16) error {
if !ebsprovider.SupportsSchemaVersion(version) {
return fmt.Errorf("%w: got %d, supported versions are %d and %d", ebsprovider.ErrUnsupportedVersion, version, ebsprovider.LegacySchemaVersion, ebsprovider.SchemaVersion)
return fmt.Errorf("%w: got %d, %s", ebsprovider.ErrUnsupportedVersion, version, ebsprovider.SupportedVersionsText())
}
return nil
}
@@ -148,7 +172,11 @@ func (p *Provider) GetCapabilities(_ context.Context, req ebsprovider.GetCapabil
if err := checkVersion(req.SchemaVersion); err != nil {
return nil, err
}
return &ebsprovider.GetCapabilitiesResponse{Versioned: ebsprovider.NewVersioned(), Capabilities: capabilities}, nil
return &ebsprovider.GetCapabilitiesResponse{
Versioned: ebsprovider.ReplyVersioned(req.SchemaVersion),
Capabilities: capabilities,
ContractVersions: ebsprovider.SupportedSchemaVersions(),
}, nil
}
// qemuImgInfo is the subset of `qemu-img info --output=json` this provider
@@ -350,7 +378,7 @@ func (p *Provider) ListVolumes(_ context.Context, req ebsprovider.ListVolumesReq
return nil, fmt.Errorf("qemunbdd: read volumes directory: %w", err)
}
response := &ebsprovider.ListVolumesResponse{Versioned: ebsprovider.NewVersioned()}
response := &ebsprovider.ListVolumesResponse{Versioned: ebsprovider.ReplyVersioned(req.SchemaVersion)}
response.Volumes, response.NextToken = ebsprovider.Page(qcow2IDs(entries), req.StartingToken, int(req.PageSize()),
func(id string) ebsprovider.VolumeRef {
return ebsprovider.VolumeRef{ID: id, Handle: p.volumePath(id)}
@@ -527,7 +555,7 @@ func (p *Provider) ListSnapshots(_ context.Context, req ebsprovider.ListSnapshot
p.mu.Lock()
defer p.mu.Unlock()
response := &ebsprovider.ListSnapshotsResponse{Versioned: ebsprovider.NewVersioned()}
response := &ebsprovider.ListSnapshotsResponse{Versioned: ebsprovider.ReplyVersioned(req.SchemaVersion)}
response.Snapshots, response.NextToken = ebsprovider.Page(qcow2IDs(entries), req.StartingToken, int(req.PageSize()),
func(id string) ebsprovider.SnapshotRef {
return ebsprovider.SnapshotRef{
@@ -686,9 +714,21 @@ func (p *Provider) PublishVolume(ctx context.Context, req ebsprovider.PublishVol
}
if pub, ok := p.published[req.VolumeID]; ok {
if pub.nodeID != req.NodeID {
return nil, fmt.Errorf("%w: volume %s is published to %s", ebsprovider.ErrVolumeInUse, req.VolumeID, pub.nodeID)
return nil, fmt.Errorf("%w: volume %s is published to %s", ebsprovider.HeldElsewhereError(req.SchemaVersion), req.VolumeID, pub.nodeID)
}
// A dead export's entry is dropped and the volume exported again, so
// a republish after a crash never returns a socket nothing serves.
if pub.dead() {
if err := p.stopOrphanedExport(pub); err != nil {
return nil, fmt.Errorf("%w: volume %s: %w", ebsprovider.ErrUnavailable, req.VolumeID, err)
}
delete(p.published, req.VolumeID)
} else {
if pub.readOnly != req.ReadOnly {
return nil, fmt.Errorf("%w: volume %s is already published read_only=%t", ebsprovider.ErrVolumeInUse, req.VolumeID, pub.readOnly)
}
return &ebsprovider.PublishedVolume{VolumeID: req.VolumeID, NodeID: pub.nodeID, Export: ebsprovider.NewNBDExport(nbd.FormatNBDSocketURI(pub.socketPath))}, nil
}
return &ebsprovider.PublishedVolume{VolumeID: req.VolumeID, NodeID: pub.nodeID, Export: ebsprovider.NewNBDExport(nbd.FormatNBDSocketURI(pub.socketPath))}, nil
}
sockPath := p.socketPath(req.VolumeID)
@@ -717,10 +757,27 @@ func (p *Provider) PublishVolume(ctx context.Context, req ebsprovider.PublishVol
}
pid := readPID(pidPath)
p.published[req.VolumeID] = &publication{nodeID: req.NodeID, socketPath: sockPath, pid: pid}
p.published[req.VolumeID] = &publication{nodeID: req.NodeID, socketPath: sockPath, pid: pid, readOnly: req.ReadOnly}
return &ebsprovider.PublishedVolume{VolumeID: req.VolumeID, NodeID: req.NodeID, Export: ebsprovider.NewNBDExport(nbd.FormatNBDSocketURI(sockPath))}, nil
}
// stopOrphanedExport stops a dead entry's qemu-nbd if it is still running,
// since a connected client keeps writing through it and a second export would
// make two writers. It stops it as UnpublishVolume does, with SIGTERM.
func (p *Provider) stopOrphanedExport(pub *publication) error {
if !hostprocess.ProcessAlive(pub.pid) {
return nil
}
wait := p.stopWait
if wait == 0 {
wait = defaultStopWait
}
if !hostprocess.TerminateProcess(pub.pid, wait) {
return fmt.Errorf("qemu-nbd %d lost its socket and did not exit", pub.pid)
}
return nil
}
// readPID best-effort reads the PID qemu-nbd's --fork wrote to pidPath. A
// missing or unparsable file (as in tests, where no real qemu-nbd runs)
// yields 0, meaning UnpublishVolume will skip signaling a process.
@@ -736,20 +793,23 @@ func readPID(pidPath string) int {
return pid
}
func (p *Provider) UnpublishVolume(_ context.Context, req ebsprovider.UnpublishVolumeRequest) error {
if err := checkVersion(req.SchemaVersion); err != nil {
return err
// UnpublishVolume honours NoSeal trivially: qemu-nbd writes straight to the
// volume's qcow2 file, so there is no separate copy to seal or to drop.
func (p *Provider) UnpublishVolume(_ context.Context, req ebsprovider.UnpublishVolumeRequest) (*ebsprovider.UnpublishedVolume, error) {
if err := ebsprovider.CheckUnpublishVersion(req); err != nil {
return nil, err
}
if req.VolumeID == "" || req.NodeID == "" {
return fmt.Errorf("%w: volume and node IDs are required", ebsprovider.ErrInvalidArgument)
return nil, fmt.Errorf("%w: volume and node IDs are required", ebsprovider.ErrInvalidArgument)
}
p.mu.Lock()
defer p.mu.Unlock()
result := &ebsprovider.UnpublishedVolume{}
pub, ok := p.published[req.VolumeID]
if !ok || pub.nodeID != req.NodeID {
return nil
return result, nil
}
if pub.pid > 0 {
if proc, err := os.FindProcess(pub.pid); err == nil {
@@ -759,5 +819,18 @@ func (p *Provider) UnpublishVolume(_ context.Context, req ebsprovider.UnpublishV
_ = os.Remove(pub.socketPath)
_ = os.Remove(p.pidFilePath(req.VolumeID))
delete(p.published, req.VolumeID)
return nil
result.TornDown = req.SchemaVersion >= ebsprovider.NoSealSchemaVersion
return result, nil
}
// HoldsUnsealedWrites answers that none are held: the qcow2 file qemu-nbd
// writes is the backend, so no write is held anywhere it does not already have.
func (p *Provider) HoldsUnsealedWrites(_ context.Context, nodeID string, volumeIDs []string) ([]string, error) {
if nodeID == "" {
return nil, fmt.Errorf("%w: node ID is required", ebsprovider.ErrInvalidArgument)
}
if slices.Contains(volumeIDs, "") {
return nil, fmt.Errorf("%w: empty volume ID", ebsprovider.ErrInvalidArgument)
}
return nil, nil
}
@@ -3,13 +3,17 @@ package qemunbdd
import (
"context"
"os"
"os/exec"
"path/filepath"
"sync"
"testing"
"time"
"github.com/mulgadc/spinifex/spinifex/providers/ebs"
hostprocess "github.com/mulgadc/spinifex/spinifex/runtime/host/process"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/sys/unix"
)
// call records one invocation the fake runner captured, so tests can assert
@@ -621,14 +625,14 @@ func TestUnpublishVolume_Success(t *testing.T) {
_, err := p.PublishVolume(context.Background(), ebsprovider.PublishVolumeRequest{Versioned: versioned(), VolumeID: "vol-unpub", NodeID: "node-1"})
require.NoError(t, err)
require.NoError(t, p.UnpublishVolume(context.Background(), ebsprovider.UnpublishVolumeRequest{Versioned: versioned(), VolumeID: "vol-unpub", NodeID: "node-1"}))
require.NoError(t, unpublishErr(p.UnpublishVolume(context.Background(), ebsprovider.UnpublishVolumeRequest{Versioned: versioned(), VolumeID: "vol-unpub", NodeID: "node-1"})))
_, err = os.Stat(p.socketPath("vol-unpub"))
require.True(t, os.IsNotExist(err))
}
func TestUnpublishVolume_AbsentIsIdempotent(t *testing.T) {
p, _ := newTestProvider(t)
require.NoError(t, p.UnpublishVolume(context.Background(), ebsprovider.UnpublishVolumeRequest{Versioned: versioned(), VolumeID: "vol-never-existed", NodeID: "node-1"}))
require.NoError(t, unpublishErr(p.UnpublishVolume(context.Background(), ebsprovider.UnpublishVolumeRequest{Versioned: versioned(), VolumeID: "vol-never-existed", NodeID: "node-1"})))
}
func TestUnpublishVolume_RepeatIsIdempotent(t *testing.T) {
@@ -638,8 +642,8 @@ func TestUnpublishVolume_RepeatIsIdempotent(t *testing.T) {
_, err := p.PublishVolume(context.Background(), ebsprovider.PublishVolumeRequest{Versioned: versioned(), VolumeID: "vol-unpub-repeat", NodeID: "node-1"})
require.NoError(t, err)
require.NoError(t, p.UnpublishVolume(context.Background(), ebsprovider.UnpublishVolumeRequest{Versioned: versioned(), VolumeID: "vol-unpub-repeat", NodeID: "node-1"}))
require.NoError(t, p.UnpublishVolume(context.Background(), ebsprovider.UnpublishVolumeRequest{Versioned: versioned(), VolumeID: "vol-unpub-repeat", NodeID: "node-1"}))
require.NoError(t, unpublishErr(p.UnpublishVolume(context.Background(), ebsprovider.UnpublishVolumeRequest{Versioned: versioned(), VolumeID: "vol-unpub-repeat", NodeID: "node-1"})))
require.NoError(t, unpublishErr(p.UnpublishVolume(context.Background(), ebsprovider.UnpublishVolumeRequest{Versioned: versioned(), VolumeID: "vol-unpub-repeat", NodeID: "node-1"})))
// Republishing after unpublish must start a fresh qemu-nbd.
_, err = p.PublishVolume(context.Background(), ebsprovider.PublishVolumeRequest{Versioned: versioned(), VolumeID: "vol-unpub-repeat", NodeID: "node-1"})
@@ -666,3 +670,79 @@ func TestGetCapabilities_Fixed(t *testing.T) {
assert.False(t, resp.Capabilities.OnlineExpansion)
assert.False(t, resp.Capabilities.OwnerRouting)
}
func unpublishErr(_ *ebsprovider.UnpublishedVolume, err error) error { return err }
// runningExport starts a process standing in for a qemu-nbd that lost its
// socket but kept running, reaped on its own goroutine as a real fork would be.
func runningExport(t *testing.T) int {
t.Helper()
hostprocess.ShortenProcessExitPollForTest(t, 5*time.Millisecond)
cmd := exec.Command("sleep", "300")
require.NoError(t, cmd.Start())
reaped := make(chan struct{})
go func() { defer close(reaped); _ = cmd.Wait() }()
t.Cleanup(func() { _ = cmd.Process.Kill(); <-reaped })
return cmd.Process.Pid
}
// unstoppableExport returns a zombie: kill(pid,0) still reaches it and no
// signal makes it exit, so it stands in for a qemu-nbd that will not stop.
func unstoppableExport(t *testing.T) int {
t.Helper()
hostprocess.ShortenProcessExitPollForTest(t, 5*time.Millisecond)
cmd := exec.Command("sleep", "300")
require.NoError(t, cmd.Start())
require.NoError(t, cmd.Process.Kill())
var info unix.Siginfo
require.NoError(t, unix.Waitid(unix.P_PID, cmd.Process.Pid, &info, unix.WEXITED|unix.WNOWAIT, nil))
t.Cleanup(func() { _ = cmd.Wait() })
return cmd.Process.Pid
}
// aliveAtRun records, at the moment qemu-nbd is run, whether pid is alive.
type aliveAtRun struct {
*fakeRunner
pid int
aliveSeen []bool
}
func (r *aliveAtRun) Run(ctx context.Context, name string, args ...string) ([]byte, error) {
if name == "qemu-nbd" {
r.aliveSeen = append(r.aliveSeen, hostprocess.ProcessAlive(r.pid))
}
return r.fakeRunner.Run(ctx, name, args...)
}
// A qemu-nbd whose socket is gone still serves a client already connected to
// it, so re-exporting beside it makes two writers on one qcow2.
func TestPublishVolume_OrphanStoppedFirst(t *testing.T) {
p, fr := newTestProvider(t)
const volumeID = "vol-orph"
require.NoError(t, os.WriteFile(p.volumePath(volumeID), nil, 0o640))
pid := runningExport(t)
run := &aliveAtRun{fakeRunner: fr, pid: pid}
p.run = run
p.published[volumeID] = &publication{nodeID: "node-1", socketPath: p.socketPath(volumeID), pid: pid}
_, err := p.PublishVolume(context.Background(), ebsprovider.PublishVolumeRequest{Versioned: versioned(), VolumeID: volumeID, NodeID: "node-1"})
require.NoError(t, err)
assert.Equal(t, []bool{false}, run.aliveSeen, "the old qemu-nbd must be gone before the volume is exported again")
}
// A qemu-nbd that will not stop is still a writer, so a second export is
// refused as retry-later and the entry kept so the next attempt tries again.
func TestPublishVolume_OrphanUnstoppable(t *testing.T) {
p, fr := newTestProvider(t)
p.stopWait = 50 * time.Millisecond
const volumeID = "vol-orphan-stuck"
require.NoError(t, os.WriteFile(p.volumePath(volumeID), nil, 0o640))
pub := &publication{nodeID: "node-1", socketPath: p.socketPath(volumeID), pid: unstoppableExport(t)}
p.published[volumeID] = pub
_, err := p.PublishVolume(context.Background(), ebsprovider.PublishVolumeRequest{Versioned: versioned(), VolumeID: volumeID, NodeID: "node-1"})
require.ErrorIs(t, err, ebsprovider.ErrUnavailable)
assert.Same(t, pub, p.published[volumeID], "the entry must stay while its process may still write")
assert.Empty(t, fr.callsSnapshot(), "no second qemu-nbd may start beside one that would not stop")
}
-16
View File
@@ -4,7 +4,6 @@ package harness
import (
"crypto/tls"
"crypto/x509"
"fmt"
"net/http"
"os"
@@ -151,18 +150,3 @@ func newAWSClient(t *testing.T, env *Env, accessKey, secretKey, sessionToken str
BedrockRuntime: bedrockruntime.New(sess),
}
}
// IgnoreCertErrors disables TLS verification on all bundled clients.
// Use only for fault-injection scenarios.
func (c *AWSClient) IgnoreCertErrors() {
tr := &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}
hc := &http.Client{Transport: tr}
c.EC2Conf.Config.HTTPClient = hc
c.ELBv2.Config.HTTPClient = hc
c.EKS.Config.HTTPClient = hc
c.IAM.Config.HTTPClient = hc
c.STS.Config.HTTPClient = hc
c.ACM.Config.HTTPClient = hc
c.ECR.Config.HTTPClient = hc
_ = (*x509.CertPool)(nil) // silence unused-import on hardened paths
}
-12
View File
@@ -78,18 +78,6 @@ func LsblkRootGiB(t *testing.T, tgt SSHTarget) int {
return int(bytesN / (1 << 30))
}
// GetSerialConsoleAccessEnabled returns the current account attribute value.
// Wraps ec2.GetSerialConsoleAccessStatus so callers don't have to deal with
// the pointer-bool field.
func GetSerialConsoleAccessEnabled(t *testing.T, c *AWSClient) bool {
t.Helper()
out, err := c.EC2.GetSerialConsoleAccessStatus(&ec2.GetSerialConsoleAccessStatusInput{})
if err != nil {
t.Fatalf("GetSerialConsoleAccessStatus: %v", err)
}
return aws.BoolValue(out.SerialConsoleAccessEnabled)
}
// EnableSerialConsoleAccess flips the account attribute on and returns the
// new value (should be true).
func EnableSerialConsoleAccess(t *testing.T, c *AWSClient) bool {
-55
View File
@@ -83,58 +83,3 @@ func IAMDeleteRoleAndProfilesBestEffort(c *AWSClient, roleName string, profileNa
}
_, _ = c.IAM.DeleteRole(&iam.DeleteRoleInput{RoleName: aws.String(roleName)})
}
// IAMDeleteGroupBestEffort tears down a group: removes any members, detaches all
// attached policies, deletes any inline policies, then deletes the group. Each
// step swallows errors so a missing fragment never cascades; usable both as a
// pre-test sweep and as a fixture-teardown cleanup. Order matters: a group cannot
// be deleted while it still has members, attached policies, or inline policies.
func IAMDeleteGroupBestEffort(c *AWSClient, groupName string, memberUsers []string, policyARNs ...string) {
for _, u := range memberUsers {
_, _ = c.IAM.RemoveUserFromGroup(&iam.RemoveUserFromGroupInput{
GroupName: aws.String(groupName),
UserName: aws.String(u),
})
}
for _, arn := range policyARNs {
_, _ = c.IAM.DetachGroupPolicy(&iam.DetachGroupPolicyInput{
GroupName: aws.String(groupName),
PolicyArn: aws.String(arn),
})
}
// Defensive: pull any other attached policies so the final DeleteGroup isn't
// blocked by a stray attach from a partial run.
if attached, err := c.IAM.ListAttachedGroupPolicies(&iam.ListAttachedGroupPoliciesInput{
GroupName: aws.String(groupName),
}); err == nil {
for _, p := range attached.AttachedPolicies {
_, _ = c.IAM.DetachGroupPolicy(&iam.DetachGroupPolicyInput{
GroupName: aws.String(groupName),
PolicyArn: p.PolicyArn,
})
}
}
// Defensive: delete any inline policies so the inline-policy guard can't block
// the final DeleteGroup after a partial run.
if inline, err := c.IAM.ListGroupPolicies(&iam.ListGroupPoliciesInput{
GroupName: aws.String(groupName),
}); err == nil {
for _, name := range inline.PolicyNames {
_, _ = c.IAM.DeleteGroupPolicy(&iam.DeleteGroupPolicyInput{
GroupName: aws.String(groupName),
PolicyName: name,
})
}
}
// Defensive: drop any members the caller didn't pass in (partial run could
// have added one), so the non-empty guard can't block the delete.
if got, err := c.IAM.GetGroup(&iam.GetGroupInput{GroupName: aws.String(groupName)}); err == nil {
for _, u := range got.Users {
_, _ = c.IAM.RemoveUserFromGroup(&iam.RemoveUserFromGroupInput{
GroupName: aws.String(groupName),
UserName: u.UserName,
})
}
}
_, _ = c.IAM.DeleteGroup(&iam.DeleteGroupInput{GroupName: aws.String(groupName)})
}
-81
View File
@@ -4,11 +4,8 @@ package harness
import (
"bytes"
"encoding/json"
"fmt"
"os"
"os/exec"
"strings"
"testing"
"time"
)
@@ -65,84 +62,6 @@ func (k *Kubectl) Run(timeout time.Duration, args ...string) (string, error) {
}
}
// AuthDiagnostics reproduces the credential exchange behind a failing call
// without mutating anything. `Unauthorized` on its own says only that the
// apiserver refused the request, and the two explanations — a token it rejected
// versus one the exec plugin never produced — need different fixes.
//
// It probes an authenticated path rather than `/version`, which is anonymously
// readable and so returns 200 under either explanation, and it invokes the
// credential plugin directly so a plugin that failed is named rather than
// inferred from a missing header.
func (k *Kubectl) AuthDiagnostics(timeout time.Duration) string {
var b strings.Builder
b.WriteString("=== kubectl auth whoami ===\n")
out, err := k.Run(timeout, "auth", "whoami")
b.WriteString(out)
if err != nil {
b.WriteString("\nerror: " + err.Error() + "\n")
}
// kube-system is not anonymously readable, so a 200 here proves a credential
// was both produced and accepted.
b.WriteString("\n=== GET /api/v1/namespaces/kube-system ===\n")
out, err = k.Run(timeout, "--v=8", "get", "--raw", "/api/v1/namespaces/kube-system")
b.WriteString(out)
if err != nil {
b.WriteString("\nerror: " + err.Error() + "\n")
}
b.WriteString("\n=== credential plugin, invoked directly ===\n")
b.WriteString(k.execPluginProbe(timeout))
return b.String()
}
// execPluginProbe runs the kubeconfig's own exec credential plugin and reports
// whether it produced a token. The token itself is never printed — CI logs are
// retained for weeks and it is a live cluster credential.
func (k *Kubectl) execPluginProbe(timeout time.Duration) string {
spec, err := k.Run(timeout, "config", "view", "--raw", "-o",
"jsonpath={.users[0].user.exec.command}{range .users[0].user.exec.args[*]} {@}{end}")
if err != nil {
return "could not read exec block from kubeconfig: " + err.Error() + "\n" + spec
}
argv := strings.Fields(strings.TrimSpace(spec))
if len(argv) == 0 {
return "kubeconfig user has no exec credential plugin\n"
}
bin, err := exec.LookPath(argv[0])
if err != nil {
return "exec plugin " + argv[0] + " not on PATH: " + err.Error() + "\n"
}
cmd := exec.Command(bin, argv[1:]...)
cmd.Env = append(os.Environ(), k.AWSEnv...)
var stdout, stderr bytes.Buffer
cmd.Stdout, cmd.Stderr = &stdout, &stderr
var b strings.Builder
fmt.Fprintf(&b, "argv: %s\n", strings.Join(argv, " "))
runErr := runWithTimeout(cmd, timeout)
fmt.Fprintf(&b, "exit: %v\n", runErr)
fmt.Fprintf(&b, "stderr: %s\n", strings.TrimSpace(stderr.String()))
var cred struct {
Status struct {
Token string `json:"token"`
ExpirationTimestamp string `json:"expirationTimestamp"`
} `json:"status"`
}
if jsonErr := json.Unmarshal(stdout.Bytes(), &cred); jsonErr != nil {
fmt.Fprintf(&b, "stdout did not parse as an ExecCredential: %v (%d bytes)\n",
jsonErr, stdout.Len())
return b.String()
}
fmt.Fprintf(&b, "token produced: %t (%d chars), expires: %q\n",
cred.Status.Token != "", len(cred.Status.Token), cred.Status.ExpirationTimestamp)
return b.String()
}
type timeoutError struct{}
func (*timeoutError) Error() string { return "kubectl timed out" }
-31
View File
@@ -151,34 +151,3 @@ func WaitForSnapshotState(t *testing.T, c *AWSClient, id, target string, opts ..
t.Logf("snapshot %s reached state %s", id, target)
return last
}
// WaitForImageState polls DescribeImages until State == target. AMI creation
// from a snapshot is the slowest path; default timeout 10min.
func WaitForImageState(t *testing.T, c *AWSClient, id, target string, opts ...PollOpt) *ec2.Image {
t.Helper()
cfg := applyOpts(pollCfg{timeout: 10 * time.Minute, interval: 2 * time.Second}, opts...)
var last *ec2.Image
var lastState string
EventuallyErr(t, func() error {
out, err := c.EC2.DescribeImages(&ec2.DescribeImagesInput{
ImageIds: []*string{aws.String(id)},
})
if err != nil {
return fmt.Errorf("describe-image %s: %w", id, err)
}
if len(out.Images) == 0 {
return fmt.Errorf("%s not found", id)
}
last = out.Images[0]
lastState = aws.StringValue(last.State)
if lastState == target {
return nil
}
if lastState == "failed" {
return fmt.Errorf("%s entered failed state: %s", id, aws.StringValue(last.StateReason.Message))
}
return fmt.Errorf("%s state=%s want=%s", id, lastState, target)
}, cfg.timeout, cfg.interval)
t.Logf("image %s reached state %s", id, target)
return last
}
-21
View File
@@ -121,27 +121,6 @@ func WaitForDBInstanceGone(t *testing.T, c *AWSClient, id string, opts ...PollOp
t.Logf("DB instance %s is gone", id)
}
// WaitForDBSnapshotAvailable polls until the snapshot reports available, which
// is the point the data exists and a restore will accept it.
func WaitForDBSnapshotAvailable(t *testing.T, c *AWSClient, id string, opts ...PollOpt) *rds.DBSnapshot {
t.Helper()
cfg := applyOpts(pollCfg{timeout: 10 * time.Minute, interval: 5 * time.Second}, opts...)
var last *rds.DBSnapshot
EventuallyErr(t, func() error {
snapshot, err := DescribeDBSnapshot(c, id)
if err != nil {
return fmt.Errorf("describe-db-snapshots %s: %w", id, err)
}
last = snapshot
if status := aws.StringValue(snapshot.Status); status != DBSnapshotAvailable {
return fmt.Errorf("%s status=%s want=%s", id, status, DBSnapshotAvailable)
}
return nil
}, cfg.timeout, cfg.interval)
t.Logf("DB snapshot %s reached status %s", id, DBSnapshotAvailable)
return last
}
// DescribeDBInstance returns the single named DB instance. A named instance that
// does not exist is an error from the API, not an empty list.
func DescribeDBInstance(c *AWSClient, id string) (*rds.DBInstance, error) {
-32
View File
@@ -83,38 +83,6 @@ func TCPRoundRobin(host string, port int, n int, timeout time.Duration) TrafficR
return r
}
// UDPRoundRobin sends n UDP datagrams to host:port, reads the echoed line per
// datagram, and counts responders. Matches the app-userdata UDP echo responder.
// UDP is connectionless, so each probe writes a payload then reads with a
// deadline; a dropped datagram counts as a failure rather than blocking.
func UDPRoundRobin(host string, port int, n int, timeout time.Duration) TrafficResult {
r := TrafficResult{Distribution: map[string]int{}, Total: n}
for i := 0; i < n; i++ {
conn, err := net.DialTimeout("udp", fmt.Sprintf("%s:%d", host, port), timeout)
if err != nil {
continue
}
if _, err := conn.Write([]byte("ping")); err != nil {
conn.Close()
continue
}
conn.SetReadDeadline(time.Now().Add(timeout))
buf := make([]byte, 256)
nb, err := conn.Read(buf)
conn.Close()
if err != nil {
continue
}
resp := strings.TrimSpace(string(buf[:nb]))
if resp == "" {
continue
}
r.Distribution[resp]++
r.Successful++
}
return r
}
// AssertRoundRobin asserts that r has at least minUnique distinct responders
// and at least minSuccess successful probes. Logs the distribution either way.
func AssertRoundRobin(t *testing.T, r TrafficResult, minUnique, minSuccess int, label string) {
+7 -4
View File
@@ -7,15 +7,16 @@ import (
"github.com/mulgadc/spinifex/spinifex/domains/ecr"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
"github.com/stretchr/testify/require"
)
// StartECRDaemonLite subscribes a real ecr.MetaServiceImpl (the same
// production service the live daemon runs, see runtime/daemon/daemon.go)
// to every ecr.* NATS subject the gateway's ECR control-plane handlers and
// ECRRegistry.Meta dial for metadata — repos, policies, lifecycle policies,
// tags, manifest records, and in-progress upload state. It is memory-backed
// (ecr.NewMemoryMetaStore) rather than JetStream-KV-backed, matching
// DaemonLite's in-scope resources.
// tags, manifest records, and in-progress upload state. It is backed by
// JetStream KV on the test's isolated NATS account, like the live daemon.
//
// Blob and manifest bytes never travel these subjects (ECRRegistry.Store
// handles those directly against its own objectstore.ObjectStore, wired in
@@ -28,8 +29,10 @@ import (
func StartECRDaemonLite(t *testing.T, gw *Gateway) *ecr.MetaServiceImpl {
t.Helper()
svc := ecr.NewMetaServiceImpl(ecr.NewMemoryMetaStore())
nc := gw.NATSConn
js, err := jetstream.New(nc)
require.NoError(t, err, "jetstream handle for the ECR meta store")
svc := ecr.NewKVMetaService(js)
sub(t, nc, ecr.SubjectRepoCreate, func(m *nats.Msg) { dispatch(m, svc.RepoCreate) })
sub(t, nc, ecr.SubjectRepoDescribe, func(m *nats.Msg) { dispatch(m, svc.RepoDescribe) })
+16 -5
View File
@@ -59,11 +59,9 @@ func startLambdaDaemonLite(t *testing.T, nc *nats.Conn) {
sub(t, nc, lambdafunction.SubjectRollbackCreate, func(m *nats.Msg) { dispatch(m, svc.RollbackCreate) })
}
// TestLambda_SignedCreateAndGetDownloadsTheStoredCode drives CreateFunction and
// GetFunction through signed ingress, the NATS-owned function record and the
// real code store on the Predastore fixture, then downloads Code.Location.
// The fixture authenticates only its service key, so that key stands in for
// the scoped code-store session; provisioning and session scoping are not here.
// Signed Create and the Get reads over NATS metadata and the Predastore
// fixture's code store, then downloads Code.Location. The fixture's service
// key stands in for the scoped code-store session, which is untested.
func TestLambda_SignedCreateAndGetDownloadsTheStoredCode(t *testing.T) {
fixture := testpredastore.Start(t)
bucket := codestore.BucketName(testRegion)
@@ -132,6 +130,19 @@ func TestLambda_SignedCreateAndGetDownloadsTheStoredCode(t *testing.T) {
require.Equal(t, "e2e", aws.StringValue(got.Configuration.Environment.Variables["MODE"]))
require.NotNil(t, got.Code.Location)
cfg, err := cli.GetFunctionConfiguration(&lambda.GetFunctionConfigurationInput{FunctionName: aws.String("e2e-fn")})
require.NoError(t, err)
require.Equal(t, got.Configuration.String(), cfg.String())
signing, err := cli.GetFunctionCodeSigningConfig(&lambda.GetFunctionCodeSigningConfigInput{FunctionName: aws.String("e2e-fn")})
require.NoError(t, err)
require.Nil(t, signing.CodeSigningConfigArn)
require.Nil(t, signing.FunctionName)
_, err = cli.GetFunctionCodeSigningConfig(&lambda.GetFunctionCodeSigningConfigInput{FunctionName: aws.String("e2e-missing")})
var aerr awserr.Error
require.ErrorAs(t, err, &aerr)
require.Equal(t, lambda.ErrCodeResourceNotFoundException, aerr.Code())
// SSL_CERT_FILE (set by the fixture) makes the default client trust it.
resp, err := http.Get(aws.StringValue(got.Code.Location)) //nolint:gosec,noctx // URL is the presigned location under test
require.NoError(t, err)