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
}