a73x

internal/server/api/types/types.go

Ref:   Size: 23.2 KiB   History

// Package types is the server HTTP API's wire contract: every request and
// response JSON shape the API speaks, and nothing else. It is a leaf — it
// imports only the standard library — so the contract can be consumed by the
// spec generator, the shared client, and the handlers without dragging in
// server internals. Enforced by an arch fitness test.
//
// One deliberate mirror exists outside this package:
// internal/agent/enrollclient types the enroll exchange itself, because the
// agent plane must not import server packages. docs/openapi.json (generated
// from these types) is the cross-check that the mirror stays honest.
package types

import (
	"encoding/json"
	"time"
)

// Capacity appears twice per Host: the host's TOTALS (capacity) and the amount
// committed to live VMs (allocated).
type Capacity struct {
	VCPUs  int64 `json:"vcpus"`
	MemMB  int64 `json:"mem_mb"`
	DiskGB int64 `json:"disk_gb"`
}

// Metrics is live MEASURED host utilization, nested in Host for
// GET /api/v1/hosts and the SSE snapshot — distinct from Capacity (totals)
// and the allocated bookkeeping.
type Metrics struct {
	UptimeS        int64   `json:"uptime_s"`
	MemUsedMB      int64   `json:"mem_used_mb"`
	MemAvailableMB int64   `json:"mem_available_mb"`
	Load1          float64 `json:"load1"`
	Load5          float64 `json:"load5"`
	Load15         float64 `json:"load15"`
	DiskUsedGB     int64   `json:"disk_used_gb"`
	DiskFreeGB     int64   `json:"disk_free_gb"`
}

// PendingUpgrade is an agent self-upgrade that has been offered to a host and
// not yet converged: the version on offer, and how long the offer has stood.
//
// The wait is served as an age rather than the instant the offer was made,
// matching seconds_since_last_seen: the server derives it on read, so a client
// whose clock disagrees with the server's still reads the true wait.
type PendingUpgrade struct {
	Version string `json:"version"`
	AgeS    int64  `json:"age_s"`
}

// Host is served by GET /api/v1/hosts and the SSE snapshot.
type Host struct {
	ID          string    `json:"id"`
	Name        string    `json:"name"`
	OS          string    `json:"os"`
	Arch        string    `json:"arch"`
	Provisioner string    `json:"provisioner"`
	BridgeCIDR  string    `json:"bridge_cidr"`
	Status      string    `json:"status"`
	EnrolledAt  time.Time `json:"enrolled_at"`
	Online      bool      `json:"online"`
	// Sync-health signals so degradation is visible at a glance. LastSeen
	// and SecondsSinceLastSeen are nil until the host has reported at least
	// once (why clients can see last_seen: null). Stale trips before Online
	// clears — the early "sync is degrading" warning. Sessions is the agent
	// (re)connect count: rising while Online means the link is flapping.
	LastSeen             *time.Time `json:"last_seen"`
	SecondsSinceLastSeen *int64     `json:"seconds_since_last_seen"`
	Stale                bool       `json:"stale"`
	Sessions             int        `json:"sessions"`
	Capacity             Capacity   `json:"capacity"`  // host TOTALS (when online)
	Allocated            Capacity   `json:"allocated"` // committed to live VMs (server-computed)
	// AgentVersion is the agent binary's stamped version from its Hello
	// ("" until a version-reporting agent connects). AgentUpdateAvailable is
	// server-computed: a newer release exists for this agent AND the host is
	// online (offerable).
	AgentVersion         string `json:"agent_version"`
	AgentUpdateAvailable bool   `json:"agent_update_available"`
	// PendingUpgrade is the upgrade this host's agent has been offered and has
	// not yet taken, null when none is outstanding. It is what the seconds
	// between the click and the new version landing look like from outside.
	PendingUpgrade *PendingUpgrade `json:"pending_upgrade"`
	// Host OS facts (persisted; refreshed from each Hello).
	OSID      string `json:"os_id"`
	OSPretty  string `json:"os_pretty"`
	OSVersion string `json:"os_version"`
	Kernel    string `json:"kernel"`
	CPUModel  string `json:"cpu_model"`
	Virt      string `json:"virt"`
	// UplinkAddr is the address this host answers on, as the host reports it —
	// the address to dial for a published guest port. Empty until the host has
	// said.
	UplinkAddr string `json:"uplink_addr"`
	// HostNetworks are the named guest networks this host's agent advertised
	// at connect (--host-network). Registry state: empty when the host has
	// not spoken since server start. A create naming one of these gains a
	// second NIC on that network; a create naming anything else is refused.
	HostNetworks []string `json:"host_networks"`
	// Metrics is live MEASURED utilization, present only when online. Distinct
	// from Allocated (control-plane bookkeeping / VM quotas).
	Metrics *Metrics `json:"metrics"`
}

// VM is served by GET /api/v1/vms and the SSE snapshot. Write-only fields —
// image_sha256, cloud_init, ssh_authorized_key — are deliberately excluded.
type VM struct {
	ID         string `json:"id"`
	HostID     string `json:"host_id"`
	Name       string `json:"name"`
	ImageURL   string `json:"image_url"`
	VCPUs      int64  `json:"vcpus"`
	MemMB      int64  `json:"mem_mb"`
	DiskGB     int64  `json:"disk_gb"`
	PowerState string `json:"power_state"`
	Status     string `json:"status"`
	LastError  string `json:"last_error"`
	// AssignedIP is the guest's address on its host's private fabric — every
	// guest has one, from boot: the gate splices to it, exposures publish it,
	// and its siblings on that host reach it there. Never routable off-host.
	AssignedIP string `json:"assigned_ip"`
	// Network is the named host network this guest attaches to with a SECOND
	// NIC, frozen at create; "" means it has only the private one.
	Network string `json:"network"`
	// NetworkIP is the address that named network's own DHCP server granted
	// that second NIC, as the host snooped it — the address the rest of that
	// network knows this guest by. Empty until the network answers, and
	// always empty for a guest that asked for none.
	NetworkIP   string    `json:"network_ip"`
	CreatedAt   time.Time `json:"created_at"`
	Deleted     bool      `json:"deleted"`
	ActualPower string    `json:"actual_power"`
	Phase       string    `json:"phase"`
	// StatusDetail is what this VM's host is doing about it right now, in the
	// host's own words: "downloading image 1.2/3.7 GiB", "preparing root disk",
	// "booting". It exists because `creating` is one word for minutes of work
	// that only the host can see inside.
	//
	// Free text for reading, never for branching: it is the host's sentence, and
	// the control plane neither parses nor validates it. Empty means "nothing to
	// add" — a settled VM, or a host too old to say — and a client shows nothing
	// rather than a placeholder.
	StatusDetail string `json:"status_detail"`
	// DestroyAt is the unix-seconds deadline at which the agent will hard-destroy
	// this VM. It is only set while the VM is quarantined for teardown (deleted +
	// guest stopped, awaiting the tombstone grace window); 0 in the normal case.
	// Clients render a countdown instead of an opaque "deleting".
	DestroyAt int64 `json:"destroy_at"`
	// Lifecycle rolls the axes above (deleted / phase / power) into one coarse
	// word so every client agrees on "what is this VM doing" without
	// re-deriving it: creating | ready | stopped | failed | deleting |
	// unreachable. A lossy read-time projection, never state — see
	// TestLifecycleIsNeverReadByAControlLoop.
	//
	// `unreachable` is the one word here that describes the PLANE rather than
	// the guest: this VM's host is not reporting, so every observation below
	// (phase, actual_power, status_detail) is absent and the durable columns
	// hold only what that host last said before it went quiet. It is not a
	// failure and nothing is reconciled — the VM may well be running fine on a
	// machine that merely lost its uplink, and desired state still stands, so a
	// create placed on a dark host is picked up when the host returns.
	Lifecycle string `json:"lifecycle"`
	// InjectedKey describes the authorized key EITRI installed in this guest at
	// create, or null when it installed none. It is a description, not the key:
	// ssh_authorized_key stays write-only like everything else a create accepts.
	//
	// It covers only what eitri put there. A key hidden inside a user's own
	// cloud_init is invisible here by design — eitri did not install it and does
	// not claim to know about it.
	InjectedKey *InjectedKey `json:"injected_key"`
	// TrustedCAs is the tenant user-CA set this guest's sshd was created to
	// trust — the CAs whose certificates can open it. It is frozen at create
	// and nothing rewrites it, so a CA the tenant registers later does NOT
	// appear here and does NOT reach this guest.
	//
	// null means the VM predates this record: it trusts whatever its tenant had
	// registered on the day it was made, and nobody wrote that down. That is
	// different from an empty list, which cannot occur — create refuses a
	// tenant with no CA — so clients must not conflate them.
	//
	TrustedCAs *[]TrustedCA `json:"trusted_cas"`
}

// InjectedKey identifies one authorized key by its OpenSSH fingerprint, the way
// `ssh-add -l` does. An empty Fingerprint means the key could not be read —
// create accepts any single-line key, since the guest's sshd is what decides
// what it honours.
type InjectedKey struct {
	Type        string `json:"type"`
	Fingerprint string `json:"fingerprint"`
	Comment     string `json:"comment"`
}

// TrustedCA names one user CA a guest trusts, by the label its tenant gave it
// and its OpenSSH SHA256 fingerprint — the same two facts GET /user-cas shows,
// so an operator can match a guest's trust against the tenant's current set by
// eye. The CA's public key line is deliberately not here: it is what the agent
// bakes, not what a reader of a VM is asking for.
//
// An empty Fingerprint means the stored key line could not be parsed, matching
// InjectedKey's reading of the same situation.
type TrustedCA struct {
	Label       string `json:"label"`
	Fingerprint string `json:"fingerprint"`
}

// StateSnapshot is the full fleet state pushed as each `event: state` frame
// over the SSE stream (GET /api/v1/events).
type StateSnapshot struct {
	Hosts         []Host `json:"hosts"`
	VMs           []VM   `json:"vms"`
	ServerVersion string `json:"server_version"`
	LatestVersion string `json:"latest_version"` // "" until the manifest is known
}

// AuditEvent is the wire shape of one audit row, served by GET /api/v1/audit
// and GET /api/v1/vms/{id}/events; detail is embedded as raw JSON (it is
// always a marshaled object).
type AuditEvent struct {
	At     time.Time       `json:"at"`
	Action string          `json:"action"`
	Detail json.RawMessage `json:"detail"`
}

// RevokedCert is the wire form of one revoked SSH cert, served by
// GET /api/v1/ssh-certs/revoked.
//
// Serial is a string because SSH serials are uint64 and routinely exceed 2^53,
// which any client parsing JSON numbers as a double silently rounds. Widening
// it to uint64 is a compile error at the call sites, and the compiler cannot
// say why — so it says it here.
type RevokedCert struct {
	Serial    string    `json:"serial"`
	RevokedAt time.Time `json:"revoked_at"`
	Reason    string    `json:"reason"`
}

// EnrollRequest is the POST /api/v1/enroll body: an agent redeeming an
// enrollment token to join the fleet.
type EnrollRequest struct {
	Token       string `json:"token"`
	Name        string `json:"name"`
	OS          string `json:"os"`
	Arch        string `json:"arch"`
	Provisioner string `json:"provisioner"`
	// BridgeCIDR is the subnet this host says its guests are on. A POINTER
	// because absent and empty differ: absent is "no opinion" — an older agent,
	// or a host that wants the fleet's suggestion — while an explicit "" is "no
	// subnet of my own", which is what a host whose OS owns the guest network
	// says. The first gets an allocation; the second is left alone to report its
	// own later. RevokeSSHCertRequest.Serial is the same trick.
	BridgeCIDR *string `json:"bridge_cidr"`
}

// CreateVMRequest is the POST /api/v1/vms body. Every field except host_id is
// optional: the server fills one-click defaults (name, image pair, sizes,
// power state) before validating.
//
// There is no persistence knob. `persistent` is a restart policy — whether a
// guest lost to a host reboot or a dead hypervisor is booted again — and every
// VM gets the answer that keeps it alive. A body that still sets the field is
// refused rather than quietly upgraded (see api.createVMBody).
type CreateVMRequest struct {
	HostID           string `json:"host_id"`
	Name             string `json:"name"`
	ImageURL         string `json:"image_url"`
	ImageSHA256      string `json:"image_sha256"`
	CloudInit        string `json:"cloud_init"`
	SSHAuthorizedKey string `json:"ssh_authorized_key"`
	PowerState       string `json:"power_state"`
	VCPUs            int64  `json:"vcpus"`
	MemMB            int64  `json:"mem_mb"`
	DiskGB           int64  `json:"disk_gb"`
	// Network names a host network to attach this guest to — one of the names
	// its host advertises in host_networks. Empty (the default) is the NAT
	// underlay. A name the chosen host is not advertising is a 409: there is
	// no silent fallback, a VM that asked for the LAN either gets it or is
	// never created.
	Network string `json:"network"`
	// VolumeClaims names this tenant's claims (ids or names) to attach after
	// the root and seed disks, in this order: the first is /dev/vdc. A
	// pending claim is bound to this VM's host; a bound one pins the VM there.
	VolumeClaims []string `json:"volume_claims"`
}

// CreateVolumeClaimRequest is the POST /api/v1/volume-claims body.
type CreateVolumeClaimRequest struct {
	Name   string `json:"name"`
	SizeGB int64  `json:"size_gb"`
}

// VolumeClaim is durable storage a tenant holds. Status is "pending" until
// the first VM naming it is created, then "bound" to that VM's host for good.
type VolumeClaim struct {
	ID     string `json:"id"`
	Name   string `json:"name"`
	SizeGB int64  `json:"size_gb"`
	Status string `json:"status"`
	HostID string `json:"host_id"` // "" until bound
	VMID   string `json:"vm_id"`   // "" when no VM holds it
	// Present is what the claim's host last said about the file behind it, and
	// null until it has said anything — a pending claim (no file to look for),
	// a host that is not reporting, or a report that predates the volume. Null
	// is not false: "nobody has looked" and "it is gone" are different answers
	// and only one of them is alarming.
	Present   *bool     `json:"present"`
	CreatedAt time.Time `json:"created_at"`
}

// Default VM sizes the control plane applies when a create request leaves a
// field at zero. They live beside CreateVMRequest so the API's defaulting and
// every other surface (the console form, the docs) name one value instead of a
// private literal — nothing pins its own default.
const (
	DefaultVCPUs  int64 = 2
	DefaultMemMB  int64 = 2048
	DefaultDiskGB int64 = 10
)

// PatchVMRequest is the PATCH /api/v1/vms/{id} body: the desired power state,
// "running" or "stopped".
type PatchVMRequest struct {
	PowerState string `json:"power_state"`
}

// UserCARequest is the body of both POST and DELETE
// /api/v1/tenants/{tenant}/user-cas: a BYO user-CA public key (authorized_keys
// line) to register or remove, with an optional label on upload.
type UserCARequest struct {
	PublicKey string `json:"public_key"`
	Label     string `json:"label"`
}

// RevokeSSHCertRequest is the POST /api/v1/ssh-certs/revoke body. It accepts
// EITHER a raw serial OR a full cert authorized-key line (from which the serial
// is extracted) — the by-line form is the ergonomic one (paste the cert you
// minted), the by-serial form is for programmatic callers. Serial is a pointer
// so an absent field is distinguishable from an explicit 0. Reason is optional
// audit metadata.
type RevokeSSHCertRequest struct {
	Serial      *uint64 `json:"serial"`
	Certificate string  `json:"certificate"`
	Reason      string  `json:"reason"`
}

// CreateExposureRequest is the POST /api/v1/vms/{id}/exposures body: publish
// guest_port of that VM. host_port is optional — omitted (or 0) allocates one
// from the reserved range 30000-32767; a named port must be >= 1024 and is
// honored or refused. protocol is "tcp" (the default when omitted) or "udp",
// and a host port is claimed per protocol, so the same number can carry one of
// each. Request-side only.
type CreateExposureRequest struct {
	GuestPort int64  `json:"guest_port"`
	HostPort  int64  `json:"host_port"` // 0 = allocate
	Protocol  string `json:"protocol"`  // "" = tcp
}

// Exposure is one published guest port, served by
// POST and GET /api/v1/vms/{id}/exposures. HostAddr and State are
// server-derived: the address comes from the host's own report, and the state
// from what its agent last said its socket is doing — "pending" until an
// agent has reported at all, then "active" (the socket is bound) or "failed"
// with the OS error in Reason. "active" describes the host half of the pipe;
// whether anything answers inside the guest is the guest's business.
type Exposure struct {
	ID        string    `json:"id"`
	VMID      string    `json:"vm_id"`
	HostID    string    `json:"host_id"`
	GuestPort int64     `json:"guest_port"`
	HostPort  int64     `json:"host_port"`
	HostAddr  string    `json:"host_addr"`
	Protocol  string    `json:"protocol"`
	Scope     string    `json:"scope"`
	State     string    `json:"state"`
	Reason    string    `json:"reason"`
	CreatedAt time.Time `json:"created_at"`
	// Sessions is what the host says this port has carried, or null when the
	// host has not said — an exposure not yet reported on, or an agent older
	// than the counters. Null is not zero: a client shows nothing at all rather
	// than presenting zeros nobody reported.
	Sessions *ExposureSessions `json:"sessions"`
}

// ExposureSessions is one published port's traffic as its host counts it.
// Active is a gauge — conversations open this instant. Refused and Dropped are
// running totals SINCE THAT AGENT STARTED, because what they count is momentary
// and a gauge would read zero between two bursts of it. An agent restart resets
// both, which is why a client says what they are counted from.
type ExposureSessions struct {
	// Active is what the port holds right now: open TCP connections, or live UDP
	// client sessions.
	Active int64 `json:"active"`
	// Refused counts callers the port turned away because it was at its cap.
	Refused int64 `json:"refused"`
	// Dropped counts conversations the host could not carry to the guest: no
	// guest address yet, a refused dial, a datagram that would not send.
	Dropped int64 `json:"dropped"`
}

// The response shapes below replace handlers' inline map[string]string
// literals. Their fields are ordered ALPHABETICALLY BY JSON KEY on purpose:
// encoding/json marshals map keys sorted, so keeping struct fields in that
// same order means swapping a map for its struct leaves the wire bytes
// byte-identical (the golden fixtures pin this).

// EnrollResponse answers POST /api/v1/enroll.
type EnrollResponse struct {
	BridgeCIDR       string `json:"bridge_cidr"`
	Credential       string `json:"credential"`
	HostID           string `json:"host_id"`
	ServerCertSHA256 string `json:"server_cert_sha256"`
}

// EnrollTokenResponse answers POST /api/v1/enroll-tokens.
type EnrollTokenResponse struct {
	Join  string `json:"join"`
	Token string `json:"token"`
}

// CreateVMResponse answers POST /api/v1/vms.
type CreateVMResponse struct {
	ID   string `json:"id"`
	Name string `json:"name"`
}

// SSHCAResponse answers GET /api/v1/ssh-ca.
type SSHCAResponse struct {
	CA string `json:"ca"`
}

// StreamTicketResponse answers POST /api/v1/stream-tickets.
type StreamTicketResponse struct {
	Ticket string `json:"ticket"`
}

// UserCAUploadResponse answers POST /api/v1/tenants/{tenant}/user-cas.
type UserCAUploadResponse struct {
	Fingerprint string `json:"fingerprint"`
}

// DelegationChallenge answers POST /api/v1/delegations: the public half of the
// keypair eitri will authenticate with, and the exact command that authorizes
// it. eitri cannot sign this itself — that is the point.
type DelegationChallenge struct {
	PublicKey    string `json:"public_key"`   // authorized_keys line, to be signed
	Principal    string `json:"principal"`    // the principal the certificate MUST carry
	Instructions string `json:"instructions"` // the ssh-keygen line, ready to run
}

// DelegationRequest posts the signed certificate back. A certificate is public
// material, so there is nothing sensitive about carrying one in a request body.
type DelegationRequest struct {
	Certificate string `json:"certificate"`
}

// Delegation describes a live delegation. It is entirely public: eitri's half
// is an ephemeral key it holds only in memory, and a certificate is not a
// secret. ExpiresAt is when eitri stops being able to reach anything.
type Delegation struct {
	PublicKey     string   `json:"public_key"`
	CAFingerprint string   `json:"ca_fingerprint"`
	KeyID         string   `json:"key_id"`
	Serial        string   `json:"serial"`
	Principals    []string `json:"principals"`
	ExpiresAt     string   `json:"expires_at"` // RFC3339
}

// UserCA is one entry in GET /api/v1/tenants/{tenant}/user-cas.
type UserCA struct {
	Fingerprint string `json:"fingerprint"`
	Label       string `json:"label"`
	PubKey      string `json:"pubkey"`
}

// Me is the signed-in identity served by GET /api/v1/me, so the SPA can render
// who is logged in and the settings page. Response-side only. Fields ordered
// alphabetically by json key (see the map-compatibility note above).
type Me struct {
	Email string `json:"email"`
	// SSHGate is the address a client dials for the jump-gate hop, host:port,
	// the same value EITRI_GATE takes. It rides the identity because a connect
	// name is half identity (the tenant below) and half plane (this), and no
	// caller needs one without the other. Empty when the plane runs no gate —
	// there is then no hop to name, and the console offers no connect recipe.
	SSHGate string `json:"ssh_gate"`
	Tenant  string `json:"tenant"`
}

// CreateAPITokenRequest is the POST /api/v1/tokens body: mint a personal access
// token. TTLSeconds is the token lifetime in seconds; 0 mints a non-expiring
// token. Request-side only — kept disjoint from the response types so the spec
// generator (which emits no `required` array for requests) treats each side
// correctly.
type CreateAPITokenRequest struct {
	Name       string `json:"name"`
	TTLSeconds int64  `json:"ttl_seconds"` // 0 = non-expiring
}

// CreateAPITokenResponse answers POST /api/v1/tokens: it carries the token
// secret EXACTLY ONCE (the server stores only its hash and never echoes it
// again). ExpiresAt is RFC3339, empty for a non-expiring token. Fields ordered
// alphabetically by json key.
type CreateAPITokenResponse struct {
	ExpiresAt string `json:"expires_at"` // RFC3339; empty = non-expiring
	ID        string `json:"id"`
	Name      string `json:"name"`
	Token     string `json:"token"`
}

// APIToken is one PAT's metadata in GET /api/v1/tokens — never the secret.
// Every timestamp is RFC3339 or empty ("" when the underlying column is NULL:
// non-expiring, never used, not revoked). Response-side only. Fields ordered
// alphabetically by json key.
type APIToken struct {
	CreatedAt  string `json:"created_at"`
	ExpiresAt  string `json:"expires_at"`
	ID         string `json:"id"`
	LastUsedAt string `json:"last_used_at"`
	Name       string `json:"name"`
	RevokedAt  string `json:"revoked_at"`
}