a73x

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"])
}