a73x

internal/server/api/volumes.go

Ref:   Size: 7.9 KiB   History

package api

import (
	"database/sql"
	"errors"
	"net/http"
	"regexp"
	"strconv"

	"github.com/a73x/eitri/internal/server/api/types"
	"github.com/a73x/eitri/internal/server/registry"
	"github.com/a73x/eitri/internal/server/store"
)

// claimName mirrors the VM name rule: DNS-label shaped, so a claim can be
// named on the command line without quoting.
var claimName = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`)

// maxClaimGB bounds a single claim; the host's disk is the real cap and
// capacity admission judges it.
const maxClaimGB = 4096

func claimToWire(c store.VolumeClaim, present *bool) types.VolumeClaim {
	status := "pending"
	if c.BoundVolumeID != "" {
		status = "bound"
	}
	return types.VolumeClaim{ID: c.ID, Name: c.Name, SizeGB: c.SizeGB, Status: status,
		HostID: c.HostID, VMID: c.VMID, Present: present, CreatedAt: c.CreatedAt}
}

// presence answers "is the file there" with what the claim's host is saying
// NOW, and nil whenever nothing is being said: an unbound claim (no file to
// look for), a host that is not reporting, or a report that does not name the
// volume. online is the caller's read of that host's registry entry.
//
// A host that has gone quiet says NOTHING rather than saying no — the same
// reading every sibling admission takes of silence (see refuseBelowFloor).
// Its last report is minutes old and the file it described may have been
// reclaimed since; answering false there would raise an alarm about a disk
// nobody has actually looked at.
func presence(hs registry.HostState, online bool, c store.VolumeClaim) *bool {
	if c.BoundVolumeID == "" || !online {
		return nil
	}
	for _, v := range hs.Report.Volumes {
		if v.VolumeID == c.BoundVolumeID {
			p := v.Present
			return &p
		}
	}
	return nil
}

// presenceOf is presence for one claim read on its own, taking the registry
// hit the list handler hoists out of its loop.
func (a *API) presenceOf(c store.VolumeClaim) *bool {
	if c.BoundVolumeID == "" {
		return nil
	}
	hs, ok := a.reg.Get(c.HostID)
	return presence(hs, ok && hs.Online, c)
}

func (a *API) handleCreateVolumeClaim(w http.ResponseWriter, r *http.Request) {
	var req types.CreateVolumeClaimRequest
	if !decodeJSON(w, r, &req) {
		return
	}
	if !claimName.MatchString(req.Name) {
		http.Error(w, "name must be 1-63 chars of [a-z0-9-], starting and ending alphanumeric", http.StatusBadRequest)
		return
	}
	if req.SizeGB < 1 || req.SizeGB > maxClaimGB {
		http.Error(w, "size_gb must be in [1, "+strconv.Itoa(maxClaimGB)+"]", http.StatusBadRequest)
		return
	}
	tenant := principalFromContext(r).Tenant
	c, err := a.st.CreateVolumeClaim(tenant, req.Name, req.SizeGB)
	if err != nil {
		if errors.Is(err, store.ErrClaimNameTaken) {
			http.Error(w, "name already in use", http.StatusConflict)
			return
		}
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}
	a.audit(tenant, "volume_claim.create", map[string]string{"claim_id": c.ID, "name": c.Name,
		"size_gb": strconv.FormatInt(c.SizeGB, 10)})
	a.notif.notify()
	// A fresh claim is Pending: it names no host, so there is nothing that
	// could have reported on it yet.
	writeJSON(w, http.StatusCreated, claimToWire(c, nil))
}

func (a *API) handleListVolumeClaims(w http.ResponseWriter, r *http.Request) {
	list, err := a.st.ListVolumeClaims(principalFromContext(r).Tenant)
	if err != nil {
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}
	// One registry read per HOST, not per claim. reg.Get deep-clones the
	// host's whole report, and claims cluster on hosts by construction — every
	// claim a VM was given is bound to that VM's host — so the per-claim read
	// this replaces cloned the same report once for each of them.
	type liveHost struct {
		state  registry.HostState
		online bool
	}
	hosts := make(map[string]liveHost)
	out := make([]types.VolumeClaim, 0, len(list))
	for _, c := range list {
		if c.BoundVolumeID == "" {
			out = append(out, claimToWire(c, nil))
			continue
		}
		h, seen := hosts[c.HostID]
		if !seen {
			hs, ok := a.reg.Get(c.HostID)
			h = liveHost{state: hs, online: ok && hs.Online}
			hosts[c.HostID] = h
		}
		out = append(out, claimToWire(c, presence(h.state, h.online, c)))
	}
	writeJSON(w, http.StatusOK, out)
}

// ownedClaim reads one claim the caller may act on. A foreign claim answers
// exactly like a missing one — existence is not leaked across tenants.
func (a *API) ownedClaim(w http.ResponseWriter, r *http.Request) (store.VolumeClaim, bool) {
	c, err := a.st.GetVolumeClaim(r.PathValue("id"))
	switch {
	case errors.Is(err, sql.ErrNoRows), err == nil && !mayActAs(principalFromContext(r), c.Tenant):
		http.Error(w, "not found", http.StatusNotFound)
		return store.VolumeClaim{}, false
	case err != nil:
		http.Error(w, "internal error", http.StatusInternalServerError)
		return store.VolumeClaim{}, false
	}
	return c, true
}

func (a *API) handleGetVolumeClaim(w http.ResponseWriter, r *http.Request) {
	c, ok := a.ownedClaim(w, r)
	if !ok {
		return
	}
	writeJSON(w, http.StatusOK, claimToWire(c, a.presenceOf(c)))
}

func (a *API) handleDeleteVolumeClaim(w http.ResponseWriter, r *http.Request) {
	c, ok := a.ownedClaim(w, r)
	if !ok {
		return
	}
	if err := a.st.TombstoneVolumeClaim(c.ID); err != nil {
		var attached *store.ClaimAttachedError
		switch {
		case errors.As(err, &attached):
			http.Error(w, "volume claim is attached to vm "+attached.VMID+"; delete that VM first", http.StatusConflict)
		case errors.Is(err, sql.ErrNoRows):
			http.Error(w, "not found", http.StatusNotFound)
		default:
			http.Error(w, "internal error", http.StatusInternalServerError)
		}
		return
	}
	a.audit(c.Tenant, "volume_claim.delete", map[string]string{"claim_id": c.ID, "name": c.Name, "volume_id": c.BoundVolumeID})
	// A bound claim's host has to be told; an unbound one has no host to tell.
	if c.HostID != "" {
		a.hub.Poke(c.HostID)
	}
	a.notif.notify()
	w.WriteHeader(http.StatusNoContent)
}

// resolveClaims turns a create's claim ids-or-names into ids, in order, and
// sums the sizes of the PENDING ones — the disk this create newly commits;
// a bound claim is already in the host's held total (see CommittedOnHost).
//
// It refuses a reference that names nothing, and one claim named twice. The
// second refusal is this layer's job rather than the store's: the store would
// see two attach attempts and answer "already attached to <this vm>", naming
// the VM being created as though a race had happened. A caller who wrote a
// name twice deserves to read that they wrote it twice.
//
// ID BEFORE NAME, in two maps rather than one. Nothing stops a tenant naming
// one claim after another's 32-hex id, and a single keyspace would let that
// name shadow the id it copies — silently attaching the wrong disk, with the
// later-created claim winning because it overwrites. An id is the unambiguous
// handle, so an id that resolves wins outright and no name can displace it.
//
// msg/code is "" / 0 when the create may proceed.
func (a *API) resolveClaims(tenant string, refs []string) (ids []string, pendingGB int64, msg string, code int, err error) {
	if len(refs) == 0 {
		return nil, 0, "", 0, nil
	}
	list, err := a.st.ListVolumeClaims(tenant)
	if err != nil {
		return nil, 0, "", 0, err
	}
	byID := make(map[string]store.VolumeClaim, len(list))
	byName := make(map[string]store.VolumeClaim, len(list))
	for _, c := range list {
		byID[c.ID] = c
		byName[c.Name] = c
	}
	seen := make(map[string]bool, len(refs))
	for _, ref := range refs {
		c, ok := byID[ref]
		if !ok {
			c, ok = byName[ref]
		}
		if !ok {
			return nil, 0, "unknown volume claim " + ref, http.StatusNotFound, nil
		}
		if seen[c.ID] {
			// By NAME, whichever way it was referenced: naming a claim once by
			// id and once by name is the way this mistake actually happens.
			return nil, 0, "volume claim " + c.Name + " named twice", http.StatusBadRequest, nil
		}
		seen[c.ID] = true
		ids = append(ids, c.ID)
		if c.BoundVolumeID == "" {
			pendingGB += c.SizeGB
		}
	}
	return ids, pendingGB, "", 0, nil
}