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"`
}