internal/server/api/volumes_test.go
Ref: Size: 12.6 KiB History
package api
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"sort"
"sync"
"testing"
"github.com/stretchr/testify/require"
"github.com/a73x/eitri/internal/server/registry"
"github.com/a73x/eitri/internal/server/release"
"github.com/a73x/eitri/internal/server/store"
)
func claimPost(t *testing.T, ts *httptest.Server, name string, size int64) map[string]any {
t.Helper()
resp := do(t, "POST", ts.URL+"/api/v1/volume-claims", testPAT, map[string]any{"name": name, "size_gb": size})
require.Equal(t, 201, resp.StatusCode)
var out map[string]any
require.NoError(t, json.NewDecoder(resp.Body).Decode(&out))
return out
}
func getClaim(t *testing.T, ts *httptest.Server, id string) (int, map[string]any) {
t.Helper()
resp := do(t, "GET", ts.URL+"/api/v1/volume-claims/"+id, testPAT, nil)
var out map[string]any
_ = json.NewDecoder(resp.Body).Decode(&out)
return resp.StatusCode, out
}
// floorVolumes makes the placeholder-free floor the tests drive against: the
// shipped Since is a tag no test host reports, so every test that wants a
// host ABOVE the floor names its own.
func floorVolumes(t *testing.T, since string) {
t.Helper()
prev := volumesFeature
volumesFeature = release.Feature{Name: "volumes", Since: since}
t.Cleanup(func() { volumesFeature = prev })
}
// onlineAt is the pair of facts that make a host judgeable: the version its
// agent named in the Hello, and a report, which is what makes it Online.
func onlineAt(reg *registry.Registry, hostID, v string) {
reg.SetAgentVersion(hostID, v)
reg.UpdateReport(hostID, registry.Report{})
}
func TestVolumeClaimCreateListGetDelete(t *testing.T) {
ts, _, _, _, _ := newServer(t)
c := claimPost(t, ts, "data", 5)
require.Equal(t, "pending", c["status"])
require.Nil(t, c["present"], "nothing has reported on a claim no host holds")
resp := do(t, "GET", ts.URL+"/api/v1/volume-claims", testPAT, nil)
require.Equal(t, 200, resp.StatusCode)
require.Len(t, decodeJSONKeys(t, resp), 1)
code, _ := getClaim(t, ts, c["id"].(string))
require.Equal(t, 200, code)
require.Equal(t, 204, do(t, "DELETE", ts.URL+"/api/v1/volume-claims/"+c["id"].(string), testPAT, nil).StatusCode)
code, _ = getClaim(t, ts, c["id"].(string))
require.Equal(t, 404, code)
}
func TestVolumeClaimValidation(t *testing.T) {
ts, _, _, _, _ := newServer(t)
for _, body := range []map[string]any{
{"name": "", "size_gb": 1}, {"name": "x", "size_gb": 0}, {"name": "x", "size_gb": -1},
{"name": "bad name", "size_gb": 1}, {"name": "x", "size_gb": 4097},
} {
require.Equal(t, 400, do(t, "POST", ts.URL+"/api/v1/volume-claims", testPAT, body).StatusCode, "%v", body)
}
claimPost(t, ts, "dup", 1)
require.Equal(t, 409, do(t, "POST", ts.URL+"/api/v1/volume-claims", testPAT, map[string]any{"name": "dup", "size_gb": 1}).StatusCode)
}
func TestVolumeClaimsAreTenantScoped(t *testing.T) {
ts, st, _, _, _ := newServer(t)
c := claimPost(t, ts, "data", 5)
other := patForOtherTenant(t, st)
id := c["id"].(string)
require.Equal(t, 404, do(t, "GET", ts.URL+"/api/v1/volume-claims/"+id, other, nil).StatusCode)
require.Equal(t, 404, do(t, "DELETE", ts.URL+"/api/v1/volume-claims/"+id, other, nil).StatusCode)
require.Len(t, decodeJSONKeys(t, do(t, "GET", ts.URL+"/api/v1/volume-claims", other, nil)), 0)
}
// A claim belonging to somebody else is not a claim this tenant can mount, on
// its own host or anywhere. resolveClaims reads the CALLER's claims only, so a
// foreign id resolves to nothing and answers exactly like a typo — existence
// is not leaked across tenants here any more than on the claim routes.
func TestCreateVMCannotNameAnotherTenantsClaim(t *testing.T) {
ts, st, _, reg, _ := newServer(t)
floorVolumes(t, "v0.0.7")
hostID := enroll(t, ts)["host_id"] // the CALLER's own host
onlineAt(reg, hostID, "v0.0.7")
other := patForOtherTenant(t, st)
resp := do(t, "POST", ts.URL+"/api/v1/volume-claims", other, map[string]any{"name": "theirs", "size_gb": 5})
require.Equal(t, 201, resp.StatusCode)
var theirs map[string]any
require.NoError(t, json.NewDecoder(resp.Body).Decode(&theirs))
theirID := theirs["id"].(string)
resp = do(t, "POST", ts.URL+"/api/v1/vms", testPAT, map[string]any{
"host_id": hostID, "name": "thief", "volume_claims": []string{theirID}})
require.Equal(t, 404, resp.StatusCode)
body, _ := io.ReadAll(resp.Body)
require.Contains(t, string(body), "unknown volume claim")
// By name too, and the name is one this tenant does not have either.
resp = do(t, "POST", ts.URL+"/api/v1/vms", testPAT, map[string]any{
"host_id": hostID, "name": "thief", "volume_claims": []string{"theirs"}})
require.Equal(t, 404, resp.StatusCode)
}
// A claim NAMED after another claim's id must not shadow it. Names are the
// tenant's to choose and ids are 32 lowercase hex — a legal name — so a single
// keyspace would let the later claim win the lookup and quietly attach the
// wrong disk. The id is the unambiguous handle and wins.
func TestCreateVMResolvesAClaimIDBeforeAName(t *testing.T) {
ts, _, _, reg, _ := newServer(t)
floorVolumes(t, "v0.0.7")
hostID := enroll(t, ts)["host_id"]
onlineAt(reg, hostID, "v0.0.7")
x := claimPost(t, ts, "alpha", 1)
xID := x["id"].(string)
// Created AFTER x, so in a single map its name would overwrite x's id.
y := claimPost(t, ts, xID, 2)
yID := y["id"].(string)
require.Equal(t, 201, do(t, "POST", ts.URL+"/api/v1/vms", testPAT, map[string]any{
"host_id": hostID, "name": "holder", "volume_claims": []string{xID}}).StatusCode)
_, gotX := getClaim(t, ts, xID)
require.Equal(t, "bound", gotX["status"], "the claim whose ID was named is the one attached")
require.NotEmpty(t, gotX["vm_id"])
_, gotY := getClaim(t, ts, yID)
require.Equal(t, "pending", gotY["status"], "the claim that merely borrowed that id as its name is untouched")
require.Equal(t, "", gotY["vm_id"])
}
// presence is the one field on a claim the control plane cannot decide for
// itself, so every way of knowing nothing has to read as null rather than as
// "the file is gone".
func TestPresenceSaysNothingUntilAHostDoes(t *testing.T) {
bound := store.VolumeClaim{ID: "c-1", HostID: "h-1", BoundVolumeID: "vol-1"}
reporting := registry.HostState{Report: registry.Report{
Volumes: []registry.VolumeStatus{{VolumeID: "vol-1", Present: true, SizeGB: 5}}}}
gone := registry.HostState{Report: registry.Report{
Volumes: []registry.VolumeStatus{{VolumeID: "vol-1", Present: false, SizeGB: 5}}}}
t.Run("unbound claim", func(t *testing.T) {
require.Nil(t, presence(reporting, true, store.VolumeClaim{ID: "c-2"}))
})
t.Run("host not reporting", func(t *testing.T) {
require.Nil(t, presence(reporting, false, bound),
"a host that has gone quiet says nothing; its last report may be minutes stale")
})
t.Run("report does not name the volume", func(t *testing.T) {
require.Nil(t, presence(registry.HostState{}, true, bound))
})
t.Run("host says it is there", func(t *testing.T) {
got := presence(reporting, true, bound)
require.NotNil(t, got)
require.True(t, *got)
})
t.Run("host says it is gone", func(t *testing.T) {
got := presence(gone, true, bound)
require.NotNil(t, got)
require.False(t, *got, "false is a real answer and must survive; only silence is null")
})
}
// Admission steps 1, 2, 3 and 6 of the spec, plus delete-while-attached.
func TestCreateVMWithVolumeClaims(t *testing.T) {
ts, st, _, reg, _ := newServer(t)
floorVolumes(t, "v0.0.7")
host := enroll(t, ts)
hostID := host["host_id"]
onlineAt(reg, hostID, "v0.0.7")
c := claimPost(t, ts, "data", 5)
cid := c["id"].(string)
create := func(host, name string, claims ...string) *http.Response {
return do(t, "POST", ts.URL+"/api/v1/vms", testPAT, map[string]any{"host_id": host, "name": name, "volume_claims": claims})
}
// 1. unknown claim → 404 naming it
resp := create(hostID, "a", "nope")
require.Equal(t, 404, resp.StatusCode)
body, _ := io.ReadAll(resp.Body)
require.Contains(t, string(body), "nope")
// happy path binds; a claim may be named by name as well as id
require.Equal(t, 201, create(hostID, "a", "data").StatusCode)
_, got := getClaim(t, ts, cid)
require.Equal(t, "bound", got["status"])
require.Equal(t, hostID, got["host_id"])
vmID := got["vm_id"].(string)
require.NotEmpty(t, vmID)
// 2. attached → 409 naming the holder
resp = create(hostID, "b", cid)
require.Equal(t, 409, resp.StatusCode)
body, _ = io.ReadAll(resp.Body)
require.Contains(t, string(body), vmID)
// delete while attached → 409
require.Equal(t, 409, do(t, "DELETE", ts.URL+"/api/v1/volume-claims/"+cid, testPAT, nil).StatusCode)
// 3. pinned to another host → 409 naming the host
require.Equal(t, 204, do(t, "DELETE", ts.URL+"/api/v1/vms/"+vmID, testPAT, nil).StatusCode)
require.NoError(t, st.HardDeleteVM(vmID, hostID))
h2 := enroll(t, ts)["host_id"]
onlineAt(reg, h2, "v0.0.7")
resp = create(h2, "c", cid)
require.Equal(t, 409, resp.StatusCode)
body, _ = io.ReadAll(resp.Body)
require.Contains(t, string(body), hostID)
// ... and on the pinned host it re-attaches.
require.Equal(t, 201, create(hostID, "c", cid).StatusCode)
}
// One claim named twice is refused before the store sees it: the store's own
// refusal would say the claim is attached to the VM being created, which reads
// as a race that never happened.
func TestCreateVMRefusesAClaimNamedTwice(t *testing.T) {
ts, _, _, reg, _ := newServer(t)
floorVolumes(t, "v0.0.7")
hostID := enroll(t, ts)["host_id"]
onlineAt(reg, hostID, "v0.0.7")
c := claimPost(t, ts, "data", 5)
resp := do(t, "POST", ts.URL+"/api/v1/vms", testPAT, map[string]any{
"host_id": hostID, "name": "double", "volume_claims": []string{"data", c["id"].(string)}})
require.Equal(t, 400, resp.StatusCode)
body, _ := io.ReadAll(resp.Body)
require.Contains(t, string(body), "volume claim data named twice",
"the refusal names the claim, not the VM the store would have blamed")
// And nothing was bound on the way to the refusal.
_, got := getClaim(t, ts, c["id"].(string))
require.Equal(t, "pending", got["status"])
}
// 4. The floor judges a silent host for a volume-bearing create only.
func TestCreateVMWithVolumeClaimsFloorsTheAgent(t *testing.T) {
ts, _, _, reg, _ := newServer(t)
floorVolumes(t, "v0.0.7")
hostID := enroll(t, ts)["host_id"]
claimPost(t, ts, "data", 5)
create := func(name string, claims ...string) *http.Response {
return do(t, "POST", ts.URL+"/api/v1/vms", testPAT, map[string]any{"host_id": hostID, "name": name, "volume_claims": claims})
}
resp := create("a", "data")
require.Equal(t, 409, resp.StatusCode, "a silent host is refused a volume-bearing create")
body, _ := io.ReadAll(resp.Body)
require.Contains(t, string(body), "volumes")
require.Contains(t, string(body), "upgrade-agent")
require.Equal(t, 201, create("plain").StatusCode, "a create with no claims is not floored")
onlineAt(reg, hostID, "v0.0.6")
require.Equal(t, 409, create("b", "data").StatusCode, "a pre-volumes agent is refused")
onlineAt(reg, hostID, "v0.0.7")
require.Equal(t, 201, create("b", "data").StatusCode)
}
// Two creates race for one pending claim: exactly one wins.
func TestTwoCreatesRaceForOneClaim(t *testing.T) {
ts, _, _, reg, _ := newServer(t)
floorVolumes(t, "v0.0.7")
hostID := enroll(t, ts)["host_id"]
onlineAt(reg, hostID, "v0.0.7")
claimPost(t, ts, "data", 1)
codes := make(chan int, 2)
var wg sync.WaitGroup
for _, name := range []string{"r1", "r2"} {
wg.Add(1)
go func() {
defer wg.Done()
codes <- do(t, "POST", ts.URL+"/api/v1/vms", testPAT, map[string]any{"host_id": hostID, "name": name, "volume_claims": []string{"data"}}).StatusCode
}()
}
wg.Wait()
close(codes)
var got []int
for c := range codes {
got = append(got, c)
}
sort.Ints(got)
require.Equal(t, []int{201, 409}, got)
}
// A claim its host has reported on carries that host's answer: present is the
// one field on a claim nothing in the control plane can decide for itself.
func TestVolumeClaimReportsWhatTheHostFound(t *testing.T) {
ts, st, _, reg, _ := newServer(t)
floorVolumes(t, "v0.0.7")
hostID := enroll(t, ts)["host_id"]
onlineAt(reg, hostID, "v0.0.7")
c := claimPost(t, ts, "data", 5)
cid := c["id"].(string)
require.Equal(t, 201, do(t, "POST", ts.URL+"/api/v1/vms", testPAT, map[string]any{
"host_id": hostID, "name": "holder", "volume_claims": []string{"data"}}).StatusCode)
// Bound, but the host has not named the volume yet: null, not false.
_, got := getClaim(t, ts, cid)
require.Nil(t, got["present"])
vols, err := st.ListVolumesForHost(hostID)
require.NoError(t, err)
require.Len(t, vols, 1)
reg.SetAgentVersion(hostID, "v0.0.7")
reg.UpdateReport(hostID, registry.Report{
Volumes: []registry.VolumeStatus{{VolumeID: vols[0].ID, Present: true, SizeGB: 5}}})
_, got = getClaim(t, ts, cid)
require.Equal(t, true, got["present"])
}