Compare commits

..

8 Commits

Author SHA1 Message Date
brynmailer-mdc e90a0a4050 fix(ebs): wrap the stop failure behind an unavailable publish
The refusal now wraps the stop error as well as ErrUnavailable, so a
caller can still inspect why the old export did not stop. Also fixes
import order and spacing that the linter flagged in the new tests.
2026-11-02 15:21:15 +10:00
brynmailer-mdc e9a77840ef test(ebs): check v3 success replies, timeouts and refusals end to end
- Unpublish is sized as data movement, and publish, unsealed writes and
  per-node capabilities keep the caller's timeout.
- A mount whose lease store or backing store is not ready reaches the
  wire as unavailable.
- A successful publish replies in the request's version at every served
  version, in natsserve and in viperblockd. Every natsserve case now gets
  its own seeded provider, so none relies on an earlier case's state.
  The conformance republish runs at every listed version.
- viperblockd's per-node capabilities list the versions it serves.
- A no_seal unpublish above the served version is refused, and the
  export is neither torn down nor sealed.
- The dead-entry republish test asserts unconditionally. The snapshot
  lease test runs against real volume state and asserts nothing was
  written. The conformance torn-down case skips a provider that does
  not list version 3.
2026-11-02 15:15:29 +10:00
brynmailer-mdc 609333df5a refactor(ebs): leave version rules to the provider and trim the v3 surface
The provider decides what a request's version allows, so natsserve no
longer gates TornDown or held-elsewhere on it: classifyError takes no
version, and the reply carries what the provider returned. viperblockd
applies the TornDown rule in unpublishVolume, its provider half.

The unpublish handlers check the version once, through
CheckUnpublishVersion, and classify its refusal. Each handler binds its
reply version once.

UnpublishedVolume is reduced to TornDown. movesData classifies a subject
by its parsed verb, which replaces isNodeSubject and the owner-verb
list. nodeSubjectWildcard is unexported. The conformance suite reads
capabilities once.
2026-11-02 15:11:01 +10:00
brynmailer-mdc dd86e61888 test(viperblock): give the no-seal tests a seal they could reach
The no-seal unpublish and abandon tests left BaseDir empty, so
volumeNeedsSeal was false and no path could seal whatever the code did.
Both now create local state under BaseDir first, and check that a seal
is reachable before asserting none ran.
2026-11-02 15:05:36 +10:00
brynmailer-mdc 72461cca0a fix(ebs): stop a running export before remounting over its lost socket
An export whose socket is gone was treated as dead even while its
process ran, and a republish started a second export beside it. A
client connected before the socket went keeps writing through the old
process, so that made two writers on one volume.

qemunbdd now SIGTERMs that qemu-nbd and waits for it to exit before
exporting again. viperblockd stops the nbdkit through unmountVolume, so
the old copy is sealed as a normal unmount would seal it. If the process
will not stop, the publish is refused as unavailable and the entry is
kept for the next attempt.
2026-11-02 15:04:39 +10:00
brynmailer-mdc 81aa7c2d05 test(ebs): conformance cases for contract schema version 3
Every provider run through the suite now has to:
- list ascending contract versions including the legacy one, answer each
  in that version, and refuse an unlisted one without changing state;
- refuse a publish elsewhere with volume_held_elsewhere only at version 3
  and volume_in_use at versions 1 and 2;
- refuse a republish in the other access mode as volume_in_use, never
  held elsewhere;
- report torn_down truthfully, refuse no_seal below version 3, and either
  honour no_seal at version 3 or refuse it as unsupported;
- answer the unsealed-writes query with a subset of what was asked.

SuiteConfig.KillExport lets a provider whose export is a process prove
that a republish after the export died returns a serving export.
qemunbdd and viperblockd supply it.
2026-11-02 14:43:37 +10:00
brynmailer-mdc 876f02c2b3 feat(viperblock): serve contract schema version 3 from viperblockd
Every provider subject replies in its request's version, and
GetCapabilities lists the contract versions and is also served on the
node's own capabilities subject.

PublishVolume no longer returns an existing entry early. It goes through
mountVolume, which hands back a live export and drops and remounts one
whose nbdkit has died. Mount failures map to not_found, held_elsewhere
(version 3 only, volume_in_use below it), volume_in_use for a mode
mismatch, unavailable for a backing or lease store that is not ready,
and internal otherwise.

UnpublishVolume with no_seal abandons the export without sealing it,
and reports whether an export was torn down.
2026-11-02 14:37:35 +10:00
brynmailer-mdc 177582a118 feat(ebs): contract schema version 3 for the provider data path
Servers accept schema versions 1 to 3 and answer every request in the
version it was sent at, while every client still sends version 1.

Version 3 adds no_seal to UnpublishVolumeRequest, refused below 3, and
torn_down to UnpublishVolumeResponse. UnpublishVolume now returns an
UnpublishedVolume so the torn-down result reaches callers.

A publish refused because another node holds the volume returns
volume_held_elsewhere at version 3 and volume_in_use below it, which is
what v1.23.0 returns. A read-only mismatch on republish stays
volume_in_use at every version.

GetCapabilities lists the contract versions served and is also answered
on a per-node subject. The unsealed-writes subject is part of
EBSProvider, and write-through providers answer it with nothing held. A
fence-notice event subject and message are defined. Unpublish gets the
data-movement timeout.

The memory provider, natsserve and qemunbdd implement all of this. A
qemunbdd republish whose qemu-nbd has died re-exports the volume instead
of returning the dead socket.
2026-11-02 14:37:27 +10:00
857 changed files with 4836 additions and 16376 deletions
+1 -2
View File
@@ -1,4 +1,4 @@
# spinifex-unit-version: 6
# spinifex-unit-version: 5
[Unit]
Description=Spinifex Daemon (VM Orchestration)
PartOf=spinifex.target
@@ -15,7 +15,6 @@ Slice=spinifex-system.slice
User=spinifex-daemon
Group=spinifex
ExecStartPre=/usr/local/bin/spx admin hostnet check
ExecStartPre=-+/usr/local/bin/spx admin node-facts collect
ExecStart=/usr/local/bin/spx service spinifex start
Restart=on-failure
RestartSec=5
-211
View File
@@ -1,211 +0,0 @@
package clusterv1
import "regexp"
const (
// NodeFactsSubject fans out a request for each daemon's hardware and
// cabling description. Its reply payload is NodeFacts.
NodeFactsSubject = "spinifex.node.facts"
nodeNetTestPrefix = "spinifex.node.nettest."
)
var nodeIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
// ValidNodeID reports whether a node name is safe to place in a subject: no
// NATS token separator or wildcard can get through.
func ValidNodeID(node string) bool {
return nodeIDPattern.MatchString(node)
}
// NetTestServeSubject asks one node to start a one-shot throughput server.
func NetTestServeSubject(node string) string {
return nodeNetTestPrefix + node + ".serve"
}
// NetTestRunSubject asks one node to run the client side of a throughput test.
func NetTestRunSubject(node string) string {
return nodeNetTestPrefix + node + ".run"
}
// Planes a node's NICs can carry, named as the installer names them.
const (
PlaneWAN = "wan"
PlaneLAN = "lan"
PlaneVPC = "vpc"
)
// NetTestPort is the TCP port reserved for node-to-node throughput tests.
const NetTestPort = 5001
// NodeFacts describes one node's hardware, OS and cabling. Fields a host
// cannot report are left empty, and Sources says why.
type NodeFacts struct {
Node string `json:"node"`
CollectedAt string `json:"collected_at,omitempty"`
FormFactor string `json:"form_factor"`
Virt string `json:"virt,omitempty"`
System SystemFacts `json:"system"`
OS OSFacts `json:"os"`
CPU CPUFacts `json:"cpu"`
Memory MemoryFacts `json:"memory"`
NICs []NICFacts `json:"nics"`
Planes []PlaneFacts `json:"planes"`
Sources map[string]string `json:"sources"`
}
// SystemFacts is the machine's identity from DMI.
type SystemFacts struct {
Vendor string `json:"vendor,omitempty"`
Product string `json:"product,omitempty"`
Family string `json:"family,omitempty"`
Version string `json:"version,omitempty"`
Serial string `json:"serial,omitempty"`
UUID string `json:"uuid,omitempty"`
ChassisType string `json:"chassis_type,omitempty"`
ChassisAsset string `json:"chassis_asset,omitempty"`
BoardVendor string `json:"board_vendor,omitempty"`
BoardProduct string `json:"board_product,omitempty"`
BoardSerial string `json:"board_serial,omitempty"`
BIOSVendor string `json:"bios_vendor,omitempty"`
BIOSVersion string `json:"bios_version,omitempty"`
BIOSDate string `json:"bios_date,omitempty"`
DeviceModel string `json:"device_model,omitempty"`
}
// OSFacts names the running OS and Spinifex build.
type OSFacts struct {
Name string `json:"name,omitempty"`
Kernel string `json:"kernel,omitempty"`
Arch string `json:"arch,omitempty"`
Version string `json:"spx_version,omitempty"`
Commit string `json:"spx_commit,omitempty"`
UptimeSeconds int64 `json:"uptime_seconds,omitempty"`
Hostname string `json:"hostname,omitempty"`
}
// CPUFacts summarises the sockets. Threads and Cores are totals. Features are
// the flags worth naming; Flags is every flag the CPU reports.
type CPUFacts struct {
Model string `json:"model,omitempty"`
Vendor string `json:"vendor,omitempty"`
Sockets int `json:"sockets"`
CoresPerSocket int `json:"cores_per_socket,omitempty"`
Cores int `json:"cores"`
Threads int `json:"threads"`
MaxMHz int `json:"max_mhz,omitempty"`
CurrentMHz int `json:"current_mhz,omitempty"`
Virtualization string `json:"virtualization,omitempty"`
Features []string `json:"features,omitempty"`
Flags []string `json:"flags,omitempty"`
}
// MemoryFacts groups identical DIMMs so a 32-slot board reads as one line.
type MemoryFacts struct {
TotalBytes uint64 `json:"total_bytes"`
SlotsUsed int `json:"slots_used"`
SlotsTotal int `json:"slots_total"`
MaxCapacity string `json:"max_capacity,omitempty"`
ECC string `json:"ecc,omitempty"`
Groups []MemoryGroup `json:"groups"`
OSTotalBytes uint64 `json:"os_total_bytes,omitempty"`
OSAvailBytes uint64 `json:"os_available_bytes,omitempty"`
HugePageBytes uint64 `json:"huge_page_bytes,omitempty"`
}
// MemoryGroup is a set of DIMMs alike in every field shown. ConfiguredMTs
// below SpeedMTs means the board runs them slower than rated.
type MemoryGroup struct {
Count int `json:"count"`
SizeBytes uint64 `json:"size_bytes"`
Type string `json:"type,omitempty"`
TypeDetail string `json:"type_detail,omitempty"`
FormFactor string `json:"form_factor,omitempty"`
SpeedMTs int `json:"speed_mts,omitempty"`
ConfiguredMTs int `json:"configured_mts,omitempty"`
Manufacturer string `json:"manufacturer,omitempty"`
PartNumber string `json:"part_number,omitempty"`
Rank string `json:"rank,omitempty"`
Serials []string `json:"serials,omitempty"`
}
// NICFacts is one physical network interface and, where LLDP reports it, the
// switch port it is cabled to.
type NICFacts struct {
Name string `json:"name"`
MAC string `json:"mac,omitempty"`
Driver string `json:"driver,omitempty"`
Firmware string `json:"firmware,omitempty"`
BusInfo string `json:"bus_info,omitempty"`
Model string `json:"model,omitempty"`
SpeedMbps int `json:"speed_mbps,omitempty"`
MaxMbps int `json:"max_mbps,omitempty"`
Duplex string `json:"duplex,omitempty"`
Port string `json:"port,omitempty"`
MTU int `json:"mtu,omitempty"`
State string `json:"state"`
Master string `json:"master,omitempty"`
Planes []string `json:"planes,omitempty"`
Neighbor *LLDPNeighbor `json:"neighbor,omitempty"`
}
// LLDPNeighbor is what the switch on the other end of a NIC advertises.
type LLDPNeighbor struct {
ChassisID string `json:"chassis_id"`
SystemName string `json:"system_name,omitempty"`
SystemDescription string `json:"system_description,omitempty"`
PortID string `json:"port_id,omitempty"`
PortDescription string `json:"port_description,omitempty"`
Capabilities []string `json:"capabilities,omitempty"`
}
// PlaneFacts is the address a plane uses on this node and the NICs under it.
type PlaneFacts struct {
Plane string `json:"plane"`
Bridge string `json:"bridge,omitempty"`
Address string `json:"address,omitempty"`
Prefix int `json:"prefix,omitempty"`
NICs []string `json:"nics,omitempty"`
MTU int `json:"mtu,omitempty"`
}
// NetTestServeRequest starts a server bound to the node's address on Plane.
type NetTestServeRequest struct {
Plane string `json:"plane"`
DurationSeconds int `json:"duration_seconds"`
}
// NetTestServeResponse names the address the server listens on.
type NetTestServeResponse struct {
Address string `json:"address,omitempty"`
Port int `json:"port,omitempty"`
Error string `json:"error,omitempty"`
}
// NetTestRunRequest runs the client towards Address from the node's own
// address on Plane.
type NetTestRunRequest struct {
Plane string `json:"plane"`
Address string `json:"address"`
Port int `json:"port"`
DurationSeconds int `json:"duration_seconds"`
Streams int `json:"streams"`
}
// NetTestRunResponse is one direction of a pair's measurement.
type NetTestRunResponse struct {
SourceAddress string `json:"source_address,omitempty"`
BitsPerSecond float64 `json:"bits_per_second"`
Bytes int64 `json:"bytes"`
Seconds float64 `json:"seconds"`
Streams int `json:"streams"`
StreamBPS []float64 `json:"stream_bps,omitempty"`
RTTMinMillis float64 `json:"rtt_min_ms,omitempty"`
RTTAvgMillis float64 `json:"rtt_avg_ms,omitempty"`
RTTMaxMillis float64 `json:"rtt_max_ms,omitempty"`
PacketLoss float64 `json:"packet_loss_percent,omitempty"`
LinkMbps int `json:"link_mbps,omitempty"`
MTU int `json:"mtu,omitempty"`
Tool string `json:"tool,omitempty"`
Error string `json:"error,omitempty"`
}
-53
View File
@@ -1,53 +0,0 @@
package telemetryv1
import "regexp"
// Node live subjects mirror the instance ones, keyed by node name. Only the
// super-admin account's gateway handler subscribes to them.
const (
nodeLiveWatchSubjectPrefix = "telemetry.watch.node."
nodeLiveSampleSubjectPrefix = "telemetry.live.node."
)
var liveNodePattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
// ValidLiveNode reports whether a node name is safe to place in a subject.
func ValidLiveNode(node string) bool {
return liveNodePattern.MatchString(node)
}
// NodeLiveWatchSubject is where a viewer asks a node to keep sampling itself.
// A request on it is answered with a NodeLiveRing.
func NodeLiveWatchSubject(node string) string {
return nodeLiveWatchSubjectPrefix + node
}
// NodeLiveSampleSubject carries one NodeLiveSample per period while watched.
func NodeLiveSampleSubject(node string) string {
return nodeLiveSampleSubjectPrefix + node
}
// NodeLiveRing answers a watch with the samples newer than its SinceMillis.
type NodeLiveRing struct {
Samples []NodeLiveSample `json:"samples"`
}
// NodeLiveSample is one host measurement. Rates are per second over the
// interval ending at TimeMillis.
type NodeLiveSample struct {
TimeMillis int64 `json:"t"`
CPUPercent float64 `json:"cpu"`
// MemoryUsedPercent is used memory less page cache, as free(1) reports it.
MemoryUsedPercent float64 `json:"mem"`
Load1 float64 `json:"load1"`
// Planes holds the traffic of each plane's physical NICs, keyed wan, lan, vpc.
Planes map[string]NodeLivePlane `json:"planes"`
}
// NodeLivePlane is one plane's traffic from the host's side.
type NodeLivePlane struct {
NetworkIn float64 `json:"netIn"`
NetworkOut float64 `json:"netOut"`
PacketsIn float64 `json:"pktIn"`
PacketsOut float64 `json:"pktOut"`
}
+1 -2
View File
@@ -99,7 +99,6 @@ behavior in other words.
| 4432 | Formation server | HTTPS | Cluster (bootstrap only) | Cluster join coordination; active only while a join token is valid. Binds the node's `--bind` (lan) address. See *Formation port lifecycle* below. | Short-lived bearer token + TLS¹ |
| 4222 | spinifex-nats (client) | NATS + TLS | Cluster | Internal service bus for EC2/EBS/VPC/S3 handlers | Token + mutual TLS (cluster CA) |
| 4248 | spinifex-nats (cluster) | NATS + TLS | Cluster | Inter-node NATS federation | Token + mutual TLS (cluster CA) |
| 5001 | iperf2 (admin network test) | TCP | Cluster, any plane | Throughput test between two nodes, started only by the super-admin account. Listens on one plane address for the length of one test, then exits. Runs with no capabilities. | None — peer-scoped by firewall, and the client may only target its own plane subnet |
| 5300 | northstar | DNS (UDP + TCP) | Cluster | Forward target for every node's per-instance DNS shim, dialled cross-node. Binds the wildcard by design. | None — restrict by firewall |
| 6660 | predastore (blob node) | QUIC / UDP | Cluster | Erasure-coded object shard transport between hosts. Multi-node clusters only — see *Predastore ports* below. | TLS 1.3, server certificate verified against the cluster CA |
| 7660 | predastore (meta node) | QUIC / UDP | Cluster | Raft consensus over global state — buckets and the object index — between hosts. Multi-node clusters only. | TLS 1.3, server certificate verified against the cluster CA |
@@ -222,7 +221,7 @@ table inet spinifex_filter {
# Cluster, from peer nodes only. Replace with your nodes' lan-plane
# addresses — a CIDR does not generalise to nodes on different subnets.
ip saddr { 10.0.1.1, 10.0.1.2, 10.0.1.3 } tcp dport { 4222, 4248, 4432, 5001, 5300, 6641, 6642, 6643, 6644, 8660 } accept
ip saddr { 10.0.1.1, 10.0.1.2, 10.0.1.3 } tcp dport { 4222, 4248, 4432, 5300, 6641, 6642, 6643, 6644, 8660 } accept
ip saddr { 10.0.1.1, 10.0.1.2, 10.0.1.3 } udp dport { 5300, 6660, 7660 } accept
# Encap, from peer chassis only. Replace with your nodes' vpc-plane
+21 -78
View File
@@ -24,7 +24,7 @@ All colours are OKLCH and come from mulgadc.com, so product and site match.
| Token | Use |
|---|---|
| `background` | The app page. Warm off-white in light, ink `#14181D` in dark. |
| `card`, `popover` | Raised surfaces. White in light, about `#1B2027` in dark. `popover` is every floating surface: tooltips, popovers, menus and pickers. |
| `card`, `popover` | Raised surfaces. White in light, about `#1B2027` in dark. |
| `muted`, `secondary`, `accent` | Hover rows, secondary buttons, quiet fills. |
| `border` | Every divider and card edge. `#DCD8CE` in light, `oklch(1 0 0 / 8%)` in dark. |
| `brand` | Brand orange `#EE4B12`, for the logo and decoration only. It fails text contrast, so it never carries text. |
@@ -39,22 +39,15 @@ Each status has a solid token plus `-foreground` (text on a tinted background),
| Status | Hue | AWS states |
|---|---|---|
| `success` | green | settled and good: running, available, active, associated, attached, in-use, healthy, ok, main |
| `warning` | brand yellow `#F5B500` | changing state and will settle: pending, stopping, creating, modifying, deleting, draining |
| `caution` | orange, hue 58 | works but needs attention: degraded, impaired, insufficient-data |
| `danger` | cool red, hue 17 | stopped, error, failed, update-failed, unhealthy, unavailable, blackhole |
| `success` | green | running, available, completed, healthy |
| `warning` | brand yellow `#F5B500` | pending, stopping, creating, modifying, deleting, draining |
| `danger` | cool red, hue 17 | stopped, error, failed, unhealthy, unavailable |
| `info` | blue | informational notes |
| `neutral` | grey `#9AA1A8` | gone or absent (terminated, deleted, no) in muted text, and any unmapped state |
| `neutral` | grey `#9AA1A8` | terminated (muted text), and any unmapped state |
`stopped` is danger because EC2 reads it that way, although RDS treats it as a resting state; splitting the map by service is its own change.
`StateBadge` (`src/components/state-badge.tsx`) is the only mapping from an AWS state to a tone, and it exposes the result as `data-tone` for tests.
Its tones are `success`, `progress` (the yellow `warning` token), `caution`, `danger`, `muted` and `neutral`.
A `progress` state carries the submit button's spinning loader, so a launching instance reads as work under way; `StatusBadge` does not, because its yellow can mean waiting on the user, as a zone's `pending nameservers` does.
It shows every state lowercase with hyphens, as EC2 writes them, so EKS's `ACTIVE` reads `active` and Bedrock's `IN_PROGRESS` reads `in-progress`; the API value, filters and CLI panels keep AWS's case.
A good or settled state is always green, never grey, and a status that is not an API state, such as a cluster's health, uses `StatusBadge` with a tone rather than a `Badge`.
The orange of `caution` is kept clear of the primary accent's hue, which marks actions and never a status.
`Badge`'s default variant is the brand primary and is never used for a status; a label that is not a status, such as `default v3` or `Leader`, uses a `secondary` or `outline` `Badge`.
Every status badge measures above 4.8:1 against its tinted background in both themes.
**Danger is separated from the primary accent by hue alone in dark mode**: the two have the same lightness.
@@ -74,21 +67,18 @@ Colour is never the only key: label every series.
| UI text, tables and forms | Geist (`font-sans`) |
| IDs, ARNs, IPs, CIDRs, code and eyebrow labels | JetBrains Mono (`font-mono`), with `tabular-nums` for figures |
| Step | rem | At the default 16px browser font | Use |
|---|---|---|---|
| `2xl` | 1.625 | 28.6px | page title (`PageHeading`) |
| `xl` | 1.375 | 24.2px | section heading |
| `lg` | 1.0625 | 18.7px | |
| `base` | 0.9375 | 16.5px | |
| `sm` | 0.875 | 15.4px | body and table default |
| `xs` | 0.8125 | 14.3px | badges, secondary text |
| `2xs` | 0.75 | 13.2px | mono eyebrow labels |
| Step | Size | Use |
|---|---|---|
| `2xl` | 26px | page title (`PageHeading`) |
| `xl` | 22px | section heading |
| `lg` | 17px | |
| `base` | 15px | |
| `sm` | 14px | body and table default |
| `xs` | 13px | badges, secondary text |
| `2xs` | 12px | mono eyebrow labels |
The root font size is 110% (`html` in `src/styles.css`), so one rem is 17.6px at the browser default.
Everything is sized in rem, so text, spacing and breakpoints scale together, as they would at 110% browser zoom, and a user's own font-size setting still applies.
The scale sits above Tailwind's defaults, as consoles such as Vultr's do, because the console read as small on a wide screen at 100%.
Code blocks are set at 0.75rem (`--text-code-block`) so a long command still fits.
Size new things in rem through the type and spacing scales, never in px, or they will not scale with the rest.
The scale sits a step above Tailwind's defaults, as consoles such as Vultr's do, because 13px body text read as cramped on a wide screen.
Code blocks are set at 12px (`--text-code-block`) so a long command still fits.
### Primary and contrast
@@ -179,22 +169,6 @@ Every other list is a `DataTable`, and the test also fails when an allowed file
| S13 | a component outside `ui/` renders a raw `<input>`, `<select>` or `<textarea>` |
| S15 | a route imports the `ui/alert` primitive instead of using `ErrorBanner` |
Rules that ADR-0016 does not yet number are named by what they hold until their amendment is accepted:
| Rule | Fails when |
|---|---|
| status pills come from StateBadge or StatusBadge | a component outside `ui/` renders `<Badge>` with the default variant |
| status pills come from StateBadge or StatusBadge | a file outside `state-badge.tsx` builds a `rounded-full` pill on a status colour |
| floating surfaces follow the theme | a file fills with `bg-foreground` or `text-background`, or a tooltip, popover, hover card, menu, select or combobox is not on `bg-popover` and `text-popover-foreground` |
| expanding a panel keeps its tab | a `fullPage` link does not pass `search={{ tab }}` |
| resource tags are edited in place | `PanelTags` or `TagsEditor` comes back |
| lists filter through one FilterBar | a component other than `FilterBar` imports `ui/toggle-group` |
| a component handed a form reads errors through `useFormState` | a file typed with `UseFormReturn` reads `form.formState` |
The last one matters because the console builds with the React compiler and the unit tests run without it.
With the compiler on, a child handed a react-hook-form object is cached on that object, whose identity never changes, so errors read from `form.formState` never draw and the submit looks dead.
`useFormState({ control })` subscribes the child itself.
An exception is named in that rule's allowlist with its reason, and the test fails when an allowed file stops needing it.
S11, S14 and S16 are held by review, since none has a shape in the source a test can read without false alarms.
@@ -203,7 +177,7 @@ S11, S14 and S16 are held by review, since none has a shape in the source a test
`src/lib/nav.ts` is the one description of the console's navigation.
The sidebar, the command palette and the breadcrumbs all read it, and `nav.test.ts` checks every destination against the generated route tree, so a renamed route fails a test rather than leaving a dead link.
- **Sidebar.** Groups take generic names rather than AWS's service marks: Compute, Databases, Authentication and Object Storage, not EC2, RDS, IAM and S3. Pages and the rest of the console keep the AWS names, and each group's `service` keeps its AWS name findable in the command palette, where typing it exactly ranks that group first. Each service group folds, and a folded group stays folded across reloads (`localStorage["spinifex-ui-nav-collapsed"]`). Navigating into a folded group unfolds it so the active item shows, and it can then be folded again, the current group included. Folding animates through the `nav` variant of `CollapsibleContent`, opening over 200ms and closing over 150ms, and switches at once under reduced motion. On a phone the sidebar is a sheet, and any navigation closes it.
- **Sidebar.** Groups take generic names rather than AWS's service marks: Compute, Databases, Authentication and Object Storage, not EC2, RDS, IAM and S3. Pages and the rest of the console keep the AWS names, and each group's `service` keeps its AWS name findable in the command palette, where typing it exactly ranks that group first. Each service group folds, and a folded group stays folded across reloads (`localStorage["spinifex-ui-nav-collapsed"]`). Navigating into a folded group unfolds it so the active item shows, and it can then be folded again, the current group included. On a phone the sidebar is a sheet, and any navigation closes it.
- **Command palette.** ⌘K or Ctrl K, or the Search button in the header. It lists every page the user can reach, every create flow (`create` on a nav item), and a theme toggle. It is navigation only, with no resource search yet.
- **Breadcrumbs.** Service, then list page, then the resource or the create flow. A resource ID is set in mono. On a phone only the last crumb shows.
- **Sign in.** The sign-in card sits over the shell on a light, barely blurred overlay rather than on a page of its own, so the console stays in view. The shell behind it is `inert`, which keeps pointer, keyboard and screen reader out of it until the user signs in.
@@ -228,13 +202,8 @@ So columns drop out when a side panel narrows the list as well as on a small scr
List pages keep their filter, state and sort in the URL, so a filtered view can be bookmarked, shared and survives a reload.
EC2 Instances is the reference page (`ec2/(instances)/-components/instances-page.tsx`):
- The first column is the instance ID in mono, opening its side panel, and the second its `Name` tag.
- A long value ends in an ellipsis and shows in full on hover (`TruncatedText`), so it never widens its column.
- A time column shows a relative time, `33 min ago` or `8 months ago`, with the full timestamp on hover (`RelativeTime`).
- A Tags column shows one tag key as a pill and `+N` for the rest, and hover lists every tag with its value (`TagPills`); it leaves out `Name`, which has its own column.
- Below `md`, only the ID, Name and State columns remain.
- The first column names the resource: the `Name` tag in the primary colour, with the ID in mono beneath it, opening its side panel.
- Bulk actions appear above the table once a row is selected. Each action counts only the selected instances it applies to, so a mixed selection never sends a call that must fail, and Terminate asks first and lists every ID.
- Search and state filters sit in one `FilterBar` (`src/components/filter-bar.tsx`) above the table: a search box, an optional set of choices with counts, and Clear filters while anything is filtered.
- An empty account gets a call to action, and an empty filter result says the filters are why.
VPCs and Internet gateways follow the same pattern.
@@ -250,12 +219,6 @@ Every list with a delete action has row checkboxes. Selecting a row shows `Selec
- **The bulk delete and the panel's delete are the same function.** Where a single delete is several calls, such as removing dependants first, the sequence lives in one place and both use it.
- The verb follows the AWS console: Release for an Elastic IP, Deregister for an image.
### Lists inside a panel
A list inside a panel tab, such as a hosted zone's records, is a `DataTable` with row checkboxes, and a click on a row opens it for editing in a `FormPanel`.
Rows the user cannot change, such as a zone's own SOA and NS, are muted and cannot be selected (`isRowActionable`).
A batch delete uses the service's batch call where it has one, so Route 53 removes the whole selection in one `ChangeResourceRecordSets`.
### Typed confirmation
A delete that cannot be walked back asks the user to type a word first, as the AWS console does before deleting a volume.
@@ -301,24 +264,13 @@ Modelled on UniFi's console: a click on a row opens the resource in a panel besi
- **URL.** The panel is search state on the list route: `selected=<id>`, `tab=overview|monitoring|settings`, and `create=true`. Defaults are stripped, so a closed panel leaves the plain list address. Opening or closing a panel pushes a history entry, so Back closes it; a tab or filter change replaces the entry.
- **Layout.** From `lg` (1024px) the panel is fixed to the right edge, full height below the header, flush with no outer margin, and it slides in over the list. The list keeps its width and columns underneath, so nothing reflows when a panel opens, and a soft gradient on the panel's left edge reads as the panel lying on top. Below `lg` there is no room to share, so the panel replaces the list as a full page with a back arrow. `useWideLayout` decides, through `matchMedia`.
- **`DetailPanel`.** A header with the resource's own page (left) and close (right), the title and its ID, a summary of state and quick actions, then icon tabs with tooltips. Escape closes it unless a dialog or menu is open over it. Closing slides the panel out over 150ms, or at once under reduced motion. Each tab, and a create form, sits in its own error boundary, so a failed query shows in the panel and the list keeps working.
- **Contents.** `PanelSection` is a folding card, `PanelFields` and `PanelField` a label and value list, with `copy` for a one-click copy of IDs and addresses, and `InlineTags` the tag list. Create forms take an `onClose(id?)` prop: embedded, they hide their back link and heading, and a create selects the new resource.
- **Contents.** `PanelSection` is a folding card, `PanelFields` and `PanelField` a label and value list, with `copy` for a one-click copy of IDs and addresses, and `PanelTags` the tag list. Create forms take an `onClose(id?)` prop: embedded, they hide their back link and heading, and a create selects the new resource.
- **One component is both the panel and the page.** Each resource has a single details component (`InstanceDetails`, `VpcDetails`, `InternetGatewayDetails`) that takes `layout="auto" | "page"`. The list renders it as the panel; `/ec2/describe-instances/$id` and its kind render the same component as a page, as one centred column under a standard left-aligned heading, with state and actions on the right and text tabs. A feature added to the panel is on the page by construction, and the page ignores Escape and has a back arrow in place of close. Expanding the panel to its page keeps the tab, and query results such as fetched console output come along from the cache.
- **A click on the list's background closes the panel**, as UniFi's does. A row selects another resource instead, and links, buttons and inputs keep their own behaviour. Selecting rows for a bulk action closes it too, so the bulk bar and the panel never compete.
- **Actions.** State actions sit in the summary as a button group, and the rest go in an Actions menu, Unikraft style. Stop, Reboot and every destructive action confirm in a dialog.
- **Loading.** A panel or page still loading shows `PanelSpinner`, a centred spinner on the background, not a grey skeleton block.
- **`DetailCard`, `DetailRow` and `ListCard` are retired.** Every resource page uses the panel sections instead.
### Tags
`InlineTags` (`src/components/inline-tags.tsx`) shows a resource's tags in its panel and edits them in place.
Each row has a pencil, which turns the key and value into inputs and becomes a tick to save, and a bin to remove the tag; `+ Add tag` appends a row being edited.
Enter saves and Escape cancels the row without closing the panel.
Every row saves on its own request through the service's own tagging call, and a failure shows beside its row.
AWS has no rename, so changing a key puts the new key and then removes the old one, and a failure between the two leaves both rather than neither.
Keys are 1 to 128 characters with no `aws:` prefix and no duplicates, and values up to 256.
Without `onPut` and `onRemove` it is read-only, as for a system image or an AWS-managed policy.
Panels use the per-service wrappers in `src/components/resource-tags.tsx` and the writers in `src/mutations/tags.ts` rather than wiring the calls themselves.
### Forms from a resource's panel
A form that acts on an open resource, such as attaching a volume, scaling a node group or adding a listener, opens in `FormPanel` (`src/components/form-panel.tsx`), never in a dialog.
@@ -333,16 +285,8 @@ Every create flow opens in the side panel over its list, through `CreatePanel`,
- **URL.** `create=true` opens it, plus a list-local field where the flow needs context (`volume` for a snapshot, `instance` for an RDS snapshot, `launch=service|task` on ECS clusters). The old `create-*` addresses redirect to the list with the panel open, so links and bookmarks keep working.
- **Form.** The fields come in the order AWS asks for them. Settings that are on or off are `Switch`es, and a multi-select picker (subnets, security groups) is a `SwitchList`, a boxed list with one switch per row. Checkboxes only select table rows.
- **CLI.** Every create form ends with a `CliCommandPanel` showing the AWS CLI commands that send the same request, built from the form's live values and only the flags the console actually sends. The commands wrap to the panel's width and never scroll sideways; Copy takes the unwrapped text.
- **Sections.** Related fields are grouped in `PanelSection` boxes, as the instance launch form groups Image, Instance, Network and Root volume.
- **Nothing to pick, no field.** An optional picker with nothing usable to choose, such as Launch Template or Placement Group with none available, is left out of the form rather than shown holding only `none`.
- **Advanced settings.** Settings most users leave at their defaults sit in a collapsed Advanced settings section, which opens by itself when a field inside it fails validation.
- **Choices with a mark.** A few named products, such as database engines or distributions, are `ChoiceTile`s with the product's own mark from `src/assets/`, not a select. One that cannot be used stays visible and disabled, saying why.
- **Durations and windows.** A duration is a select of presets with the AWS default first and named (`Auto (5 min)` for a record's TTL), keeping a custom value the API returned. A time window, such as an RDS backup or maintenance window, is a day, start time and length picker that refuses an overlap before submitting.
- **Labels by type.** Where one field means different things by type, its label follows the type, as a record's value reads `IPv4 address` for A and `Mail server` for MX.
- **Long pickers.** A `SwitchList` that can run long, such as security groups, scrolls inside its box rather than pushing the form down.
- **Help.** A field's longer explanation goes in an (i) beside its label, through the `info` prop of `TextField` and `SelectField`, rather than in text under the field. Text under a field is for what the user must know before submitting.
- **Submit.** `FormActions` cross-fades the submit label into a spinner with its pending label over 150ms, at one width, so the button never changes size, and `FormLock`, a native `<fieldset disabled>`, disables every control above it until the request settles. Nothing above the form appears or moves, and the panel does not scroll.
- **CLI.** Every create form ends with a `CliCommandPanel` showing the AWS CLI commands that send the same request, built from the form's live values and only the flags the console actually sends.
- **Submit.** `FormActions` turns the submit button into a spinner with its pending label in place, and `FormLock`, a native `<fieldset disabled>`, disables every control above it until the request settles. Nothing above the form appears or moves, and the panel does not scroll.
- **Failure.** A refused request shows beside the button through `FormActions`'s `error`, and the form unlocks. The handler wraps only the mutation call in `try`, with an empty `catch` that returns, because react-hook-form rethrows whatever the handler throws and the error is already on screen from the mutation's state.
- **Success.** `onClose(id)` closes the panel and selects the new resource, where the list has a panel to select it in.
- **Appliance images.** A create that needs an ECS, EKS or RDS appliance image shows the missing-image callout inside its panel, never on the list, and only the step that needs the image is blocked.
@@ -362,7 +306,6 @@ A gap of more than three sample periods breaks the line rather than drawing acro
## Dates
`formatDateTime` writes the AWS console's form in the viewer's zone, `2026/06/04 02:00 GMT-5`, with a 24-hour clock and UTC as plain `GMT`.
In a table the time is relative (`relativeTime` in `src/lib/relative-time.ts`), rounded down to the largest whole unit, and the full form shows on hover.
## Testing
+2 -4
View File
@@ -732,8 +732,7 @@ table inet spinifex_filter {
# inventory and running instances to anyone who asks. 8660 is
# predastore's unauthenticated /healthz and /readyz, which name the
# peers a gate cannot reach, so it belongs here for the same reason.
# 5001 is the admin network test, listening only while one runs.
ip saddr $spinifex_peers tcp dport { 4222, 4248, 4432, 5001, 5300, 6641, 6642, 6643, 6644, 8660 } accept
ip saddr $spinifex_peers tcp dport { 4222, 4248, 4432, 5300, 6641, 6642, 6643, 6644, 8660 } accept
ip saddr $spinifex_peers udp dport { 5300, 6660, 7660 } accept
# Encap plane, peer-scoped: Geneve, IKE, NAT-T and ESP. Geneve is a
@@ -1310,7 +1309,6 @@ qemu-utils gdisk ovmf qemu-efi-aarch64 less
libvirt-daemon-system libvirt-clients
pciutils
jq curl iproute2 iputils-arping ethtool netcat-openbsd wget unzip xz-utils file
dmidecode iperf
ovn-central ovn-host openvswitch-switch openvswitch-ipsec strongswan-charon dhcpcd-base
chrony nftables"
@@ -1320,7 +1318,7 @@ chrony nftables"
OL_RUNTIME_PACKAGES="nbdkit nbdkit-basic-filters
qemu-kvm qemu-img edk2-ovmf seabios-bin less
pciutils
jq curl iproute iputils ethtool nmap-ncat wget unzip xz file dmidecode
jq curl iproute iputils ethtool nmap-ncat wget unzip xz file
ovn25.03-central ovn25.03-host openvswitch3.5 openvswitch3.5-ipsec libreswan dhcpcd
chrony nftables iptables-nft systemd-resolved"
+5 -22
View File
@@ -224,7 +224,7 @@ func (s *VPCServiceImpl) CreateNetworkInterface(ctx context.Context, input *ec2.
groupNames := make(map[string]string)
s.fillSGNames(ctx, accountID, record.SecurityGroupIds, groupNames)
return &ec2.CreateNetworkInterfaceOutput{
NetworkInterface: s.eniRecordToEC2(&record, accountID, groupNames, s.vpcDNSHostnames(ctx, accountID, record.VpcId)),
NetworkInterface: s.eniRecordToEC2(&record, accountID, groupNames),
}, nil
}
@@ -595,7 +595,7 @@ func (s *VPCServiceImpl) DescribeNetworkInterfaces(ctx context.Context, input *e
}
s.fillSGNames(ctx, accountID, record.SecurityGroupIds, groupNames)
enis = append(enis, s.eniRecordToEC2(&record, accountID, groupNames, s.vpcDNSHostnames(ctx, accountID, record.VpcId)))
enis = append(enis, s.eniRecordToEC2(&record, accountID, groupNames))
}
// If specific ENI IDs were requested but not found, return error
@@ -1076,26 +1076,9 @@ func (s *VPCServiceImpl) fillSGNames(ctx context.Context, accountID string, ids
}
}
// vpcDNSHostnames reports whether the VPC has enableDnsHostnames on. An
// unreadable VPC reads as off, so its interfaces go unnamed.
func (s *VPCServiceImpl) vpcDNSHostnames(ctx context.Context, accountID, vpcID string) bool {
entry, err := s.vpcKV.Get(ctx, kvutil.AccountKey(accountID, vpcID))
if err != nil {
slog.WarnContext(ctx, "vpcDNSHostnames: VPC read failed", "vpcId", vpcID, "err", err)
return false
}
var rec VPCRecord
if err := json.Unmarshal(entry.Value(), &rec); err != nil {
slog.WarnContext(ctx, "vpcDNSHostnames: VPC unmarshal failed", "vpcId", vpcID, "err", err)
return false
}
return rec.EnableDnsHostnames
}
// eniRecordToEC2 converts an ENI record to an EC2 NetworkInterface, naming
// each security group from groupNames. AWS names the interface only while its
// VPC has enableDnsHostnames on.
func (s *VPCServiceImpl) eniRecordToEC2(record *ENIRecord, accountID string, groupNames map[string]string, dnsHostnames bool) *ec2.NetworkInterface {
// each security group from groupNames.
func (s *VPCServiceImpl) eniRecordToEC2(record *ENIRecord, accountID string, groupNames map[string]string) *ec2.NetworkInterface {
// ENIs with spinifex:managed-by tag are system-managed (e.g. by ELBv2)
requesterManaged := record.Tags["spinifex:managed-by"] != ""
@@ -1133,7 +1116,7 @@ func (s *VPCServiceImpl) eniRecordToEC2(record *ENIRecord, accountID string, gro
eni.Groups = groups
}
if dnsHostnames && s.privateDNSName != nil && record.PrivateIpAddress != "" {
if s.privateDNSName != nil && record.PrivateIpAddress != "" {
if name := s.privateDNSName(record.PrivateIpAddress); name != "" {
eni.PrivateDnsName = aws.String(name)
eni.PrivateIpAddresses[0].PrivateDnsName = aws.String(name)
+13 -56
View File
@@ -55,11 +55,15 @@ func TestNetworkInterface_NamesGroupsAndPrivateAddress(t *testing.T) {
vpcId := createTestVPC(t, svc, "10.0.0.0/16")
subnetId := createTestSubnet(t, svc, vpcId, "10.0.1.0/24")
sgId := createTestSG(t, svc, vpcId, "web")
setHostnames(t, svc, vpcId, true)
// Without a namer an interface carries no private name.
unnamed := createTestENI(t, svc, subnetId)
assert.Nil(t, describeTestENI(t, svc, unnamed).PrivateDnsName)
desc, err := svc.DescribeNetworkInterfaces(context.Background(), &ec2.DescribeNetworkInterfacesInput{
NetworkInterfaceIds: []*string{aws.String(unnamed)},
}, testAccountID)
require.NoError(t, err)
require.Len(t, desc.NetworkInterfaces, 1)
assert.Nil(t, desc.NetworkInterfaces[0].PrivateDnsName)
svc.SetPrivateDNSNamer(func(ip string) string { return "name-of-" + ip })
out, err := svc.CreateNetworkInterface(context.Background(), &ec2.CreateNetworkInterfaceInput{
@@ -68,8 +72,13 @@ func TestNetworkInterface_NamesGroupsAndPrivateAddress(t *testing.T) {
}, testAccountID)
require.NoError(t, err)
desc := describeTestENI(t, svc, *out.NetworkInterface.NetworkInterfaceId)
for name, eni := range map[string]*ec2.NetworkInterface{"create": out.NetworkInterface, "describe": desc} {
desc, err = svc.DescribeNetworkInterfaces(context.Background(), &ec2.DescribeNetworkInterfacesInput{
NetworkInterfaceIds: []*string{out.NetworkInterface.NetworkInterfaceId},
}, testAccountID)
require.NoError(t, err)
require.Len(t, desc.NetworkInterfaces, 1)
for name, eni := range map[string]*ec2.NetworkInterface{"create": out.NetworkInterface, "describe": desc.NetworkInterfaces[0]} {
want := "name-of-" + aws.StringValue(eni.PrivateIpAddress)
assert.Equal(t, want, aws.StringValue(eni.PrivateDnsName), name)
require.Len(t, eni.PrivateIpAddresses, 1, name)
@@ -80,58 +89,6 @@ func TestNetworkInterface_NamesGroupsAndPrivateAddress(t *testing.T) {
}
}
func TestNetworkInterface_PrivateDnsNameFollowsVpcDnsHostnames(t *testing.T) {
t.Parallel()
svc := setupTestVPCService(t)
svc.SetPrivateDNSNamer(func(ip string) string { return "name-of-" + ip })
vpcId := createTestVPC(t, svc, "10.0.0.0/16")
subnetId := createTestSubnet(t, svc, vpcId, "10.0.1.0/24")
// A new VPC has enableDnsHostnames off, so its interfaces go unnamed.
out, err := svc.CreateNetworkInterface(context.Background(), &ec2.CreateNetworkInterfaceInput{
SubnetId: aws.String(subnetId),
}, testAccountID)
require.NoError(t, err)
eniId := *out.NetworkInterface.NetworkInterfaceId
for name, eni := range map[string]*ec2.NetworkInterface{"create": out.NetworkInterface, "describe": describeTestENI(t, svc, eniId)} {
assert.Nil(t, eni.PrivateDnsName, name)
require.Len(t, eni.PrivateIpAddresses, 1, name)
assert.Nil(t, eni.PrivateIpAddresses[0].PrivateDnsName, name)
}
// Turning the flag on names the existing interface.
setHostnames(t, svc, vpcId, true)
eni := describeTestENI(t, svc, eniId)
want := "name-of-" + aws.StringValue(eni.PrivateIpAddress)
assert.Equal(t, want, aws.StringValue(eni.PrivateDnsName))
assert.Equal(t, want, aws.StringValue(eni.PrivateIpAddresses[0].PrivateDnsName))
// And turning it off again drops the name.
setHostnames(t, svc, vpcId, false)
eni = describeTestENI(t, svc, eniId)
assert.Nil(t, eni.PrivateDnsName)
assert.Nil(t, eni.PrivateIpAddresses[0].PrivateDnsName)
}
func setHostnames(t *testing.T, svc *VPCServiceImpl, vpcId string, on bool) {
t.Helper()
_, err := svc.ModifyVpcAttribute(context.Background(), &ec2.ModifyVpcAttributeInput{
VpcId: aws.String(vpcId),
EnableDnsHostnames: &ec2.AttributeBooleanValue{Value: aws.Bool(on)},
}, testAccountID)
require.NoError(t, err)
}
func describeTestENI(t *testing.T, svc *VPCServiceImpl, eniId string) *ec2.NetworkInterface {
t.Helper()
desc, err := svc.DescribeNetworkInterfaces(context.Background(), &ec2.DescribeNetworkInterfacesInput{
NetworkInterfaceIds: []*string{aws.String(eniId)},
}, testAccountID)
require.NoError(t, err)
require.Len(t, desc.NetworkInterfaces, 1)
return desc.NetworkInterfaces[0]
}
func TestCreateNetworkInterface_SequentialIPs(t *testing.T) {
t.Parallel()
svc := setupTestVPCService(t)
+3 -14
View File
@@ -288,7 +288,7 @@ func (s *Service) DescribeClusters(ctx context.Context, input *ecs.DescribeClust
names = []string{defaultCluster}
}
includeSettings := slices.Contains(awsStringSlice(input.Include), ecs.ClusterFieldSettings)
out := &ecs.DescribeClustersOutput{Clusters: []*ecs.Cluster{}, Failures: []*ecs.Failure{}}
out := &ecs.DescribeClustersOutput{}
for _, name := range names {
name = ClusterShortName(name)
var rec ClusterRecord
@@ -458,7 +458,7 @@ func (s *Service) RegisterTaskDefinition(ctx context.Context, input *ecs.Registe
if family == "" {
return nil, errors.New(awserrors.ErrorECSInvalidParameter)
}
if err := validateContainerDefs(input.ContainerDefinitions, aws.StringValue(input.Memory)); err != nil {
if err := validateContainerDefs(input.ContainerDefinitions); err != nil {
return nil, err
}
warnUnsupportedLogDrivers(ctx, family, input.ContainerDefinitions)
@@ -497,23 +497,12 @@ func (s *Service) RegisterTaskDefinition(ctx context.Context, input *ecs.Registe
// The test here is per value, not per field: an empty list or a false/unset
// flag requests nothing and is accepted, because there is nothing to enforce
// or refuse. Only a value that asks for the restriction is rejected.
//
// Ahead of those, AWS's own rules: every container needs an image, and without
// a task-level memory every container needs memory or memoryReservation.
func validateContainerDefs(defs []*ecs.ContainerDefinition, taskMemory string) error {
func validateContainerDefs(defs []*ecs.ContainerDefinition) error {
for _, c := range defs {
if c == nil {
continue
}
name := aws.StringValue(c.Name)
if aws.StringValue(c.Image) == "" {
return awserrors.Errorf(awserrors.ErrorECSClientException,
"Container.image should not be null or empty.")
}
if taskMemory == "" && c.Memory == nil && c.MemoryReservation == nil {
return awserrors.Errorf(awserrors.ErrorECSClientException,
"Invalid setting for container '%s'. At least one of 'memory' or 'memoryReservation' must be specified.", name)
}
if len(c.Secrets) > 0 {
return errors.New(awserrors.ErrorECSInvalidParameter)
}
-24
View File
@@ -812,12 +812,6 @@ func TestService_ListTasks_EmptyIsPresentNotAbsent(t *testing.T) {
assert.Empty(t, out.TaskArns)
}
func TestService_ListTasks_ClusterNotFound(t *testing.T) {
svc, _ := newTestService(t)
_, err := svc.ListTasks(context.Background(), &ecs.ListTasksInput{Cluster: aws.String("ghost")}, testAccountID)
require.EqualError(t, err, awserrors.ErrorECSClusterNotFound)
}
func TestService_ListContainerInstances_EmptyIsPresentNotAbsent(t *testing.T) {
svc, _ := newTestService(t)
_, err := svc.CreateCluster(context.Background(), &ecs.CreateClusterInput{ClusterName: aws.String("web")}, testAccountID)
@@ -835,21 +829,3 @@ func TestAccountIDFromBucket(t *testing.T) {
_, ok = accountIDFromBucket("some-other-bucket")
assert.False(t, ok)
}
func TestService_DescribeClusters_ListsAlwaysPresent(t *testing.T) {
svc, _ := newTestService(t)
_, err := svc.CreateCluster(context.Background(), &ecs.CreateClusterInput{ClusterName: aws.String("web")}, testAccountID)
require.NoError(t, err)
found, err := svc.DescribeClusters(context.Background(), &ecs.DescribeClustersInput{Clusters: []*string{aws.String("web")}}, testAccountID)
require.NoError(t, err)
require.Len(t, found.Clusters, 1)
assert.NotNil(t, found.Failures)
assert.Empty(t, found.Failures)
miss, err := svc.DescribeClusters(context.Background(), &ecs.DescribeClustersInput{Clusters: []*string{aws.String("nope")}}, testAccountID)
require.NoError(t, err)
require.Len(t, miss.Failures, 1)
assert.NotNil(t, miss.Clusters)
assert.Empty(t, miss.Clusters)
}
+2 -2
View File
@@ -313,7 +313,7 @@ func TestService_CreateService_ProjectsNetworkConfigurationAndManagedTags(t *tes
Family: aws.String("app"),
NetworkMode: aws.String(NetworkModeAwsvpc),
ContainerDefinitions: []*ecs.ContainerDefinition{{
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true), Memory: aws.Int64(256),
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true),
}},
}, testAccountID)
require.NoError(t, err)
@@ -352,7 +352,7 @@ func TestService_UpdateService_ChangesNetworkConfiguration(t *testing.T) {
Family: aws.String("app"),
NetworkMode: aws.String(NetworkModeAwsvpc),
ContainerDefinitions: []*ecs.ContainerDefinition{{
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true), Memory: aws.Int64(256),
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true),
}},
}, testAccountID)
require.NoError(t, err)
@@ -16,8 +16,6 @@ import (
// agent to reach anything other than PENDING.
func seedTasks(t *testing.T, svc *ecsdomain.Service, cluster string, recs ...*ecsdomain.TaskRecord) {
t.Helper()
_, err := svc.CreateCluster(t.Context(), &ecs.CreateClusterInput{ClusterName: aws.String(cluster)}, ecsdomain.TestAccountID)
require.NoError(t, err)
kv, err := svc.Bucket(t.Context(), ecsdomain.TestAccountID)
require.NoError(t, err)
for _, r := range recs {
+18 -57
View File
@@ -19,7 +19,7 @@ func TestRegisterTaskDefinition_RejectsSecrets(t *testing.T) {
_, err := svc.RegisterTaskDefinition(context.Background(), &ecs.RegisterTaskDefinitionInput{
Family: aws.String("app"),
ContainerDefinitions: []*ecs.ContainerDefinition{{
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true), Memory: aws.Int64(256),
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true),
Secrets: []*ecs.Secret{{
Name: aws.String("DB_PASSWORD"), ValueFrom: aws.String("arn:aws:ssm:::parameter/db"),
}},
@@ -37,7 +37,7 @@ func TestRegisterTaskDefinition_AcceptsUnsupportedLogDriver(t *testing.T) {
_, err := svc.RegisterTaskDefinition(context.Background(), &ecs.RegisterTaskDefinitionInput{
Family: aws.String("app"),
ContainerDefinitions: []*ecs.ContainerDefinition{{
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true), Memory: aws.Int64(256),
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true),
LogConfiguration: &ecs.LogConfiguration{
LogDriver: aws.String("awslogs"),
Options: map[string]*string{"awslogs-group": aws.String("/ecs/app")},
@@ -67,7 +67,7 @@ func TestRegisterTaskDefinition_EchoesRequiresCompatibilities(t *testing.T) {
Family: aws.String("app"),
RequiresCompatibilities: aws.StringSlice([]string{"EC2"}),
ContainerDefinitions: []*ecs.ContainerDefinition{{
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true), Memory: aws.Int64(256),
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true),
}},
}, testAccountID)
require.NoError(t, err)
@@ -91,7 +91,7 @@ func TestRegisterTaskDefinition_EchoesRuntimePlatform(t *testing.T) {
OperatingSystemFamily: aws.String("LINUX"),
},
ContainerDefinitions: []*ecs.ContainerDefinition{{
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true), Memory: aws.Int64(256),
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true),
}},
}, testAccountID)
require.NoError(t, err)
@@ -113,7 +113,7 @@ func TestRegisterTaskDefinition_RuntimePlatformOmittedWhenUnset(t *testing.T) {
_, err := svc.RegisterTaskDefinition(context.Background(), &ecs.RegisterTaskDefinitionInput{
Family: aws.String("app"),
ContainerDefinitions: []*ecs.ContainerDefinition{{
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true), Memory: aws.Int64(256),
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true),
}},
}, testAccountID)
require.NoError(t, err)
@@ -253,7 +253,7 @@ func TestRegisterTaskDefinition_EchoesRuntimeFields(t *testing.T) {
_, err := svc.RegisterTaskDefinition(context.Background(), &ecs.RegisterTaskDefinitionInput{
Family: aws.String("app"),
ContainerDefinitions: []*ecs.ContainerDefinition{{
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true), Memory: aws.Int64(256),
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true),
User: aws.String("1000:1000"),
ReadonlyRootFilesystem: aws.Bool(true),
Privileged: aws.Bool(false),
@@ -310,7 +310,7 @@ func TestRegisterTaskDefinition_RuntimeFieldsOmittedWhenUnset(t *testing.T) {
_, err := svc.RegisterTaskDefinition(context.Background(), &ecs.RegisterTaskDefinitionInput{
Family: aws.String("app"),
ContainerDefinitions: []*ecs.ContainerDefinition{{
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true), Memory: aws.Int64(256),
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true),
}},
}, testAccountID)
require.NoError(t, err)
@@ -347,7 +347,7 @@ func TestRegisterTaskDefinition_EchoesEmptyCollections(t *testing.T) {
_, err := svc.RegisterTaskDefinition(context.Background(), &ecs.RegisterTaskDefinitionInput{
Family: aws.String("app"),
ContainerDefinitions: []*ecs.ContainerDefinition{{
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true), Memory: aws.Int64(256),
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true),
Environment: []*ecs.KeyValuePair{},
MountPoints: []*ecs.MountPoint{},
VolumesFrom: []*ecs.VolumeFrom{},
@@ -386,7 +386,7 @@ func TestRegisterTaskDefinition_FillsAWSDefaults(t *testing.T) {
Family: aws.String("app"),
NetworkMode: aws.String("awsvpc"),
ContainerDefinitions: []*ecs.ContainerDefinition{{
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true), Memory: aws.Int64(256),
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true),
PortMappings: []*ecs.PortMapping{{ContainerPort: aws.Int64(80)}},
}},
}, testAccountID)
@@ -420,7 +420,7 @@ func TestRegisterTaskDefinition_BridgeLeavesHostPortUnset(t *testing.T) {
Family: aws.String("app"),
NetworkMode: aws.String("bridge"),
ContainerDefinitions: []*ecs.ContainerDefinition{{
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true), Memory: aws.Int64(256),
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true),
PortMappings: []*ecs.PortMapping{{ContainerPort: aws.Int64(53), Protocol: aws.String("udp")}},
}},
}, testAccountID)
@@ -435,7 +435,7 @@ func TestRegisterTaskDefinition_AcceptsEmptyRuntimeFields(t *testing.T) {
_, err := svc.RegisterTaskDefinition(context.Background(), &ecs.RegisterTaskDefinitionInput{
Family: aws.String("app"),
ContainerDefinitions: []*ecs.ContainerDefinition{{
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true), Memory: aws.Int64(256),
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true),
MountPoints: []*ecs.MountPoint{},
VolumesFrom: []*ecs.VolumeFrom{},
SystemControls: []*ecs.SystemControl{},
@@ -455,7 +455,7 @@ func TestRegisterTaskDefinition_RejectsMountPoints(t *testing.T) {
_, err := svc.RegisterTaskDefinition(context.Background(), &ecs.RegisterTaskDefinitionInput{
Family: aws.String("app"),
ContainerDefinitions: []*ecs.ContainerDefinition{{
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true), Memory: aws.Int64(256),
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true),
MountPoints: []*ecs.MountPoint{{
SourceVolume: aws.String("data"), ContainerPath: aws.String("/data"),
}},
@@ -476,7 +476,7 @@ func TestRegisterTaskDefinition_RejectsVolumesFrom(t *testing.T) {
_, err := svc.RegisterTaskDefinition(context.Background(), &ecs.RegisterTaskDefinitionInput{
Family: aws.String("app"),
ContainerDefinitions: []*ecs.ContainerDefinition{{
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true), Memory: aws.Int64(256),
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true),
VolumesFrom: []*ecs.VolumeFrom{{SourceContainer: aws.String("other")}},
}},
}, testAccountID)
@@ -494,7 +494,7 @@ func TestRegisterTaskDefinition_RejectsLinuxParametersDevices(t *testing.T) {
_, err := svc.RegisterTaskDefinition(context.Background(), &ecs.RegisterTaskDefinitionInput{
Family: aws.String("app"),
ContainerDefinitions: []*ecs.ContainerDefinition{{
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true), Memory: aws.Int64(256),
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true),
LinuxParameters: &ecs.LinuxParameters{
Devices: []*ecs.Device{{HostPath: aws.String("/dev/fuse")}},
},
@@ -514,7 +514,7 @@ func TestRegisterTaskDefinition_RejectsInitProcessEnabled(t *testing.T) {
_, err := svc.RegisterTaskDefinition(context.Background(), &ecs.RegisterTaskDefinitionInput{
Family: aws.String("app"),
ContainerDefinitions: []*ecs.ContainerDefinition{{
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true), Memory: aws.Int64(256),
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true),
LinuxParameters: &ecs.LinuxParameters{InitProcessEnabled: aws.Bool(true)},
}},
}, testAccountID)
@@ -533,7 +533,7 @@ func TestRegisterTaskDefinition_RejectsSharedMemoryAndTmpfs(t *testing.T) {
_, err := svc.RegisterTaskDefinition(context.Background(), &ecs.RegisterTaskDefinitionInput{
Family: aws.String("shm"),
ContainerDefinitions: []*ecs.ContainerDefinition{{
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true), Memory: aws.Int64(256),
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true),
LinuxParameters: &ecs.LinuxParameters{SharedMemorySize: aws.Int64(64)},
}},
}, testAccountID)
@@ -545,7 +545,7 @@ func TestRegisterTaskDefinition_RejectsSharedMemoryAndTmpfs(t *testing.T) {
_, err = svc.RegisterTaskDefinition(context.Background(), &ecs.RegisterTaskDefinitionInput{
Family: aws.String("tmpfs"),
ContainerDefinitions: []*ecs.ContainerDefinition{{
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true), Memory: aws.Int64(256),
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true),
LinuxParameters: &ecs.LinuxParameters{
Tmpfs: []*ecs.Tmpfs{{ContainerPath: aws.String("/tmp"), Size: aws.Int64(64)}},
},
@@ -567,7 +567,7 @@ func TestRunTask_AssignCarriesRuntimeFields(t *testing.T) {
_, err = svc.RegisterTaskDefinition(context.Background(), &ecs.RegisterTaskDefinitionInput{
Family: aws.String("app"),
ContainerDefinitions: []*ecs.ContainerDefinition{{
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true), Memory: aws.Int64(256),
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true),
User: aws.String("1000"),
ReadonlyRootFilesystem: aws.Bool(true),
Privileged: aws.Bool(true),
@@ -608,42 +608,3 @@ func TestRunTask_AssignCarriesRuntimeFields(t *testing.T) {
assert.Equal(t, "net.core.somaxconn", ac.SystemControls[0].Namespace)
assert.Equal(t, []string{"SYS_PTRACE"}, ac.CapAdd)
}
// AWS rejects a container with no image before checking its memory, and
// without task-level memory requires memory or memoryReservation per container.
func TestRegisterTaskDefinition_RequiresImageAndMemory(t *testing.T) {
tests := []struct {
name string
taskMemory *string
container *ecs.ContainerDefinition
wantErr string
}{
{"no image or memory", nil, &ecs.ContainerDefinition{Name: aws.String("web")},
"Container.image should not be null or empty."},
{"empty image", aws.String("512"), &ecs.ContainerDefinition{Name: aws.String("web"), Image: aws.String("")},
"Container.image should not be null or empty."},
{"no memory", nil, &ecs.ContainerDefinition{Name: aws.String("web"), Image: aws.String("registry/web:1")},
"Invalid setting for container 'web'. At least one of 'memory' or 'memoryReservation' must be specified."},
{"memoryReservation only", nil, &ecs.ContainerDefinition{
Name: aws.String("web"), Image: aws.String("registry/web:1"), MemoryReservation: aws.Int64(128)}, ""},
{"task-level memory", aws.String("512"), &ecs.ContainerDefinition{
Name: aws.String("web"), Image: aws.String("registry/web:1")}, ""},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
svc, _ := newTestService(t)
_, err := svc.RegisterTaskDefinition(context.Background(), &ecs.RegisterTaskDefinitionInput{
Family: aws.String("app"), Memory: tc.taskMemory,
ContainerDefinitions: []*ecs.ContainerDefinition{tc.container},
}, testAccountID)
if tc.wantErr == "" {
require.NoError(t, err)
return
}
code, msg, ok := awserrors.ResolveErrorDetail(err)
require.True(t, ok)
assert.Equal(t, awserrors.ErrorECSClientException, code)
assert.Equal(t, tc.wantErr, msg)
})
}
}
-8
View File
@@ -303,14 +303,6 @@ func (s *Service) ListTasks(ctx context.Context, input *ecs.ListTasksInput, acco
if err != nil {
return nil, err
}
var clusterRec ClusterRecord
found, err := getJSON(ctx, kv, ClusterMetaKey(cluster), &clusterRec)
if err != nil {
return nil, err
}
if !found {
return nil, errors.New(awserrors.ErrorECSClusterNotFound)
}
keys, err := keysWithPrefix(ctx, kv, TasksPrefix(cluster))
if err != nil {
return nil, err
+4 -8
View File
@@ -3503,18 +3503,14 @@ func (s *ELBv2ServiceImpl) tgRecordToSDK(r *TargetGroupRecord, lbArns []string)
HealthCheckEnabled: aws.Bool(r.HealthCheck.Enabled),
HealthCheckProtocol: aws.String(r.HealthCheck.Protocol),
HealthCheckPort: aws.String(r.HealthCheck.Port),
HealthCheckPath: aws.String(r.HealthCheck.Path),
HealthCheckIntervalSeconds: aws.Int64(r.HealthCheck.IntervalSeconds),
HealthCheckTimeoutSeconds: aws.Int64(r.HealthCheck.TimeoutSeconds),
HealthyThresholdCount: aws.Int64(r.HealthCheck.HealthyThreshold),
UnhealthyThresholdCount: aws.Int64(r.HealthCheck.UnhealthyThreshold),
}
// AWS omits both keys on TCP target groups, which have no path or matcher.
if r.HealthCheck.Path != "" {
tg.HealthCheckPath = aws.String(r.HealthCheck.Path)
}
if r.HealthCheck.Matcher != "" {
tg.Matcher = &elbv2.Matcher{HttpCode: aws.String(r.HealthCheck.Matcher)}
Matcher: &elbv2.Matcher{
HttpCode: aws.String(r.HealthCheck.Matcher),
},
}
// Omitted for NLB (TCP/UDP/TLS) target groups, matching AWS, which returns
+4 -5
View File
@@ -459,12 +459,11 @@ func TestCreateTargetGroup_TCPProtocol(t *testing.T) {
assert.Equal(t, int64(5432), *tg.Port)
// NLB health check defaults: TCP protocol, no path, no matcher.
assert.Equal(t, "TCP", *tg.HealthCheckProtocol)
assert.Nil(t, tg.HealthCheckPath)
assert.Nil(t, tg.Matcher)
assert.Equal(t, int64(30), *tg.HealthCheckIntervalSeconds)
assert.Empty(t, *tg.HealthCheckPath)
assert.Empty(t, *tg.Matcher.HttpCode)
assert.Equal(t, int64(10), *tg.HealthCheckTimeoutSeconds)
assert.Equal(t, int64(5), *tg.HealthyThresholdCount)
assert.Equal(t, int64(2), *tg.UnhealthyThresholdCount)
assert.Equal(t, int64(3), *tg.HealthyThresholdCount)
assert.Equal(t, int64(3), *tg.UnhealthyThresholdCount)
}
func TestCreateTargetGroup_NLBProtocols(t *testing.T) {
+2 -2
View File
@@ -99,8 +99,8 @@ const (
// Default health check values (NLB).
DefaultNLBHealthCheckInterval = 30
DefaultNLBHealthCheckTimeout = 10
DefaultNLBHealthyThreshold = 5
DefaultNLBUnhealthyThreshold = 2
DefaultNLBHealthyThreshold = 3
DefaultNLBUnhealthyThreshold = 3
DefaultNLBHealthCheckProtocol = ProtocolTCP
DefaultNLBHealthCheckPort = "traffic-port"
+3 -2
View File
@@ -31,7 +31,7 @@ type VolumeService interface {
// Publisher is the provider pair used to reach the new volume's blocks.
type Publisher interface {
PublishVolume(context.Context, ebsprovider.PublishVolumeRequest) (*ebsprovider.PublishedVolume, error)
UnpublishVolume(context.Context, ebsprovider.UnpublishVolumeRequest) error
UnpublishVolume(context.Context, ebsprovider.UnpublishVolumeRequest) (*ebsprovider.UnpublishedVolume, error)
}
// Request names the volume to create.
@@ -204,9 +204,10 @@ func (p *Preparer) fill(ctx context.Context, volumeID, image string) error {
func (p *Preparer) unpublish(ctx context.Context, volumeID string) error {
cctx, cancel := p.cleanupContext(ctx)
defer cancel()
return p.Provider.UnpublishVolume(cctx, ebsprovider.UnpublishVolumeRequest{
_, err := p.Provider.UnpublishVolume(cctx, ebsprovider.UnpublishVolumeRequest{
Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID, NodeID: p.NodeID,
})
return err
}
// fail discards the volume when its export is known closed and otherwise
@@ -105,14 +105,17 @@ func (h *harness) PublishVolume(_ context.Context, req ebsprovider.PublishVolume
return &ebsprovider.PublishedVolume{VolumeID: req.VolumeID, NodeID: req.NodeID, Export: export}, nil
}
func (h *harness) UnpublishVolume(ctx context.Context, req ebsprovider.UnpublishVolumeRequest) error {
func (h *harness) UnpublishVolume(ctx context.Context, req ebsprovider.UnpublishVolumeRequest) (*ebsprovider.UnpublishedVolume, error) {
h.record("unpublish " + req.VolumeID + " on " + req.NodeID)
h.unpublishCtxOK = ctx.Err() == nil
if h.unpublishBlocks {
<-ctx.Done()
return ctx.Err()
return nil, ctx.Err()
}
return h.unpublishErr
if h.unpublishErr != nil {
return nil, h.unpublishErr
}
return &ebsprovider.UnpublishedVolume{}, nil
}
func (h *harness) preparer(t *testing.T) *Preparer {
+1 -1
View File
@@ -1268,7 +1268,7 @@ var ErrorLookup = map[string]ErrorMessage{
ErrorEKSResourceInUse: {HTTPCode: 409, Message: "A cluster with this name already exists in this account and Region. Delete the existing cluster, or choose a different name."},
ErrorEKSResourceNotFound: {HTTPCode: 404, Message: "The specified cluster could not be found. You can view your available clusters with ListClusters. Amazon EKS clusters are Region specific."},
ErrorECSClientException: {HTTPCode: 400, Message: "Unable to describe task definition."},
ErrorECSClusterNotFound: {HTTPCode: 400, Message: "Cluster not found."},
ErrorECSClusterNotFound: {HTTPCode: 400, Message: "The specified cluster was not found. You can view your available clusters with ListClusters. Amazon ECS clusters are Region specific."},
ErrorECSInvalidParameter: {HTTPCode: 400, Message: "The specified parameter is not valid. Review the available parameters for the API request."},
ErrorECSServiceNotFound: {HTTPCode: 400, Message: "The specified service was not found. You can view your available services with ListServices. Amazon ECS services are cluster specific and Region specific."},
ErrorRetryableError: {HTTPCode: 400, Message: "A request submitted by an AWS service on your behalf could not be completed. The requesting service might automatically retry the request."},
+1 -1
View File
@@ -76,7 +76,7 @@ func ImportImage(ctx context.Context, provider ebsprovider.EBSProvider, opts Imp
// Unpublish regardless: a volume left published cannot be snapshotted or
// attached, and on the failure path it would strand the export too.
if err := provider.UnpublishVolume(ctx, ebsprovider.UnpublishVolumeRequest{
if _, err := provider.UnpublishVolume(ctx, ebsprovider.UnpublishVolumeRequest{
Versioned: ebsprovider.NewVersioned(),
VolumeID: opts.VolumeID,
NodeID: opts.NodeID,
+1 -1
View File
@@ -49,7 +49,7 @@ func (p *recordingProvider) PublishVolume(ctx context.Context, req ebsprovider.P
return &ebsprovider.PublishedVolume{VolumeID: req.VolumeID, NodeID: req.NodeID, Export: export}, nil
}
func (p *recordingProvider) UnpublishVolume(ctx context.Context, req ebsprovider.UnpublishVolumeRequest) error {
func (p *recordingProvider) UnpublishVolume(ctx context.Context, req ebsprovider.UnpublishVolumeRequest) (*ebsprovider.UnpublishedVolume, error) {
p.calls = append(p.calls, "unpublish")
return p.MemoryProvider.UnpublishVolume(ctx, req)
}
+19 -42
View File
@@ -106,33 +106,10 @@ func flushSupported(w http.ResponseWriter) bool {
}
func streamInstanceMetrics(w http.ResponseWriter, r *http.Request, nc *nats.Conn, accountID, instanceID string, since int64) error {
return streamLive(w, r, nc, liveSubjects{
action: GetInstanceMetricsAction,
sample: telemetryv1.LiveSampleSubject(accountID, instanceID),
watch: telemetryv1.LiveWatchSubject(accountID, instanceID),
absent: awserrors.ErrorInvalidInstanceIDNotFound,
}, since, func(data []byte) ([]telemetryv1.LiveSample, error) {
var ring telemetryv1.LiveRing
err := json.Unmarshal(data, &ring)
return ring.Samples, err
}, func(s telemetryv1.LiveSample) int64 { return s.TimeMillis })
}
// liveSubjects names one live source: where its samples are published, where
// to ask for its ring, and the error for a source nobody answers for.
type liveSubjects struct {
action string
sample string
watch string
absent string
}
func streamLive[S any](w http.ResponseWriter, r *http.Request, nc *nats.Conn, src liveSubjects, since int64,
decodeRing func([]byte) ([]S, error), stamp func(S) int64) error {
samples := make(chan *nats.Msg, 64)
sub, err := nc.ChanSubscribe(src.sample, samples)
sub, err := nc.ChanSubscribe(telemetryv1.LiveSampleSubject(accountID, instanceID), samples)
if err != nil {
slog.ErrorContext(r.Context(), src.action+": subscribe", "err", err)
slog.ErrorContext(r.Context(), "GetInstanceMetrics: subscribe", "err", err)
return errors.New(awserrors.ErrorServerInternal)
}
defer func() { _ = sub.Unsubscribe() }()
@@ -141,21 +118,22 @@ func streamLive[S any](w http.ResponseWriter, r *http.Request, nc *nats.Conn, sr
if err != nil {
return errors.New(awserrors.ErrorInternalError)
}
watchSubject := telemetryv1.LiveWatchSubject(accountID, instanceID)
// Subscribed first, then asked for the ring, so nothing falls between them.
// No responder is a source that is not running in this account, which
// No responder is an instance that is not running in this account, which
// reads the same as one that does not exist.
reply, err := nc.Request(src.watch, watch, liveWatchTimeout)
reply, err := nc.Request(watchSubject, watch, liveWatchTimeout)
if err != nil {
if errors.Is(err, nats.ErrNoResponders) || errors.Is(err, nats.ErrTimeout) {
return errors.New(src.absent)
return errors.New(awserrors.ErrorInvalidInstanceIDNotFound)
}
slog.ErrorContext(r.Context(), src.action+": watch request", "err", err)
slog.ErrorContext(r.Context(), "GetInstanceMetrics: watch request", "err", err)
return errors.New(awserrors.ErrorServerInternal)
}
ring, err := decodeRing(reply.Data)
if err != nil {
slog.ErrorContext(r.Context(), src.action+": malformed ring", "err", err)
var ring telemetryv1.LiveRing
if err := json.Unmarshal(reply.Data, &ring); err != nil {
slog.ErrorContext(r.Context(), "GetInstanceMetrics: malformed ring", "err", err)
return errors.New(awserrors.ErrorServerInternal)
}
@@ -168,23 +146,22 @@ func streamLive[S any](w http.ResponseWriter, r *http.Request, nc *nats.Conn, sr
// From here the status is committed, so failures end the stream quietly and
// the client reconnects.
last := since
send := func(s S) bool {
t := stamp(s)
if t <= last {
send := func(s telemetryv1.LiveSample) bool {
if s.TimeMillis <= last {
return true
}
data, err := json.Marshal(s)
if err != nil {
return true
}
last = t
if _, err := fmt.Fprintf(w, "id: %d\nevent: sample\ndata: %s\n\n", t, data); err != nil {
last = s.TimeMillis
if _, err := fmt.Fprintf(w, "id: %d\nevent: sample\ndata: %s\n\n", s.TimeMillis, data); err != nil {
return false
}
return ctrl.Flush() == nil
}
for _, s := range ring {
for _, s := range ring.Samples {
if !send(s) {
return nil
}
@@ -207,17 +184,17 @@ func streamLive[S any](w http.ResponseWriter, r *http.Request, nc *nats.Conn, sr
case <-end.C:
return nil
case <-renew.C:
if err := nc.Publish(src.watch, watch); err != nil {
slog.DebugContext(r.Context(), src.action+": renew watch", "err", err)
if err := nc.Publish(watchSubject, watch); err != nil {
slog.DebugContext(r.Context(), "GetInstanceMetrics: renew watch", "err", err)
}
case <-keepAlive.C:
if _, err := fmt.Fprint(w, ": keep-alive\n\n"); err != nil || ctrl.Flush() != nil {
return nil
}
case msg := <-samples:
var s S
var s telemetryv1.LiveSample
if err := json.Unmarshal(msg.Data, &s); err != nil {
slog.DebugContext(r.Context(), src.action+": malformed sample", "err", err)
slog.DebugContext(r.Context(), "GetInstanceMetrics: malformed sample", "err", err)
continue
}
if !send(s) {
-432
View File
@@ -1,432 +0,0 @@
package admin
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log/slog"
"slices"
"strconv"
"time"
clusterv1 "github.com/mulgadc/spinifex/contracts/cluster/v1"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
natsmsg "github.com/mulgadc/spinifex/spinifex/foundation/messaging/nats"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
)
// GetNodeFactsOutput is the response for GetNodeFacts.
type GetNodeFactsOutput struct {
Nodes []clusterv1.NodeFacts `json:"nodes"`
}
// nodeFactsTimeout allows for a node rebuilding its facts, which reads every NIC.
const nodeFactsTimeout = 7 * time.Second
// GetNodeFacts gathers every node's hardware and network facts.
func GetNodeFacts(ctx context.Context, nc *nats.Conn, expectedNodes int) (*GetNodeFactsOutput, error) {
frames, _, err := natsmsg.Gather(ctx, nc, clusterv1.NodeFactsSubject, []byte("{}"),
natsmsg.GatherOpts{Timeout: nodeFactsTimeout, ExpectedNodes: expectedNodes})
if err != nil {
return nil, err
}
out := &GetNodeFactsOutput{Nodes: make([]clusterv1.NodeFacts, 0, len(frames))}
for _, frame := range frames {
var f clusterv1.NodeFacts
if json.Unmarshal(frame.Data, &f) == nil && f.Node != "" {
out.Nodes = append(out.Nodes, f)
}
}
slices.SortFunc(out.Nodes, func(a, b clusterv1.NodeFacts) int {
switch {
case a.Node < b.Node:
return -1
case a.Node > b.Node:
return 1
}
return 0
})
return out, nil
}
// Network test states.
const (
NetworkTestRunning = "running"
NetworkTestComplete = "complete"
NetworkTestFailed = "failed"
NetworkTestPending = "pending"
)
// Bounds on one network test, so an admin cannot tie the cluster's links up.
const (
defaultNetworkTestSeconds = 10
defaultNetworkTestStreams = 4
maxNetworkTestRuns = 24
maxNetworkTestListed = 20
networkTestRunSlack = 20 * time.Second
networkTestServeTimeout = 5 * time.Second
)
// NetworkTest is one admin-requested test: every ordered pair of nodes on a
// plane, run one after another so no two share a link.
type NetworkTest struct {
TestID string `json:"testId"`
Plane string `json:"plane"`
Status string `json:"status"`
DurationSeconds int `json:"durationSeconds"`
Streams int `json:"streams"`
StartedAt time.Time `json:"startedAt"`
CompletedAt *time.Time `json:"completedAt,omitempty"`
Error string `json:"error,omitempty"`
Runs []NetworkTestResult `json:"runs"`
}
// NetworkTestResult is one direction between two nodes.
type NetworkTestResult struct {
clusterv1.NetTestRunResponse
From string `json:"from"`
To string `json:"to"`
Status string `json:"status"`
}
// NetworkTestStore holds tests and the one cluster-wide claim on running one.
type NetworkTestStore interface {
// Claim takes the cluster-wide slot for testID until the given time, and
// reports ErrNetworkTestBusy while another unexpired claim holds it.
Claim(ctx context.Context, testID string, until time.Time) error
Release(ctx context.Context, testID string) error
Put(ctx context.Context, t *NetworkTest) error
Get(ctx context.Context, testID string) (*NetworkTest, error)
List(ctx context.Context) ([]NetworkTest, error)
}
// ErrNetworkTestBusy is a claim refused because another test is running.
var ErrNetworkTestBusy = errors.New("a network test is already running")
const (
networkTestBucket = "spinifex-network-tests"
networkTestTTL = 7 * 24 * time.Hour
networkTestClaimKey = "active"
networkTestPrefix = "test."
)
type networkTestClaim struct {
TestID string `json:"testId"`
Until time.Time `json:"until"`
}
// KVNetworkTests is the NetworkTestStore over a JetStream KV bucket, shared
// by every gateway so a test started through one is visible through all.
type KVNetworkTests struct {
tests *kvstore.Store[NetworkTest]
claims *kvstore.Store[networkTestClaim]
}
var _ NetworkTestStore = (*KVNetworkTests)(nil)
// NewKVNetworkTests constructs the store over the cluster's JetStream client.
func NewKVNetworkTests(js jetstream.JetStream) *KVNetworkTests {
b := kvstore.NewBucket(js, kvstore.Config{
Name: networkTestBucket,
History: 1,
TTL: networkTestTTL,
Missing: "network tests: no JetStream client configured",
})
return &KVNetworkTests{tests: kvstore.On[NetworkTest](b), claims: kvstore.On[networkTestClaim](b)}
}
// Claim is a create-only write; an expired claim, left by a gateway that died
// mid-test, is replaced under its revision so two takers cannot both win.
func (s *KVNetworkTests) Claim(ctx context.Context, testID string, until time.Time) error {
claim := &networkTestClaim{TestID: testID, Until: until}
_, err := s.claims.Create(ctx, networkTestClaimKey, claim)
if !errors.Is(err, kvstore.ErrExists) {
return err
}
held, rev, err := s.claims.Get(ctx, networkTestClaimKey)
if err != nil {
return err
}
if time.Now().Before(held.Until) {
return ErrNetworkTestBusy
}
if _, err := s.claims.CompareAndSet(ctx, networkTestClaimKey, claim, rev); err != nil {
if errors.Is(err, kvstore.ErrConflict) {
return ErrNetworkTestBusy
}
return err
}
return nil
}
// Release drops the claim only if testID still holds it.
func (s *KVNetworkTests) Release(ctx context.Context, testID string) error {
held, rev, err := s.claims.Get(ctx, networkTestClaimKey)
if errors.Is(err, kvstore.ErrNotFound) {
return nil
}
if err != nil {
return err
}
if held.TestID != testID {
return nil
}
return s.claims.CompareAndDelete(ctx, networkTestClaimKey, rev)
}
func (s *KVNetworkTests) Put(ctx context.Context, t *NetworkTest) error {
return s.tests.Set(ctx, networkTestPrefix+t.TestID, t)
}
func (s *KVNetworkTests) Get(ctx context.Context, testID string) (*NetworkTest, error) {
t, _, err := s.tests.Get(ctx, networkTestPrefix+testID)
return t, err
}
func (s *KVNetworkTests) List(ctx context.Context) ([]NetworkTest, error) {
return s.tests.List(ctx, networkTestPrefix)
}
// StartNetworkTest validates the request, claims the cluster-wide slot,
// records the test and returns at once; the runs converge in the background.
func (d Deps) StartNetworkTest(ctx context.Context, args map[string]string) (*NetworkTest, error) {
if d.NetworkTests == nil || d.NATS == nil {
return nil, errors.New(awserrors.ErrorServerInternal)
}
plane := args["Plane"]
if plane != clusterv1.PlaneWAN && plane != clusterv1.PlaneLAN && plane != clusterv1.PlaneVPC {
return nil, awserrors.Errorf(awserrors.ErrorInvalidParameterValue, "Plane must be wan, lan or vpc")
}
duration, err := intArg(args, "DurationSeconds", defaultNetworkTestSeconds, minNetTestSeconds, maxNetTestSeconds)
if err != nil {
return nil, err
}
streams, err := intArg(args, "Streams", defaultNetworkTestStreams, 1, maxNetTestStreams)
if err != nil {
return nil, err
}
runs, err := networkTestPairs(d.NodeIDs, args["Source"], args["Target"])
if err != nil {
return nil, err
}
test := &NetworkTest{
TestID: newNetworkTestID(), Plane: plane, Status: NetworkTestRunning,
DurationSeconds: duration, Streams: streams, StartedAt: time.Now().UTC(), Runs: runs,
}
perRun := time.Duration(duration)*time.Second + networkTestRunSlack + networkTestServeTimeout
until := time.Now().Add(time.Duration(len(runs))*perRun + time.Minute)
switch err := d.NetworkTests.Claim(ctx, test.TestID, until); {
case errors.Is(err, ErrNetworkTestBusy):
return nil, awserrors.Errorf(awserrors.ErrorIncorrectState, "%s", err.Error())
case err != nil:
slog.ErrorContext(ctx, "StartNetworkTest: claim", "err", err)
return nil, errors.New(awserrors.ErrorServerInternal)
}
if err := d.NetworkTests.Put(ctx, test); err != nil {
_ = d.NetworkTests.Release(ctx, test.TestID)
slog.ErrorContext(ctx, "StartNetworkTest: record", "err", err)
return nil, errors.New(awserrors.ErrorServerInternal)
}
slog.InfoContext(ctx, "network test started", "test_id", test.TestID, "plane", plane, "runs", len(runs))
started := *test
started.Runs = slices.Clone(test.Runs)
go d.runNetworkTest(context.WithoutCancel(ctx), test, perRun)
return &started, nil
}
// These mirror the daemon's limits, which refuse anything outside them.
const (
minNetTestSeconds = 5
maxNetTestSeconds = 30
maxNetTestStreams = 16
)
func intArg(args map[string]string, name string, def, lo, hi int) (int, error) {
raw := args[name]
if raw == "" {
return def, nil
}
v, err := strconv.Atoi(raw)
if err != nil || v < lo || v > hi {
return 0, awserrors.Errorf(awserrors.ErrorInvalidParameterValue, "%s must be between %d and %d", name, lo, hi)
}
return v, nil
}
// networkTestPairs is both directions between source and target, or with no
// target every ordered pair of nodes, the source's first.
func networkTestPairs(nodes []string, source, target string) ([]NetworkTestResult, error) {
known := func(n string) bool { return slices.Contains(nodes, n) }
if source != "" && !known(source) {
return nil, awserrors.Errorf(awserrors.ErrorInvalidParameterValue, "unknown Source node")
}
if target != "" && !known(target) {
return nil, awserrors.Errorf(awserrors.ErrorInvalidParameterValue, "unknown Target node")
}
var runs []NetworkTestResult
add := func(from, to string) {
runs = append(runs, NetworkTestResult{From: from, To: to, Status: NetworkTestPending})
}
switch {
case source != "" && target != "":
if source == target {
return nil, awserrors.Errorf(awserrors.ErrorInvalidParameterCombination, "Source and Target must differ")
}
add(source, target)
add(target, source)
case target != "":
return nil, awserrors.Errorf(awserrors.ErrorMissingParameter, "Target needs a Source")
default:
order := slices.Clone(nodes)
slices.Sort(order)
if source != "" {
order = append([]string{source}, slices.DeleteFunc(order, func(n string) bool { return n == source })...)
}
for _, from := range order {
for _, to := range order {
if from != to {
add(from, to)
}
}
}
}
if len(runs) == 0 {
return nil, awserrors.Errorf(awserrors.ErrorInvalidParameterValue, "a network test needs at least two nodes")
}
if len(runs) > maxNetworkTestRuns {
return nil, awserrors.Errorf(awserrors.ErrorInvalidParameterValue, "too many nodes for a full mesh; name a Source and Target")
}
return runs, nil
}
func newNetworkTestID() string {
b := make([]byte, 8)
_, _ = rand.Read(b)
return "nt-" + hex.EncodeToString(b)
}
// runNetworkTest drives each run in turn and writes the record after each,
// so a poller sees results as they land.
func (d Deps) runNetworkTest(parent context.Context, test *NetworkTest, perRun time.Duration) {
ctx, cancel := context.WithTimeout(parent, time.Duration(len(test.Runs))*perRun+time.Minute)
defer cancel()
defer func() {
if err := d.NetworkTests.Release(parent, test.TestID); err != nil {
slog.Warn("network test: release claim", "test_id", test.TestID, "err", err)
}
}()
failed := 0
for i := range test.Runs {
run := &test.Runs[i]
run.Status = NetworkTestRunning
d.saveNetworkTest(ctx, test)
res, err := d.runNetworkTestPair(ctx, test, run.From, run.To, perRun)
run.NetTestRunResponse = res
if err != nil {
run.Status, run.Error = NetworkTestFailed, err.Error()
failed++
} else {
run.Status = NetworkTestComplete
}
}
now := time.Now().UTC()
test.CompletedAt = &now
test.Status = NetworkTestComplete
if failed == len(test.Runs) {
test.Status, test.Error = NetworkTestFailed, "every run failed"
}
// The run deadline may have passed, and the final status must still land.
d.saveNetworkTest(parent, test)
slog.Info("network test finished", "test_id", test.TestID, "status", test.Status, "failed_runs", failed)
}
func (d Deps) saveNetworkTest(ctx context.Context, test *NetworkTest) {
if err := d.NetworkTests.Put(ctx, test); err != nil {
slog.Warn("network test: write record", "test_id", test.TestID, "err", err)
}
}
func (d Deps) runNetworkTestPair(ctx context.Context, test *NetworkTest, from, to string, perRun time.Duration) (clusterv1.NetTestRunResponse, error) {
var res clusterv1.NetTestRunResponse
serveReq, err := json.Marshal(clusterv1.NetTestServeRequest{Plane: test.Plane, DurationSeconds: test.DurationSeconds})
if err != nil {
return res, fmt.Errorf("encode serve request: %w", err)
}
sctx, scancel := context.WithTimeout(ctx, networkTestServeTimeout)
defer scancel()
msg, err := d.NATS.RequestWithContext(sctx, clusterv1.NetTestServeSubject(to), serveReq)
if err != nil {
return res, fmt.Errorf("%s did not start its server: %w", to, err)
}
var served clusterv1.NetTestServeResponse
if err := json.Unmarshal(msg.Data, &served); err != nil {
return res, fmt.Errorf("%s: malformed serve reply", to)
}
if served.Error != "" {
return res, fmt.Errorf("%s: %s", to, served.Error)
}
runReq, err := json.Marshal(clusterv1.NetTestRunRequest{
Plane: test.Plane, Address: served.Address, Port: served.Port,
DurationSeconds: test.DurationSeconds, Streams: test.Streams,
})
if err != nil {
return res, fmt.Errorf("encode run request: %w", err)
}
rctx, cancel := context.WithTimeout(ctx, perRun)
defer cancel()
msg, err = d.NATS.RequestWithContext(rctx, clusterv1.NetTestRunSubject(from), runReq)
if err != nil {
return res, fmt.Errorf("%s did not report a result: %w", from, err)
}
if err := json.Unmarshal(msg.Data, &res); err != nil {
return res, fmt.Errorf("%s: malformed result", from)
}
if res.Error != "" {
return res, errors.New(res.Error)
}
return res, nil
}
// DescribeNetworkTestsOutput is the response for DescribeNetworkTests.
type DescribeNetworkTestsOutput struct {
Tests []NetworkTest `json:"tests"`
}
// DescribeNetworkTests returns one test by ID, or the most recent ones.
func (d Deps) DescribeNetworkTests(ctx context.Context, args map[string]string) (*DescribeNetworkTestsOutput, error) {
if d.NetworkTests == nil {
return nil, errors.New(awserrors.ErrorServerInternal)
}
if id := args["TestId"]; id != "" {
t, err := d.NetworkTests.Get(ctx, id)
if errors.Is(err, kvstore.ErrNotFound) {
return nil, awserrors.Errorf(awserrors.ErrorResourceNotFound, "no network test %s", id)
}
if err != nil {
return nil, err
}
return &DescribeNetworkTestsOutput{Tests: []NetworkTest{*t}}, nil
}
tests, err := d.NetworkTests.List(ctx)
if err != nil {
return nil, err
}
slices.SortFunc(tests, func(a, b NetworkTest) int { return b.StartedAt.Compare(a.StartedAt) })
if len(tests) > maxNetworkTestListed {
tests = tests[:maxNetworkTestListed]
}
if tests == nil {
tests = []NetworkTest{}
}
return &DescribeNetworkTestsOutput{Tests: tests}, nil
}
@@ -1,220 +0,0 @@
package admin
import (
"context"
"encoding/json"
"errors"
"testing"
"time"
"github.com/mulgadc/bluebottle/pkg/iampolicy"
clusterv1 "github.com/mulgadc/spinifex/contracts/cluster/v1"
"github.com/mulgadc/spinifex/internal/testkit"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
"github.com/nats-io/nats.go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGetNodeMetricsAsksThePolicyBeforeAnythingElse(t *testing.T) {
var asked []string
deny := func(_, act string, _ []string, _ iampolicy.ConditionKeys) error {
asked = append(asked, act)
return errors.New(awserrors.ErrorAccessDenied)
}
_, err := serve(t, Deps{}, awsidentifiers.DefaultAccountID(), action(GetNodeMetricsAction, "NodeId", "node1"), deny)
requireCode(t, err, awserrors.ErrorAccessDenied)
assert.Equal(t, []string{GetNodeMetricsAction}, asked)
}
func TestGetNodeMetricsRefusesANodeOutsideTheCluster(t *testing.T) {
d := Deps{NodeIDs: []string{"node1", "node2"}}
for _, node := range []string{"node9", "node1.>", "*"} {
_, err := serve(t, d, awsidentifiers.DefaultAccountID(), action(GetNodeMetricsAction, "NodeId", node), nil)
code, _ := awserrors.ResolveErrorCode(err)
assert.Equal(t, awserrors.ErrorInvalidParameterValue, code, node)
}
}
func TestNetworkTestPairs(t *testing.T) {
nodes := []string{"c", "a", "b"}
pair, err := networkTestPairs(nodes, "a", "b")
require.NoError(t, err)
assert.Equal(t, [][2]string{{"a", "b"}, {"b", "a"}}, directions(pair))
mesh, err := networkTestPairs(nodes, "b", "")
require.NoError(t, err)
assert.Equal(t, [][2]string{{"b", "a"}, {"b", "c"}, {"a", "b"}, {"a", "c"}, {"c", "b"}, {"c", "a"}}, directions(mesh))
for _, c := range []struct{ source, target, code string }{
{"a", "a", awserrors.ErrorInvalidParameterCombination},
{"", "b", awserrors.ErrorMissingParameter},
{"z", "", awserrors.ErrorInvalidParameterValue},
{"a", "z", awserrors.ErrorInvalidParameterValue},
} {
_, err := networkTestPairs(nodes, c.source, c.target)
code, _ := awserrors.ResolveErrorCode(err)
assert.Equal(t, c.code, code, "%s -> %s", c.source, c.target)
}
_, err = networkTestPairs([]string{"solo"}, "", "")
assert.Error(t, err, "one node has nobody to test against")
}
func directions(runs []NetworkTestResult) [][2]string {
out := make([][2]string, len(runs))
for i, r := range runs {
out[i] = [2]string{r.From, r.To}
}
return out
}
func TestStartNetworkTestValidatesBeforeClaiming(t *testing.T) {
_, nc, js := testutil.StartTestJetStream(t)
d := Deps{NATS: nc, NetworkTests: NewKVNetworkTests(js), NodeIDs: []string{"a", "b"}}
for _, args := range []map[string]string{
{"Plane": "mgmt"},
{"Plane": "lan", "DurationSeconds": "600"},
{"Plane": "lan", "Streams": "0"},
{"Plane": "lan", "Source": "a", "Target": "a"},
} {
_, err := d.StartNetworkTest(context.Background(), args)
assert.Error(t, err, "%v", args)
}
tests, err := d.NetworkTests.List(context.Background())
require.NoError(t, err)
assert.Empty(t, tests, "a refused request records nothing")
}
func TestNetworkTestClaimIsClusterWide(t *testing.T) {
_, _, js := testutil.StartTestJetStream(t)
ctx := context.Background()
first, second := NewKVNetworkTests(js), NewKVNetworkTests(js)
require.NoError(t, first.Claim(ctx, "nt-1", time.Now().Add(time.Minute)))
assert.ErrorIs(t, second.Claim(ctx, "nt-2", time.Now().Add(time.Minute)), ErrNetworkTestBusy)
require.NoError(t, second.Release(ctx, "nt-2"), "releasing a claim it does not hold is a no-op")
assert.ErrorIs(t, second.Claim(ctx, "nt-2", time.Now().Add(time.Minute)), ErrNetworkTestBusy)
require.NoError(t, first.Release(ctx, "nt-1"))
require.NoError(t, second.Claim(ctx, "nt-2", time.Now().Add(-time.Second)))
require.NoError(t, first.Claim(ctx, "nt-3", time.Now().Add(time.Minute)), "an expired claim is taken over")
}
// fakeNode answers the serve and run halves as a daemon would, reporting a
// fixed throughput, or failing the serve when broken.
func fakeNode(t *testing.T, nc *nats.Conn, node, addr string, bps float64, broken bool) {
t.Helper()
serveSub, err := nc.Subscribe(clusterv1.NetTestServeSubject(node), func(m *nats.Msg) {
resp := clusterv1.NetTestServeResponse{Address: addr, Port: clusterv1.NetTestPort}
if broken {
resp = clusterv1.NetTestServeResponse{Error: "iperf (version 2) is not installed on this node"}
}
data, _ := json.Marshal(resp)
_ = m.Respond(data)
})
require.NoError(t, err)
runSub, err := nc.Subscribe(clusterv1.NetTestRunSubject(node), func(m *nats.Msg) {
var req clusterv1.NetTestRunRequest
_ = json.Unmarshal(m.Data, &req)
data, _ := json.Marshal(clusterv1.NetTestRunResponse{
SourceAddress: addr, BitsPerSecond: bps, Streams: req.Streams, LinkMbps: 25000, Tool: "iperf2",
})
_ = m.Respond(data)
})
require.NoError(t, err)
t.Cleanup(func() { _ = serveSub.Unsubscribe(); _ = runSub.Unsubscribe() })
}
func TestNetworkTestRunsEveryDirectionAndRecordsEachResult(t *testing.T) {
_, nc, js := testutil.StartTestJetStream(t)
ctx := context.Background()
d := Deps{NATS: nc, NetworkTests: NewKVNetworkTests(js), NodeIDs: []string{"a", "b", "c"}}
fakeNode(t, nc, "a", "10.10.8.4", 23e9, false)
fakeNode(t, nc, "b", "10.10.8.5", 22e9, false)
fakeNode(t, nc, "c", "10.10.8.6", 0, true)
started, err := d.StartNetworkTest(ctx, map[string]string{"Plane": "lan", "DurationSeconds": "5", "Streams": "2"})
require.NoError(t, err)
assert.Equal(t, NetworkTestRunning, started.Status)
assert.Len(t, started.Runs, 6)
_, err = d.StartNetworkTest(ctx, map[string]string{"Plane": "lan"})
code, _ := awserrors.ResolveErrorCode(err)
assert.Equal(t, awserrors.ErrorIncorrectState, code, "one test at a time across the cluster")
var done NetworkTest
require.Eventually(t, func() bool {
out, err := d.DescribeNetworkTests(ctx, map[string]string{"TestId": started.TestID})
if err != nil || len(out.Tests) != 1 {
return false
}
done = out.Tests[0]
return done.Status != NetworkTestRunning
}, 10*time.Second, 20*time.Millisecond)
assert.Equal(t, NetworkTestComplete, done.Status, "some runs succeeded")
byDirection := map[[2]string]NetworkTestResult{}
for _, r := range done.Runs {
byDirection[[2]string{r.From, r.To}] = r
}
ab := byDirection[[2]string{"a", "b"}]
assert.Equal(t, NetworkTestComplete, ab.Status)
assert.InDelta(t, 23e9, ab.BitsPerSecond, 1)
assert.Equal(t, 2, ab.Streams)
assert.Equal(t, NetworkTestFailed, byDirection[[2]string{"a", "c"}].Status)
assert.Contains(t, byDirection[[2]string{"a", "c"}].Error, "not installed")
assert.Equal(t, NetworkTestComplete, byDirection[[2]string{"c", "a"}].Status, "c can still send")
again, err := d.StartNetworkTest(ctx, map[string]string{"Plane": "lan", "Source": "a", "Target": "b", "DurationSeconds": "5"})
require.NoError(t, err, "the claim is released when the test ends")
list, err := d.DescribeNetworkTests(ctx, map[string]string{})
require.NoError(t, err)
require.Len(t, list.Tests, 2)
assert.Equal(t, again.TestID, list.Tests[0].TestID, "newest first")
require.Eventually(t, func() bool {
out, err := d.DescribeNetworkTests(ctx, map[string]string{"TestId": again.TestID})
return err == nil && out.Tests[0].Status != NetworkTestRunning
}, 10*time.Second, 20*time.Millisecond)
}
func TestNetworkTestFailsARunWhoseServerNeverAnswers(t *testing.T) {
_, nc, js := testutil.StartTestJetStream(t)
ctx := context.Background()
d := Deps{NATS: nc, NetworkTests: NewKVNetworkTests(js), NodeIDs: []string{"a", "b"}}
fakeNode(t, nc, "a", "10.10.8.4", 23e9, false)
silent, err := nc.Subscribe(clusterv1.NetTestServeSubject("b"), func(*nats.Msg) {})
require.NoError(t, err)
t.Cleanup(func() { _ = silent.Unsubscribe() })
started, err := d.StartNetworkTest(ctx, map[string]string{"Plane": "lan", "Source": "a", "Target": "b", "DurationSeconds": "5"})
require.NoError(t, err)
var done NetworkTest
require.Eventually(t, func() bool {
out, err := d.DescribeNetworkTests(ctx, map[string]string{"TestId": started.TestID})
if err != nil || len(out.Tests) != 1 {
return false
}
done = out.Tests[0]
return done.Status != NetworkTestRunning
}, networkTestServeTimeout+3*time.Second, 50*time.Millisecond, "the serve request has its own deadline")
assert.Equal(t, NetworkTestFailed, done.Status)
require.NotEmpty(t, done.Runs)
assert.Equal(t, [2]string{"a", "b"}, [2]string{done.Runs[0].From, done.Runs[0].To})
assert.Contains(t, done.Runs[0].Error, "b did not start its server")
}
func TestDescribeNetworkTestsUnknownID(t *testing.T) {
_, _, js := testutil.StartTestJetStream(t)
d := Deps{NetworkTests: NewKVNetworkTests(js)}
_, err := d.DescribeNetworkTests(context.Background(), map[string]string{"TestId": "nt-missing"})
code, _ := awserrors.ResolveErrorCode(err)
assert.Equal(t, awserrors.ErrorResourceNotFound, code)
}
-63
View File
@@ -1,63 +0,0 @@
package admin
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"slices"
"strconv"
telemetryv1 "github.com/mulgadc/spinifex/contracts/telemetry/v1"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
otelsetup "github.com/mulgadc/spinifex/spinifex/foundation/telemetry"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
)
// GetNodeMetricsAction streams one node's host load. The dispatcher has
// already passed the policy and super-admin gates before it runs.
const GetNodeMetricsAction = "GetNodeMetrics"
func (d Deps) getNodeMetrics(w http.ResponseWriter, inv dispatch.Invocation, queryArgs map[string]string) error {
r := inv.Request
node := queryArgs["NodeId"]
if node == "" {
return errors.New(awserrors.ErrorMissingParameter)
}
if !telemetryv1.ValidLiveNode(node) || (len(d.NodeIDs) > 0 && !slices.Contains(d.NodeIDs, node)) {
return awserrors.Errorf(awserrors.ErrorInvalidParameterValue, "unknown NodeId")
}
var since int64
if raw := queryArgs["Since"]; raw != "" {
parsed, err := strconv.ParseInt(raw, 10, 64)
if err != nil || parsed < 0 {
return errors.New(awserrors.ErrorInvalidParameterValue)
}
since = parsed
}
if d.NATS == nil {
return errors.New(awserrors.ErrorServerInternal)
}
if !flushSupported(w) {
slog.ErrorContext(r.Context(), "GetNodeMetrics: response writer cannot flush")
return errors.New(awserrors.ErrorInternalError)
}
release, ok, _ := liveStreams.acquire(inv.AccountID)
if !ok {
otelsetup.RecordLiveStream(r.Context(), otelsetup.LiveStreamRefusedTotal)
return errors.New(awserrors.ErrorRequestLimitExceeded)
}
defer release()
return streamLive(w, r, d.NATS, liveSubjects{
action: GetNodeMetricsAction,
sample: telemetryv1.NodeLiveSampleSubject(node),
watch: telemetryv1.NodeLiveWatchSubject(node),
absent: awserrors.ErrorInvalidParameterValue,
}, since, func(data []byte) ([]telemetryv1.NodeLiveSample, error) {
var ring telemetryv1.NodeLiveRing
err := json.Unmarshal(data, &ring)
return ring.Samples, err
}, func(s telemetryv1.NodeLiveSample) int64 { return s.TimeMillis })
}
+14 -34
View File
@@ -63,28 +63,22 @@ type Deps struct {
Region string
SQSWorkerPolling func() bool
SQSTenantAPIRegistered func() bool
// NetworkTests holds node-to-node throughput tests; nil refuses them.
NetworkTests NetworkTestStore
}
// adminActions are the actions only the super-admin account may call.
var adminActions = map[string]bool{
"GetVersion": true,
"GetNodes": true,
"GetNodeFacts": true,
"StartNetworkTest": true,
"DescribeNetworkTests": true,
"GetVMs": true,
"GetStorageStatus": true,
"PromoteImage": true,
"GrantModelAccess": true,
"RevokeModelAccess": true,
"ListModelAccess": true,
"ListOchreCatalog": true,
"EnsureSQSWorker": true,
"EnableSQS": true,
"DescribeSQS": true,
"GetVersion": true,
"GetNodes": true,
"GetVMs": true,
"GetStorageStatus": true,
"PromoteImage": true,
"GrantModelAccess": true,
"RevokeModelAccess": true,
"ListModelAccess": true,
"ListOchreCatalog": true,
"EnsureSQSWorker": true,
"EnableSQS": true,
"DescribeSQS": true,
}
// AdminActionNames lists the actions only the super-admin account may call, sorted.
@@ -94,7 +88,7 @@ func AdminActionNames() []string {
// ActionNames lists every action the registration dispatches, sorted.
func ActionNames() []string {
names := append(AdminActionNames(), GetInstanceMetricsAction, GetInstanceTypeCapacityAction, GetNodeMetricsAction)
names := append(AdminActionNames(), GetInstanceMetricsAction, GetInstanceTypeCapacityAction)
slices.Sort(names)
return names
}
@@ -173,16 +167,11 @@ func (d Deps) dispatch(w http.ResponseWriter, inv dispatch.Invocation) error {
return errors.New(awserrors.ErrorServerInternal)
}
if (adminActions[action] || action == GetNodeMetricsAction) && accountID != awsidentifiers.DefaultAccountID() {
if adminActions[action] && accountID != awsidentifiers.DefaultAccountID() {
slog.InfoContext(r.Context(), "Spinifex dispatch: non-admin access denied", "action", action, "accountID", accountID)
return errors.New(awserrors.ErrorAccessDenied)
}
// Streams rather than returning one document, and only after both gates.
if action == GetNodeMetricsAction {
return d.getNodeMetrics(w, inv, args)
}
output, err := d.run(r.Context(), action, args, accountID)
if err != nil {
return err
@@ -212,15 +201,6 @@ func (d Deps) run(ctx context.Context, action string, args map[string]string, ac
return nil, errors.New(awserrors.ErrorServerInternal)
}
return GetNodes(ctx, d.NATS, d.activeNodes(ctx))
case "GetNodeFacts":
if d.NATS == nil {
return nil, errors.New(awserrors.ErrorServerInternal)
}
return GetNodeFacts(ctx, d.NATS, d.activeNodes(ctx))
case "StartNetworkTest":
return d.StartNetworkTest(ctx, args)
case "DescribeNetworkTests":
return d.DescribeNetworkTests(ctx, args)
case GetInstanceTypeCapacityAction:
if d.NATS == nil {
return nil, errors.New(awserrors.ErrorServerInternal)
+2 -2
View File
@@ -64,7 +64,7 @@ func TestRegistration_GetVersion_Admin(t *testing.T) {
}
func TestRegistration_EveryActionDeniesNonAdminAccount(t *testing.T) {
for _, name := range append(AdminActionNames(), GetNodeMetricsAction) {
for _, name := range AdminActionNames() {
t.Run(name, func(t *testing.T) {
_, err := serve(t, Deps{}, "000000000002", action(name), nil)
requireCode(t, err, awserrors.ErrorAccessDenied)
@@ -243,7 +243,7 @@ func TestNewRegistration_DeclaresTheAdminActionsAsItsInventory(t *testing.T) {
assert.Equal(t, ServiceName, reg.Service)
assert.Equal(t, dispatch.ErrorEnvelopeXML, reg.Errors)
assert.ElementsMatch(t, []string{
"GetVersion", "GetNodes", "GetNodeFacts", "GetNodeMetrics", "StartNetworkTest", "DescribeNetworkTests", "GetVMs", "GetStorageStatus", "PromoteImage",
"GetVersion", "GetNodes", "GetVMs", "GetStorageStatus", "PromoteImage",
"GrantModelAccess", "RevokeModelAccess", "ListModelAccess", "ListOchreCatalog",
"EnableSQS", "DescribeSQS",
"EnsureSQSWorker",
-30
View File
@@ -1,30 +0,0 @@
package cli
import (
"github.com/mulgadc/spinifex/spinifex/runtime/host/nodefacts"
"github.com/spf13/cobra"
)
var adminNodeFactsCmd = &cobra.Command{
Use: "node-facts",
Short: "Record this node's hardware and cabling for the operator console",
}
var adminNodeFactsCollectCmd = &cobra.Command{
Use: "collect",
Short: "Run the root-only hardware probes (run by spinifex-daemon.service as root)",
Long: `Runs dmidecode, ethtool, udevadm, networkctl lldp and systemd-detect-virt, and
writes their raw output to /etc/spinifex/node-facts/ readable by the spinifex group.
The daemon parses the files on request. A probe that fails leaves no file, and the
console shows that source as unavailable.`,
Args: cobra.NoArgs,
SilenceUsage: true,
RunE: func(cmd *cobra.Command, _ []string) error {
return nodefacts.Collect(cmd.Context(), nodefacts.DefaultDir, "/sys")
},
}
func init() {
adminCmd.AddCommand(adminNodeFactsCmd)
adminNodeFactsCmd.AddCommand(adminNodeFactsCollectCmd)
}
-2
View File
@@ -15,7 +15,6 @@ import (
"github.com/mulgadc/spinifex/spinifex/domains/ec2/instance/state"
"github.com/mulgadc/spinifex/spinifex/domains/network/external"
"github.com/mulgadc/spinifex/spinifex/providers/ebs/viperblock"
"github.com/mulgadc/spinifex/spinifex/runtime/daemon"
"github.com/mulgadc/spinifex/spinifex/runtime/roles/awsgw"
"github.com/mulgadc/spinifex/spinifex/runtime/roles/nats"
"github.com/mulgadc/spinifex/spinifex/runtime/roles/northstar"
@@ -536,7 +535,6 @@ var spinifexStartCmd = &cobra.Command{
defer initTelemetry("spinifex-daemon", false)()
daemon.SetBuildInfo(Version, Commit)
svc, err := service.New("spinifex", clusterConfig)
if err != nil {
+1 -1
View File
@@ -8,7 +8,7 @@ package systemd
var Units = map[string]string{
"regenerate-ssh-host-keys.service": "# spinifex-unit-version: 1\n[Unit]\nDescription=Regenerate SSH host keys\nConditionPathExists=!/etc/ssh/ssh_host_ed25519_key\nBefore=ssh.service\n\n[Service]\nType=oneshot\nExecStart=/usr/bin/ssh-keygen -A\nRemainAfterExit=yes\n# No ExecStop: the keygen process has already exited by the time this unit\n# is stopped, so there is nothing to signal.\nTimeoutStopSec=5\n\n[Install]\nWantedBy=multi-user.target\n",
"spinifex-awsgw.service": "# spinifex-unit-version: 1\n[Unit]\nDescription=Spinifex AWS Gateway\nPartOf=spinifex.target\nAfter=spinifex-nats.service spinifex-daemon.service\nWants=spinifex-nats.service\n\n[Service]\nEnvironmentFile=-/etc/spinifex/telemetry.env\nType=simple\nSlice=spinifex-system.slice\nUser=spinifex-gw\nGroup=spinifex\nExecStartPre=/var/lib/spinifex/wait-for-nats.sh\nExecStart=/usr/local/bin/spx service awsgw start\nRestart=on-failure\nRestartSec=5\n# No SIGTERM handler in this process: it exits immediately on signal, so this\n# only bounds an unexpectedly wedged process before SIGKILL.\nTimeoutStopSec=10\n\nOOMScoreAdjust=-500\n# Per-service ceiling bounds a leak in this service alone rather than\n# tripping the slice-wide cap, which would pick an arbitrary victim.\nMemoryHigh=10%\nMemoryMax=20%\nEnvironment=SPINIFEX_CONFIG_PATH=/etc/spinifex/spinifex.toml\nEnvironment=SPINIFEX_BASE_DIR=/var/lib/spinifex/awsgw/\nEnvironment=SPINIFEX_AWSGW_TLS_CERT=/etc/spinifex/server.pem\nEnvironment=SPINIFEX_AWSGW_TLS_KEY=/etc/spinifex/server.key\n\n# Filesystem access\nNoNewPrivileges=yes\nProtectSystem=strict\nPrivateDevices=yes\nReadOnlyPaths=/etc/spinifex/spinifex.toml /etc/spinifex/master.key /etc/spinifex/server.pem /etc/spinifex/server.key /etc/spinifex/ca.pem\nReadWritePaths=/var/lib/spinifex/awsgw /var/log/spinifex\n\n# Hardening\nProtectHome=yes\nPrivateTmp=yes\nRestrictSUIDSGID=yes\nLockPersonality=yes\nRestrictRealtime=yes\nRestrictNamespaces=yes\nProtectClock=yes\nProtectKernelTunables=yes\nProtectKernelModules=yes\nProtectKernelLogs=yes\nProtectControlGroups=yes\nProtectProc=invisible\nSystemCallArchitectures=native\nMemoryDenyWriteExecute=yes\nRestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK\nCapabilityBoundingSet=\nProtectHostname=yes\nKeyringMode=private\nRemoveIPC=yes\nLimitCORE=0\nLimitNOFILE=65536\nLimitNPROC=4096\n\n[Install]\nWantedBy=spinifex.target\n",
"spinifex-daemon.service": "# spinifex-unit-version: 6\n[Unit]\nDescription=Spinifex Daemon (VM Orchestration)\nPartOf=spinifex.target\nAfter=spinifex-nats.service spinifex-viperblock.service ovsdb-server.service ovs-vswitchd.service ovn-controller.service openvswitch-ipsec.service spinifex-hostnet.service\n# Wants (not Requires): boot without openvswitch-ipsec, survive OVS restarts via hostnet\nWants=openvswitch-ipsec.service spinifex-hostnet.service\n\n[Service]\nEnvironmentFile=-/etc/spinifex/telemetry.env\n# ZFS root: installer raises SPINIFEX_RESERVED_MEM_GB by the ARC ceiling the admission gate cannot see\nEnvironmentFile=-/etc/spinifex/host.env\nType=simple\nSlice=spinifex-system.slice\nUser=spinifex-daemon\nGroup=spinifex\nExecStartPre=/usr/local/bin/spx admin hostnet check\nExecStartPre=-+/usr/local/bin/spx admin node-facts collect\nExecStart=/usr/local/bin/spx service spinifex start\nRestart=on-failure\nRestartSec=5\n# KillMode=process: guest QEMU survives daemon restart; the new daemon reattaches (DDIL)\nKillMode=process\nTimeoutStopSec=15\n# RG-4: infra tier — reaped only after every guest\nOOMScoreAdjust=-500\n# Guest QEMU shares this cgroup: the ceiling tracks the slice budget, not daemon overhead\nMemoryHigh=75%\nMemoryMax=85%\nEnvironment=SPINIFEX_CONFIG_PATH=/etc/spinifex/spinifex.toml\nEnvironment=SPINIFEX_BASE_DIR=/var/lib/spinifex/spinifex/\nEnvironment=SPINIFEX_WAL_DIR=/var/lib/spinifex/spinifex/\n# Pin to host tmpfs: QEMU pidfiles/QMP sockets must survive PrivateTmp namespace churn (DDIL)\nEnvironment=XDG_RUNTIME_DIR=/run/spinifex\n\n# RG-9: daemon tier — privileged by necessity (GPU vfio). No CAP_NET_ADMIN here,\n# so unlike vpcd it still escalates ip/sysctl and NoNewPrivileges stays omitted.\nAmbientCapabilities=CAP_SYS_ADMIN CAP_DAC_OVERRIDE\nProtectSystem=full\nPrivateDevices=no\nDevicePolicy=closed\nDeviceAllow=/dev/kvm rw\nDeviceAllow=/dev/net/tun rw\nDeviceAllow=/dev/vhost-net rw\nDeviceAllow=char-vfio rw\nDeviceAllow=/dev/vfio/vfio rw\nReadOnlyPaths=/etc/spinifex/spinifex.toml /etc/spinifex/server.pem /etc/spinifex/server.key /etc/spinifex/ca.pem\n# /proc/sys/net/ipv4/conf: ProtectKernelTunables would keep the per-tap IMDS\n# rp_filter/accept_local read-only; /etc/spinifex/firewall: the root helper writes peers.nft.\nReadWritePaths=/var/lib/spinifex/spinifex /var/log/spinifex /run/spinifex /run/spinifex/nbd /proc/sys/net/ipv4/conf -/etc/spinifex/firewall\n\n# RG-9: hardening baseline\nProtectHome=yes\nPrivateTmp=yes\nRestrictSUIDSGID=yes\nLockPersonality=yes\nRestrictRealtime=yes\nRestrictNamespaces=yes\nProtectClock=yes\nProtectKernelTunables=yes\nProtectKernelModules=yes\nProtectKernelLogs=yes\nProtectControlGroups=yes\nProtectProc=invisible\nSystemCallArchitectures=native\nMemoryDenyWriteExecute=yes\nRestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK\nProtectHostname=yes\nKeyringMode=private\nRemoveIPC=yes\nLimitCORE=0\nLimitNOFILE=65536\nLimitNPROC=4096\n# LimitMEMLOCK=infinity: VFIO pins all guest RAM\nLimitMEMLOCK=infinity\n\n[Install]\nWantedBy=spinifex.target\n",
"spinifex-daemon.service": "# spinifex-unit-version: 5\n[Unit]\nDescription=Spinifex Daemon (VM Orchestration)\nPartOf=spinifex.target\nAfter=spinifex-nats.service spinifex-viperblock.service ovsdb-server.service ovs-vswitchd.service ovn-controller.service openvswitch-ipsec.service spinifex-hostnet.service\n# Wants (not Requires): boot without openvswitch-ipsec, survive OVS restarts via hostnet\nWants=openvswitch-ipsec.service spinifex-hostnet.service\n\n[Service]\nEnvironmentFile=-/etc/spinifex/telemetry.env\n# ZFS root: installer raises SPINIFEX_RESERVED_MEM_GB by the ARC ceiling the admission gate cannot see\nEnvironmentFile=-/etc/spinifex/host.env\nType=simple\nSlice=spinifex-system.slice\nUser=spinifex-daemon\nGroup=spinifex\nExecStartPre=/usr/local/bin/spx admin hostnet check\nExecStart=/usr/local/bin/spx service spinifex start\nRestart=on-failure\nRestartSec=5\n# KillMode=process: guest QEMU survives daemon restart; the new daemon reattaches (DDIL)\nKillMode=process\nTimeoutStopSec=15\n# RG-4: infra tier — reaped only after every guest\nOOMScoreAdjust=-500\n# Guest QEMU shares this cgroup: the ceiling tracks the slice budget, not daemon overhead\nMemoryHigh=75%\nMemoryMax=85%\nEnvironment=SPINIFEX_CONFIG_PATH=/etc/spinifex/spinifex.toml\nEnvironment=SPINIFEX_BASE_DIR=/var/lib/spinifex/spinifex/\nEnvironment=SPINIFEX_WAL_DIR=/var/lib/spinifex/spinifex/\n# Pin to host tmpfs: QEMU pidfiles/QMP sockets must survive PrivateTmp namespace churn (DDIL)\nEnvironment=XDG_RUNTIME_DIR=/run/spinifex\n\n# RG-9: daemon tier — privileged by necessity (GPU vfio). No CAP_NET_ADMIN here,\n# so unlike vpcd it still escalates ip/sysctl and NoNewPrivileges stays omitted.\nAmbientCapabilities=CAP_SYS_ADMIN CAP_DAC_OVERRIDE\nProtectSystem=full\nPrivateDevices=no\nDevicePolicy=closed\nDeviceAllow=/dev/kvm rw\nDeviceAllow=/dev/net/tun rw\nDeviceAllow=/dev/vhost-net rw\nDeviceAllow=char-vfio rw\nDeviceAllow=/dev/vfio/vfio rw\nReadOnlyPaths=/etc/spinifex/spinifex.toml /etc/spinifex/server.pem /etc/spinifex/server.key /etc/spinifex/ca.pem\n# /proc/sys/net/ipv4/conf: ProtectKernelTunables would keep the per-tap IMDS\n# rp_filter/accept_local read-only; /etc/spinifex/firewall: the root helper writes peers.nft.\nReadWritePaths=/var/lib/spinifex/spinifex /var/log/spinifex /run/spinifex /run/spinifex/nbd /proc/sys/net/ipv4/conf -/etc/spinifex/firewall\n\n# RG-9: hardening baseline\nProtectHome=yes\nPrivateTmp=yes\nRestrictSUIDSGID=yes\nLockPersonality=yes\nRestrictRealtime=yes\nRestrictNamespaces=yes\nProtectClock=yes\nProtectKernelTunables=yes\nProtectKernelModules=yes\nProtectKernelLogs=yes\nProtectControlGroups=yes\nProtectProc=invisible\nSystemCallArchitectures=native\nMemoryDenyWriteExecute=yes\nRestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK\nProtectHostname=yes\nKeyringMode=private\nRemoveIPC=yes\nLimitCORE=0\nLimitNOFILE=65536\nLimitNPROC=4096\n# LimitMEMLOCK=infinity: VFIO pins all guest RAM\nLimitMEMLOCK=infinity\n\n[Install]\nWantedBy=spinifex.target\n",
"spinifex-firewall.service": "# spinifex-unit-version: 2\n[Unit]\nDescription=Spinifex host firewall (nftables input policy)\nDocumentation=file:/etc/spinifex/firewall/spinifex.nft\nWants=network-pre.target\nBefore=network-pre.target shutdown.target\nDefaultDependencies=no\n\n[Service]\nType=oneshot\nRemainAfterExit=yes\nStandardInput=null\nProtectSystem=full\nProtectHome=true\n# Unconditional: connection tracking loads on every node, so a flow that opens\n# while the policy is not armed survives the arm. The policy itself still loads\n# only where the daemon has written a peer file.\nExecStart=/usr/local/lib/spinifex/spinifex-firewall-apply boot\nExecReload=/usr/local/lib/spinifex/spinifex-firewall-apply boot\nTimeoutStopSec=10s\n\n# No ExecStop: flushing on stop would take vpcd's MASQUERADE and per-EIP FORWARD\n# rules with it. Remove the policy deliberately with\n# `nft delete table inet spinifex_filter`.\n\n[Install]\nWantedBy=sysinit.target\n",
"spinifex-guests.slice": "# spinifex-unit-version: 1\n[Unit]\nDescription=Spinifex guest VM tier\nBefore=slices.target\n\n[Slice]\n# RG-3: child of spinifex.slice (spinifex-guests -> spinifex), sibling of\n# spinifex-system. Parent for the per-instance spinifex-instance-<id>.slice\n# leaves (0002 RG-7). Inherits the spinifex.slice ceiling; no extra limit here.\n",
"spinifex-hostnet.service": "# spinifex-unit-version: 2\n[Unit]\nDescription=Spinifex host network wiring (veth pairs, mgmt address, encap route)\n# PartOf/WantedBy ovs-vswitchd: re-applied whenever OVS starts; daemon and vpcd only Want it\nRequires=ovs-vswitchd.service\nAfter=ovs-vswitchd.service NetworkManager-wait-online.service\nPartOf=ovs-vswitchd.service\n# Stopping OVS stops ovn-controller through its Requires= chain and starting OVS\n# does not bring it back, so the start that re-applies hostnet restarts it too\nWants=ovn-controller.service\nBefore=spinifex.target\n\n[Service]\nType=oneshot\nRemainAfterExit=yes\nExecStart=/usr/local/bin/spx admin hostnet apply\n# Covers the 120s wait behind setup-ovn.sh plus the 60s wait for links\nTimeoutStartSec=240\nTimeoutStopSec=10\nProtectSystem=full\nProtectHome=yes\nPrivateTmp=yes\n\n[Install]\nWantedBy=spinifex.target ovs-vswitchd.service\n",
@@ -425,7 +425,6 @@ var unitBodyHashes = map[string]map[int]string{
3: "8059fe13a678b8cb2a383ae75c069560cf39adece9a9394e17781998c711cd57",
4: "f66e1240cc5655f6a13f3b56ca282c73e2fe289f2e51d0ce624d14b90f12f838",
5: "d3d25d7eb0051ccf97f70838ddff763bade38bef934b8f85733e048151ac9319",
6: "4db31f63c0b9b297a3ec33c34df99c2ec1424374d39f1752f1fd1dc4ec887316",
},
"spinifex-firewall.service": {
1: "01d0a7a79f47eedca02aaab3ff97a0b6462d61b7834e16c7336a7b96034dc392",
+1 -1
View File
@@ -137,7 +137,7 @@ func RunBenchSuite(b *testing.B, provider ebsprovider.EBSProvider, cfg BenchConf
}); err != nil {
b.Fatal(err)
}
if err := provider.UnpublishVolume(context.Background(), ebsprovider.UnpublishVolumeRequest{
if _, err := provider.UnpublishVolume(context.Background(), ebsprovider.UnpublishVolumeRequest{
Versioned: ebsprovider.NewVersioned(),
VolumeID: volumeID,
NodeID: cfg.node(),
+231 -21
View File
@@ -6,6 +6,9 @@ package conformance
import (
"bytes"
"context"
"errors"
"fmt"
"slices"
"strings"
"sync"
"testing"
@@ -34,18 +37,24 @@ var ReferenceCapabilities = ebsprovider.Capabilities{
// capabilitiesOf reads what a provider advertises, so the suite can branch on
// it the way the contract tells callers to.
func capabilitiesOf(t *testing.T, provider ebsprovider.EBSProvider) ebsprovider.Capabilities {
func capabilitiesOf(t *testing.T, provider ebsprovider.EBSProvider) *ebsprovider.GetCapabilitiesResponse {
t.Helper()
resp, err := provider.GetCapabilities(context.Background(), ebsprovider.GetCapabilitiesRequest{Versioned: ebsprovider.NewVersioned()})
require.NoError(t, err)
require.NotNil(t, resp)
return resp.Capabilities
return resp
}
// maxListPages bounds a drain so a provider whose token fails to advance
// fails the test instead of hanging it.
const maxListPages = 100
// unpublish is UnpublishVolume for a case that asserts only on the error.
func unpublish(ctx context.Context, provider ebsprovider.EBSProvider, req ebsprovider.UnpublishVolumeRequest) error {
_, err := provider.UnpublishVolume(ctx, req)
return err
}
// volumeIDs projects one page of refs down to its IDs.
func volumeIDs(refs []ebsprovider.VolumeRef) []string {
ids := make([]string, 0, len(refs))
@@ -131,6 +140,11 @@ type SuiteConfig struct {
// also be published elsewhere. Empty skips that subtest, for a caller with
// only one node to offer.
OtherNodeID string
// KillExport ends the process serving a published export without telling
// the provider, standing in for a crash. Nil skips the dead-export case,
// for a provider whose export is not a separate process.
KillExport func(t *testing.T, published *ebsprovider.PublishedVolume)
}
func (c SuiteConfig) node() string {
@@ -176,7 +190,8 @@ func RunSuiteWithConfig(t *testing.T, newRawProvider func(t *testing.T) ebsprovi
return newTrackingProvider(t, newRawProvider(t))
}
capabilities := capabilitiesOf(t, newProvider(t))
advertised := capabilitiesOf(t, newProvider(t))
capabilities, contractVersions := advertised.Capabilities, advertised.ContractVersions
t.Run("GetCapabilities", func(t *testing.T) {
provider := newProvider(t)
@@ -193,6 +208,44 @@ func RunSuiteWithConfig(t *testing.T, newRawProvider func(t *testing.T) ebsprovi
assert.Equal(t, resp.Capabilities, again.Capabilities, "GetCapabilities must be stable across calls")
})
t.Run("ContractVersions", func(t *testing.T) {
t.Run("listed versions are ascending and include the legacy version", func(t *testing.T) {
require.NotEmpty(t, contractVersions, "a caller cannot choose a version for a provider that lists none")
assert.Contains(t, contractVersions, ebsprovider.LegacySchemaVersion,
"dropping the legacy version strands every caller still in an N/N-1 rollout")
for i := 1; i < len(contractVersions); i++ {
assert.Lessf(t, contractVersions[i-1], contractVersions[i], "contract versions %v are not strictly ascending", contractVersions)
}
})
t.Run("every listed version is answered in that version", func(t *testing.T) {
provider := newProvider(t)
for _, version := range contractVersions {
resp, err := provider.GetCapabilities(context.Background(), ebsprovider.GetCapabilitiesRequest{Versioned: ebsprovider.Versioned{SchemaVersion: version}})
require.NoErrorf(t, err, "version %d is listed as served", version)
assert.Equalf(t, version, resp.SchemaVersion, "a reply in another version than the request's is one an older caller may not parse")
}
})
t.Run("an unlisted version is refused without changing state", func(t *testing.T) {
provider := newProvider(t)
ctx := context.Background()
unknown := ebsprovider.Versioned{SchemaVersion: ebsprovider.SchemaVersion + 1}
_, err := provider.GetCapabilities(ctx, ebsprovider.GetCapabilitiesRequest{Versioned: unknown})
require.ErrorIs(t, err, ebsprovider.ErrUnsupportedVersion)
volumeID := cfg.id("vol-ver-unknown")
_, err = provider.CreateVolume(ctx, ebsprovider.CreateVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID, CapacityRange: ebsprovider.CapacityRange{RequiredBytes: 1 << 30}})
require.NoError(t, err)
_, err = provider.PublishVolume(ctx, ebsprovider.PublishVolumeRequest{Versioned: unknown, VolumeID: volumeID, NodeID: cfg.node()})
require.ErrorIs(t, err, ebsprovider.ErrUnsupportedVersion)
// A refused publish that still exported the volume would leave it
// published, and a published volume cannot be deleted.
require.NoError(t, provider.DeleteVolume(ctx, ebsprovider.DeleteVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID}),
"a publish refused for its version must not have published the volume")
})
})
t.Run("CreateVolume", func(t *testing.T) {
t.Run("success", func(t *testing.T) {
provider := newProvider(t)
@@ -385,8 +438,6 @@ func RunSuiteWithConfig(t *testing.T, newRawProvider func(t *testing.T) ebsprovi
})
t.Run("ListVolumes", func(t *testing.T) {
capabilities := capabilitiesOf(t, newProvider(t))
t.Run("unsupported_capability when enumeration is not advertised", func(t *testing.T) {
if capabilities.VolumeEnumeration {
t.Skip("provider advertises volume enumeration")
@@ -694,8 +745,6 @@ func RunSuiteWithConfig(t *testing.T, newRawProvider func(t *testing.T) ebsprovi
})
t.Run("ListSnapshots", func(t *testing.T) {
capabilities := capabilitiesOf(t, newProvider(t))
t.Run("unsupported_capability when enumeration is not advertised", func(t *testing.T) {
if capabilities.SnapshotEnumeration {
t.Skip("provider advertises snapshot enumeration")
@@ -798,16 +847,23 @@ func RunSuiteWithConfig(t *testing.T, newRawProvider func(t *testing.T) ebsprovi
}
})
// A republish succeeds at every listed version, so a caller at any of
// them gets the running export back rather than a refusal.
t.Run("idempotent republish to the same node", func(t *testing.T) {
provider := newProvider(t)
ctx := context.Background()
_, err := provider.CreateVolume(ctx, ebsprovider.CreateVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: cfg.id("vol-pub-idem"), CapacityRange: ebsprovider.CapacityRange{RequiredBytes: 1 << 30}})
require.NoError(t, err)
first, err := provider.PublishVolume(ctx, ebsprovider.PublishVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: cfg.id("vol-pub-idem"), NodeID: cfg.node()})
require.NoError(t, err)
second, err := provider.PublishVolume(ctx, ebsprovider.PublishVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: cfg.id("vol-pub-idem"), NodeID: cfg.node()})
require.NoError(t, err)
assert.Equal(t, first, second)
for _, version := range contractVersions {
t.Run(fmt.Sprintf("v%d", version), func(t *testing.T) {
provider := newProvider(t)
ctx := context.Background()
volumeID := cfg.id(fmt.Sprintf("vol-pub-idem-v%d", version))
_, err := provider.CreateVolume(ctx, ebsprovider.CreateVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID, CapacityRange: ebsprovider.CapacityRange{RequiredBytes: 1 << 30}})
require.NoError(t, err)
first, err := provider.PublishVolume(ctx, ebsprovider.PublishVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID, NodeID: cfg.node()})
require.NoError(t, err)
second, err := provider.PublishVolume(ctx, ebsprovider.PublishVolumeRequest{Versioned: ebsprovider.Versioned{SchemaVersion: version}, VolumeID: volumeID, NodeID: cfg.node()})
require.NoError(t, err)
assert.Equal(t, first, second)
})
}
})
t.Run("volume_in_use when published to a different node", func(t *testing.T) {
@@ -824,6 +880,76 @@ func RunSuiteWithConfig(t *testing.T, newRawProvider func(t *testing.T) ebsprovi
require.ErrorIs(t, err, ebsprovider.ErrVolumeInUse)
})
t.Run("held elsewhere is reported only from the version that defines it", func(t *testing.T) {
if cfg.otherNode() == "" {
t.Skip("no second node configured")
}
for _, version := range contractVersions {
t.Run(fmt.Sprintf("v%d", version), func(t *testing.T) {
provider := newProvider(t)
ctx := context.Background()
volumeID := cfg.id(fmt.Sprintf("vol-pub-held-v%d", version))
_, err := provider.CreateVolume(ctx, ebsprovider.CreateVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID, CapacityRange: ebsprovider.CapacityRange{RequiredBytes: 1 << 30}})
require.NoError(t, err)
_, err = provider.PublishVolume(ctx, ebsprovider.PublishVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID, NodeID: cfg.node()})
require.NoError(t, err)
_, err = provider.PublishVolume(ctx, ebsprovider.PublishVolumeRequest{Versioned: ebsprovider.Versioned{SchemaVersion: version}, VolumeID: volumeID, NodeID: cfg.otherNode()})
require.ErrorIs(t, err, ebsprovider.ErrVolumeInUse, "every version must still read the refusal as in use")
if version >= ebsprovider.HeldElsewhereSchemaVersion {
assert.ErrorIs(t, err, ebsprovider.ErrVolumeHeldElsewhere)
} else {
assert.NotErrorIs(t, err, ebsprovider.ErrVolumeHeldElsewhere,
"a version %d caller predates held_elsewhere and must get the volume_in_use it has always got", version)
}
})
}
})
t.Run("a republish in the other access mode is in use, not held elsewhere", func(t *testing.T) {
if !capabilities.ReadOnlyPublish {
t.Skip("read-only publish is not advertised")
}
for _, version := range contractVersions {
t.Run(fmt.Sprintf("v%d", version), func(t *testing.T) {
provider := newProvider(t)
ctx := context.Background()
volumeID := cfg.id(fmt.Sprintf("vol-pub-mode-v%d", version))
_, err := provider.CreateVolume(ctx, ebsprovider.CreateVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID, CapacityRange: ebsprovider.CapacityRange{RequiredBytes: 1 << 30}})
require.NoError(t, err)
_, err = provider.PublishVolume(ctx, ebsprovider.PublishVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID, NodeID: cfg.node()})
require.NoError(t, err)
_, err = provider.PublishVolume(ctx, ebsprovider.PublishVolumeRequest{Versioned: ebsprovider.Versioned{SchemaVersion: version}, VolumeID: volumeID, NodeID: cfg.node(), ReadOnly: true})
require.ErrorIs(t, err, ebsprovider.ErrVolumeInUse)
assert.NotErrorIs(t, err, ebsprovider.ErrVolumeHeldElsewhere,
"this node holds the volume, so a caller told it is held elsewhere would wait for a node that does not exist")
})
}
})
t.Run("a republish after the export died returns a serving export", func(t *testing.T) {
if cfg.KillExport == nil {
t.Skip("no way to kill this provider's export")
}
RequireNBDTools(t)
provider := newProvider(t)
ctx := context.Background()
volumeID := cfg.id("vol-pub-dead")
_, err := provider.CreateVolume(ctx, ebsprovider.CreateVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID, CapacityRange: ebsprovider.CapacityRange{RequiredBytes: 1 << 30}})
require.NoError(t, err)
first, err := provider.PublishVolume(ctx, ebsprovider.PublishVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID, NodeID: cfg.node()})
require.NoError(t, err)
firstURI := dialURI(t, publishedNBDURI(t, first))
cfg.KillExport(t, first)
require.False(t, nbdReachable(firstURI), "the export still answers, so the kill did not take and this case proves nothing")
second, err := provider.PublishVolume(ctx, ebsprovider.PublishVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID, NodeID: cfg.node()})
require.NoError(t, err, "a dead export must not make its volume unpublishable")
nbdInfoRetry(t, dialURI(t, publishedNBDURI(t, second)))
})
t.Run("not_found on absent volume", func(t *testing.T) {
provider := newProvider(t)
_, err := provider.PublishVolume(context.Background(), ebsprovider.PublishVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: cfg.id("vol-never-existed"), NodeID: cfg.node()})
@@ -845,21 +971,105 @@ func RunSuiteWithConfig(t *testing.T, newRawProvider func(t *testing.T) ebsprovi
require.NoError(t, err)
_, err = provider.PublishVolume(ctx, ebsprovider.PublishVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: cfg.id("vol-unpub-ok"), NodeID: cfg.node()})
require.NoError(t, err)
require.NoError(t, provider.UnpublishVolume(ctx, ebsprovider.UnpublishVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: cfg.id("vol-unpub-ok"), NodeID: cfg.node()}))
require.NoError(t, unpublish(ctx, provider, ebsprovider.UnpublishVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: cfg.id("vol-unpub-ok"), NodeID: cfg.node()}))
})
t.Run("absent target is idempotent", func(t *testing.T) {
provider := newProvider(t)
require.NoError(t, provider.UnpublishVolume(context.Background(), ebsprovider.UnpublishVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: cfg.id("vol-never-existed"), NodeID: cfg.node()}))
require.NoError(t, unpublish(context.Background(), provider, ebsprovider.UnpublishVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: cfg.id("vol-never-existed"), NodeID: cfg.node()}))
})
t.Run("torn down reports whether an export was removed", func(t *testing.T) {
if !slices.Contains(contractVersions, ebsprovider.NoSealSchemaVersion) {
t.Skipf("torn_down is defined from version %d, which the provider does not list", ebsprovider.NoSealSchemaVersion)
}
provider := newProvider(t)
ctx := context.Background()
volumeID := cfg.id("vol-unpub-torn")
v3 := ebsprovider.Versioned{SchemaVersion: ebsprovider.NoSealSchemaVersion}
_, err := provider.CreateVolume(ctx, ebsprovider.CreateVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID, CapacityRange: ebsprovider.CapacityRange{RequiredBytes: 1 << 30}})
require.NoError(t, err)
_, err = provider.PublishVolume(ctx, ebsprovider.PublishVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID, NodeID: cfg.node()})
require.NoError(t, err)
first, err := provider.UnpublishVolume(ctx, ebsprovider.UnpublishVolumeRequest{Versioned: v3, VolumeID: volumeID, NodeID: cfg.node()})
require.NoError(t, err)
assert.True(t, first.TornDown, "the volume was published, so this unpublish removed an export")
again, err := provider.UnpublishVolume(ctx, ebsprovider.UnpublishVolumeRequest{Versioned: v3, VolumeID: volumeID, NodeID: cfg.node()})
require.NoError(t, err)
assert.False(t, again.TornDown, "nothing was left to remove, so a retry must not claim it removed something")
})
t.Run("no_seal below the version that defines it is refused", func(t *testing.T) {
for _, version := range contractVersions {
if version >= ebsprovider.NoSealSchemaVersion {
continue
}
t.Run(fmt.Sprintf("v%d", version), func(t *testing.T) {
provider := newProvider(t)
ctx := context.Background()
volumeID := cfg.id(fmt.Sprintf("vol-unpub-noseal-v%d", version))
_, err := provider.CreateVolume(ctx, ebsprovider.CreateVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID, CapacityRange: ebsprovider.CapacityRange{RequiredBytes: 1 << 30}})
require.NoError(t, err)
_, err = provider.PublishVolume(ctx, ebsprovider.PublishVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID, NodeID: cfg.node()})
require.NoError(t, err)
err = unpublish(ctx, provider, ebsprovider.UnpublishVolumeRequest{Versioned: ebsprovider.Versioned{SchemaVersion: version}, VolumeID: volumeID, NodeID: cfg.node(), NoSeal: true})
require.ErrorIs(t, err, ebsprovider.ErrInvalidArgument,
"a version %d server would ignore no_seal and seal, so the flag must be refused rather than dropped", version)
err = provider.DeleteVolume(ctx, ebsprovider.DeleteVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID})
require.ErrorIs(t, err, ebsprovider.ErrVolumeInUse, "the refused unpublish must have left the volume published")
})
}
})
t.Run("no_seal is honoured or refused as unsupported", func(t *testing.T) {
provider := newProvider(t)
ctx := context.Background()
volumeID := cfg.id("vol-unpub-noseal")
v3 := ebsprovider.Versioned{SchemaVersion: ebsprovider.NoSealSchemaVersion}
_, err := provider.CreateVolume(ctx, ebsprovider.CreateVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID, CapacityRange: ebsprovider.CapacityRange{RequiredBytes: 1 << 30}})
require.NoError(t, err)
_, err = provider.PublishVolume(ctx, ebsprovider.PublishVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID, NodeID: cfg.node()})
require.NoError(t, err)
result, err := provider.UnpublishVolume(ctx, ebsprovider.UnpublishVolumeRequest{Versioned: v3, VolumeID: volumeID, NodeID: cfg.node(), NoSeal: true})
if errors.Is(err, ebsprovider.ErrUnsupportedVersion) {
return
}
require.NoError(t, err)
assert.True(t, result.TornDown, "the volume was published, so a no_seal unpublish removed an export")
again, err := provider.UnpublishVolume(ctx, ebsprovider.UnpublishVolumeRequest{Versioned: v3, VolumeID: volumeID, NodeID: cfg.node(), NoSeal: true})
require.NoError(t, err)
assert.False(t, again.TornDown)
_, err = provider.PublishVolume(ctx, ebsprovider.PublishVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID, NodeID: cfg.node()})
require.NoError(t, err, "a volume dropped without sealing must still be publishable")
})
t.Run("invalid_argument on missing ids", func(t *testing.T) {
provider := newProvider(t)
err := provider.UnpublishVolume(context.Background(), ebsprovider.UnpublishVolumeRequest{Versioned: ebsprovider.NewVersioned()})
err := unpublish(context.Background(), provider, ebsprovider.UnpublishVolumeRequest{Versioned: ebsprovider.NewVersioned()})
require.ErrorIs(t, err, ebsprovider.ErrInvalidArgument)
})
})
t.Run("HoldsUnsealedWrites", func(t *testing.T) {
t.Run("answers with a subset of the volumes asked about", func(t *testing.T) {
provider := newProvider(t)
ctx := context.Background()
volumeID := cfg.id("vol-unsealed")
_, err := provider.CreateVolume(ctx, ebsprovider.CreateVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID, CapacityRange: ebsprovider.CapacityRange{RequiredBytes: 1 << 30}})
require.NoError(t, err)
_, err = provider.PublishVolume(ctx, ebsprovider.PublishVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID, NodeID: cfg.node()})
require.NoError(t, err)
asked := []string{volumeID, cfg.id("vol-unsealed-never")}
held, err := provider.HoldsUnsealedWrites(ctx, cfg.node(), asked)
require.NoError(t, err, "an error reads as cannot say, which blocks every start elsewhere")
assert.Subset(t, asked, held, "a volume nobody asked about is not an answer to the question")
})
})
t.Run("Exclusion", func(t *testing.T) {
exclusion := capabilities.Exclusion
@@ -907,7 +1117,7 @@ func RunSuiteWithConfig(t *testing.T, newRawProvider func(t *testing.T) ebsprovi
// Releasing the claim must make the volume publishable again, or
// the guarantee is indistinguishable from a permanent lock.
require.NoError(t, provider.UnpublishVolume(ctx, ebsprovider.UnpublishVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID, NodeID: cfg.node()}))
require.NoError(t, unpublish(ctx, provider, ebsprovider.UnpublishVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID, NodeID: cfg.node()}))
_, err = provider.PublishVolume(ctx, ebsprovider.PublishVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID, NodeID: cfg.otherNode()})
require.NoError(t, err, "a released claim must be reclaimable, or an unpublish strands the volume")
})
@@ -968,7 +1178,7 @@ func RunSuiteWithConfig(t *testing.T, newRawProvider func(t *testing.T) ebsprovi
require.ErrorIs(t, err, ebsprovider.ErrVolumeInUse,
"deleting a published volume must be refused; the export is still serving reads and writes against it")
require.NoError(t, provider.UnpublishVolume(ctx, ebsprovider.UnpublishVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID, NodeID: cfg.node()}))
require.NoError(t, unpublish(ctx, provider, ebsprovider.UnpublishVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID, NodeID: cfg.node()}))
require.NoError(t, provider.DeleteVolume(ctx, ebsprovider.DeleteVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID}),
"once unpublished the delete must go through, or a volume can never be removed after being used")
})
@@ -217,7 +217,7 @@ func cleanupVolume(t *testing.T, provider ebsprovider.EBSProvider, cfg NBDClient
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
if err := provider.UnpublishVolume(ctx, ebsprovider.UnpublishVolumeRequest{
if _, err := provider.UnpublishVolume(ctx, ebsprovider.UnpublishVolumeRequest{
Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID, NodeID: cfg.NodeID,
}); err != nil {
t.Logf("cleanup: unpublish %s: %v", volumeID, err)
@@ -310,7 +310,7 @@ func RunNBDClientSuiteWithConfig(t *testing.T, newProvider func(t *testing.T) eb
// holes to a server that really tracks allocation.
t.Run("sparse extent reporting matches the advertised capability", func(t *testing.T) {
provider := newProvider(t)
capabilities := capabilitiesOf(t, provider)
capabilities := capabilitiesOf(t, provider).Capabilities
nbdURI := publishForNBD(t, provider, cfg, cfg.VolumePrefix+"-extents", false)
holes := slices.ContainsFunc(nbdMap(t, dialURI(t, nbdURI)), func(extent nbdExtent) bool {
@@ -323,7 +323,7 @@ func RunNBDClientSuiteWithConfig(t *testing.T, newProvider func(t *testing.T) eb
t.Run("read-only publish matches the advertised capability", func(t *testing.T) {
provider := newProvider(t)
capabilities := capabilitiesOf(t, provider)
capabilities := capabilitiesOf(t, provider).Capabilities
if !capabilities.ReadOnlyPublish {
// Refusing is the contract for a provider that cannot honour it;
@@ -356,11 +356,12 @@ func RunNBDClientSuiteWithConfig(t *testing.T, newProvider func(t *testing.T) eb
uri := dialURI(t, nbdURI)
require.True(t, nbdReachable(uri), "export should be reachable before unpublish")
require.NoError(t, provider.UnpublishVolume(context.Background(), ebsprovider.UnpublishVolumeRequest{
_, err := provider.UnpublishVolume(context.Background(), ebsprovider.UnpublishVolumeRequest{
Versioned: ebsprovider.NewVersioned(),
VolumeID: volumeID,
NodeID: cfg.NodeID,
}))
})
require.NoError(t, err)
assert.Eventually(t, func() bool { return !nbdReachable(uri) }, 30*time.Second, 250*time.Millisecond,
"export still answers after UnpublishVolume")
@@ -84,12 +84,12 @@ func (p *trackingProvider) PublishVolume(ctx context.Context, req ebsprovider.Pu
return pub, err
}
func (p *trackingProvider) UnpublishVolume(ctx context.Context, req ebsprovider.UnpublishVolumeRequest) error {
err := p.EBSProvider.UnpublishVolume(ctx, req)
func (p *trackingProvider) UnpublishVolume(ctx context.Context, req ebsprovider.UnpublishVolumeRequest) (*ebsprovider.UnpublishedVolume, error) {
result, err := p.EBSProvider.UnpublishVolume(ctx, req)
if err == nil {
delete(p.published, req.VolumeID)
}
return err
return result, err
}
// cleanup removes what the subtest left behind, in dependency order: a
@@ -100,7 +100,7 @@ func (p *trackingProvider) cleanup() {
ctx := context.Background()
for volumeID, nodeID := range p.published {
if err := p.EBSProvider.UnpublishVolume(ctx, ebsprovider.UnpublishVolumeRequest{
if _, err := p.EBSProvider.UnpublishVolume(ctx, ebsprovider.UnpublishVolumeRequest{
Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID, Handle: p.volumes[volumeID], NodeID: nodeID,
}); err != nil {
p.t.Logf("conformance cleanup: unpublish %s: %v", volumeID, err)
+37 -9
View File
@@ -12,6 +12,11 @@ var (
ErrUnsupportedVersion = errors.New("unsupported EBS provider schema version")
ErrVolumeInUse = errors.New("volume is in use")
// ErrVolumeHeldElsewhere is a publish refused because another node holds
// the volume's claim. It wraps ErrVolumeInUse, so a caller that only asks
// whether the volume is in use still matches it.
ErrVolumeHeldElsewhere = fmt.Errorf("volume is held by another node: %w", ErrVolumeInUse)
// ErrUnsupportedCapability is returned when a request asks for optional
// behaviour this provider does not advertise in Capabilities. It is
// distinct from ErrInvalidArgument: the request is well formed.
@@ -28,19 +33,35 @@ var (
ErrUnavailable = errors.New("provider is temporarily unable to answer")
)
// HeldElsewhereSchemaVersion is the first version a held-elsewhere refusal is
// reported as such. Earlier requests get volume_in_use, as v1.23.0 sends it.
const HeldElsewhereSchemaVersion uint16 = 3
// HeldElsewhereError is the sentinel a held-elsewhere refusal of a request at
// version wraps: ErrVolumeHeldElsewhere from HeldElsewhereSchemaVersion on.
func HeldElsewhereError(version uint16) error {
if version >= HeldElsewhereSchemaVersion {
return ErrVolumeHeldElsewhere
}
return ErrVolumeInUse
}
// ErrorCode is the wire form of a provider error in ProviderError. Unwrap maps
// every code but internal back to the matching Err sentinel.
type ErrorCode string
const (
ErrorCodeAlreadyExists ErrorCode = "already_exists"
ErrorCodeInvalidArgument ErrorCode = "invalid_argument"
ErrorCodeNotFound ErrorCode = "not_found"
ErrorCodeUnsupportedVersion ErrorCode = "unsupported_version"
ErrorCodeVolumeInUse ErrorCode = "volume_in_use"
ErrorCodeUnsupportedCap ErrorCode = "unsupported_capability"
ErrorCodeUnavailable ErrorCode = "unavailable"
ErrorCodeInternal ErrorCode = "internal"
ErrorCodeAlreadyExists ErrorCode = "already_exists"
ErrorCodeInvalidArgument ErrorCode = "invalid_argument"
ErrorCodeNotFound ErrorCode = "not_found"
ErrorCodeUnsupportedVersion ErrorCode = "unsupported_version"
ErrorCodeVolumeInUse ErrorCode = "volume_in_use"
ErrorCodeVolumeHeldElsewhere ErrorCode = "volume_held_elsewhere"
ErrorCodeUnsupportedCap ErrorCode = "unsupported_capability"
// ErrorCodeUnavailable is the retry-later code: among others, a mount
// against a backing store that is not ready yet returns it.
ErrorCodeUnavailable ErrorCode = "unavailable"
ErrorCodeInternal ErrorCode = "internal"
)
// Retryable reports whether repeating the identical request could succeed.
@@ -78,6 +99,8 @@ func (e *ProviderError) Unwrap() error {
return ErrUnsupportedVersion
case ErrorCodeVolumeInUse:
return ErrVolumeInUse
case ErrorCodeVolumeHeldElsewhere:
return ErrVolumeHeldElsewhere
case ErrorCodeUnsupportedCap:
return ErrUnsupportedCapability
case ErrorCodeUnavailable:
@@ -89,7 +112,12 @@ func (e *ProviderError) Unwrap() error {
func checkVersion(version uint16) error {
if !SupportsSchemaVersion(version) {
return fmt.Errorf("%w: got %d, supported versions are %d and %d", ErrUnsupportedVersion, version, LegacySchemaVersion, SchemaVersion)
return fmt.Errorf("%w: got %d, %s", ErrUnsupportedVersion, version, SupportedVersionsText())
}
return nil
}
// SupportedVersionsText names the served version range for error messages.
func SupportedVersionsText() string {
return fmt.Sprintf("supported versions are %d to %d", LegacySchemaVersion, SchemaVersion)
}
+1 -1
View File
@@ -105,7 +105,7 @@ func soakOneVolume(ctx context.Context, provider ebsprovider.EBSProvider, volume
if _, err := provider.PublishVolume(ctx, ebsprovider.PublishVolumeRequest{
Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID, NodeID: "node-1",
}); err == nil {
_ = provider.UnpublishVolume(ctx, ebsprovider.UnpublishVolumeRequest{
_, _ = provider.UnpublishVolume(ctx, ebsprovider.UnpublishVolumeRequest{
Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID, NodeID: "node-1",
})
}
+22 -3
View File
@@ -21,6 +21,7 @@ const (
verbSnapshotDelete = "snapshot.delete"
verbSnapshotCopy = "snapshot.copy"
verbSnapshotList = "snapshot.list"
verbUnsealedWrites = "volume.unsealed"
)
// lieFlavours are the ways a successful answer can be wrong. Each one is a
@@ -240,10 +241,28 @@ func (p *Provider) PublishVolume(ctx context.Context, req ebsprovider.PublishVol
return published, nil
}
func (p *Provider) UnpublishVolume(ctx context.Context, req ebsprovider.UnpublishVolumeRequest) error {
func (p *Provider) UnpublishVolume(ctx context.Context, req ebsprovider.UnpublishVolumeRequest) (*ebsprovider.UnpublishedVolume, error) {
injection := p.draw(verbVolumeUnpublish, req.VolumeID)
if stop, err := p.apply(ctx, injection); stop || err != nil {
return err
return nil, err
}
return p.after(injection, p.inner.UnpublishVolume(ctx, req))
result, err := p.inner.UnpublishVolume(ctx, req)
if err := p.after(injection, err); err != nil {
return nil, err
}
return result, nil
}
// HoldsUnsealedWrites is never made to lie: a false "none held" lets an
// instance start elsewhere over writes only this node has.
func (p *Provider) HoldsUnsealedWrites(ctx context.Context, nodeID string, volumeIDs []string) ([]string, error) {
injection := p.drawTruthful(verbUnsealedWrites, nodeID)
if stop, err := p.apply(ctx, injection); stop || err != nil {
return nil, err
}
held, err := p.inner.HoldsUnsealedWrites(ctx, nodeID, volumeIDs)
if err := p.after(injection, err); err != nil {
return nil, err
}
return held, nil
}
+36 -10
View File
@@ -27,6 +27,7 @@ type memoryVolume struct {
parameters []byte
seed []byte
published *PublishedVolume
readOnly bool
}
var _ EBSProvider = (*MemoryProvider)(nil)
@@ -53,7 +54,11 @@ func (m *MemoryProvider) GetCapabilities(_ context.Context, req GetCapabilitiesR
if err := checkVersion(req.SchemaVersion); err != nil {
return nil, err
}
return &GetCapabilitiesResponse{Versioned: NewVersioned(), Capabilities: m.capabilities}, nil
return &GetCapabilitiesResponse{
Versioned: ReplyVersioned(req.SchemaVersion),
Capabilities: m.capabilities,
ContractVersions: SupportedSchemaVersions(),
}, nil
}
func (m *MemoryProvider) CreateVolume(_ context.Context, req CreateVolumeRequest) (*Volume, error) {
@@ -167,7 +172,7 @@ func (m *MemoryProvider) ListVolumes(_ context.Context, req ListVolumesRequest)
defer m.mu.RUnlock()
ids := slices.Sorted(maps.Keys(m.volumes))
response := &ListVolumesResponse{Versioned: NewVersioned()}
response := &ListVolumesResponse{Versioned: ReplyVersioned(req.SchemaVersion)}
response.Volumes, response.NextToken = Page(ids, req.StartingToken, int(req.PageSize()),
func(id string) VolumeRef {
return VolumeRef{ID: id, Handle: m.volumes[id].volume.Handle}
@@ -338,7 +343,7 @@ func (m *MemoryProvider) ListSnapshots(_ context.Context, req ListSnapshotsReque
defer m.mu.RUnlock()
ids := slices.Sorted(maps.Keys(m.snapshots))
response := &ListSnapshotsResponse{Versioned: NewVersioned()}
response := &ListSnapshotsResponse{Versioned: ReplyVersioned(req.SchemaVersion)}
response.Snapshots, response.NextToken = Page(ids, req.StartingToken, int(req.PageSize()),
func(id string) SnapshotRef {
return SnapshotRef{
@@ -368,7 +373,10 @@ func (m *MemoryProvider) PublishVolume(_ context.Context, req PublishVolumeReque
}
if volume.published != nil {
if volume.published.NodeID != req.NodeID {
return nil, fmt.Errorf("%w: volume %s is published to %s", ErrVolumeInUse, req.VolumeID, volume.published.NodeID)
return nil, fmt.Errorf("%w: volume %s is published to %s", HeldElsewhereError(req.SchemaVersion), req.VolumeID, volume.published.NodeID)
}
if volume.readOnly != req.ReadOnly {
return nil, fmt.Errorf("%w: volume %s is already published read_only=%t", ErrVolumeInUse, req.VolumeID, volume.readOnly)
}
return clonePublished(volume.published), nil
}
@@ -377,26 +385,44 @@ func (m *MemoryProvider) PublishVolume(_ context.Context, req PublishVolumeReque
NodeID: req.NodeID,
Export: NewNBDExport("nbd+unix:///?socket=/memory/" + req.VolumeID + ".sock"),
}
volume.readOnly = req.ReadOnly
volume.volume.State = VolumeStateInUse
return clonePublished(volume.published), nil
}
func (m *MemoryProvider) UnpublishVolume(_ context.Context, req UnpublishVolumeRequest) error {
if err := checkVersion(req.SchemaVersion); err != nil {
return err
// UnpublishVolume honours NoSeal trivially: the volume's bytes live in this
// process, so there is no local copy to seal or to drop.
func (m *MemoryProvider) UnpublishVolume(_ context.Context, req UnpublishVolumeRequest) (*UnpublishedVolume, error) {
if err := CheckUnpublishVersion(req); err != nil {
return nil, err
}
if req.VolumeID == "" || req.NodeID == "" {
return fmt.Errorf("%w: volume and node IDs are required", ErrInvalidArgument)
return nil, fmt.Errorf("%w: volume and node IDs are required", ErrInvalidArgument)
}
m.mu.Lock()
defer m.mu.Unlock()
result := &UnpublishedVolume{}
volume := m.volumes[req.VolumeID]
if volume == nil || volume.published == nil || volume.published.NodeID != req.NodeID {
return nil
return result, nil
}
volume.published = nil
volume.readOnly = false
volume.volume.State = VolumeStateAvailable
return nil
result.TornDown = req.SchemaVersion >= NoSealSchemaVersion
return result, nil
}
// HoldsUnsealedWrites answers that none are held: every write lands in this
// process's map, which is the backend, before it is acknowledged.
func (m *MemoryProvider) HoldsUnsealedWrites(_ context.Context, nodeID string, volumeIDs []string) ([]string, error) {
if nodeID == "" {
return nil, fmt.Errorf("%w: node ID is required", ErrInvalidArgument)
}
if slices.Contains(volumeIDs, "") {
return nil, fmt.Errorf("%w: empty volume ID", ErrInvalidArgument)
}
return nil, nil
}
func cloneVolume(volume *Volume) *Volume {
+4 -3
View File
@@ -56,9 +56,10 @@ func TestMemoryProviderLifecycleAndIdempotency(t *testing.T) {
Versioned: NewVersioned(), VolumeID: volume.ID, Handle: volume.Handle,
}), ErrVolumeInUse)
require.NoError(t, provider.UnpublishVolume(ctx, UnpublishVolumeRequest{
_, err = provider.UnpublishVolume(ctx, UnpublishVolumeRequest{
Versioned: NewVersioned(), VolumeID: volume.ID, Handle: volume.Handle, NodeID: "node-1",
}))
})
require.NoError(t, err)
expanded, err := provider.ExpandVolume(ctx, ExpandVolumeRequest{
Versioned: NewVersioned(), VolumeID: volume.ID, Handle: volume.Handle,
@@ -141,7 +142,7 @@ func TestMemoryProviderChecksVersionOnEveryMethod(t *testing.T) {
_, err = provider.PublishVolume(ctx, PublishVolumeRequest{VolumeID: "vol-1", NodeID: "node-1"})
require.ErrorIs(t, err, ErrUnsupportedVersion)
err = provider.UnpublishVolume(ctx, UnpublishVolumeRequest{VolumeID: "vol-1", NodeID: "node-1"})
_, err = provider.UnpublishVolume(ctx, UnpublishVolumeRequest{VolumeID: "vol-1", NodeID: "node-1"})
require.ErrorIs(t, err, ErrUnsupportedVersion)
}
+85 -52
View File
@@ -39,20 +39,22 @@ const defaultRequestTimeout = 30 * time.Second
// codebase's existing budget for an EBS operation the caller cannot bound.
const dataMovementTimeout = 180 * time.Second
// dataMovementSubjects names every subject that references or moves real data.
// Everything absent answers from state already held and keeps the caller's
// timeout, so a subject added without a deliberate decision stays fast.
var dataMovementSubjects = map[string]struct{}{
CreateVolumeSubject: {},
ExpandVolumeSubject: {},
CopySnapshotSubject: {},
DeleteVolumeSubject: {},
DeleteSnapshotSubject: {},
// dataMovementVerbs names every operation that references or moves real data,
// however its subject addresses it. Everything absent answers from state
// already held and keeps the caller's timeout, so a new verb stays fast.
var dataMovementVerbs = map[string]bool{
verbVolumeCreate: true,
verbVolumeExpand: true,
verbVolumeDelete: true,
verbSnapshotCreate: true,
verbSnapshotCopy: true,
verbSnapshotDelete: true,
// Unpublish seals local state back to the backend, which takes as long as
// the volume has unsealed writes.
verbVolumeUnpublish: true,
}
// timeoutFor sizes a request to the work its subject names. The per-volume
// snapshot create and the owner-routed variants carry no constant, so they are
// matched by prefix and by verb.
// timeoutFor sizes a request to the work its subject names.
func (p *NATSProvider) timeoutFor(subject string) time.Duration {
if movesData(subject) {
return max(p.requestTimeout, dataMovementTimeout)
@@ -60,28 +62,8 @@ func (p *NATSProvider) timeoutFor(subject string) time.Duration {
return p.requestTimeout
}
// movesData answers for the named subjects, for the per-volume snapshot create
// prefix, and for the owner-routed variants, none of which carry a constant.
func movesData(subject string) bool {
if _, ok := dataMovementSubjects[subject]; ok {
return true
}
if strings.HasPrefix(subject, SnapshotCreateSubjectPrefix) {
return true
}
_, verb, ok := ParseOwnerSubject(subject)
return ok && isDataMovementVerb(verb)
}
// isDataMovementVerb answers for the owner-routed subjects. volume.describe is
// the one owner verb that reads state rather than moving data.
func isDataMovementVerb(verb string) bool {
switch verb {
case verbSnapshotCreate, verbSnapshotCopy, verbVolumeExpand:
return true
default:
return false
}
return dataMovementVerbs[parseSubject(subject).verb]
}
// SnapshotSubject addresses a create by source volume so the owning node can
@@ -104,32 +86,65 @@ func SnapshotCompletionSubject(snapshotID string) (string, error) {
return "ebs.provider.v1.snapshot.response." + snapshotID, nil
}
// PublishSubject returns nodeID's per-node mount subject
// (ebs.provider.v1.<node>.mount), which serves PublishVolume on that node.
func PublishSubject(nodeID string) (string, error) {
// Last tokens of the per-node subjects: ebs.provider.v1.<node>.<verb>.
const (
publishVerb = "mount"
unpublishVerb = "unmount"
unsealedVerb = "unsealed"
nodeCapabilitiesVerb = "capabilities"
fenceNoticeVerb = "fenced"
)
// nodeSubjectWildcard is the subject matching verb on every node, for a server
// that stands in for every node's provider.
func nodeSubjectWildcard(verb string) string { return subjectPrefix + "*." + verb }
// Wildcards over every node for each per-node subject.
var (
PublishSubjectWildcard = nodeSubjectWildcard(publishVerb)
UnpublishSubjectWildcard = nodeSubjectWildcard(unpublishVerb)
UnsealedWritesSubjectWildcard = nodeSubjectWildcard(unsealedVerb)
NodeCapabilitiesSubjectWildcard = nodeSubjectWildcard(nodeCapabilitiesVerb)
)
func nodeSubject(nodeID, verb string) (string, error) {
if err := validateSubjectToken(nodeID); err != nil {
return "", err
}
return subjectPrefix + nodeID + ".mount", nil
return subjectPrefix + nodeID + "." + verb, nil
}
// PublishSubject returns nodeID's per-node mount subject
// (ebs.provider.v1.<node>.mount), which serves PublishVolume on that node.
func PublishSubject(nodeID string) (string, error) {
return nodeSubject(nodeID, publishVerb)
}
// UnpublishSubject returns nodeID's per-node unmount subject
// (ebs.provider.v1.<node>.unmount), which serves UnpublishVolume on that node.
func UnpublishSubject(nodeID string) (string, error) {
if err := validateSubjectToken(nodeID); err != nil {
return "", err
}
return subjectPrefix + nodeID + ".unmount", nil
return nodeSubject(nodeID, unpublishVerb)
}
// UnsealedWritesSubject returns nodeID's per-node subject
// (ebs.provider.v1.<node>.unsealed), which answers whether that node holds
// unsealed writes for a set of volumes.
func UnsealedWritesSubject(nodeID string) (string, error) {
if err := validateSubjectToken(nodeID); err != nil {
return "", err
}
return subjectPrefix + nodeID + ".unsealed", nil
return nodeSubject(nodeID, unsealedVerb)
}
// NodeCapabilitiesSubject returns nodeID's per-node GetCapabilities subject
// (ebs.provider.v1.<node>.capabilities). Unlike CapabilitiesSubject, which
// any node's provider answers, only nodeID's provider answers it.
func NodeCapabilitiesSubject(nodeID string) (string, error) {
return nodeSubject(nodeID, nodeCapabilitiesVerb)
}
// FenceNoticeSubject returns nodeID's fence-notice subject
// (ebs.provider.v1.<node>.fenced), on which a provider that revoked a live
// export there sends a FenceNotice for that node's daemon to acknowledge.
func FenceNoticeSubject(nodeID string) (string, error) {
return nodeSubject(nodeID, fenceNoticeVerb)
}
// ownerSubjectPrefix is common to every owner-routed subject OwnerSubject
@@ -224,11 +239,26 @@ func NewNATSProvider(conn *nats.Conn, requestTimeout time.Duration) *NATSProvide
}
func (p *NATSProvider) GetCapabilities(ctx context.Context, req GetCapabilitiesRequest) (*GetCapabilitiesResponse, error) {
return p.getCapabilities(ctx, CapabilitiesSubject, req)
}
// GetNodeCapabilities asks nodeID's own provider what it supports, over
// NodeCapabilitiesSubject. A provider that predates that subject gets no
// responder, which a caller must count as no evidence.
func (p *NATSProvider) GetNodeCapabilities(ctx context.Context, nodeID string, req GetCapabilitiesRequest) (*GetCapabilitiesResponse, error) {
subject, err := NodeCapabilitiesSubject(nodeID)
if err != nil {
return nil, err
}
return p.getCapabilities(ctx, subject, req)
}
func (p *NATSProvider) getCapabilities(ctx context.Context, subject string, req GetCapabilitiesRequest) (*GetCapabilitiesResponse, error) {
if err := checkVersion(req.SchemaVersion); err != nil {
return nil, err
}
var response GetCapabilitiesResponse
if err := p.request(ctx, CapabilitiesSubject, req, &response); err != nil {
if err := p.request(ctx, subject, req, &response); err != nil {
return nil, err
}
if err := responseError(response.SchemaVersion, response.Error); err != nil {
@@ -490,19 +520,22 @@ func (p *NATSProvider) PublishVolume(ctx context.Context, req PublishVolumeReque
return response.Published, nil
}
func (p *NATSProvider) UnpublishVolume(ctx context.Context, req UnpublishVolumeRequest) error {
if err := checkVersion(req.SchemaVersion); err != nil {
return err
func (p *NATSProvider) UnpublishVolume(ctx context.Context, req UnpublishVolumeRequest) (*UnpublishedVolume, error) {
if err := CheckUnpublishVersion(req); err != nil {
return nil, err
}
subject, err := UnpublishSubject(req.NodeID)
if err != nil {
return err
return nil, err
}
var response UnpublishVolumeResponse
if err := p.request(ctx, subject, req, &response); err != nil {
return err
return nil, err
}
return responseError(response.SchemaVersion, response.Error)
if err := responseError(response.SchemaVersion, response.Error); err != nil {
return nil, err
}
return &UnpublishedVolume{TornDown: response.TornDown}, nil
}
// HoldsUnsealedWrites returns the subset of volumeIDs for which nodeID's
+14 -3
View File
@@ -250,7 +250,7 @@ func TestNATSProviderChecksVersionBeforeTransport(t *testing.T) {
require.ErrorIs(t, err, ErrUnsupportedVersion)
})
t.Run("UnpublishVolume", func(t *testing.T) {
err := provider.UnpublishVolume(ctx, UnpublishVolumeRequest{VolumeID: "vol-1", NodeID: "node-1"})
_, err := provider.UnpublishVolume(ctx, UnpublishVolumeRequest{VolumeID: "vol-1", NodeID: "node-1"})
require.ErrorIs(t, err, ErrUnsupportedVersion)
})
}
@@ -563,7 +563,7 @@ func TestNATSProviderPropagatesProviderErrors(t *testing.T) {
respondWith(t, conn, subject, UnpublishVolumeResponse{
Versioned: NewVersioned(), Error: &ProviderError{Code: ErrorCodeNotFound, Message: "no such mount"},
})
err = NewNATSProvider(conn, time.Second).UnpublishVolume(t.Context(), UnpublishVolumeRequest{
_, err = NewNATSProvider(conn, time.Second).UnpublishVolume(t.Context(), UnpublishVolumeRequest{
Versioned: NewVersioned(), VolumeID: "vol-1", NodeID: "node-err",
})
require.ErrorIs(t, err, ErrNotFound)
@@ -601,7 +601,7 @@ func TestNATSProviderValidatesSubjectTokensBeforeTransport(t *testing.T) {
require.ErrorIs(t, err, ErrInvalidArgument)
})
t.Run("UnpublishVolume", func(t *testing.T) {
err := provider.UnpublishVolume(ctx, UnpublishVolumeRequest{Versioned: NewVersioned(), VolumeID: "vol-1", NodeID: "node.*"})
_, err := provider.UnpublishVolume(ctx, UnpublishVolumeRequest{Versioned: NewVersioned(), VolumeID: "vol-1", NodeID: "node.*"})
require.ErrorIs(t, err, ErrInvalidArgument)
})
}
@@ -712,6 +712,14 @@ func TestTimeoutForSizesEachSubjectToItsWork(t *testing.T) {
require.NoError(t, err)
ownerDescribe, err := GetVolumeOwnerSubject("vol-1")
require.NoError(t, err)
unpublish, err := UnpublishSubject("node-1")
require.NoError(t, err)
publish, err := PublishSubject("node-1")
require.NoError(t, err)
unsealed, err := UnsealedWritesSubject("node-1")
require.NoError(t, err)
nodeCapabilities, err := NodeCapabilitiesSubject("node-1")
require.NoError(t, err)
// A delete costs one round trip per object, so its duration is a function
// of the volume. On prod a 100 GiB volume's delete took 52 s against a 30 s
@@ -721,6 +729,8 @@ func TestTimeoutForSizesEachSubjectToItsWork(t *testing.T) {
CreateVolumeSubject, ExpandVolumeSubject, CopySnapshotSubject,
DeleteVolumeSubject, DeleteSnapshotSubject,
snapshotCreate, ownerCopy, ownerExpand, ownerCreate,
// Unpublish seals the volume's unsealed writes back to the backend.
unpublish,
}
for _, subject := range slow {
assert.Equal(t, dataMovementTimeout, p.timeoutFor(subject),
@@ -730,6 +740,7 @@ func TestTimeoutForSizesEachSubjectToItsWork(t *testing.T) {
fast := []string{
CapabilitiesSubject, GetVolumeSubject, ListVolumesSubject,
ListSnapshotsSubject, ownerDescribe,
publish, unsealed, nodeCapabilities,
}
for _, subject := range fast {
assert.Equal(t, callerTimeout, p.timeoutFor(subject),
@@ -23,6 +23,7 @@ func TestClassifyError(t *testing.T) {
{ebsprovider.ErrVolumeInUse, ebsprovider.ErrorCodeVolumeInUse},
{ebsprovider.ErrUnsupportedCapability, ebsprovider.ErrorCodeUnsupportedCap},
{ebsprovider.ErrUnavailable, ebsprovider.ErrorCodeUnavailable},
{ebsprovider.ErrVolumeHeldElsewhere, ebsprovider.ErrorCodeVolumeHeldElsewhere},
}
for _, tt := range tests {
@@ -96,8 +96,12 @@ func (p *failingProvider) PublishVolume(context.Context, ebsprovider.PublishVolu
return nil, p.err
}
func (p *failingProvider) UnpublishVolume(context.Context, ebsprovider.UnpublishVolumeRequest) error {
return p.err
func (p *failingProvider) UnpublishVolume(context.Context, ebsprovider.UnpublishVolumeRequest) (*ebsprovider.UnpublishedVolume, error) {
return nil, p.err
}
func (p *failingProvider) HoldsUnsealedWrites(context.Context, string, []string) ([]string, error) {
return nil, p.err
}
// wireVerb names one subject Serve subscribes, a valid request body for it,
@@ -120,6 +124,10 @@ func wireVerbs(t *testing.T) []wireVerb {
require.NoError(t, err)
unmountSubject, err := ebsprovider.UnpublishSubject("node-1")
require.NoError(t, err)
unsealedSubject, err := ebsprovider.UnsealedWritesSubject("node-1")
require.NoError(t, err)
nodeCapabilitiesSubject, err := ebsprovider.NodeCapabilitiesSubject("node-1")
require.NoError(t, err)
return []wireVerb{
{name: "capabilities", subject: ebsprovider.CapabilitiesSubject,
@@ -146,9 +154,122 @@ func wireVerbs(t *testing.T) []wireVerb {
request: ebsprovider.PublishVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: "vol-1", NodeID: "node-1"}},
{name: "unmount", subject: unmountSubject,
request: ebsprovider.UnpublishVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: "vol-1", NodeID: "node-1"}},
{name: "unsealed", subject: unsealedSubject,
request: ebsprovider.HoldsUnsealedWritesRequest{Versioned: ebsprovider.NewVersioned(), NodeID: "node-1", VolumeIDs: []string{"vol-1"}}},
{name: "node.capabilities", subject: nodeCapabilitiesSubject,
request: ebsprovider.GetCapabilitiesRequest{Versioned: ebsprovider.NewVersioned()}},
}
}
// withVersion re-encodes a wireVerb request at version, so one table drives
// every served version without a copy of each request per version.
func withVersion(t *testing.T, request any, version uint16) []byte {
t.Helper()
payload, err := json.Marshal(request)
require.NoError(t, err)
var body map[string]any
require.NoError(t, json.Unmarshal(payload, &body))
body["schema_version"] = version
payload, err = json.Marshal(body)
require.NoError(t, err)
return payload
}
// TestHandlersReplyInRequestVersion is what lets a version 3 server serve a
// v1.23.0 caller: every reply, success or provider failure, carries the
// version the request was sent at, never the server's newest.
func TestHandlersReplyInRequestVersion(t *testing.T) {
failing := serveProvider(t, &failingProvider{err: fmt.Errorf("vol-1: %w", ebsprovider.ErrUnavailable)}, natsserve.Options{NoQueueGroup: true})
for _, version := range ebsprovider.SupportedSchemaVersions() {
for _, verb := range wireVerbs(t) {
// Each success case gets its own seeded provider, so none relies on
// an earlier case having created or deleted something.
t.Run(fmt.Sprintf("success/v%d/%s", version, verb.name), func(t *testing.T) {
conn := serveProvider(t, seededProvider(t), natsserve.Options{NoQueueGroup: true})
for _, envelope := range requestAt(t, conn, verb, version) {
require.Nil(t, envelope.Error, "the case must succeed for it to check a success reply")
assert.Equal(t, version, envelope.SchemaVersion)
}
})
t.Run(fmt.Sprintf("failure/v%d/%s", version, verb.name), func(t *testing.T) {
envelopes := requestAt(t, failing, verb, version)
// A snapshot create is accepted first; the failure is in its completion.
require.NotNil(t, envelopes[len(envelopes)-1].Error, "the case must fail for it to check a failure reply")
for _, envelope := range envelopes {
assert.Equal(t, version, envelope.SchemaVersion)
}
})
}
}
}
// seededProvider holds what every wireVerbs request needs to succeed: vol-1
// and snap-1 taken from it, with enumeration advertised so listing succeeds.
func seededProvider(t *testing.T) ebsprovider.EBSProvider {
t.Helper()
provider := ebsprovider.NewMemoryProvider(ebsprovider.Capabilities{VolumeEnumeration: true, SnapshotEnumeration: true})
ctx := context.Background()
_, err := provider.CreateVolume(ctx, ebsprovider.CreateVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: "vol-1", CapacityRange: ebsprovider.CapacityRange{RequiredBytes: 1 << 30}})
require.NoError(t, err)
_, err = provider.CreateSnapshot(ctx, ebsprovider.CreateSnapshotRequest{Versioned: ebsprovider.NewVersioned(), SnapshotID: "snap-1", VolumeID: "vol-1"})
require.NoError(t, err)
return provider
}
// requestAt sends verb at version and returns the reply envelope, followed by
// the completion's for a verb that publishes one.
func requestAt(t *testing.T, conn *nats.Conn, verb wireVerb, version uint16) []wireEnvelope {
t.Helper()
var completionSub *nats.Subscription
if verb.completionSubject != "" {
var err error
completionSub, err = conn.SubscribeSync(verb.completionSubject)
require.NoError(t, err)
t.Cleanup(func() { _ = completionSub.Unsubscribe() })
require.NoError(t, conn.Flush())
}
msg, err := conn.Request(verb.subject, withVersion(t, verb.request, version), requestTimeout)
require.NoError(t, err)
envelopes := []wireEnvelope{decodeEnvelope(t, msg.Data)}
if completionSub != nil {
msg, err = completionSub.NextMsg(requestTimeout)
require.NoError(t, err)
envelopes = append(envelopes, decodeEnvelope(t, msg.Data))
}
return envelopes
}
// TestUnpublishNoSealOnTheWire pins the server half of no_seal: refused below
// version 3 without reaching the provider, and reported as torn down at 3.
func TestUnpublishNoSealOnTheWire(t *testing.T) {
provider := ebsprovider.NewMemoryProvider(ebsprovider.Capabilities{})
conn := serveProvider(t, provider, natsserve.Options{NoQueueGroup: true})
ctx := context.Background()
_, err := provider.CreateVolume(ctx, ebsprovider.CreateVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: "vol-1", CapacityRange: ebsprovider.CapacityRange{RequiredBytes: 1 << 30}})
require.NoError(t, err)
_, err = provider.PublishVolume(ctx, ebsprovider.PublishVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: "vol-1", NodeID: "node-1"})
require.NoError(t, err)
subject, err := ebsprovider.UnpublishSubject("node-1")
require.NoError(t, err)
request := ebsprovider.UnpublishVolumeRequest{VolumeID: "vol-1", NodeID: "node-1", NoSeal: true}
msg, err := conn.Request(subject, withVersion(t, request, ebsprovider.DescriptorSchemaVersion), requestTimeout)
require.NoError(t, err)
envelope := decodeEnvelope(t, msg.Data)
require.NotNil(t, envelope.Error)
assert.Equal(t, ebsprovider.ErrorCodeInvalidArgument, envelope.Error.Code)
err = provider.DeleteVolume(ctx, ebsprovider.DeleteVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: "vol-1"})
require.ErrorIs(t, err, ebsprovider.ErrVolumeInUse, "the refused unpublish must not have reached the provider")
msg, err = conn.Request(subject, withVersion(t, request, ebsprovider.NoSealSchemaVersion), requestTimeout)
require.NoError(t, err)
var resp ebsprovider.UnpublishVolumeResponse
require.NoError(t, json.Unmarshal(msg.Data, &resp))
require.Nil(t, resp.Error)
assert.True(t, resp.TornDown)
}
// TestHandlersRejectMalformedRequests pins the caller-fault arm of every
// subject: a body that is not JSON must come back as a versioned
// invalid_argument reply, not as a dropped message the caller waits out.
@@ -184,7 +305,8 @@ func TestHandlersRejectVersionSkew(t *testing.T) {
assert.Equal(t, ebsprovider.LegacySchemaVersion, envelope.SchemaVersion, "the refusal itself must be versioned")
require.NotNil(t, envelope.Error)
assert.Equal(t, ebsprovider.ErrorCodeUnsupportedVersion, envelope.Error.Code)
assert.Equal(t, fmt.Sprintf("unsupported schema version 99; supported versions are %d and %d", ebsprovider.LegacySchemaVersion, ebsprovider.SchemaVersion), envelope.Error.Message)
assert.Contains(t, envelope.Error.Message, "99", "the refusal must name the version it refused")
assert.Contains(t, envelope.Error.Message, ebsprovider.SupportedVersionsText(), "the refusal must name the versions served")
})
}
}
@@ -282,9 +404,10 @@ func TestServedProviderRoundTrip(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, ebsprovider.VolumeStateInUse, inUse.State)
require.NoError(t, client.UnpublishVolume(ctx, ebsprovider.UnpublishVolumeRequest{
_, err = client.UnpublishVolume(ctx, ebsprovider.UnpublishVolumeRequest{
Versioned: ebsprovider.NewVersioned(), VolumeID: "vol-roundtrip", NodeID: "node-1",
}))
})
require.NoError(t, err)
available, err := client.GetVolume(ctx, ebsprovider.GetVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: "vol-roundtrip"})
require.NoError(t, err)
assert.Equal(t, ebsprovider.VolumeStateAvailable, available.State)
@@ -614,6 +737,22 @@ func TestServeNodeIDScopesMountSubjects(t *testing.T) {
require.ErrorIs(t, err, nats.ErrNoResponders, "another node's mount subject must go unanswered")
}
// TestGetNodeCapabilitiesIsAnsweredOnlyByThatNode is why the per-node subject
// exists: the shared one is answered by whichever node's provider is fastest,
// which says nothing about the provider on the node a caller will mount on.
func TestGetNodeCapabilitiesIsAnsweredOnlyByThatNode(t *testing.T) {
conn := serveProvider(t, ebsprovider.NewMemoryProvider(ebsprovider.Capabilities{}), natsserve.Options{NodeID: "node-1"})
client := ebsprovider.NewNATSProvider(conn, requestTimeout)
request := ebsprovider.GetCapabilitiesRequest{Versioned: ebsprovider.NewVersioned()}
resp, err := client.GetNodeCapabilities(t.Context(), "node-1", request)
require.NoError(t, err)
assert.Equal(t, ebsprovider.SupportedSchemaVersions(), resp.ContractVersions)
_, err = client.GetNodeCapabilities(t.Context(), "node-2", request)
require.ErrorIs(t, err, nats.ErrNoResponders, "another node's capabilities subject must go unanswered")
}
// TestServeRejectsUnroutableNodeID covers the subject-build failure path: an
// unsubscribable node ID must fail Serve outright rather than leave a server
// running with the shared subjects subscribed and no mount subject at all.
@@ -622,7 +761,7 @@ func TestServeRejectsUnroutableNodeID(t *testing.T) {
stop, err := natsserve.Serve(t.Context(), conn, ebsprovider.NewMemoryProvider(ebsprovider.Capabilities{}), natsserve.Options{NodeID: "node.*"})
require.ErrorIs(t, err, ebsprovider.ErrInvalidArgument)
assert.Contains(t, err.Error(), "build publish subject")
assert.Contains(t, err.Error(), "build per-node subject for "+ebsprovider.PublishSubjectWildcard)
assert.Nil(t, stop)
_, err = conn.Request(ebsprovider.CapabilitiesSubject, []byte(fmt.Sprintf(`{"schema_version":%d}`, ebsprovider.SchemaVersion)), 200*time.Millisecond)
+152 -96
View File
@@ -36,9 +36,9 @@ type Options struct {
// across workers does not apply.
NoQueueGroup bool
// NodeID scopes PublishVolume/UnpublishVolume to this node's own
// subjects (ebsprovider.PublishSubject / UnpublishSubject), matching a
// production daemon that can only mount volumes on its own node. Left empty (the default), Serve instead subscribes the wildcard "ebs.provider.v1.*.mount"/"unmount" subjects, answering requests for any node.
// NodeID scopes the per-node subjects (publish, unpublish, unsealed writes
// and per-node capabilities) to this node, as a production daemon serves
// them. Left empty, Serve subscribes their wildcards and answers for any node.
NodeID string
}
@@ -127,31 +127,27 @@ func Serve(ctx context.Context, nc *nats.Conn, provider ebsprovider.EBSProvider,
}
}
if opts.NodeID == "" {
if err := subscribePlain("ebs.provider.v1.*.mount", handlePublishVolume(provider)); err != nil {
cleanup()
return nil, err
nodeSteps := []struct {
wildcard string
build func(string) (string, error)
handler msgHandler
}{
{ebsprovider.PublishSubjectWildcard, ebsprovider.PublishSubject, handlePublishVolume(provider)},
{ebsprovider.UnpublishSubjectWildcard, ebsprovider.UnpublishSubject, handleUnpublishVolume(provider)},
{ebsprovider.UnsealedWritesSubjectWildcard, ebsprovider.UnsealedWritesSubject, handleHoldsUnsealedWrites(provider)},
{ebsprovider.NodeCapabilitiesSubjectWildcard, ebsprovider.NodeCapabilitiesSubject, handleCapabilities(provider)},
}
for _, step := range nodeSteps {
subject := step.wildcard
if opts.NodeID != "" {
built, err := step.build(opts.NodeID)
if err != nil {
cleanup()
return nil, fmt.Errorf("build per-node subject for %s: %w", step.wildcard, err)
}
subject = built
}
if err := subscribePlain("ebs.provider.v1.*.unmount", handleUnpublishVolume(provider)); err != nil {
cleanup()
return nil, err
}
} else {
publishSubject, err := ebsprovider.PublishSubject(opts.NodeID)
if err != nil {
cleanup()
return nil, fmt.Errorf("build publish subject: %w", err)
}
if err := subscribePlain(publishSubject, handlePublishVolume(provider)); err != nil {
cleanup()
return nil, err
}
unpublishSubject, err := ebsprovider.UnpublishSubject(opts.NodeID)
if err != nil {
cleanup()
return nil, fmt.Errorf("build unpublish subject: %w", err)
}
if err := subscribePlain(unpublishSubject, handleUnpublishVolume(provider)); err != nil {
if err := subscribePlain(subject, step.handler); err != nil {
cleanup()
return nil, err
}
@@ -188,7 +184,7 @@ func badRequestError(err error) *ebsprovider.ProviderError {
}
func versionError(got uint16) *ebsprovider.ProviderError {
return &ebsprovider.ProviderError{Code: ebsprovider.ErrorCodeUnsupportedVersion, Message: fmt.Sprintf("unsupported schema version %d; supported versions are %d and %d", got, ebsprovider.LegacySchemaVersion, ebsprovider.SchemaVersion)}
return &ebsprovider.ProviderError{Code: ebsprovider.ErrorCodeUnsupportedVersion, Message: fmt.Sprintf("unsupported schema version %d; %s", got, ebsprovider.SupportedVersionsText())}
}
func invalidArgumentError(format string, args ...any) *ebsprovider.ProviderError {
@@ -202,6 +198,7 @@ func internalError(format string, args ...any) *ebsprovider.ProviderError {
// classifyError maps a provider error to the wire ProviderError shape,
// using only the sentinel errors and ErrorCode values errors.go exports:
// it has no forward (error -> ProviderError) mapper of its own to call.
// The provider picks the sentinel for the request's version (HeldElsewhereError).
func classifyError(err error) *ebsprovider.ProviderError {
switch {
case errors.Is(err, ebsprovider.ErrAlreadyExists):
@@ -212,6 +209,8 @@ func classifyError(err error) *ebsprovider.ProviderError {
return &ebsprovider.ProviderError{Code: ebsprovider.ErrorCodeNotFound, Message: err.Error()}
case errors.Is(err, ebsprovider.ErrUnsupportedVersion):
return &ebsprovider.ProviderError{Code: ebsprovider.ErrorCodeUnsupportedVersion, Message: err.Error()}
case errors.Is(err, ebsprovider.ErrVolumeHeldElsewhere):
return &ebsprovider.ProviderError{Code: ebsprovider.ErrorCodeVolumeHeldElsewhere, Message: err.Error()}
case errors.Is(err, ebsprovider.ErrVolumeInUse):
return &ebsprovider.ProviderError{Code: ebsprovider.ErrorCodeVolumeInUse, Message: err.Error()}
case errors.Is(err, ebsprovider.ErrUnsupportedCapability):
@@ -226,178 +225,202 @@ func classifyError(err error) *ebsprovider.ProviderError {
func handleCapabilities(provider ebsprovider.EBSProvider) msgHandler {
return func(ctx context.Context, msg *nats.Msg) {
var req ebsprovider.GetCapabilitiesRequest
if err := json.Unmarshal(msg.Data, &req); err != nil {
respond(ctx, msg, ebsprovider.GetCapabilitiesResponse{Versioned: ebsprovider.NewVersioned(), Error: badRequestError(err)})
err := json.Unmarshal(msg.Data, &req)
v := ebsprovider.ReplyVersioned(req.SchemaVersion)
if err != nil {
respond(ctx, msg, ebsprovider.GetCapabilitiesResponse{Versioned: v, Error: badRequestError(err)})
return
}
if !ebsprovider.SupportsSchemaVersion(req.SchemaVersion) {
respond(ctx, msg, ebsprovider.GetCapabilitiesResponse{Versioned: ebsprovider.NewVersioned(), Error: versionError(req.SchemaVersion)})
respond(ctx, msg, ebsprovider.GetCapabilitiesResponse{Versioned: v, Error: versionError(req.SchemaVersion)})
return
}
resp, err := provider.GetCapabilities(ctx, req)
if err != nil {
respond(ctx, msg, ebsprovider.GetCapabilitiesResponse{Versioned: ebsprovider.NewVersioned(), Error: classifyError(err)})
respond(ctx, msg, ebsprovider.GetCapabilitiesResponse{Versioned: v, Error: classifyError(err)})
return
}
respond(ctx, msg, *resp)
reply := *resp
reply.Versioned = v
respond(ctx, msg, reply)
}
}
func handleCreateVolume(provider ebsprovider.EBSProvider) msgHandler {
return func(ctx context.Context, msg *nats.Msg) {
var req ebsprovider.CreateVolumeRequest
if err := json.Unmarshal(msg.Data, &req); err != nil {
respond(ctx, msg, ebsprovider.CreateVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: badRequestError(err)})
err := json.Unmarshal(msg.Data, &req)
v := ebsprovider.ReplyVersioned(req.SchemaVersion)
if err != nil {
respond(ctx, msg, ebsprovider.CreateVolumeResponse{Versioned: v, Error: badRequestError(err)})
return
}
if !ebsprovider.SupportsSchemaVersion(req.SchemaVersion) {
respond(ctx, msg, ebsprovider.CreateVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: versionError(req.SchemaVersion)})
respond(ctx, msg, ebsprovider.CreateVolumeResponse{Versioned: v, Error: versionError(req.SchemaVersion)})
return
}
vol, err := provider.CreateVolume(ctx, req)
if err != nil {
respond(ctx, msg, ebsprovider.CreateVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: classifyError(err)})
respond(ctx, msg, ebsprovider.CreateVolumeResponse{Versioned: v, Error: classifyError(err)})
return
}
respond(ctx, msg, ebsprovider.CreateVolumeResponse{Versioned: ebsprovider.NewVersioned(), Volume: vol})
respond(ctx, msg, ebsprovider.CreateVolumeResponse{Versioned: v, Volume: vol})
}
}
func handleGetVolume(provider ebsprovider.EBSProvider) msgHandler {
return func(ctx context.Context, msg *nats.Msg) {
var req ebsprovider.GetVolumeRequest
if err := json.Unmarshal(msg.Data, &req); err != nil {
respond(ctx, msg, ebsprovider.GetVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: badRequestError(err)})
err := json.Unmarshal(msg.Data, &req)
v := ebsprovider.ReplyVersioned(req.SchemaVersion)
if err != nil {
respond(ctx, msg, ebsprovider.GetVolumeResponse{Versioned: v, Error: badRequestError(err)})
return
}
if !ebsprovider.SupportsSchemaVersion(req.SchemaVersion) {
respond(ctx, msg, ebsprovider.GetVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: versionError(req.SchemaVersion)})
respond(ctx, msg, ebsprovider.GetVolumeResponse{Versioned: v, Error: versionError(req.SchemaVersion)})
return
}
vol, err := provider.GetVolume(ctx, req)
if err != nil {
respond(ctx, msg, ebsprovider.GetVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: classifyError(err)})
respond(ctx, msg, ebsprovider.GetVolumeResponse{Versioned: v, Error: classifyError(err)})
return
}
respond(ctx, msg, ebsprovider.GetVolumeResponse{Versioned: ebsprovider.NewVersioned(), Volume: vol})
respond(ctx, msg, ebsprovider.GetVolumeResponse{Versioned: v, Volume: vol})
}
}
func handleListVolumes(provider ebsprovider.EBSProvider) msgHandler {
return func(ctx context.Context, msg *nats.Msg) {
var req ebsprovider.ListVolumesRequest
if err := json.Unmarshal(msg.Data, &req); err != nil {
respond(ctx, msg, ebsprovider.ListVolumesResponse{Versioned: ebsprovider.NewVersioned(), Error: badRequestError(err)})
err := json.Unmarshal(msg.Data, &req)
v := ebsprovider.ReplyVersioned(req.SchemaVersion)
if err != nil {
respond(ctx, msg, ebsprovider.ListVolumesResponse{Versioned: v, Error: badRequestError(err)})
return
}
if !ebsprovider.SupportsSchemaVersion(req.SchemaVersion) {
respond(ctx, msg, ebsprovider.ListVolumesResponse{Versioned: ebsprovider.NewVersioned(), Error: versionError(req.SchemaVersion)})
respond(ctx, msg, ebsprovider.ListVolumesResponse{Versioned: v, Error: versionError(req.SchemaVersion)})
return
}
resp, err := provider.ListVolumes(ctx, req)
if err != nil {
respond(ctx, msg, ebsprovider.ListVolumesResponse{Versioned: ebsprovider.NewVersioned(), Error: classifyError(err)})
respond(ctx, msg, ebsprovider.ListVolumesResponse{Versioned: v, Error: classifyError(err)})
return
}
respond(ctx, msg, *resp)
reply := *resp
reply.Versioned = v
respond(ctx, msg, reply)
}
}
func handleListSnapshots(provider ebsprovider.EBSProvider) msgHandler {
return func(ctx context.Context, msg *nats.Msg) {
var req ebsprovider.ListSnapshotsRequest
if err := json.Unmarshal(msg.Data, &req); err != nil {
respond(ctx, msg, ebsprovider.ListSnapshotsResponse{Versioned: ebsprovider.NewVersioned(), Error: badRequestError(err)})
err := json.Unmarshal(msg.Data, &req)
v := ebsprovider.ReplyVersioned(req.SchemaVersion)
if err != nil {
respond(ctx, msg, ebsprovider.ListSnapshotsResponse{Versioned: v, Error: badRequestError(err)})
return
}
if !ebsprovider.SupportsSchemaVersion(req.SchemaVersion) {
respond(ctx, msg, ebsprovider.ListSnapshotsResponse{Versioned: ebsprovider.NewVersioned(), Error: versionError(req.SchemaVersion)})
respond(ctx, msg, ebsprovider.ListSnapshotsResponse{Versioned: v, Error: versionError(req.SchemaVersion)})
return
}
resp, err := provider.ListSnapshots(ctx, req)
if err != nil {
respond(ctx, msg, ebsprovider.ListSnapshotsResponse{Versioned: ebsprovider.NewVersioned(), Error: classifyError(err)})
respond(ctx, msg, ebsprovider.ListSnapshotsResponse{Versioned: v, Error: classifyError(err)})
return
}
respond(ctx, msg, *resp)
reply := *resp
reply.Versioned = v
respond(ctx, msg, reply)
}
}
func handleExpandVolume(provider ebsprovider.EBSProvider) msgHandler {
return func(ctx context.Context, msg *nats.Msg) {
var req ebsprovider.ExpandVolumeRequest
if err := json.Unmarshal(msg.Data, &req); err != nil {
respond(ctx, msg, ebsprovider.ExpandVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: badRequestError(err)})
err := json.Unmarshal(msg.Data, &req)
v := ebsprovider.ReplyVersioned(req.SchemaVersion)
if err != nil {
respond(ctx, msg, ebsprovider.ExpandVolumeResponse{Versioned: v, Error: badRequestError(err)})
return
}
if !ebsprovider.SupportsSchemaVersion(req.SchemaVersion) {
respond(ctx, msg, ebsprovider.ExpandVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: versionError(req.SchemaVersion)})
respond(ctx, msg, ebsprovider.ExpandVolumeResponse{Versioned: v, Error: versionError(req.SchemaVersion)})
return
}
vol, err := provider.ExpandVolume(ctx, req)
if err != nil {
respond(ctx, msg, ebsprovider.ExpandVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: classifyError(err)})
respond(ctx, msg, ebsprovider.ExpandVolumeResponse{Versioned: v, Error: classifyError(err)})
return
}
respond(ctx, msg, ebsprovider.ExpandVolumeResponse{Versioned: ebsprovider.NewVersioned(), Volume: vol})
respond(ctx, msg, ebsprovider.ExpandVolumeResponse{Versioned: v, Volume: vol})
}
}
func handleDeleteVolume(provider ebsprovider.EBSProvider) msgHandler {
return func(ctx context.Context, msg *nats.Msg) {
var req ebsprovider.DeleteVolumeRequest
if err := json.Unmarshal(msg.Data, &req); err != nil {
respond(ctx, msg, ebsprovider.DeleteVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: badRequestError(err)})
err := json.Unmarshal(msg.Data, &req)
v := ebsprovider.ReplyVersioned(req.SchemaVersion)
if err != nil {
respond(ctx, msg, ebsprovider.DeleteVolumeResponse{Versioned: v, Error: badRequestError(err)})
return
}
if !ebsprovider.SupportsSchemaVersion(req.SchemaVersion) {
respond(ctx, msg, ebsprovider.DeleteVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: versionError(req.SchemaVersion)})
respond(ctx, msg, ebsprovider.DeleteVolumeResponse{Versioned: v, Error: versionError(req.SchemaVersion)})
return
}
if err := provider.DeleteVolume(ctx, req); err != nil {
respond(ctx, msg, ebsprovider.DeleteVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: classifyError(err)})
respond(ctx, msg, ebsprovider.DeleteVolumeResponse{Versioned: v, Error: classifyError(err)})
return
}
respond(ctx, msg, ebsprovider.DeleteVolumeResponse{Versioned: ebsprovider.NewVersioned()})
respond(ctx, msg, ebsprovider.DeleteVolumeResponse{Versioned: v})
}
}
func handleDeleteSnapshot(provider ebsprovider.EBSProvider) msgHandler {
return func(ctx context.Context, msg *nats.Msg) {
var req ebsprovider.DeleteSnapshotRequest
if err := json.Unmarshal(msg.Data, &req); err != nil {
respond(ctx, msg, ebsprovider.DeleteSnapshotResponse{Versioned: ebsprovider.NewVersioned(), Error: badRequestError(err)})
err := json.Unmarshal(msg.Data, &req)
v := ebsprovider.ReplyVersioned(req.SchemaVersion)
if err != nil {
respond(ctx, msg, ebsprovider.DeleteSnapshotResponse{Versioned: v, Error: badRequestError(err)})
return
}
if !ebsprovider.SupportsSchemaVersion(req.SchemaVersion) {
respond(ctx, msg, ebsprovider.DeleteSnapshotResponse{Versioned: ebsprovider.NewVersioned(), Error: versionError(req.SchemaVersion)})
respond(ctx, msg, ebsprovider.DeleteSnapshotResponse{Versioned: v, Error: versionError(req.SchemaVersion)})
return
}
if err := provider.DeleteSnapshot(ctx, req); err != nil {
respond(ctx, msg, ebsprovider.DeleteSnapshotResponse{Versioned: ebsprovider.NewVersioned(), Error: classifyError(err)})
respond(ctx, msg, ebsprovider.DeleteSnapshotResponse{Versioned: v, Error: classifyError(err)})
return
}
respond(ctx, msg, ebsprovider.DeleteSnapshotResponse{Versioned: ebsprovider.NewVersioned()})
respond(ctx, msg, ebsprovider.DeleteSnapshotResponse{Versioned: v})
}
}
func handleCopySnapshot(provider ebsprovider.EBSProvider) msgHandler {
return func(ctx context.Context, msg *nats.Msg) {
var req ebsprovider.CopySnapshotRequest
if err := json.Unmarshal(msg.Data, &req); err != nil {
respond(ctx, msg, ebsprovider.CopySnapshotResponse{Versioned: ebsprovider.NewVersioned(), Error: badRequestError(err)})
err := json.Unmarshal(msg.Data, &req)
v := ebsprovider.ReplyVersioned(req.SchemaVersion)
if err != nil {
respond(ctx, msg, ebsprovider.CopySnapshotResponse{Versioned: v, Error: badRequestError(err)})
return
}
if !ebsprovider.SupportsSchemaVersion(req.SchemaVersion) {
respond(ctx, msg, ebsprovider.CopySnapshotResponse{Versioned: ebsprovider.NewVersioned(), Error: versionError(req.SchemaVersion)})
respond(ctx, msg, ebsprovider.CopySnapshotResponse{Versioned: v, Error: versionError(req.SchemaVersion)})
return
}
snap, err := provider.CopySnapshot(ctx, req)
if err != nil {
respond(ctx, msg, ebsprovider.CopySnapshotResponse{Versioned: ebsprovider.NewVersioned(), Error: classifyError(err)})
respond(ctx, msg, ebsprovider.CopySnapshotResponse{Versioned: v, Error: classifyError(err)})
return
}
respond(ctx, msg, ebsprovider.CopySnapshotResponse{Versioned: ebsprovider.NewVersioned(), Snapshot: snap})
respond(ctx, msg, ebsprovider.CopySnapshotResponse{Versioned: v, Snapshot: snap})
}
}
@@ -407,12 +430,14 @@ func handleCopySnapshot(provider ebsprovider.EBSProvider) msgHandler {
func handleCreateSnapshot(nc *nats.Conn, provider ebsprovider.EBSProvider) msgHandler {
return func(ctx context.Context, msg *nats.Msg) {
var req ebsprovider.CreateSnapshotRequest
if err := json.Unmarshal(msg.Data, &req); err != nil {
respond(ctx, msg, ebsprovider.CreateSnapshotResponse{Versioned: ebsprovider.NewVersioned(), Error: badRequestError(err)})
err := json.Unmarshal(msg.Data, &req)
v := ebsprovider.ReplyVersioned(req.SchemaVersion)
if err != nil {
respond(ctx, msg, ebsprovider.CreateSnapshotResponse{Versioned: v, Error: badRequestError(err)})
return
}
if !ebsprovider.SupportsSchemaVersion(req.SchemaVersion) {
respond(ctx, msg, ebsprovider.CreateSnapshotResponse{Versioned: ebsprovider.NewVersioned(), Error: versionError(req.SchemaVersion)})
respond(ctx, msg, ebsprovider.CreateSnapshotResponse{Versioned: v, Error: versionError(req.SchemaVersion)})
return
}
@@ -420,23 +445,23 @@ func handleCreateSnapshot(nc *nats.Conn, provider ebsprovider.EBSProvider) msgHa
// token (SnapshotCreateSubjectPrefix + volumeID); a mismatch with
// the request body means the two disagree about which volume this create is for.
if subjectVolumeID, ok := strings.CutPrefix(msg.Subject, ebsprovider.SnapshotCreateSubjectPrefix); ok && subjectVolumeID != req.VolumeID {
respond(ctx, msg, ebsprovider.CreateSnapshotResponse{Versioned: ebsprovider.NewVersioned(), Error: invalidArgumentError("volume id %q in subject does not match request volume id %q", subjectVolumeID, req.VolumeID)})
respond(ctx, msg, ebsprovider.CreateSnapshotResponse{Versioned: v, Error: invalidArgumentError("volume id %q in subject does not match request volume id %q", subjectVolumeID, req.VolumeID)})
return
}
completionSubject, err := ebsprovider.SnapshotCompletionSubject(req.SnapshotID)
if err != nil {
respond(ctx, msg, ebsprovider.CreateSnapshotResponse{Versioned: ebsprovider.NewVersioned(), Error: classifyError(err)})
respond(ctx, msg, ebsprovider.CreateSnapshotResponse{Versioned: v, Error: classifyError(err)})
return
}
operationID, err := newOperationID()
if err != nil {
respond(ctx, msg, ebsprovider.CreateSnapshotResponse{Versioned: ebsprovider.NewVersioned(), Error: internalError("generate operation id: %v", err)})
respond(ctx, msg, ebsprovider.CreateSnapshotResponse{Versioned: v, Error: internalError("generate operation id: %v", err)})
return
}
respond(ctx, msg, ebsprovider.CreateSnapshotResponse{
Versioned: ebsprovider.NewVersioned(),
Versioned: v,
OperationID: operationID,
CompletionSubject: completionSubject,
Snapshot: &ebsprovider.Snapshot{
@@ -452,7 +477,7 @@ func handleCreateSnapshot(nc *nats.Conn, provider ebsprovider.EBSProvider) msgHa
func completeCreateSnapshot(ctx context.Context, nc *nats.Conn, provider ebsprovider.EBSProvider, req ebsprovider.CreateSnapshotRequest, operationID, completionSubject string) {
completion := ebsprovider.CreateSnapshotResponse{
Versioned: ebsprovider.NewVersioned(),
Versioned: ebsprovider.ReplyVersioned(req.SchemaVersion),
OperationID: operationID,
CompletionSubject: completionSubject,
}
@@ -488,27 +513,29 @@ func newOperationID() (string, error) {
func handlePublishVolume(provider ebsprovider.EBSProvider) msgHandler {
return func(ctx context.Context, msg *nats.Msg) {
var req ebsprovider.PublishVolumeRequest
if err := json.Unmarshal(msg.Data, &req); err != nil {
respond(ctx, msg, ebsprovider.PublishVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: badRequestError(err)})
err := json.Unmarshal(msg.Data, &req)
v := ebsprovider.ReplyVersioned(req.SchemaVersion)
if err != nil {
respond(ctx, msg, ebsprovider.PublishVolumeResponse{Versioned: v, Error: badRequestError(err)})
return
}
if !ebsprovider.SupportsSchemaVersion(req.SchemaVersion) {
respond(ctx, msg, ebsprovider.PublishVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: versionError(req.SchemaVersion)})
respond(ctx, msg, ebsprovider.PublishVolumeResponse{Versioned: v, Error: versionError(req.SchemaVersion)})
return
}
pub, err := provider.PublishVolume(ctx, req)
if err != nil {
respond(ctx, msg, ebsprovider.PublishVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: classifyError(err)})
respond(ctx, msg, ebsprovider.PublishVolumeResponse{Versioned: v, Error: classifyError(err)})
return
}
if pub == nil {
respond(ctx, msg, ebsprovider.PublishVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: internalError("provider returned no published volume for %s", req.VolumeID)})
respond(ctx, msg, ebsprovider.PublishVolumeResponse{Versioned: v, Error: internalError("provider returned no published volume for %s", req.VolumeID)})
return
}
// An unattachable export is the provider's fault, so it is reported as
// internal rather than relayed for the client to reject.
if err := pub.Export.Validate(); err != nil {
respond(ctx, msg, ebsprovider.PublishVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: internalError("provider returned an invalid export for %s: %v", req.VolumeID, err)})
respond(ctx, msg, ebsprovider.PublishVolumeResponse{Versioned: v, Error: internalError("provider returned an invalid export for %s: %v", req.VolumeID, err)})
return
}
if req.SchemaVersion == ebsprovider.LegacySchemaVersion {
@@ -517,36 +544,65 @@ func handlePublishVolume(provider ebsprovider.EBSProvider) msgHandler {
// A v1 caller cannot safely consume a block export. Do not emit an
// empty legacy URI and leave the old client to misattach it.
respond(ctx, msg, ebsprovider.PublishVolumeResponseV1{
Versioned: ebsprovider.NewVersioned(),
Versioned: v,
Error: &ebsprovider.ProviderError{Code: ebsprovider.ErrorCodeUnsupportedVersion, Message: err.Error()},
})
return
}
respond(ctx, msg, ebsprovider.PublishVolumeResponseV1{
Versioned: ebsprovider.NewVersioned(),
Versioned: v,
Published: legacy,
})
return
}
respond(ctx, msg, ebsprovider.PublishVolumeResponse{Versioned: ebsprovider.NewLatestVersioned(), Published: pub})
respond(ctx, msg, ebsprovider.PublishVolumeResponse{Versioned: v, Published: pub})
}
}
func handleUnpublishVolume(provider ebsprovider.EBSProvider) msgHandler {
return func(ctx context.Context, msg *nats.Msg) {
var req ebsprovider.UnpublishVolumeRequest
if err := json.Unmarshal(msg.Data, &req); err != nil {
respond(ctx, msg, ebsprovider.UnpublishVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: badRequestError(err)})
err := json.Unmarshal(msg.Data, &req)
v := ebsprovider.ReplyVersioned(req.SchemaVersion)
if err != nil {
respond(ctx, msg, ebsprovider.UnpublishVolumeResponse{Versioned: v, Error: badRequestError(err)})
return
}
if err := ebsprovider.CheckUnpublishVersion(req); err != nil {
respond(ctx, msg, ebsprovider.UnpublishVolumeResponse{Versioned: v, Error: classifyError(err)})
return
}
result, err := provider.UnpublishVolume(ctx, req)
if err != nil {
respond(ctx, msg, ebsprovider.UnpublishVolumeResponse{Versioned: v, Error: classifyError(err)})
return
}
response := ebsprovider.UnpublishVolumeResponse{Versioned: v}
if result != nil {
response.TornDown = result.TornDown
}
respond(ctx, msg, response)
}
}
func handleHoldsUnsealedWrites(provider ebsprovider.EBSProvider) msgHandler {
return func(ctx context.Context, msg *nats.Msg) {
var req ebsprovider.HoldsUnsealedWritesRequest
err := json.Unmarshal(msg.Data, &req)
v := ebsprovider.ReplyVersioned(req.SchemaVersion)
if err != nil {
respond(ctx, msg, ebsprovider.HoldsUnsealedWritesResponse{Versioned: v, Error: badRequestError(err)})
return
}
if !ebsprovider.SupportsSchemaVersion(req.SchemaVersion) {
respond(ctx, msg, ebsprovider.UnpublishVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: versionError(req.SchemaVersion)})
respond(ctx, msg, ebsprovider.HoldsUnsealedWritesResponse{Versioned: v, Error: versionError(req.SchemaVersion)})
return
}
if err := provider.UnpublishVolume(ctx, req); err != nil {
respond(ctx, msg, ebsprovider.UnpublishVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: classifyError(err)})
held, err := provider.HoldsUnsealedWrites(ctx, req.NodeID, req.VolumeIDs)
if err != nil {
respond(ctx, msg, ebsprovider.HoldsUnsealedWritesResponse{Versioned: v, Error: classifyError(err)})
return
}
respond(ctx, msg, ebsprovider.UnpublishVolumeResponse{Versioned: ebsprovider.NewVersioned()})
respond(ctx, msg, ebsprovider.HoldsUnsealedWritesResponse{Versioned: v, Held: held})
}
}
+99 -10
View File
@@ -19,10 +19,20 @@ const (
// evidence that the peer supports a newer representation.
LegacySchemaVersion uint16 = 1
// DescriptorSchemaVersion is the first version whose PublishVolume reply
// carries a VolumeExport descriptor, and so the first that can describe a
// block export.
DescriptorSchemaVersion uint16 = 2
// NoSealSchemaVersion is the first version that carries
// UnpublishVolumeRequest.NoSeal. A provider that does not serve it refuses
// the request as unsupported rather than dropping the flag and sealing.
NoSealSchemaVersion uint16 = 3
// SchemaVersion is the newest wire-contract version understood by this
// build. Every request and response carries a version so an unsupported
// representation fails at the boundary rather than silently decoding it.
SchemaVersion uint16 = 2
SchemaVersion uint16 = NoSealSchemaVersion
)
// Versioned is embedded in every request and response sent over NATS.
@@ -41,14 +51,39 @@ func NewVersioned() Versioned { return Versioned{SchemaVersion: LegacySchemaVers
func NewLatestVersioned() Versioned { return Versioned{SchemaVersion: SchemaVersion} }
// SupportsSchemaVersion reports whether this build can safely decode version.
// Every version from LegacySchemaVersion to SchemaVersion is served.
func SupportsSchemaVersion(version uint16) bool {
return version == LegacySchemaVersion || version == SchemaVersion
return version >= LegacySchemaVersion && version <= SchemaVersion
}
// SupportedSchemaVersions lists, in ascending order, every contract version
// this build serves. GetCapabilitiesResponse.ContractVersions reports it.
func SupportedSchemaVersions() []uint16 {
versions := make([]uint16, 0, SchemaVersion-LegacySchemaVersion+1)
for v := LegacySchemaVersion; v <= SchemaVersion; v++ {
versions = append(versions, v)
}
return versions
}
// ReplyVersioned stamps a reply with its request's version, so a body is never
// labelled newer than its shape. A version this build cannot serve is answered
// at LegacySchemaVersion, the one every peer decodes.
func ReplyVersioned(requested uint16) Versioned {
if !SupportsSchemaVersion(requested) {
return NewVersioned()
}
return Versioned{SchemaVersion: requested}
}
// EBSProvider is the block-storage controller contract consumed by Spinifex.
// Its operation and idempotency shape follows the CSI controller service, but
// the transport remains NATS request/reply rather than CSI's gRPC transport.
type EBSProvider interface {
// Required of every provider: a caller counts no answer as held, which
// would keep an instance from ever starting on another node.
UnsealedWritesChecker
GetCapabilities(context.Context, GetCapabilitiesRequest) (*GetCapabilitiesResponse, error)
CreateVolume(context.Context, CreateVolumeRequest) (*Volume, error)
GetVolume(context.Context, GetVolumeRequest) (*Volume, error)
@@ -60,7 +95,7 @@ type EBSProvider interface {
CopySnapshot(context.Context, CopySnapshotRequest) (*Snapshot, error)
ListSnapshots(context.Context, ListSnapshotsRequest) (*ListSnapshotsResponse, error)
PublishVolume(context.Context, PublishVolumeRequest) (*PublishedVolume, error)
UnpublishVolume(context.Context, UnpublishVolumeRequest) error
UnpublishVolume(context.Context, UnpublishVolumeRequest) (*UnpublishedVolume, error)
}
// Provider is retained as a concise alias for implementation code. New
@@ -164,8 +199,14 @@ type GetCapabilitiesRequest struct{ Versioned }
type GetCapabilitiesResponse struct {
Versioned
Capabilities Capabilities `json:"capabilities"`
Error *ProviderError `json:"error,omitempty"`
Capabilities Capabilities `json:"capabilities"`
// ContractVersions lists every schema version the answering provider
// serves, ascending. A provider that predates the field leaves it empty,
// which a caller must read as no evidence, not as version 1 only.
ContractVersions []uint16 `json:"contract_versions,omitempty"`
Error *ProviderError `json:"error,omitempty"`
}
// CapacityRange mirrors CSI's capacity-range semantics. RequiredBytes is the
@@ -563,7 +604,7 @@ func (p *PublishedVolume) AsV1() (*PublishedVolumeV1, error) {
return nil, err
}
if p.Export.Kind != ExportKindNBD {
return nil, fmt.Errorf("%s export requires schema version %d", p.Export.Kind, SchemaVersion)
return nil, fmt.Errorf("%s export requires schema version %d", p.Export.Kind, DescriptorSchemaVersion)
}
return &PublishedVolumeV1{
VolumeID: p.VolumeID,
@@ -617,6 +658,31 @@ type UnpublishVolumeRequest struct {
VolumeID string `json:"volume_id"`
Handle string `json:"handle,omitempty"`
NodeID string `json:"node_id"`
// NoSeal drops this node's copy unsealed, because a newer owner's supersedes
// it; unlike AWS DetachVolume Force, which leaves a copy that must seal.
// Carried only at NoSealSchemaVersion or later (CheckUnpublishVersion).
NoSeal bool `json:"no_seal,omitempty"`
}
// CheckUnpublishVersion refuses a request this build cannot serve, and a
// NoSeal sent below NoSealSchemaVersion: a provider at that version would
// drop the flag and seal, so no provider may honour it there either.
func CheckUnpublishVersion(req UnpublishVolumeRequest) error {
if err := checkVersion(req.SchemaVersion); err != nil {
return err
}
if req.NoSeal && req.SchemaVersion < NoSealSchemaVersion {
return fmt.Errorf("%w: no_seal requires schema version %d, got %d", ErrInvalidArgument, NoSealSchemaVersion, req.SchemaVersion)
}
return nil
}
// UnpublishedVolume is the outcome of an UnpublishVolume that succeeded.
// TornDown reports whether an export existed and was removed; it is reported
// only at NoSealSchemaVersion or later, and is false at earlier versions.
type UnpublishedVolume struct {
TornDown bool
}
// UnpublishVolumeResponse is the reply to UnpublishVolumeRequest. A nil Error
@@ -624,13 +690,14 @@ type UnpublishVolumeRequest struct {
type UnpublishVolumeResponse struct {
Versioned
Error *ProviderError `json:"error,omitempty"`
// TornDown mirrors UnpublishedVolume.TornDown.
TornDown bool `json:"torn_down,omitempty"`
Error *ProviderError `json:"error,omitempty"`
}
// UnsealedWritesChecker answers, for one node, which of a set of volumes it
// still holds writes for that the backend may not have. It is separate from
// EBSProvider because only the instance start path asks, and only of the node
// that last ran the instance.
// still holds writes for that the backend may not have. A write-through
// provider answers with none. Consumers that only ask this depend on it alone.
type UnsealedWritesChecker interface {
HoldsUnsealedWrites(ctx context.Context, nodeID string, volumeIDs []string) ([]string, error)
}
@@ -652,3 +719,25 @@ type HoldsUnsealedWritesResponse struct {
Held []string `json:"held,omitempty"`
Error *ProviderError `json:"error,omitempty"`
}
// FenceNotice announces that a provider revoked a live export on NodeID
// because another node now holds the volume, so that node's daemon can stop
// the guest using it. Sent over FenceNoticeSubject until acknowledged.
type FenceNotice struct {
Versioned
VolumeID string `json:"volume_id"`
NodeID string `json:"node_id"`
Winner string `json:"winner,omitempty"`
Reason string `json:"reason,omitempty"`
}
// FenceNoticeAck is the daemon's answer to a FenceNotice. Any answer ends the
// sender's retries; InstanceID names the guest stopped, if one was, and Error
// says why the daemon could not act on the notice.
type FenceNoticeAck struct {
Versioned
InstanceID string `json:"instance_id,omitempty"`
Error *ProviderError `json:"error,omitempty"`
}
+10 -2
View File
@@ -46,6 +46,8 @@ const (
verbVolumeUnpublish = "volume.unpublish"
verbSnapshotDelete = "snapshot.delete"
verbSnapshotList = "snapshot.list"
verbUnsealedWrites = "volume.unsealed"
verbFenceNotice = "volume.fenced"
)
// subjectVerbs maps every fixed subject to its verb. The dynamic subject
@@ -107,10 +109,16 @@ func parseSubject(subject string) subjectTarget {
return subjectTarget{verb: VerbUnknown}
}
switch suffix {
case "mount":
case publishVerb:
return subjectTarget{verb: verbVolumePublish, nodeID: nodeID}
case "unmount":
case unpublishVerb:
return subjectTarget{verb: verbVolumeUnpublish, nodeID: nodeID}
case unsealedVerb:
return subjectTarget{verb: verbUnsealedWrites, nodeID: nodeID}
case nodeCapabilitiesVerb:
return subjectTarget{verb: verbCapabilities, nodeID: nodeID}
case fenceNoticeVerb:
return subjectTarget{verb: verbFenceNotice, nodeID: nodeID}
default:
return subjectTarget{verb: VerbUnknown}
}
+9
View File
@@ -30,6 +30,12 @@ func TestSubjectVerb_StripsIDsFromEveryDynamicSubject(t *testing.T) {
require.NoError(t, err)
unpublish, err := UnpublishSubject(nodeID)
require.NoError(t, err)
unsealed, err := UnsealedWritesSubject(nodeID)
require.NoError(t, err)
nodeCapabilities, err := NodeCapabilitiesSubject(nodeID)
require.NoError(t, err)
fenced, err := FenceNoticeSubject(nodeID)
require.NoError(t, err)
cases := map[string]string{
CapabilitiesSubject: verbCapabilities,
@@ -47,6 +53,9 @@ func TestSubjectVerb_StripsIDsFromEveryDynamicSubject(t *testing.T) {
snapshotCreate: verbSnapshotCreate,
publish: verbVolumePublish,
unpublish: verbVolumeUnpublish,
unsealed: verbUnsealedWrites,
nodeCapabilities: verbCapabilities,
fenced: verbFenceNotice,
}
for subject, want := range cases {
@@ -15,6 +15,7 @@ import (
clusterv1 "github.com/mulgadc/spinifex/contracts/cluster/v1"
"github.com/mulgadc/spinifex/spinifex/providers/ebs"
"github.com/mulgadc/spinifex/spinifex/providers/ebs/conformance"
hostprocess "github.com/mulgadc/spinifex/spinifex/runtime/host/process"
testpredastore "github.com/mulgadc/spinifex/tests/fixtures/predastore"
"github.com/nats-io/nats.go/jetstream"
"github.com/stretchr/testify/require"
@@ -61,7 +62,8 @@ func requireExportTools(t *testing.T) string {
// would go to a subject nobody serves and time out instead of being refused.
// Both share the NATS the volume leases live in and the bucket the volumes
// live in, so a second opener is refused by the lease, not by the harness.
func inProcessProvider(t *testing.T) (func(t *testing.T) ebsprovider.EBSProvider, string, string) {
// The last return kills a published export's nbdkit behind the handlers' back.
func inProcessProvider(t *testing.T) (func(t *testing.T) ebsprovider.EBSProvider, string, string, func(*testing.T, *ebsprovider.PublishedVolume)) {
t.Helper()
pluginPath := requireExportTools(t)
@@ -98,10 +100,21 @@ func inProcessProvider(t *testing.T) (func(t *testing.T) ebsprovider.EBSProvider
_, err = js.CreateKeyValue(t.Context(), jetstream.KeyValueConfig{Bucket: clusterv1.ClusterStateBucket})
require.NoError(t, err)
killExport := func(t *testing.T, published *ebsprovider.PublishedVolume) {
t.Helper()
for _, node := range []*Config{first, second} {
if mv, ok := findMountedVolume(node, published.VolumeID); ok && node.NodeName == published.NodeID {
require.NoError(t, hostprocess.ForceKillProcess(mv.PID, 5*time.Second))
return
}
}
t.Fatalf("no mounted entry for %s on %s", published.VolumeID, published.NodeID)
}
return func(t *testing.T) ebsprovider.EBSProvider {
t.Helper()
return ebsprovider.NewNATSProvider(nc, 60*time.Second)
}, first.NodeName, second.NodeName
}, first.NodeName, second.NodeName, killExport
}
// runPrefix names one run's volumes so it cannot meet an earlier run's
@@ -113,11 +126,12 @@ func runPrefix() string {
// TestViperblockdConformance judges the provider handlers by the same suite
// MemoryProvider and qemunbdd answer to.
func TestViperblockdConformance(t *testing.T) {
newProvider, nodeID, otherNodeID := inProcessProvider(t)
newProvider, nodeID, otherNodeID, killExport := inProcessProvider(t)
conformance.RunSuiteWithConfig(t, newProvider, conformance.SuiteConfig{
NamePrefix: runPrefix(),
NodeID: nodeID,
OtherNodeID: otherNodeID,
KillExport: killExport,
})
}
@@ -125,7 +139,7 @@ func TestViperblockdConformance(t *testing.T) {
// cannot: whether the nbdkit export viperblockd publishes is usable by libnbd,
// which knows nothing about this codebase.
func TestViperblockdNBDClient(t *testing.T) {
newProvider, nodeID, _ := inProcessProvider(t)
newProvider, nodeID, _, _ := inProcessProvider(t)
conformance.RunNBDClientSuiteWithConfig(t, newProvider, conformance.NBDClientConfig{
NodeID: nodeID,
VolumePrefix: "vol-" + runPrefix() + "nbd",
@@ -0,0 +1,339 @@
package viperblock
import (
"context"
"encoding/json"
"errors"
"fmt"
"maps"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/mulgadc/spinifex/internal/testkit/deadpid"
"github.com/mulgadc/spinifex/spinifex/providers/ebs"
"github.com/mulgadc/spinifex/spinifex/runtime/compute/nbd"
"github.com/mulgadc/viperblock/viperblock"
"github.com/nats-io/nats.go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestProviderHandlers_RepliesInRequestVersion holds every subject to the
// rule a mixed-version rollout depends on: a caller at version N must get a
// reply at N, success or error, or it may not be able to parse it.
func TestProviderHandlers_RepliesInRequestVersion(t *testing.T) {
prevProbe := ownerProbeTimeout
ownerProbeTimeout = 100 * time.Millisecond
t.Cleanup(func() { ownerProbeTimeout = prevProbe })
_, natsURL := setupEmbeddedNATS(t)
cfg := setupTestConfig(t, natsURL)
nc := startProviderSubjects(t, cfg, natsURL)
for _, version := range ebsprovider.SupportedSchemaVersions() {
for _, tc := range providerSubjectCases() {
t.Run(fmt.Sprintf("v%d/%s", version, tc.name), func(t *testing.T) {
body := map[string]any{"schema_version": version}
maps.Copy(body, tc.body)
msg := requestProvider(t, nc, tc.subject, marshalRequest(t, body))
var resp versionedErrorResponse
require.NoError(t, json.Unmarshal(msg.Data, &resp))
assert.Equal(t, version, resp.SchemaVersion)
})
}
}
}
// TestProviderHandlers_UnpublishNoSealNeverSeals is the reason no_seal exists:
// a node whose copy is behind a newer owner's must drop it, and a seal would
// write that stale copy over the owner's block map.
func TestProviderHandlers_UnpublishNoSealNeverSeals(t *testing.T) {
_, natsURL := setupEmbeddedNATS(t)
const volumeName = "vol-unpubnoseal01"
sealed := false
cfg := fencedConfig(t, natsURL, "test-node", volumeName)
withLocalState(t, cfg, volumeName)
cfg.sealVolume = func(context.Context, string) error { sealed = true; return nil }
nc := startProviderSubjects(t, cfg, natsURL)
subject, err := ebsprovider.UnpublishSubject(cfg.NodeName)
require.NoError(t, err)
body := marshalRequest(t, map[string]any{
"schema_version": ebsprovider.NoSealSchemaVersion,
"volume_id": volumeName,
"node_id": cfg.NodeName,
"no_seal": true,
})
msg := requestProvider(t, nc, subject, body)
var resp ebsprovider.UnpublishVolumeResponse
require.NoError(t, json.Unmarshal(msg.Data, &resp))
require.Nil(t, resp.Error)
assert.True(t, resp.TornDown, "the volume was mounted, so the export was torn down")
assert.False(t, sealed, "a no_seal unpublish must never seal")
_, mounted := findMountedVolume(cfg, volumeName)
assert.False(t, mounted, "the entry must go with the export")
}
// TestProviderHandlers_UnpublishNoSealBelowV3Refused covers the other half: a
// caller below version 3 cannot ask for no_seal, so the flag is refused rather
// than ignored, and the export is left alone.
func TestProviderHandlers_UnpublishNoSealBelowV3Refused(t *testing.T) {
_, natsURL := setupEmbeddedNATS(t)
const volumeName = "vol-unpubnosealv2"
sealed := false
cfg := fencedConfig(t, natsURL, "test-node", volumeName)
cfg.sealVolume = func(context.Context, string) error { sealed = true; return nil }
nc := startProviderSubjects(t, cfg, natsURL)
subject, err := ebsprovider.UnpublishSubject(cfg.NodeName)
require.NoError(t, err)
body := marshalRequest(t, map[string]any{
"schema_version": ebsprovider.DescriptorSchemaVersion,
"volume_id": volumeName,
"node_id": cfg.NodeName,
"no_seal": true,
})
msg := requestProvider(t, nc, subject, body)
var resp ebsprovider.UnpublishVolumeResponse
require.NoError(t, json.Unmarshal(msg.Data, &resp))
require.NotNil(t, resp.Error)
assert.Equal(t, ebsprovider.ErrorCodeInvalidArgument, resp.Error.Code)
assert.False(t, sealed)
_, mounted := findMountedVolume(cfg, volumeName)
assert.True(t, mounted, "a refused unpublish must leave the export running")
}
// TestProviderHandlers_PublishVolume_DeadEntryIsNotHandedBack pins the removed
// early return: an entry whose nbdkit has died must be dropped and remounted,
// never answered with a URI nothing serves.
func TestProviderHandlers_PublishVolume_DeadEntryIsNotHandedBack(t *testing.T) {
_, natsURL := setupEmbeddedNATS(t)
cfg := setupTestConfig(t, natsURL)
nc := startProviderSubjects(t, cfg, natsURL)
const volumeName = "vol-pubdeadentry1"
const deadURI = "nbd://127.0.0.1:1"
cfg.MountedVolumes = append(cfg.MountedVolumes, MountedVolume{
Name: volumeName, VB: createTestVBWithState(t, volumeName), PID: deadpid.PID, NBDURI: deadURI,
})
subject, err := ebsprovider.PublishSubject(cfg.NodeName)
require.NoError(t, err)
body := marshalRequest(t, map[string]any{
"schema_version": ebsprovider.SchemaVersion,
"volume_id": volumeName,
"node_id": cfg.NodeName,
})
msg := requestProvider(t, nc, subject, body)
var resp ebsprovider.PublishVolumeResponse
require.NoError(t, json.Unmarshal(msg.Data, &resp))
assert.True(t, resp.Error != nil || resp.Published != nil, "the reply must be a refusal or an export")
assert.NotContains(t, string(msg.Data), deadURI, "the dead entry's URI was handed back")
mv, _ := findMountedVolume(cfg, volumeName)
assert.NotEqual(t, deadURI, mv.NBDURI, "the dead entry must not survive the republish")
}
// TestProviderHandlers_PublishVolume_RepublishRepliesInRequestVersion checks a
// successful publish reply, not only refusals, at every served version: a
// republish of a live export is the success path that needs no nbdkit.
func TestProviderHandlers_PublishVolume_RepublishRepliesInRequestVersion(t *testing.T) {
_, natsURL := setupEmbeddedNATS(t)
cfg := setupTestConfig(t, natsURL)
nc := startProviderSubjects(t, cfg, natsURL)
const volumeName = "vol-republishver1"
socket := filepath.Join(t.TempDir(), "nbd.sock")
require.NoError(t, os.WriteFile(socket, nil, 0o600))
liveURI := nbd.FormatNBDSocketURI(socket)
cfg.MountedVolumes = append(cfg.MountedVolumes, MountedVolume{
Name: volumeName, VB: createTestVBWithState(t, volumeName), PID: os.Getpid(), Socket: socket, NBDURI: liveURI,
})
subject, err := ebsprovider.PublishSubject(cfg.NodeName)
require.NoError(t, err)
for _, version := range ebsprovider.SupportedSchemaVersions() {
t.Run(fmt.Sprintf("v%d", version), func(t *testing.T) {
msg := requestProvider(t, nc, subject, marshalRequest(t, map[string]any{
"schema_version": version,
"volume_id": volumeName,
"node_id": cfg.NodeName,
}))
var resp versionedErrorResponse
require.NoError(t, json.Unmarshal(msg.Data, &resp))
require.Nil(t, resp.Error, "the republish must succeed for this to check a success reply")
assert.Equal(t, version, resp.SchemaVersion)
assert.Contains(t, string(msg.Data), socket, "the live export is handed back")
})
}
}
// TestProviderHandlers_NodeCapabilitiesListsServedVersions pins the per-node
// subject to the same contract versions this build serves.
func TestProviderHandlers_NodeCapabilitiesListsServedVersions(t *testing.T) {
_, natsURL := setupEmbeddedNATS(t)
cfg := setupTestConfig(t, natsURL)
nc := startProviderSubjects(t, cfg, natsURL)
subject, err := ebsprovider.NodeCapabilitiesSubject(cfg.NodeName)
require.NoError(t, err)
msg := requestProvider(t, nc, subject, marshalRequest(t, map[string]any{"schema_version": ebsprovider.SchemaVersion}))
var resp ebsprovider.GetCapabilitiesResponse
require.NoError(t, json.Unmarshal(msg.Data, &resp))
require.Nil(t, resp.Error)
assert.Equal(t, ebsprovider.SupportedSchemaVersions(), resp.ContractVersions)
}
// TestProviderHandlers_UnpublishNoSealAboveServedVersionRefused covers a caller
// newer than this build: a no_seal it cannot vouch for is refused, and the
// export is neither torn down nor sealed.
func TestProviderHandlers_UnpublishNoSealAboveServedVersionRefused(t *testing.T) {
_, natsURL := setupEmbeddedNATS(t)
const volumeName = "vol-unpubnosealv4"
sealed := false
cfg := fencedConfig(t, natsURL, "test-node", volumeName)
withLocalState(t, cfg, volumeName)
cfg.sealVolume = func(context.Context, string) error { sealed = true; return nil }
nc := startProviderSubjects(t, cfg, natsURL)
subject, err := ebsprovider.UnpublishSubject(cfg.NodeName)
require.NoError(t, err)
msg := requestProvider(t, nc, subject, marshalRequest(t, map[string]any{
"schema_version": ebsprovider.SchemaVersion + 1,
"volume_id": volumeName,
"node_id": cfg.NodeName,
"no_seal": true,
}))
var resp ebsprovider.UnpublishVolumeResponse
require.NoError(t, json.Unmarshal(msg.Data, &resp))
require.NotNil(t, resp.Error)
assert.Equal(t, ebsprovider.ErrorCodeUnsupportedVersion, resp.Error.Code)
assert.False(t, resp.TornDown)
assert.False(t, sealed, "a refused unpublish must not seal")
_, mounted := findMountedVolume(cfg, volumeName)
assert.True(t, mounted, "a refused unpublish must leave the export running")
}
// TestPublishError_Codes pins the code each mount failure reaches a caller as.
// held_elsewhere is gated on the request version because v1.23.0 callers only
// know volume_in_use, and a mode mismatch is never held_elsewhere.
func TestPublishError_Codes(t *testing.T) {
leaseHeld := fmt.Errorf("acquire: %w", errVolumeLeaseHeld)
cases := []struct {
name string
version uint16
err error
want ebsprovider.ErrorCode
}{
{"absent volume", ebsprovider.SchemaVersion, fmt.Errorf("load: %w", viperblock.ErrStateNotFound), ebsprovider.ErrorCodeNotFound},
{"lease held at v1", ebsprovider.LegacySchemaVersion, leaseHeld, ebsprovider.ErrorCodeVolumeInUse},
{"lease held at v2", ebsprovider.DescriptorSchemaVersion, leaseHeld, ebsprovider.ErrorCodeVolumeInUse},
{"lease held at v3", ebsprovider.NoSealSchemaVersion, leaseHeld, ebsprovider.ErrorCodeVolumeHeldElsewhere},
{"mode mismatch at v3", ebsprovider.NoSealSchemaVersion, fmt.Errorf("mounted: %w", errReadOnlyMismatch), ebsprovider.ErrorCodeVolumeInUse},
{"backing store not ready", ebsprovider.SchemaVersion, fmt.Errorf("load: %w", viperblock.ErrStateBackendUnavailable), ebsprovider.ErrorCodeUnavailable},
{"lease store not ready", ebsprovider.SchemaVersion, fmt.Errorf("open: %w", ErrLeaseStoreUnavailable), ebsprovider.ErrorCodeUnavailable},
{"anything else", ebsprovider.SchemaVersion, errors.New("nbdkit exited"), ebsprovider.ErrorCodeInternal},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := publishError(tc.version, "vol-publisherror01", tc.err)
require.NotNil(t, got)
assert.Equal(t, tc.want, got.Code)
})
}
assert.True(t, ebsprovider.ErrorCodeUnavailable.Retryable(), "a backing store that is not ready must read as retry-later")
}
// TestProviderHandlers_PublishVolume_StoreNotReadyIsUnavailable carries a mount
// refused because a store is not ready yet to the wire as retry-later, so a
// caller waits for the store instead of giving up on the volume.
func TestProviderHandlers_PublishVolume_StoreNotReadyIsUnavailable(t *testing.T) {
for _, cause := range []error{ErrLeaseStoreUnavailable, viperblock.ErrStateBackendUnavailable} {
t.Run(cause.Error(), func(t *testing.T) {
_, natsURL := setupEmbeddedNATS(t)
cfg := setupTestConfig(t, natsURL)
cfg.constructVB = func(context.Context, string) (*viperblock.VB, int, error) {
return nil, 0, fmt.Errorf("open: %w", cause)
}
nc := startProviderSubjects(t, cfg, natsURL)
subject, err := ebsprovider.PublishSubject(cfg.NodeName)
require.NoError(t, err)
msg := requestProvider(t, nc, subject, marshalRequest(t, map[string]any{
"schema_version": ebsprovider.SchemaVersion,
"volume_id": "vol-storenotready",
"node_id": cfg.NodeName,
}))
var resp ebsprovider.PublishVolumeResponse
require.NoError(t, json.Unmarshal(msg.Data, &resp))
require.NotNil(t, resp.Error)
assert.Equal(t, ebsprovider.ErrorCodeUnavailable, resp.Error.Code)
assert.Nil(t, resp.Published)
})
}
}
// TestProviderHandlers_CreateSnapshot_QueueRefusesWhileLeaseHeldElsewhere is
// what makes a snapshot from a non-owner safe: whichever node answers the queue
// subject must not snapshot a checkpoint the holder may still be writing.
func TestProviderHandlers_CreateSnapshot_QueueRefusesWhileLeaseHeldElsewhere(t *testing.T) {
const volumeName = "vol-snapqueueheld1"
const snapshotID = "snap-queueheld00001"
// The volume really exists in the backend, so only the lease stands
// between this request and a snapshot written beside the holder's writes.
key := testMasterKey(t)
backend := newRecordingS3(t, "/"+volumeName+"/config.json", seedVolumeConfig(t, volumeName, key))
_, natsURL := setupEmbeddedNATS(t)
cfg := setupTestConfig(t, natsURL)
cfg.S3Host = backend.URL
cfg.masterKey = key
nc := startProviderSubjects(t, cfg, natsURL)
holder := newTestLeases(t, natsURL, "node-other")
_, err := holder.acquire(t.Context(), volumeName)
require.NoError(t, err)
completionSubject, err := ebsprovider.SnapshotCompletionSubject(snapshotID)
require.NoError(t, err)
completions := make(chan *nats.Msg, 1)
sub, err := nc.ChanSubscribe(completionSubject, completions)
require.NoError(t, err)
t.Cleanup(func() { _ = sub.Unsubscribe() })
require.NoError(t, nc.Flush())
body := marshalRequest(t, map[string]any{
"schema_version": ebsprovider.SchemaVersion,
"volume_id": volumeName,
"snapshot_id": snapshotID,
})
accepted := requestProvider(t, nc, ebsprovider.SnapshotCreateSubjectPrefix+volumeName, body)
var acceptance ebsprovider.CreateSnapshotResponse
require.NoError(t, json.Unmarshal(accepted.Data, &acceptance))
require.Nil(t, acceptance.Error)
select {
case msg := <-completions:
var completion ebsprovider.CreateSnapshotResponse
require.NoError(t, json.Unmarshal(msg.Data, &completion))
require.NotNil(t, completion.Error, "a snapshot under another node's lease must be refused")
assert.Equal(t, ebsprovider.ErrorCodeUnavailable, completion.Error.Code)
assert.Nil(t, completion.Snapshot)
case <-time.After(10 * time.Second):
t.Fatal("no completion published")
}
assert.Empty(t, backend.matching(func(r string) bool { return strings.HasPrefix(r, "PUT ") && strings.Contains(r, snapshotID) }),
"no snapshot may be written while another node holds the volume")
}
@@ -149,6 +149,16 @@ func RegisterProviderSubjects(cfg *Config, nc *nats.Conn) error {
return fmt.Errorf("subscribe to %s: %w", unsealedSubject, err)
}
// The queue subject is answered by any node's provider; this one only by
// this node's, so a daemon can learn what its own provider serves.
nodeCapabilitiesSubject, err := ebsprovider.NodeCapabilitiesSubject(cfg.NodeName)
if err != nil {
return fmt.Errorf("build node capabilities subject: %w", err)
}
if _, err := nc.Subscribe(nodeCapabilitiesSubject, tracedProviderHandler(handleProviderCapabilities)); err != nil {
return fmt.Errorf("subscribe to %s: %w", nodeCapabilitiesSubject, err)
}
return nil
}
@@ -188,7 +198,7 @@ func badRequestError(err error) *ebsprovider.ProviderError {
}
func unsupportedVersionError(got uint16) *ebsprovider.ProviderError {
return &ebsprovider.ProviderError{Code: ebsprovider.ErrorCodeUnsupportedVersion, Message: fmt.Sprintf("unsupported schema version %d; supported versions are %d and %d", got, ebsprovider.LegacySchemaVersion, ebsprovider.SchemaVersion)}
return &ebsprovider.ProviderError{Code: ebsprovider.ErrorCodeUnsupportedVersion, Message: fmt.Sprintf("unsupported schema version %d; %s", got, ebsprovider.SupportedVersionsText())}
}
// respondPublishedVolume selects the exact publish response a caller asked
@@ -199,19 +209,19 @@ func respondPublishedVolume(ctx context.Context, msg *nats.Msg, schemaVersion ui
legacy, err := published.AsV1()
if err != nil {
respondProvider(ctx, msg, ebsprovider.PublishVolumeResponseV1{
Versioned: ebsprovider.NewVersioned(),
Versioned: ebsprovider.ReplyVersioned(schemaVersion),
Error: &ebsprovider.ProviderError{Code: ebsprovider.ErrorCodeUnsupportedVersion, Message: err.Error()},
})
return
}
respondProvider(ctx, msg, ebsprovider.PublishVolumeResponseV1{
Versioned: ebsprovider.NewVersioned(),
Versioned: ebsprovider.ReplyVersioned(schemaVersion),
Published: legacy,
})
return
}
respondProvider(ctx, msg, ebsprovider.PublishVolumeResponse{
Versioned: ebsprovider.NewLatestVersioned(),
Versioned: ebsprovider.ReplyVersioned(schemaVersion),
Published: published,
})
}
@@ -232,6 +242,14 @@ func volumeInUseError(format string, args ...any) *ebsprovider.ProviderError {
return &ebsprovider.ProviderError{Code: ebsprovider.ErrorCodeVolumeInUse, Message: fmt.Sprintf(format, args...)}
}
func heldElsewhereError(format string, args ...any) *ebsprovider.ProviderError {
return &ebsprovider.ProviderError{Code: ebsprovider.ErrorCodeVolumeHeldElsewhere, Message: fmt.Sprintf(format, args...)}
}
// errReadOnlyMismatch is a remount asking for the other access mode than the
// running export's, which is fixed when nbdkit starts.
var errReadOnlyMismatch = errors.New("already mounted with the other access mode")
func internalError(format string, args ...any) *ebsprovider.ProviderError {
return &ebsprovider.ProviderError{Code: ebsprovider.ErrorCodeInternal, Message: fmt.Sprintf(format, args...)}
}
@@ -276,18 +294,21 @@ func findMountedVolume(cfg *Config, volumeID string) (MountedVolume, bool) {
func handleProviderCapabilities(ctx context.Context, msg *nats.Msg) {
var req ebsprovider.GetCapabilitiesRequest
if err := json.Unmarshal(msg.Data, &req); err != nil {
err := json.Unmarshal(msg.Data, &req)
v := ebsprovider.ReplyVersioned(req.SchemaVersion)
if err != nil {
slog.Error("ebs.provider.capabilities: bad request", "err", err)
respondProvider(ctx, msg, ebsprovider.GetCapabilitiesResponse{Versioned: ebsprovider.NewVersioned(), Error: badRequestError(err)})
respondProvider(ctx, msg, ebsprovider.GetCapabilitiesResponse{Versioned: v, Error: badRequestError(err)})
return
}
if !ebsprovider.SupportsSchemaVersion(req.SchemaVersion) {
slog.Error("ebs.provider.capabilities: unsupported schema version", "version", req.SchemaVersion)
respondProvider(ctx, msg, ebsprovider.GetCapabilitiesResponse{Versioned: ebsprovider.NewVersioned(), Error: unsupportedVersionError(req.SchemaVersion)})
respondProvider(ctx, msg, ebsprovider.GetCapabilitiesResponse{Versioned: v, Error: unsupportedVersionError(req.SchemaVersion)})
return
}
respondProvider(ctx, msg, ebsprovider.GetCapabilitiesResponse{
Versioned: ebsprovider.NewVersioned(),
Versioned: v,
ContractVersions: ebsprovider.SupportedSchemaVersions(),
Capabilities: ebsprovider.Capabilities{
// Every snapshot freezes the live checkpoint rather than
// depending on a guest-coordinated quiesce.
@@ -387,24 +408,26 @@ func listTopLevelPrefixes(ctx context.Context, store objectstore.ObjectStore, bu
func handleListVolumes(ctx context.Context, cfg *Config, msg *nats.Msg) {
var req ebsprovider.ListVolumesRequest
if err := json.Unmarshal(msg.Data, &req); err != nil {
err := json.Unmarshal(msg.Data, &req)
v := ebsprovider.ReplyVersioned(req.SchemaVersion)
if err != nil {
slog.Error("ebs.provider.volume.list: bad request", "err", err)
respondProvider(ctx, msg, ebsprovider.ListVolumesResponse{Versioned: ebsprovider.NewVersioned(), Error: badRequestError(err)})
respondProvider(ctx, msg, ebsprovider.ListVolumesResponse{Versioned: v, Error: badRequestError(err)})
return
}
if !ebsprovider.SupportsSchemaVersion(req.SchemaVersion) {
respondProvider(ctx, msg, ebsprovider.ListVolumesResponse{Versioned: ebsprovider.NewVersioned(), Error: unsupportedVersionError(req.SchemaVersion)})
respondProvider(ctx, msg, ebsprovider.ListVolumesResponse{Versioned: v, Error: unsupportedVersionError(req.SchemaVersion)})
return
}
ids, err := listVolumePrefixes(ctx, providerObjectStoreFactory(cfg), cfg.Bucket)
if err != nil {
slog.Error("ebs.provider.volume.list: failed to enumerate volumes", "err", err)
respondProvider(ctx, msg, ebsprovider.ListVolumesResponse{Versioned: ebsprovider.NewVersioned(), Error: internalError("enumerate volumes: %v", err)})
respondProvider(ctx, msg, ebsprovider.ListVolumesResponse{Versioned: v, Error: internalError("enumerate volumes: %v", err)})
return
}
response := ebsprovider.ListVolumesResponse{Versioned: ebsprovider.NewVersioned()}
response := ebsprovider.ListVolumesResponse{Versioned: v}
response.Volumes, response.NextToken = ebsprovider.Page(ids, req.StartingToken, int(req.PageSize()),
func(id string) ebsprovider.VolumeRef {
return ebsprovider.VolumeRef{ID: id, Handle: volumeHandle(id)}
@@ -417,24 +440,26 @@ func handleListVolumes(ctx context.Context, cfg *Config, msg *nats.Msg) {
// encrypted state, and reading it would mean a fetch per snapshot listed.
func handleListSnapshots(ctx context.Context, cfg *Config, msg *nats.Msg) {
var req ebsprovider.ListSnapshotsRequest
if err := json.Unmarshal(msg.Data, &req); err != nil {
err := json.Unmarshal(msg.Data, &req)
v := ebsprovider.ReplyVersioned(req.SchemaVersion)
if err != nil {
slog.Error("ebs.provider.snapshot.list: bad request", "err", err)
respondProvider(ctx, msg, ebsprovider.ListSnapshotsResponse{Versioned: ebsprovider.NewVersioned(), Error: badRequestError(err)})
respondProvider(ctx, msg, ebsprovider.ListSnapshotsResponse{Versioned: v, Error: badRequestError(err)})
return
}
if !ebsprovider.SupportsSchemaVersion(req.SchemaVersion) {
respondProvider(ctx, msg, ebsprovider.ListSnapshotsResponse{Versioned: ebsprovider.NewVersioned(), Error: unsupportedVersionError(req.SchemaVersion)})
respondProvider(ctx, msg, ebsprovider.ListSnapshotsResponse{Versioned: v, Error: unsupportedVersionError(req.SchemaVersion)})
return
}
ids, err := listSnapshotPrefixes(ctx, providerObjectStoreFactory(cfg), cfg.Bucket)
if err != nil {
slog.Error("ebs.provider.snapshot.list: failed to enumerate snapshots", "err", err)
respondProvider(ctx, msg, ebsprovider.ListSnapshotsResponse{Versioned: ebsprovider.NewVersioned(), Error: internalError("enumerate snapshots: %v", err)})
respondProvider(ctx, msg, ebsprovider.ListSnapshotsResponse{Versioned: v, Error: internalError("enumerate snapshots: %v", err)})
return
}
response := ebsprovider.ListSnapshotsResponse{Versioned: ebsprovider.NewVersioned()}
response := ebsprovider.ListSnapshotsResponse{Versioned: v}
response.Snapshots, response.NextToken = ebsprovider.Page(ids, req.StartingToken, int(req.PageSize()),
func(id string) ebsprovider.SnapshotRef {
return ebsprovider.SnapshotRef{ID: id, Handle: snapshotHandle(id)}
@@ -474,40 +499,42 @@ func buildProviderVBConfig(cfg *Config, volumeID string, volumeSizeBytes uint64,
func handleCreateVolume(ctx context.Context, cfg *Config, msg *nats.Msg) {
var req ebsprovider.CreateVolumeRequest
if err := json.Unmarshal(msg.Data, &req); err != nil {
err := json.Unmarshal(msg.Data, &req)
v := ebsprovider.ReplyVersioned(req.SchemaVersion)
if err != nil {
slog.Error("ebs.provider.volume.create: bad request", "err", err)
respondProvider(ctx, msg, ebsprovider.CreateVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: badRequestError(err)})
respondProvider(ctx, msg, ebsprovider.CreateVolumeResponse{Versioned: v, Error: badRequestError(err)})
return
}
if !ebsprovider.SupportsSchemaVersion(req.SchemaVersion) {
slog.Error("ebs.provider.volume.create: unsupported schema version", "version", req.SchemaVersion)
respondProvider(ctx, msg, ebsprovider.CreateVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: unsupportedVersionError(req.SchemaVersion)})
respondProvider(ctx, msg, ebsprovider.CreateVolumeResponse{Versioned: v, Error: unsupportedVersionError(req.SchemaVersion)})
return
}
if !validVolumeName(req.VolumeID) {
slog.Error("ebs.provider.volume.create: invalid volume id", "volume", req.VolumeID)
respondProvider(ctx, msg, ebsprovider.CreateVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: invalidArgumentError("invalid volume id %q", req.VolumeID)})
respondProvider(ctx, msg, ebsprovider.CreateVolumeResponse{Versioned: v, Error: invalidArgumentError("invalid volume id %q", req.VolumeID)})
return
}
if req.CapacityRange.RequiredBytes <= 0 || (req.CapacityRange.LimitBytes > 0 && req.CapacityRange.RequiredBytes > req.CapacityRange.LimitBytes) {
respondProvider(ctx, msg, ebsprovider.CreateVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: invalidArgumentError("invalid capacity range")})
respondProvider(ctx, msg, ebsprovider.CreateVolumeResponse{Versioned: v, Error: invalidArgumentError("invalid capacity range")})
return
}
if req.SourceSnapshotID != "" && req.SourceSnapshotVolumeID == "" {
respondProvider(ctx, msg, ebsprovider.CreateVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: invalidArgumentError("source_snapshot_volume_id is required with source_snapshot_id")})
respondProvider(ctx, msg, ebsprovider.CreateVolumeResponse{Versioned: v, Error: invalidArgumentError("source_snapshot_volume_id is required with source_snapshot_id")})
return
}
if err := ebsprovider.ValidateSeedData(req.SeedData); err != nil {
slog.Error("ebs.provider.volume.create: seed data rejected", "volume", req.VolumeID, "err", err)
respondProvider(ctx, msg, ebsprovider.CreateVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: invalidArgumentError("%v", err)})
respondProvider(ctx, msg, ebsprovider.CreateVolumeResponse{Versioned: v, Error: invalidArgumentError("%v", err)})
return
}
if int64(len(req.SeedData)) > req.CapacityRange.RequiredBytes {
respondProvider(ctx, msg, ebsprovider.CreateVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: invalidArgumentError("seed data is larger than the requested capacity")})
respondProvider(ctx, msg, ebsprovider.CreateVolumeResponse{Versioned: v, Error: invalidArgumentError("seed data is larger than the requested capacity")})
return
}
if len(req.SeedData) > 0 && req.SourceSnapshotID != "" {
respondProvider(ctx, msg, ebsprovider.CreateVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: invalidArgumentError("seed data cannot be combined with a source snapshot")})
respondProvider(ctx, msg, ebsprovider.CreateVolumeResponse{Versioned: v, Error: invalidArgumentError("seed data cannot be combined with a source snapshot")})
return
}
@@ -516,16 +543,16 @@ func handleCreateVolume(ctx context.Context, cfg *Config, msg *nats.Msg) {
case err == nil:
if existing.CapacityBytes != req.CapacityRange.RequiredBytes {
slog.Error("ebs.provider.volume.create: volume exists with different capacity", "volume", req.VolumeID)
respondProvider(ctx, msg, ebsprovider.CreateVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: alreadyExistsError("volume %s already exists with a different capacity", req.VolumeID)})
respondProvider(ctx, msg, ebsprovider.CreateVolumeResponse{Versioned: v, Error: alreadyExistsError("volume %s already exists with a different capacity", req.VolumeID)})
return
}
respondProvider(ctx, msg, ebsprovider.CreateVolumeResponse{Versioned: ebsprovider.NewVersioned(), Volume: existing})
respondProvider(ctx, msg, ebsprovider.CreateVolumeResponse{Versioned: v, Volume: existing})
return
case errors.Is(err, viperblock.ErrStateNotFound):
// Volume does not exist yet: fall through and create it.
default:
slog.Error("ebs.provider.volume.create: existence check failed", "volume", req.VolumeID, "err", err)
respondProvider(ctx, msg, ebsprovider.CreateVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: internalError("check existing volume: %v", err)})
respondProvider(ctx, msg, ebsprovider.CreateVolumeResponse{Versioned: v, Error: internalError("check existing volume: %v", err)})
return
}
@@ -534,7 +561,7 @@ func handleCreateVolume(ctx context.Context, cfg *Config, msg *nats.Msg) {
vb, err := viperblock.New(vbconfig, "s3", s3cfg)
if err != nil {
slog.Error("ebs.provider.volume.create: new viperblock failed", "volume", req.VolumeID, "err", err)
respondProvider(ctx, msg, ebsprovider.CreateVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: internalError("new viperblock: %v", err)})
respondProvider(ctx, msg, ebsprovider.CreateVolumeResponse{Versioned: v, Error: internalError("new viperblock: %v", err)})
return
}
defer vb.Detach()
@@ -542,26 +569,26 @@ func handleCreateVolume(ctx context.Context, cfg *Config, msg *nats.Msg) {
if err := vb.Backend.InitCtx(ctx); err != nil {
slog.Error("ebs.provider.volume.create: backend init failed", "volume", req.VolumeID, "err", err)
respondProvider(ctx, msg, ebsprovider.CreateVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: internalError("backend init: %v", err)})
respondProvider(ctx, msg, ebsprovider.CreateVolumeResponse{Versioned: v, Error: internalError("backend init: %v", err)})
return
}
if req.SourceSnapshotID != "" {
if perr := verifySourceSnapshot(vb, req.SourceSnapshotID, req.SourceSnapshotVolumeID); perr != nil {
slog.Error("ebs.provider.volume.create: source snapshot rejected", "volume", req.VolumeID, "snapshot", req.SourceSnapshotID, "err", perr.Message)
respondProvider(ctx, msg, ebsprovider.CreateVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: perr})
respondProvider(ctx, msg, ebsprovider.CreateVolumeResponse{Versioned: v, Error: perr})
return
}
}
if len(req.SeedData) > 0 {
if err := seedVolume(ctx, vb, req.SeedData); err != nil {
slog.Error("ebs.provider.volume.create: seed failed", "volume", req.VolumeID, "bytes", len(req.SeedData), "err", err)
respondProvider(ctx, msg, ebsprovider.CreateVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: internalError("seed volume: %v", err)})
respondProvider(ctx, msg, ebsprovider.CreateVolumeResponse{Versioned: v, Error: internalError("seed volume: %v", err)})
return
}
} else {
if err := vb.SaveStateCtx(ctx); err != nil {
slog.Error("ebs.provider.volume.create: save state failed", "volume", req.VolumeID, "err", err)
respondProvider(ctx, msg, ebsprovider.CreateVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: internalError("save state: %v", err)})
respondProvider(ctx, msg, ebsprovider.CreateVolumeResponse{Versioned: v, Error: internalError("save state: %v", err)})
return
}
// A state read prefers a local copy over the backend, so state left on
@@ -574,7 +601,7 @@ func handleCreateVolume(ctx context.Context, cfg *Config, msg *nats.Msg) {
slog.Info("ebs.provider.volume.create: created", "volume", req.VolumeID, "capacityBytes", req.CapacityRange.RequiredBytes, "seedBytes", len(req.SeedData))
respondProvider(ctx, msg, ebsprovider.CreateVolumeResponse{
Versioned: ebsprovider.NewVersioned(),
Versioned: v,
Volume: &ebsprovider.Volume{
ID: req.VolumeID,
CapacityBytes: req.CapacityRange.RequiredBytes,
@@ -667,72 +694,76 @@ func describeVolumeEngine(ctx context.Context, cfg *Config, volumeID string) (*e
func handleGetVolume(ctx context.Context, cfg *Config, msg *nats.Msg) {
var req ebsprovider.GetVolumeRequest
if err := json.Unmarshal(msg.Data, &req); err != nil {
err := json.Unmarshal(msg.Data, &req)
v := ebsprovider.ReplyVersioned(req.SchemaVersion)
if err != nil {
slog.Error("ebs.provider.volume.describe: bad request", "err", err)
respondProvider(ctx, msg, ebsprovider.GetVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: badRequestError(err)})
respondProvider(ctx, msg, ebsprovider.GetVolumeResponse{Versioned: v, Error: badRequestError(err)})
return
}
if !ebsprovider.SupportsSchemaVersion(req.SchemaVersion) {
respondProvider(ctx, msg, ebsprovider.GetVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: unsupportedVersionError(req.SchemaVersion)})
respondProvider(ctx, msg, ebsprovider.GetVolumeResponse{Versioned: v, Error: unsupportedVersionError(req.SchemaVersion)})
return
}
if !validVolumeName(req.VolumeID) {
respondProvider(ctx, msg, ebsprovider.GetVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: invalidArgumentError("invalid volume id %q", req.VolumeID)})
respondProvider(ctx, msg, ebsprovider.GetVolumeResponse{Versioned: v, Error: invalidArgumentError("invalid volume id %q", req.VolumeID)})
return
}
volume, err := describeVolumeEngine(ctx, cfg, req.VolumeID)
if err != nil {
if errors.Is(err, viperblock.ErrStateNotFound) {
respondProvider(ctx, msg, ebsprovider.GetVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: notFoundError("volume %s not found", req.VolumeID)})
respondProvider(ctx, msg, ebsprovider.GetVolumeResponse{Versioned: v, Error: notFoundError("volume %s not found", req.VolumeID)})
return
}
slog.Error("ebs.provider.volume.describe: failed", "volume", req.VolumeID, "err", err)
respondProvider(ctx, msg, ebsprovider.GetVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: internalError("describe volume: %v", err)})
respondProvider(ctx, msg, ebsprovider.GetVolumeResponse{Versioned: v, Error: internalError("describe volume: %v", err)})
return
}
respondProvider(ctx, msg, ebsprovider.GetVolumeResponse{Versioned: ebsprovider.NewVersioned(), Volume: volume})
respondProvider(ctx, msg, ebsprovider.GetVolumeResponse{Versioned: v, Volume: volume})
}
func handleExpandVolume(ctx context.Context, cfg *Config, msg *nats.Msg) {
var req ebsprovider.ExpandVolumeRequest
if err := json.Unmarshal(msg.Data, &req); err != nil {
err := json.Unmarshal(msg.Data, &req)
v := ebsprovider.ReplyVersioned(req.SchemaVersion)
if err != nil {
slog.Error("ebs.provider.volume.expand: bad request", "err", err)
respondProvider(ctx, msg, ebsprovider.ExpandVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: badRequestError(err)})
respondProvider(ctx, msg, ebsprovider.ExpandVolumeResponse{Versioned: v, Error: badRequestError(err)})
return
}
if !ebsprovider.SupportsSchemaVersion(req.SchemaVersion) {
respondProvider(ctx, msg, ebsprovider.ExpandVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: unsupportedVersionError(req.SchemaVersion)})
respondProvider(ctx, msg, ebsprovider.ExpandVolumeResponse{Versioned: v, Error: unsupportedVersionError(req.SchemaVersion)})
return
}
if !validVolumeName(req.VolumeID) {
respondProvider(ctx, msg, ebsprovider.ExpandVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: invalidArgumentError("invalid volume id %q", req.VolumeID)})
respondProvider(ctx, msg, ebsprovider.ExpandVolumeResponse{Versioned: v, Error: invalidArgumentError("invalid volume id %q", req.VolumeID)})
return
}
if req.CapacityRange.RequiredBytes <= 0 {
respondProvider(ctx, msg, ebsprovider.ExpandVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: invalidArgumentError("invalid capacity range")})
respondProvider(ctx, msg, ebsprovider.ExpandVolumeResponse{Versioned: v, Error: invalidArgumentError("invalid capacity range")})
return
}
if mv, ok := findMountedVolume(cfg, req.VolumeID); ok && mv.VB != nil {
slog.Error("ebs.provider.volume.expand: volume is mounted", "volume", req.VolumeID)
respondProvider(ctx, msg, ebsprovider.ExpandVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: volumeInUseError("volume %s is mounted; detach before expanding", req.VolumeID)})
respondProvider(ctx, msg, ebsprovider.ExpandVolumeResponse{Versioned: v, Error: volumeInUseError("volume %s is mounted; detach before expanding", req.VolumeID)})
return
}
vb, lease, err := openVolumeVB(ctx, cfg, req.VolumeID)
if err != nil {
if errors.Is(err, viperblock.ErrStateNotFound) {
respondProvider(ctx, msg, ebsprovider.ExpandVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: notFoundError("volume %s not found", req.VolumeID)})
respondProvider(ctx, msg, ebsprovider.ExpandVolumeResponse{Versioned: v, Error: notFoundError("volume %s not found", req.VolumeID)})
return
}
if errors.Is(err, errVolumeLeaseHeld) {
slog.Error("ebs.provider.volume.expand: volume is leased elsewhere", "volume", req.VolumeID, "err", err)
respondProvider(ctx, msg, ebsprovider.ExpandVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: volumeInUseError("volume %s is open elsewhere: %v", req.VolumeID, err)})
respondProvider(ctx, msg, ebsprovider.ExpandVolumeResponse{Versioned: v, Error: volumeInUseError("volume %s is open elsewhere: %v", req.VolumeID, err)})
return
}
slog.Error("ebs.provider.volume.expand: open volume failed", "volume", req.VolumeID, "err", err)
respondProvider(ctx, msg, ebsprovider.ExpandVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: internalError("open volume: %v", err)})
respondProvider(ctx, msg, ebsprovider.ExpandVolumeResponse{Versioned: v, Error: internalError("open volume: %v", err)})
return
}
defer func() {
@@ -742,7 +773,7 @@ func handleExpandVolume(ctx context.Context, cfg *Config, msg *nats.Msg) {
currentBytes := safecast.Uint64ToInt64(vb.GetVolumeSize())
if req.CapacityRange.RequiredBytes < currentBytes {
respondProvider(ctx, msg, ebsprovider.ExpandVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: invalidArgumentError("volume expansion is grow-only")})
respondProvider(ctx, msg, ebsprovider.ExpandVolumeResponse{Versioned: v, Error: invalidArgumentError("volume expansion is grow-only")})
return
}
@@ -751,18 +782,18 @@ func handleExpandVolume(ctx context.Context, cfg *Config, msg *nats.Msg) {
rawConfig, err := json.Marshal(vc)
if err != nil {
slog.Error("ebs.provider.volume.expand: marshal volume config failed", "volume", req.VolumeID, "err", err)
respondProvider(ctx, msg, ebsprovider.ExpandVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: internalError("marshal volume config: %v", err)})
respondProvider(ctx, msg, ebsprovider.ExpandVolumeResponse{Versioned: v, Error: internalError("marshal volume config: %v", err)})
return
}
if err := applyConfigUpdate(ctx, vb, rawConfig); err != nil {
slog.Error("ebs.provider.volume.expand: apply config update failed", "volume", req.VolumeID, "err", err)
respondProvider(ctx, msg, ebsprovider.ExpandVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: internalError("apply config update: %v", err)})
respondProvider(ctx, msg, ebsprovider.ExpandVolumeResponse{Versioned: v, Error: internalError("apply config update: %v", err)})
return
}
slog.Info("ebs.provider.volume.expand: expanded", "volume", req.VolumeID, "capacityBytes", req.CapacityRange.RequiredBytes)
respondProvider(ctx, msg, ebsprovider.ExpandVolumeResponse{
Versioned: ebsprovider.NewVersioned(),
Versioned: v,
Volume: &ebsprovider.Volume{
ID: req.VolumeID,
CapacityBytes: safecast.Uint64ToInt64(vb.GetVolumeSize()),
@@ -856,17 +887,19 @@ func deleteObjectPrefix(ctx context.Context, store objectstore.ObjectStore, buck
func handleDeleteVolume(ctx context.Context, cfg *Config, nc *nats.Conn, msg *nats.Msg) {
var req ebsprovider.DeleteVolumeRequest
if err := json.Unmarshal(msg.Data, &req); err != nil {
err := json.Unmarshal(msg.Data, &req)
v := ebsprovider.ReplyVersioned(req.SchemaVersion)
if err != nil {
slog.Error("ebs.provider.volume.delete: bad request", "err", err)
respondProvider(ctx, msg, ebsprovider.DeleteVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: badRequestError(err)})
respondProvider(ctx, msg, ebsprovider.DeleteVolumeResponse{Versioned: v, Error: badRequestError(err)})
return
}
if !ebsprovider.SupportsSchemaVersion(req.SchemaVersion) {
respondProvider(ctx, msg, ebsprovider.DeleteVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: unsupportedVersionError(req.SchemaVersion)})
respondProvider(ctx, msg, ebsprovider.DeleteVolumeResponse{Versioned: v, Error: unsupportedVersionError(req.SchemaVersion)})
return
}
if !validVolumeName(req.VolumeID) {
respondProvider(ctx, msg, ebsprovider.DeleteVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: invalidArgumentError("invalid volume id %q", req.VolumeID)})
respondProvider(ctx, msg, ebsprovider.DeleteVolumeResponse{Versioned: v, Error: invalidArgumentError("invalid volume id %q", req.VolumeID)})
return
}
@@ -880,7 +913,7 @@ func handleDeleteVolume(ctx context.Context, cfg *Config, nc *nats.Conn, msg *na
}
if mountedHere || ownership == ownershipMounted {
slog.Error("ebs.provider.volume.delete: volume is published", "volume", req.VolumeID)
respondProvider(ctx, msg, ebsprovider.DeleteVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: volumeInUseError("volume %s is published; unpublish it before deleting", req.VolumeID)})
respondProvider(ctx, msg, ebsprovider.DeleteVolumeResponse{Versioned: v, Error: volumeInUseError("volume %s is published; unpublish it before deleting", req.VolumeID)})
return
}
// Ownership could not be established. Deleting is irreversible, so an
@@ -888,7 +921,7 @@ func handleDeleteVolume(ctx context.Context, cfg *Config, nc *nats.Conn, msg *na
// retry once the owner is reachable.
if ownership == ownershipUnknown {
slog.Error("ebs.provider.volume.delete: could not establish whether the volume is published", "volume", req.VolumeID)
respondProvider(ctx, msg, ebsprovider.DeleteVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: unavailableError("volume %s: could not establish whether it is published; retry when its owner is reachable", req.VolumeID)})
respondProvider(ctx, msg, ebsprovider.DeleteVolumeResponse{Versioned: v, Error: unavailableError("volume %s: could not establish whether it is published; retry when its owner is reachable", req.VolumeID)})
return
}
@@ -908,44 +941,46 @@ func handleDeleteVolume(ctx context.Context, cfg *Config, nc *nats.Conn, msg *na
store := providerObjectStoreFactory(cfg)
if err := deleteObjectPrefix(ctx, store, cfg.Bucket, req.VolumeID+"-efi/"); err != nil {
slog.Error("ebs.provider.volume.delete: failed to delete aux objects", "volume", req.VolumeID, "err", err)
respondProvider(ctx, msg, ebsprovider.DeleteVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: internalError("delete aux objects: %v", err)})
respondProvider(ctx, msg, ebsprovider.DeleteVolumeResponse{Versioned: v, Error: internalError("delete aux objects: %v", err)})
return
}
if err := deleteObjectPrefix(ctx, store, cfg.Bucket, req.VolumeID+"/"); err != nil {
slog.Error("ebs.provider.volume.delete: failed to delete volume objects", "volume", req.VolumeID, "err", err)
respondProvider(ctx, msg, ebsprovider.DeleteVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: internalError("delete volume objects: %v", err)})
respondProvider(ctx, msg, ebsprovider.DeleteVolumeResponse{Versioned: v, Error: internalError("delete volume objects: %v", err)})
return
}
slog.Info("ebs.provider.volume.delete: deleted", "volume", req.VolumeID)
respondProvider(ctx, msg, ebsprovider.DeleteVolumeResponse{Versioned: ebsprovider.NewVersioned()})
respondProvider(ctx, msg, ebsprovider.DeleteVolumeResponse{Versioned: v})
}
func handleDeleteSnapshot(ctx context.Context, cfg *Config, msg *nats.Msg) {
var req ebsprovider.DeleteSnapshotRequest
if err := json.Unmarshal(msg.Data, &req); err != nil {
err := json.Unmarshal(msg.Data, &req)
v := ebsprovider.ReplyVersioned(req.SchemaVersion)
if err != nil {
slog.Error("ebs.provider.snapshot.delete: bad request", "err", err)
respondProvider(ctx, msg, ebsprovider.DeleteSnapshotResponse{Versioned: ebsprovider.NewVersioned(), Error: badRequestError(err)})
respondProvider(ctx, msg, ebsprovider.DeleteSnapshotResponse{Versioned: v, Error: badRequestError(err)})
return
}
if !ebsprovider.SupportsSchemaVersion(req.SchemaVersion) {
respondProvider(ctx, msg, ebsprovider.DeleteSnapshotResponse{Versioned: ebsprovider.NewVersioned(), Error: unsupportedVersionError(req.SchemaVersion)})
respondProvider(ctx, msg, ebsprovider.DeleteSnapshotResponse{Versioned: v, Error: unsupportedVersionError(req.SchemaVersion)})
return
}
if !validVolumeName(req.SnapshotID) {
respondProvider(ctx, msg, ebsprovider.DeleteSnapshotResponse{Versioned: ebsprovider.NewVersioned(), Error: invalidArgumentError("invalid snapshot id %q", req.SnapshotID)})
respondProvider(ctx, msg, ebsprovider.DeleteSnapshotResponse{Versioned: v, Error: invalidArgumentError("invalid snapshot id %q", req.SnapshotID)})
return
}
store := providerObjectStoreFactory(cfg)
if err := deleteObjectPrefix(ctx, store, cfg.Bucket, req.SnapshotID+"/"); err != nil {
slog.Error("ebs.provider.snapshot.delete: failed", "snapshot", req.SnapshotID, "err", err)
respondProvider(ctx, msg, ebsprovider.DeleteSnapshotResponse{Versioned: ebsprovider.NewVersioned(), Error: internalError("delete snapshot objects: %v", err)})
respondProvider(ctx, msg, ebsprovider.DeleteSnapshotResponse{Versioned: v, Error: internalError("delete snapshot objects: %v", err)})
return
}
slog.Info("ebs.provider.snapshot.delete: deleted", "snapshot", req.SnapshotID)
respondProvider(ctx, msg, ebsprovider.DeleteSnapshotResponse{Versioned: ebsprovider.NewVersioned()})
respondProvider(ctx, msg, ebsprovider.DeleteSnapshotResponse{Versioned: v})
}
// handleCopySnapshot serves ebs.provider.v1.snapshot.copy. Unlike
@@ -955,21 +990,23 @@ func handleDeleteSnapshot(ctx context.Context, cfg *Config, msg *nats.Msg) {
// pattern.
func handleCopySnapshot(ctx context.Context, cfg *Config, msg *nats.Msg) {
var req ebsprovider.CopySnapshotRequest
if err := json.Unmarshal(msg.Data, &req); err != nil {
err := json.Unmarshal(msg.Data, &req)
v := ebsprovider.ReplyVersioned(req.SchemaVersion)
if err != nil {
slog.Error("ebs.provider.snapshot.copy: bad request", "err", err)
respondProvider(ctx, msg, ebsprovider.CopySnapshotResponse{Versioned: ebsprovider.NewVersioned(), Error: badRequestError(err)})
respondProvider(ctx, msg, ebsprovider.CopySnapshotResponse{Versioned: v, Error: badRequestError(err)})
return
}
if !ebsprovider.SupportsSchemaVersion(req.SchemaVersion) {
respondProvider(ctx, msg, ebsprovider.CopySnapshotResponse{Versioned: ebsprovider.NewVersioned(), Error: unsupportedVersionError(req.SchemaVersion)})
respondProvider(ctx, msg, ebsprovider.CopySnapshotResponse{Versioned: v, Error: unsupportedVersionError(req.SchemaVersion)})
return
}
if !validVolumeName(req.SourceSnapshotID) || !validVolumeName(req.DestinationSnapshotID) || !validVolumeName(req.VolumeID) {
respondProvider(ctx, msg, ebsprovider.CopySnapshotResponse{Versioned: ebsprovider.NewVersioned(), Error: invalidArgumentError("invalid source snapshot, destination snapshot, or volume id")})
respondProvider(ctx, msg, ebsprovider.CopySnapshotResponse{Versioned: v, Error: invalidArgumentError("invalid source snapshot, destination snapshot, or volume id")})
return
}
if req.SourceSnapshotID == req.DestinationSnapshotID {
respondProvider(ctx, msg, ebsprovider.CopySnapshotResponse{Versioned: ebsprovider.NewVersioned(), Error: invalidArgumentError("destination snapshot %q must differ from the source", req.DestinationSnapshotID)})
respondProvider(ctx, msg, ebsprovider.CopySnapshotResponse{Versioned: v, Error: invalidArgumentError("destination snapshot %q must differ from the source", req.DestinationSnapshotID)})
return
}
@@ -977,20 +1014,20 @@ func handleCopySnapshot(ctx context.Context, cfg *Config, msg *nats.Msg) {
if err != nil {
switch {
case errors.Is(err, errSnapshotDestinationExists):
respondProvider(ctx, msg, ebsprovider.CopySnapshotResponse{Versioned: ebsprovider.NewVersioned(), Error: alreadyExistsError("snapshot %s already exists", req.DestinationSnapshotID)})
respondProvider(ctx, msg, ebsprovider.CopySnapshotResponse{Versioned: v, Error: alreadyExistsError("snapshot %s already exists", req.DestinationSnapshotID)})
case errors.Is(err, viperblock.ErrSnapshotVolumeMismatch):
respondProvider(ctx, msg, ebsprovider.CopySnapshotResponse{Versioned: ebsprovider.NewVersioned(), Error: invalidArgumentError("%v", err)})
respondProvider(ctx, msg, ebsprovider.CopySnapshotResponse{Versioned: v, Error: invalidArgumentError("%v", err)})
case errors.Is(err, viperblock.ErrStateNotFound), errors.Is(err, os.ErrNotExist):
respondProvider(ctx, msg, ebsprovider.CopySnapshotResponse{Versioned: ebsprovider.NewVersioned(), Error: notFoundError("source snapshot %s not found on volume %s", req.SourceSnapshotID, req.VolumeID)})
respondProvider(ctx, msg, ebsprovider.CopySnapshotResponse{Versioned: v, Error: notFoundError("source snapshot %s not found on volume %s", req.SourceSnapshotID, req.VolumeID)})
default:
slog.Error("ebs.provider.snapshot.copy: failed", "volume", req.VolumeID, "source", req.SourceSnapshotID, "destination", req.DestinationSnapshotID, "err", err)
respondProvider(ctx, msg, ebsprovider.CopySnapshotResponse{Versioned: ebsprovider.NewVersioned(), Error: internalError("copy snapshot: %v", err)})
respondProvider(ctx, msg, ebsprovider.CopySnapshotResponse{Versioned: v, Error: internalError("copy snapshot: %v", err)})
}
return
}
slog.Info("ebs.provider.snapshot.copy: copied", "volume", req.VolumeID, "source", req.SourceSnapshotID, "destination", req.DestinationSnapshotID)
respondProvider(ctx, msg, ebsprovider.CopySnapshotResponse{Versioned: ebsprovider.NewVersioned(), Snapshot: snapshot})
respondProvider(ctx, msg, ebsprovider.CopySnapshotResponse{Versioned: v, Snapshot: snapshot})
}
// snapshotConfigExists probes whether snapshotID's config object is already
@@ -1055,13 +1092,15 @@ func copySnapshotMetaWithVB(vb *viperblock.VB, volumeID, srcSnapshotID, dstSnaps
func handleCreateSnapshot(ctx context.Context, cfg *Config, nc *nats.Conn, msg *nats.Msg) {
var req ebsprovider.CreateSnapshotRequest
if err := json.Unmarshal(msg.Data, &req); err != nil {
err := json.Unmarshal(msg.Data, &req)
v := ebsprovider.ReplyVersioned(req.SchemaVersion)
if err != nil {
slog.Error("ebs.provider.snapshot.create: bad request", "err", err)
respondProvider(ctx, msg, ebsprovider.CreateSnapshotResponse{Versioned: ebsprovider.NewVersioned(), Error: badRequestError(err)})
respondProvider(ctx, msg, ebsprovider.CreateSnapshotResponse{Versioned: v, Error: badRequestError(err)})
return
}
if !ebsprovider.SupportsSchemaVersion(req.SchemaVersion) {
respondProvider(ctx, msg, ebsprovider.CreateSnapshotResponse{Versioned: ebsprovider.NewVersioned(), Error: unsupportedVersionError(req.SchemaVersion)})
respondProvider(ctx, msg, ebsprovider.CreateSnapshotResponse{Versioned: v, Error: unsupportedVersionError(req.SchemaVersion)})
return
}
@@ -1076,23 +1115,23 @@ func handleCreateSnapshot(ctx context.Context, cfg *Config, nc *nats.Conn, msg *
}
if !validVolumeName(subjectVolumeID) || subjectVolumeID != req.VolumeID {
slog.Error("ebs.provider.snapshot.create: subject/volume mismatch", "subject", msg.Subject, "volume", req.VolumeID)
respondProvider(ctx, msg, ebsprovider.CreateSnapshotResponse{Versioned: ebsprovider.NewVersioned(), Error: invalidArgumentError("volume id %q in subject does not match request volume id %q", subjectVolumeID, req.VolumeID)})
respondProvider(ctx, msg, ebsprovider.CreateSnapshotResponse{Versioned: v, Error: invalidArgumentError("volume id %q in subject does not match request volume id %q", subjectVolumeID, req.VolumeID)})
return
}
if !validVolumeName(req.SnapshotID) {
respondProvider(ctx, msg, ebsprovider.CreateSnapshotResponse{Versioned: ebsprovider.NewVersioned(), Error: invalidArgumentError("invalid snapshot id %q", req.SnapshotID)})
respondProvider(ctx, msg, ebsprovider.CreateSnapshotResponse{Versioned: v, Error: invalidArgumentError("invalid snapshot id %q", req.SnapshotID)})
return
}
completionSubject, err := ebsprovider.SnapshotCompletionSubject(req.SnapshotID)
if err != nil {
respondProvider(ctx, msg, ebsprovider.CreateSnapshotResponse{Versioned: ebsprovider.NewVersioned(), Error: invalidArgumentError("%s", err.Error())})
respondProvider(ctx, msg, ebsprovider.CreateSnapshotResponse{Versioned: v, Error: invalidArgumentError("%s", err.Error())})
return
}
operationID := awsidentifiers.GenerateResourceID("op")
respondProvider(ctx, msg, ebsprovider.CreateSnapshotResponse{
Versioned: ebsprovider.NewVersioned(),
Versioned: v,
OperationID: operationID,
CompletionSubject: completionSubject,
Snapshot: &ebsprovider.Snapshot{
@@ -1117,7 +1156,7 @@ func handleCreateSnapshot(ctx context.Context, cfg *Config, nc *nats.Conn, msg *
// contract NATSProvider.CreateSnapshot waits on.
func completeCreateSnapshot(ctx context.Context, cfg *Config, nc *nats.Conn, req ebsprovider.CreateSnapshotRequest, operationID, completionSubject string) {
completion := ebsprovider.CreateSnapshotResponse{
Versioned: ebsprovider.NewVersioned(),
Versioned: ebsprovider.ReplyVersioned(req.SchemaVersion),
OperationID: operationID,
CompletionSubject: completionSubject,
}
@@ -1396,19 +1435,20 @@ func mountVolume(ctx context.Context, cfg *Config, nc *nats.Conn, volumeName str
// ebs.mount subject is the route production actually takes: a retried
// attach racing a fresh one would otherwise start two real exports.
if mv, ok := findMountedVolume(cfg, volumeName); ok {
// An entry whose socket is gone describes an export that no longer
// exists, and returning its URI makes the volume unstartable forever:
// every caller waits for a socket nothing will create. Dropping it and
// mounting again is safe because an absent socket cannot be serving.
// Returning a dead entry's URI makes the volume unstartable forever:
// every caller waits for a socket nothing will create. The entry is
// dropped and the volume mounted again once its nbdkit is gone.
if stale, why := mountEntryIsStale(mv); stale {
slog.WarnContext(ctx, "ebs.mount: dropping stale mount entry and remounting",
"volume", volumeName, "uri", mv.NBDURI, "reason", why)
releaseStaleMount(ctx, cfg, volumeName)
if err := releaseStaleMount(ctx, cfg, volumeName); err != nil {
return viperblocklegacyv1.EBSMountResponse{Error: err.Error()}, err
}
} else {
// Access mode is fixed when nbdkit starts, so a remount asking for
// the other mode cannot be answered with the running export.
if mv.ReadOnly != readOnly {
err := fmt.Errorf("volume %s is already mounted read_only=%t on this node", volumeName, mv.ReadOnly)
err := fmt.Errorf("volume %s is already mounted read_only=%t on this node: %w", volumeName, mv.ReadOnly, errReadOnlyMismatch)
return viperblocklegacyv1.EBSMountResponse{Error: err.Error()}, err
}
slog.InfoContext(ctx, "ebs.mount: already mounted, returning existing export", "volume", volumeName, "uri", mv.NBDURI)
@@ -1675,13 +1715,13 @@ func unmountVolume(ctx context.Context, cfg *Config, volumeName string) (viperbl
// drains to the backend and leaves a receipt. SIGKILLing into that
// discards the drain and leaves the WAL for the fallback seal below to
// replay, which costs far more than the caller's deadline allows.
hostprocess.TerminateProcess(matched.PID, pluginSealGrace)
hostprocess.TerminateProcess(matched.PID, cfg.exportSealGrace())
// The seal below rewrites the directory nbdkit writes, so a kill that
// did not take makes it a concurrent write to that directory. Fail the
// unmount instead: the entry stays mounted and a retry re-attempts
// both, which is what a failed seal already does.
if err := hostprocess.ForceKillProcess(matched.PID, fenceKillTimeout); err != nil {
if err := hostprocess.ForceKillProcess(matched.PID, cfg.exportKillWait()); err != nil {
slog.ErrorContext(ctx, "ebs.unmount: nbdkit did not exit, refusing to seal underneath a live writer",
"volume", matched.Name, "pid", matched.PID, "err", err)
ebsResponse.Error = fmt.Sprintf("kill nbdkit for %s: %v", matched.Name, err)
@@ -1777,54 +1817,27 @@ func unmountVolume(ctx context.Context, cfg *Config, volumeName string) (viperbl
// unlike the legacy handler there is no queue-group fallback to reason about.
func handlePublishVolume(ctx context.Context, cfg *Config, nc *nats.Conn, msg *nats.Msg) {
var req ebsprovider.PublishVolumeRequest
if err := json.Unmarshal(msg.Data, &req); err != nil {
err := json.Unmarshal(msg.Data, &req)
v := ebsprovider.ReplyVersioned(req.SchemaVersion)
if err != nil {
slog.Error("ebs.provider.volume.publish: bad request", "err", err)
respondProvider(ctx, msg, ebsprovider.PublishVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: badRequestError(err)})
respondProvider(ctx, msg, ebsprovider.PublishVolumeResponse{Versioned: v, Error: badRequestError(err)})
return
}
if !ebsprovider.SupportsSchemaVersion(req.SchemaVersion) {
slog.Error("ebs.provider.volume.publish: unsupported schema version", "version", req.SchemaVersion)
respondProvider(ctx, msg, ebsprovider.PublishVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: unsupportedVersionError(req.SchemaVersion)})
respondProvider(ctx, msg, ebsprovider.PublishVolumeResponse{Versioned: v, Error: unsupportedVersionError(req.SchemaVersion)})
return
}
if !validVolumeName(req.VolumeID) {
respondProvider(ctx, msg, ebsprovider.PublishVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: invalidArgumentError("invalid volume id %q", req.VolumeID)})
respondProvider(ctx, msg, ebsprovider.PublishVolumeResponse{Versioned: v, Error: invalidArgumentError("invalid volume id %q", req.VolumeID)})
return
}
// Idempotent republish: a volume this node already has mounted must not
// start a second nbdkit against it (the double-writer hazard this
// provider boundary exists to prevent). Match memory.go's behaviour.
if mv, ok := findMountedVolume(cfg, req.VolumeID); ok {
// Access mode is fixed when nbdkit starts, so a republish asking for
// the other mode cannot be answered with the running export.
if mv.ReadOnly != req.ReadOnly {
respondProvider(ctx, msg, ebsprovider.PublishVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: volumeInUseError("volume %s is already published read_only=%t on this node", req.VolumeID, mv.ReadOnly)})
return
}
slog.Info("ebs.provider.volume.publish: already published, returning existing attachment", "volume", req.VolumeID)
respondPublishedVolume(ctx, msg, req.SchemaVersion, &ebsprovider.PublishedVolume{
VolumeID: req.VolumeID,
NodeID: cfg.NodeName,
Export: ebsprovider.NewNBDExport(mv.NBDURI),
})
return
}
// mountVolume returns a live entry's export, and drops and remounts one
// whose export has died, so a republish never hands back a dead URI.
mountResp, err := mountVolume(ctx, cfg, nc, req.VolumeID, req.ReadOnly)
if err != nil {
if errors.Is(err, viperblock.ErrStateNotFound) {
respondProvider(ctx, msg, ebsprovider.PublishVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: notFoundError("volume %s not found", req.VolumeID)})
return
}
// Another node holds the volume's lease. This is the exclusion working,
// not a failure: reporting it as internal tells the caller something
// broke when the answer is that someone else has the volume.
if errors.Is(err, errVolumeLeaseHeld) {
respondProvider(ctx, msg, ebsprovider.PublishVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: volumeInUseError("volume %s is held by another node: %v", req.VolumeID, err)})
return
}
slog.Error("ebs.provider.volume.publish: mount failed", "volume", req.VolumeID, "err", err)
respondProvider(ctx, msg, ebsprovider.PublishVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: internalError("mount volume: %v", err)})
respondProvider(ctx, msg, ebsprovider.PublishVolumeResponse{Versioned: v, Error: publishError(req.SchemaVersion, req.VolumeID, err)})
return
}
@@ -1836,59 +1849,110 @@ func handlePublishVolume(ctx context.Context, cfg *Config, nc *nats.Conn, msg *n
})
}
// publishError classifies a failed mount for the contract reply. A lease held
// by another node is the exclusion working, and a backing store that is not
// ready yet is worth retrying, so neither is reported as internal.
func publishError(version uint16, volumeID string, err error) *ebsprovider.ProviderError {
switch {
case errors.Is(err, viperblock.ErrStateNotFound):
return notFoundError("volume %s not found", volumeID)
case errors.Is(err, errVolumeLeaseHeld) && version >= ebsprovider.HeldElsewhereSchemaVersion:
return heldElsewhereError("volume %s is held by another node: %v", volumeID, err)
case errors.Is(err, errVolumeLeaseHeld):
// Below version 3 the refusal keeps the code v1.23.0 sends.
return volumeInUseError("volume %s is held by another node: %v", volumeID, err)
case errors.Is(err, errReadOnlyMismatch):
return volumeInUseError("%v", err)
case mountErrRetryable(err), errors.Is(err, ebsprovider.ErrUnavailable):
return unavailableError("mount volume %s: %v", volumeID, err)
default:
slog.Error("ebs.provider.volume.publish: mount failed", "volume", volumeID, "err", err)
return internalError("mount volume: %v", err)
}
}
// handleUnpublishVolume serves ebs.provider.v1.<node>.unmount, fronting the
// same seal-decision path ebs.unmount uses. A failed seal is reported as an
// error while leaving the volume in cfg.MountedVolumes (see unmountVolume),
// never as success.
func handleUnpublishVolume(ctx context.Context, cfg *Config, msg *nats.Msg) {
var req ebsprovider.UnpublishVolumeRequest
if err := json.Unmarshal(msg.Data, &req); err != nil {
err := json.Unmarshal(msg.Data, &req)
v := ebsprovider.ReplyVersioned(req.SchemaVersion)
if err != nil {
slog.Error("ebs.provider.volume.unpublish: bad request", "err", err)
respondProvider(ctx, msg, ebsprovider.UnpublishVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: badRequestError(err)})
respondProvider(ctx, msg, ebsprovider.UnpublishVolumeResponse{Versioned: v, Error: badRequestError(err)})
return
}
if !ebsprovider.SupportsSchemaVersion(req.SchemaVersion) {
slog.Error("ebs.provider.volume.unpublish: unsupported schema version", "version", req.SchemaVersion)
respondProvider(ctx, msg, ebsprovider.UnpublishVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: unsupportedVersionError(req.SchemaVersion)})
if err := ebsprovider.CheckUnpublishVersion(req); err != nil {
slog.Error("ebs.provider.volume.unpublish: request refused", "version", req.SchemaVersion, "no_seal", req.NoSeal, "err", err)
respondProvider(ctx, msg, ebsprovider.UnpublishVolumeResponse{Versioned: v, Error: unpublishRequestError(req.SchemaVersion, err)})
return
}
if !validVolumeName(req.VolumeID) {
respondProvider(ctx, msg, ebsprovider.UnpublishVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: invalidArgumentError("invalid volume id %q", req.VolumeID)})
respondProvider(ctx, msg, ebsprovider.UnpublishVolumeResponse{Versioned: v, Error: invalidArgumentError("invalid volume id %q", req.VolumeID)})
return
}
tornDown, err := unpublishVolume(ctx, cfg, req)
if err != nil {
slog.Error("ebs.provider.volume.unpublish: unmount failed", "volume", req.VolumeID, "no_seal", req.NoSeal, "err", err)
respondProvider(ctx, msg, ebsprovider.UnpublishVolumeResponse{Versioned: v, Error: internalError("unmount volume: %v", err)})
return
}
slog.Info("ebs.provider.volume.unpublish: unpublished", "volume", req.VolumeID, "node", cfg.NodeName, "no_seal", req.NoSeal, "torn_down", tornDown)
respondProvider(ctx, msg, ebsprovider.UnpublishVolumeResponse{Versioned: v, TornDown: tornDown})
}
// unpublishRequestError classifies a CheckUnpublishVersion refusal.
func unpublishRequestError(version uint16, err error) *ebsprovider.ProviderError {
if errors.Is(err, ebsprovider.ErrUnsupportedVersion) {
return unsupportedVersionError(version)
}
return invalidArgumentError("%v", err)
}
// unpublishVolume tears down this node's export and reports whether there was
// one, which is only reported from NoSealSchemaVersion on. NoSeal drops it
// unsealed, as an abandon does; otherwise it seals first, as ebs.unmount does.
func unpublishVolume(ctx context.Context, cfg *Config, req ebsprovider.UnpublishVolumeRequest) (bool, error) {
tornDown, err := tearDownPublication(ctx, cfg, req)
return tornDown && req.SchemaVersion >= ebsprovider.NoSealSchemaVersion, err
}
func tearDownPublication(ctx context.Context, cfg *Config, req ebsprovider.UnpublishVolumeRequest) (bool, error) {
if req.NoSeal {
return cfg.abandonVolume(ctx, req.VolumeID, "unpublish with no_seal")
}
unmountResp, err := unmountVolume(ctx, cfg, req.VolumeID)
if err != nil {
// Nothing mounted here is already the state unpublish asks for, so a
// retry after a request that completed server-side converges rather
// than erroring, matching the legacy path's unmountResponseError.
if unmountResp.NotFound {
slog.Info("ebs.provider.volume.unpublish: already unpublished", "volume", req.VolumeID, "node", cfg.NodeName)
respondProvider(ctx, msg, ebsprovider.UnpublishVolumeResponse{Versioned: ebsprovider.NewVersioned()})
return
return unmountResp.Reaped, nil
}
slog.Error("ebs.provider.volume.unpublish: unmount failed", "volume", req.VolumeID, "err", err)
respondProvider(ctx, msg, ebsprovider.UnpublishVolumeResponse{Versioned: ebsprovider.NewVersioned(), Error: internalError("unmount volume: %v", err)})
return
return false, err
}
slog.Info("ebs.provider.volume.unpublish: unpublished", "volume", req.VolumeID, "node", cfg.NodeName)
respondProvider(ctx, msg, ebsprovider.UnpublishVolumeResponse{Versioned: ebsprovider.NewVersioned()})
return true, nil
}
func handleHoldsUnsealedWrites(ctx context.Context, cfg *Config, msg *nats.Msg) {
var req ebsprovider.HoldsUnsealedWritesRequest
if err := json.Unmarshal(msg.Data, &req); err != nil {
respondProvider(ctx, msg, ebsprovider.HoldsUnsealedWritesResponse{Versioned: ebsprovider.NewVersioned(), Error: badRequestError(err)})
err := json.Unmarshal(msg.Data, &req)
v := ebsprovider.ReplyVersioned(req.SchemaVersion)
if err != nil {
respondProvider(ctx, msg, ebsprovider.HoldsUnsealedWritesResponse{Versioned: v, Error: badRequestError(err)})
return
}
if !ebsprovider.SupportsSchemaVersion(req.SchemaVersion) {
respondProvider(ctx, msg, ebsprovider.HoldsUnsealedWritesResponse{Versioned: ebsprovider.NewVersioned(), Error: unsupportedVersionError(req.SchemaVersion)})
respondProvider(ctx, msg, ebsprovider.HoldsUnsealedWritesResponse{Versioned: v, Error: unsupportedVersionError(req.SchemaVersion)})
return
}
for _, volume := range req.VolumeIDs {
if !validVolumeName(volume) {
respondProvider(ctx, msg, ebsprovider.HoldsUnsealedWritesResponse{Versioned: ebsprovider.NewVersioned(), Error: invalidArgumentError("invalid volume id %q", volume)})
respondProvider(ctx, msg, ebsprovider.HoldsUnsealedWritesResponse{Versioned: v, Error: invalidArgumentError("invalid volume id %q", volume)})
return
}
}
@@ -1896,19 +1960,18 @@ func handleHoldsUnsealedWrites(ctx context.Context, cfg *Config, msg *nats.Msg)
held, err := cfg.unsealedWritesHeld(ctx, req.VolumeIDs)
if err != nil {
slog.Error("ebs.provider.unsealed: could not read dirty markers", "node", cfg.NodeName, "err", err)
respondProvider(ctx, msg, ebsprovider.HoldsUnsealedWritesResponse{Versioned: ebsprovider.NewVersioned(), Error: internalError("read dirty markers: %v", err)})
respondProvider(ctx, msg, ebsprovider.HoldsUnsealedWritesResponse{Versioned: v, Error: internalError("read dirty markers: %v", err)})
return
}
respondProvider(ctx, msg, ebsprovider.HoldsUnsealedWritesResponse{Versioned: ebsprovider.NewVersioned(), Held: held})
respondProvider(ctx, msg, ebsprovider.HoldsUnsealedWritesResponse{Versioned: v, Held: held})
}
// mountEntryIsStale reports whether a MountedVolume describes an export that is
// no longer running, and why.
// mountEntryIsStale reports whether a MountedVolume describes an export that no
// new client can reach, and why.
//
// Only positive evidence of death counts. The entry guards against a second
// nbdkit for one volume, so treating a live export as stale would create the
// double-writer hazard it exists to prevent — an absent socket or a dead pid
// are the two conditions under which no export can be serving.
// Only positive evidence counts: an absent socket or a dead pid. An absent
// socket does not mean the nbdkit has exited, since a client that connected
// before it went keeps writing through it, so releaseStaleMount stops it.
func mountEntryIsStale(mv MountedVolume) (bool, string) {
if mv.Socket != "" {
if _, err := os.Stat(mv.Socket); errors.Is(err, os.ErrNotExist) {
@@ -1923,7 +1986,18 @@ func mountEntryIsStale(mv MountedVolume) (bool, string) {
// releaseStaleMount drops a dead export's entry and everything it held, so the
// remount that follows starts from the same state a clean unmount would leave.
func releaseStaleMount(ctx context.Context, cfg *Config, volumeName string) {
//
// An nbdkit still running behind a lost socket is a writer, so it goes through
// unmountVolume, which stops it and seals its copy. If it will not stop, the
// entry stays and the error wraps ErrUnavailable: a remount would be a second writer.
func releaseStaleMount(ctx context.Context, cfg *Config, volumeName string) error {
if mv, ok := findMountedVolume(cfg, volumeName); ok && hostprocess.ProcessAlive(mv.PID) {
if _, err := unmountVolume(ctx, cfg, volumeName); err != nil {
return fmt.Errorf("%w: stop nbdkit %d behind the lost socket of %s: %w", ebsprovider.ErrUnavailable, mv.PID, volumeName, err)
}
return nil
}
cfg.mu.Lock()
var matched MountedVolume
found := false
@@ -1936,7 +2010,7 @@ func releaseStaleMount(ctx context.Context, cfg *Config, volumeName string) {
}
cfg.mu.Unlock()
if !found {
return
return nil
}
if matched.ConfigSub != nil {
@@ -1961,4 +2035,5 @@ func releaseStaleMount(ctx context.Context, cfg *Config, volumeName string) {
}
}
cfg.releaseVolumeLease(ctx, matched.Lease)
return nil
}
@@ -150,6 +150,11 @@ func providerSubjectCases() []providerSubjectCase {
"volume_id": "vol-testunpublish01",
"node_id": "test-node",
}},
{"holds_unsealed_writes", "ebs.provider.v1.test-node.unsealed", map[string]any{
"node_id": "test-node",
"volume_ids": []string{"vol-testunsealed01"},
}},
{"node_capabilities", "ebs.provider.v1.test-node.capabilities", map[string]any{}},
}
}
@@ -867,7 +872,7 @@ func TestProviderHandlers_PublishVolume_IdempotentRepublish(t *testing.T) {
cfg.MountedVolumes = append(cfg.MountedVolumes, MountedVolume{
Name: volumeName,
VB: vb,
PID: 424242,
PID: os.Getpid(),
NBDURI: existingURI,
})
@@ -893,7 +898,7 @@ func TestProviderHandlers_PublishVolume_IdempotentRepublish(t *testing.T) {
pid := cfg.MountedVolumes[0].PID
cfg.mu.Unlock()
assert.Equal(t, 1, count, "republish of an already-mounted volume must not add a second entry")
assert.Equal(t, 424242, pid, "republish must not spawn a second nbdkit (PID must be unchanged)")
assert.Equal(t, os.Getpid(), pid, "republish must not spawn a second nbdkit (PID must be unchanged)")
}
func TestProviderHandlers_PublishVolume_LegacyV1Wire(t *testing.T) {
@@ -905,7 +910,7 @@ func TestProviderHandlers_PublishVolume_LegacyV1Wire(t *testing.T) {
const existingURI = "nbd:unix:/fake/vol-publegacyv1.sock"
vb := createTestVBWithState(t, volumeName)
cfg.MountedVolumes = append(cfg.MountedVolumes, MountedVolume{
Name: volumeName, VB: vb, PID: 424242, NBDURI: existingURI,
Name: volumeName, VB: vb, PID: os.Getpid(), NBDURI: existingURI,
})
publishSubject, err := ebsprovider.PublishSubject(cfg.NodeName)
@@ -939,7 +944,7 @@ func TestProviderHandlers_PublishVolume_ReadOnlyModeMismatchRefused(t *testing.T
cfg.MountedVolumes = append(cfg.MountedVolumes, MountedVolume{
Name: volumeName,
VB: vb,
PID: 424243,
PID: os.Getpid(),
NBDURI: "nbd:unix:/fake/vol-readonlypub001.sock",
ReadOnly: false,
})
@@ -1038,8 +1043,8 @@ func TestProviderHandlers_PublishUnpublish_RequireNodeName(t *testing.T) {
cfgNamed := setupTestConfig(t, natsURL2)
ncNamed := startProviderSubjects(t, cfgNamed, natsURL2)
assert.Equal(t, ncNamed.NumSubscriptions()-3, ncNoNode.NumSubscriptions(),
"an empty NodeName must skip exactly the publish, unpublish and unsealed-writes subscriptions")
assert.Equal(t, ncNamed.NumSubscriptions()-4, ncNoNode.NumSubscriptions(),
"an empty NodeName must skip exactly the publish, unpublish, unsealed-writes and node capabilities subscriptions")
// The other provider subjects must still work without a node name.
body := marshalRequest(t, map[string]any{"schema_version": ebsprovider.SchemaVersion})
@@ -4,12 +4,20 @@ package viperblock
//exporting them would widen the package API for a test rather than for a caller.
import (
"context"
"errors"
"os"
"os/exec"
"path/filepath"
"testing"
"time"
"github.com/mulgadc/spinifex/spinifex/providers/ebs"
hostprocess "github.com/mulgadc/spinifex/spinifex/runtime/host/process"
"github.com/mulgadc/viperblock/viperblock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/sys/unix"
)
// A mount entry pointing at a socket that no longer exists describes an export
@@ -47,11 +55,11 @@ func TestMountEntryIsStaleWhenProcessIsGone(t *testing.T) {
func TestReleaseStaleMountDropsTheEntry(t *testing.T) {
cfg := &Config{MountedVolumes: []MountedVolume{{Name: "vol-1"}, {Name: "vol-2"}}}
releaseStaleMount(t.Context(), cfg, "vol-1")
require.NoError(t, releaseStaleMount(t.Context(), cfg, "vol-1"))
require.Len(t, cfg.MountedVolumes, 1)
assert.Equal(t, "vol-2", cfg.MountedVolumes[0].Name)
releaseStaleMount(t.Context(), cfg, "vol-absent")
require.NoError(t, releaseStaleMount(t.Context(), cfg, "vol-absent"))
assert.Len(t, cfg.MountedVolumes, 1, "releasing an unmounted volume must be a no-op")
}
@@ -64,3 +72,76 @@ func deadPID(t *testing.T) int {
require.NoError(t, err)
return state.Pid()
}
// orphanedConfig is fencedConfig with the entry's socket gone and local state
// under BaseDir, so a stop through the unmount path has a copy to seal.
func orphanedConfig(t *testing.T, volumeName string, pid int) *Config {
t.Helper()
_, natsURL := setupEmbeddedNATS(t)
cfg := fencedConfig(t, natsURL, "node-a", volumeName)
withLocalState(t, cfg, volumeName)
cfg.MountedVolumes[0].Socket = filepath.Join(t.TempDir(), "gone.sock")
if pid != 0 {
cfg.MountedVolumes[0].PID = pid
}
return cfg
}
// An nbdkit whose socket is gone still serves a client connected before it
// went, so a remount beside it is a second writer. It has to be stopped, and
// its copy sealed as any unmount would, before the volume is opened again.
func TestMountVolume_SocketGoneProcessAliveIsStoppedBeforeRemount(t *testing.T) {
const volumeName = "vol-orphanalive1"
cfg := orphanedConfig(t, volumeName, 0)
pid := cfg.MountedVolumes[0].PID
require.True(t, hostprocess.ProcessAlive(pid))
sealed := false
cfg.sealVolume = func(context.Context, string) error { sealed = true; return nil }
errNoRemount := errors.New("remount stops here")
var aliveAtRemount []bool
cfg.constructVB = func(context.Context, string) (*viperblock.VB, int, error) {
aliveAtRemount = append(aliveAtRemount, hostprocess.ProcessAlive(pid))
return nil, 0, errNoRemount
}
_, err := mountVolume(t.Context(), cfg, nil, volumeName, false)
require.ErrorIs(t, err, errNoRemount, "the remount must be reached once the old nbdkit is stopped")
assert.Equal(t, []bool{false}, aliveAtRemount, "the old nbdkit must be gone before the volume is opened again")
assert.True(t, sealed, "the old export's copy is sealed as a normal unmount would seal it")
}
// An nbdkit that will not stop is still a writer, so the publish is refused as
// retry-later and the entry kept for the next attempt to retry the stop.
func TestMountVolume_SocketGoneProcessUnstoppableIsUnavailable(t *testing.T) {
const volumeName = "vol-orphanstuck1"
cfg := orphanedConfig(t, volumeName, zombiePID(t))
cfg.exportStopWait = 50 * time.Millisecond
sealed := false
cfg.sealVolume = func(context.Context, string) error { sealed = true; return nil }
cfg.constructVB = func(context.Context, string) (*viperblock.VB, int, error) {
t.Error("a volume must not be opened again beside an nbdkit that would not stop")
return nil, 0, errors.New("unreachable")
}
_, err := mountVolume(t.Context(), cfg, nil, volumeName, false)
require.ErrorIs(t, err, ebsprovider.ErrUnavailable)
assert.Equal(t, ebsprovider.ErrorCodeUnavailable, publishError(ebsprovider.SchemaVersion, volumeName, err).Code)
_, mounted := findMountedVolume(cfg, volumeName)
assert.True(t, mounted, "the entry must stay while its nbdkit may still write")
assert.False(t, sealed, "nothing may seal underneath a writer that is still running")
}
// zombiePID returns an exited, unreaped child: kill(pid,0) still reaches it and
// no signal makes it go, so it stands in for an nbdkit that will not stop.
func zombiePID(t *testing.T) int {
t.Helper()
hostprocess.ShortenProcessExitPollForTest(t, 5*time.Millisecond)
cmd := exec.Command("sleep", "300")
require.NoError(t, cmd.Start())
require.NoError(t, cmd.Process.Kill())
var info unix.Siginfo
require.NoError(t, unix.Waitid(unix.P_PID, cmd.Process.Pid, &info, unix.WEXITED|unix.WNOWAIT, nil))
t.Cleanup(func() { _ = cmd.Wait() })
return cmd.Process.Pid
}
@@ -214,6 +214,10 @@ type Config struct {
fenceWatchEvery time.Duration
processAlive func(pid int) bool
// exportStopWait overrides both waits unmountVolume gives nbdkit to exit,
// pluginSealGrace and fenceKillTimeout. Zero means those.
exportStopWait time.Duration
// fenceNoticeEvery overrides the wait between fence notice attempts. Zero
// means defaultFenceNoticeRetry.
fenceNoticeEvery time.Duration
@@ -245,6 +249,20 @@ type Config struct {
mu sync.Mutex
}
func (cfg *Config) exportSealGrace() time.Duration {
if cfg.exportStopWait > 0 {
return cfg.exportStopWait
}
return pluginSealGrace
}
func (cfg *Config) exportKillWait() time.Duration {
if cfg.exportStopWait > 0 {
return cfg.exportStopWait
}
return fenceKillTimeout
}
// seal persists volumeName's block map to predastore, honouring a test's
// injected seal if there is one.
func (cfg *Config) seal(ctx context.Context, volumeName string) error {
@@ -63,6 +63,7 @@ func TestVolumeAbandon_DoesNotSeal(t *testing.T) {
sealed := false
cfg := fencedConfig(t, natsURL, "node-a", volumeName)
withLocalState(t, cfg, volumeName)
cfg.sealVolume = func(context.Context, string) error { sealed = true; return nil }
_, err := cfg.abandonVolume(t.Context(), volumeName, "the instance moved")
@@ -5,7 +5,9 @@ package viperblock
import (
"context"
"os"
"os/exec"
"path/filepath"
"sync/atomic"
"testing"
"time"
@@ -70,6 +72,15 @@ func fencedConfig(t *testing.T, natsURL, owner, volumeName string) *Config {
return cfg
}
// withLocalState gives cfg a BaseDir holding local state for volumeName, so a
// teardown that seals has a copy to seal and a test that no seal ran means it.
func withLocalState(t *testing.T, cfg *Config, volumeName string) {
t.Helper()
cfg.BaseDir = t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(cfg.BaseDir, volumeName), 0o750))
require.True(t, volumeNeedsSeal(volumeName, cfg.BaseDir), "a seal must be reachable for the test to mean anything")
}
// TestVolumeFence_TearsDownWhenAnotherNodeHoldsTheLease is the property the
// fence exists for. A node that has lost its lease is a second writer, and the
// only way to stop being one without a backend that can refuse is to give up
-14
View File
@@ -40,7 +40,6 @@ import (
clusterv1 "github.com/mulgadc/spinifex/contracts/cluster/v1"
"github.com/mulgadc/spinifex/contracts/ec2/v1"
operatorv1 "github.com/mulgadc/spinifex/contracts/operator/v1"
telemetryv1 "github.com/mulgadc/spinifex/contracts/telemetry/v1"
viperblocklegacyv1 "github.com/mulgadc/spinifex/contracts/viperblockd/legacy/v1"
"github.com/mulgadc/spinifex/spinifex/bootstrap/config"
"github.com/mulgadc/spinifex/spinifex/bootstrap/preflight"
@@ -170,7 +169,6 @@ type Daemon struct {
spotInstanceService *ec2spotinstance.SpotInstanceServiceImpl
vpcService *ec2vpc.VPCServiceImpl
sqsWorker *sqsWorkerEnsurer
insight *nodeInsight
eipService ec2eip.EIPService
elbv2Service *elbv2domain.ELBv2ServiceImpl
eksService *ekscluster.EKSServiceImpl
@@ -907,10 +905,6 @@ func (d *Daemon) subscribeAll() error {
if d.sqsWorker == nil {
d.sqsWorker = newSQSWorkerEnsurer(d.buildSQSWorkerLauncher)
}
if d.insight == nil {
d.insight = newNodeInsight(d.node)
d.insight.live = &nodeLiveSampler{insight: d.insight, nc: d.natsConn, subject: telemetryv1.NodeLiveSampleSubject(d.node)}
}
subs := []natsSub{
// ec2.RunInstances is handled by dynamic per-instance-type subscriptions
// managed by ResourceManager.initSubscriptions()
@@ -1050,7 +1044,6 @@ func (d *Daemon) subscribeAll() error {
{clusterv1.NodeHealthSubject(d.node), d.handleHealthCheck, ""},
{clusterv1.NodesDiscoverSubject, d.handleNodeDiscover, ""},
{clusterv1.NodeStatusSubject, d.handleNodeStatus, ""},
{clusterv1.NodeFactsSubject, d.handleNodeFacts, ""},
{clusterv1.NodeVMsSubject, d.handleNodeVMs, ""},
{operatorv1.StorageConfigSubject, d.handleStorageConfig, ""},
{"spinifex.image.promote", d.handleSpinifexPromoteImage, "spinifex-workers"},
@@ -1064,13 +1057,6 @@ func (d *Daemon) subscribeAll() error {
{"spinifex.cluster.shutdown.persist", d.handleShutdownPersist, ""},
{"spinifex.cluster.shutdown.infra", d.handleShutdownInfra, ""},
}
if clusterv1.ValidNodeID(d.node) {
subs = append(subs,
natsSub{clusterv1.NetTestServeSubject(d.node), d.handleNetTestServe, ""},
natsSub{clusterv1.NetTestRunSubject(d.node), d.handleNetTestRun, ""},
natsSub{telemetryv1.NodeLiveWatchSubject(d.node), d.handleNodeLiveWatch, ""},
)
}
// ELBv2 operations require a resolved gateway URL.
// Without a subscriber the gateway returns nats.ErrNoResponders → ServiceUnavailable.
-397
View File
@@ -1,397 +0,0 @@
package daemon
import (
"context"
"encoding/json"
"log/slog"
"net"
"os"
"path/filepath"
"slices"
"strconv"
"strings"
"sync"
"time"
clusterv1 "github.com/mulgadc/spinifex/contracts/cluster/v1"
telemetryv1 "github.com/mulgadc/spinifex/contracts/telemetry/v1"
"github.com/mulgadc/spinifex/spinifex/runtime/host/nettest"
"github.com/mulgadc/spinifex/spinifex/runtime/host/nodefacts"
"github.com/nats-io/nats.go"
)
// Build-time version and commit, set by the cmd package before the daemon starts.
var (
buildVersion = "dev"
buildCommit = "unknown"
)
// SetBuildInfo records the build-time version and commit the node reports.
func SetBuildInfo(version, commit string) {
buildVersion, buildCommit = version, commit
}
// nodeFactsTTL bounds how often a request rebuilds the facts; a re-cabled
// port shows within it.
const nodeFactsTTL = 15 * time.Second
// nodeInsight serves the operator's view of this host: its facts, a live
// sampler of its own load, and the two halves of a throughput test.
type nodeInsight struct {
node string
env nodefacts.Env
mu sync.Mutex
facts clusterv1.NodeFacts
factsAt time.Time
live *nodeLiveSampler
server nettest.Server
// testing serialises the client side, so two tests never share a link.
testing sync.Mutex
}
func newNodeInsight(node string) *nodeInsight {
return &nodeInsight{node: node, env: nodefacts.DefaultEnv(node, buildVersion, buildCommit)}
}
func (n *nodeInsight) currentFacts(ctx context.Context) clusterv1.NodeFacts {
n.mu.Lock()
defer n.mu.Unlock()
if time.Since(n.factsAt) < nodeFactsTTL {
return n.facts
}
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
n.facts = nodefacts.Build(ctx, n.env)
n.factsAt = time.Now()
return n.facts
}
func (n *nodeInsight) planeAddress(ctx context.Context, plane string) string {
addr, _ := n.planeSubnet(ctx, plane)
return addr
}
// planeSubnet is the node's address on plane and the subnet it sits in. A
// client may only be pointed inside that subnet, never at an arbitrary host.
func (n *nodeInsight) planeSubnet(ctx context.Context, plane string) (string, *net.IPNet) {
for _, p := range n.currentFacts(ctx).Planes {
if p.Plane != plane || p.Address == "" {
continue
}
_, subnet, err := net.ParseCIDR(p.Address + "/" + strconv.Itoa(p.Prefix))
if err != nil {
return p.Address, nil
}
return p.Address, subnet
}
return "", nil
}
func (n *nodeInsight) planeLink(ctx context.Context, plane string) (speed, mtu int) {
f := n.currentFacts(ctx)
for _, p := range f.Planes {
if p.Plane != plane {
continue
}
mtu = p.MTU
for _, nic := range f.NICs {
if slices.Contains(p.NICs, nic.Name) {
speed = max(speed, nic.SpeedMbps)
}
}
}
return speed, mtu
}
func (d *Daemon) handleNodeFacts(msg *nats.Msg) string {
respondWithJSON(d.node, msg, d.insight.currentFacts(context.Background()))
return outcomeSuccess
}
func validPlane(plane string) bool {
return plane == clusterv1.PlaneWAN || plane == clusterv1.PlaneLAN || plane == clusterv1.PlaneVPC
}
func (d *Daemon) handleNetTestServe(msg *nats.Msg) string {
var req clusterv1.NetTestServeRequest
if err := json.Unmarshal(msg.Data, &req); err != nil || !validPlane(req.Plane) ||
req.DurationSeconds < nettest.MinDuration || req.DurationSeconds > nettest.MaxDuration {
respondWithJSON(d.node, msg, clusterv1.NetTestServeResponse{Error: "invalid request"})
return outcomeError
}
addr := d.insight.planeAddress(context.Background(), req.Plane)
if addr == "" {
respondWithJSON(d.node, msg, clusterv1.NetTestServeResponse{Error: "this node has no address on the " + req.Plane + " plane"})
return outcomeError
}
if err := d.insight.server.Serve(addr, req.DurationSeconds); err != nil {
respondWithJSON(d.node, msg, clusterv1.NetTestServeResponse{Error: err.Error()})
return outcomeError
}
respondWithJSON(d.node, msg, clusterv1.NetTestServeResponse{Address: addr, Port: clusterv1.NetTestPort})
return outcomeSuccess
}
// handleNetTestRun runs the client in its own goroutine: it takes the test's
// duration, and the subscription must keep serving other requests meanwhile.
func (d *Daemon) handleNetTestRun(msg *nats.Msg) string {
var req clusterv1.NetTestRunRequest
if err := json.Unmarshal(msg.Data, &req); err != nil || !validPlane(req.Plane) ||
req.DurationSeconds < nettest.MinDuration || req.DurationSeconds > nettest.MaxDuration ||
req.Streams < 1 || req.Streams > nettest.MaxStreams || req.Port != clusterv1.NetTestPort {
respondWithJSON(d.node, msg, clusterv1.NetTestRunResponse{Error: "invalid request"})
return outcomeError
}
go func() {
ctx := context.Background()
addr, subnet := d.insight.planeSubnet(ctx, req.Plane)
if addr == "" {
respondWithJSON(d.node, msg, clusterv1.NetTestRunResponse{Error: "this node has no address on the " + req.Plane + " plane"})
return
}
if target := net.ParseIP(req.Address); subnet == nil || target == nil || !subnet.Contains(target) {
respondWithJSON(d.node, msg, clusterv1.NetTestRunResponse{Error: "target is outside this node's " + req.Plane + " subnet"})
return
}
if !d.insight.testing.TryLock() {
respondWithJSON(d.node, msg, clusterv1.NetTestRunResponse{Error: "a test is already running from this node"})
return
}
defer d.insight.testing.Unlock()
res, err := nettest.Run(ctx, req, addr)
res.LinkMbps, res.MTU = d.insight.planeLink(ctx, req.Plane)
if err != nil {
res.Error = err.Error()
slog.Info("node network test failed", "plane", req.Plane, "target", req.Address, "err", err)
}
respondWithJSON(d.node, msg, res)
}()
return outcomeSuccess
}
// nodeLiveSampler samples this host every period while somebody watches, as
// the instance sampler does, and drops its ring when the lease lapses.
type nodeLiveSampler struct {
insight *nodeInsight
nc *nats.Conn
subject string
mu sync.Mutex
until time.Time
running bool
ring []telemetryv1.NodeLiveSample
}
func (d *Daemon) handleNodeLiveWatch(msg *nats.Msg) string {
var watch telemetryv1.LiveWatch
if len(msg.Data) > 0 {
if err := json.Unmarshal(msg.Data, &watch); err != nil {
return outcomeError
}
}
samples := d.insight.live.watch(watch.SinceMillis)
if msg.Reply != "" {
data, err := json.Marshal(telemetryv1.NodeLiveRing{Samples: samples})
if err == nil {
_ = msg.Respond(data)
}
}
return outcomeSuccess
}
func (l *nodeLiveSampler) watch(sinceMillis int64) []telemetryv1.NodeLiveSample {
l.mu.Lock()
defer l.mu.Unlock()
l.until = time.Now().Add(telemetryv1.LiveWatchLeaseMillis * time.Millisecond)
if !l.running {
l.running = true
go l.run()
}
out := make([]telemetryv1.NodeLiveSample, 0, len(l.ring))
for _, s := range l.ring {
if s.TimeMillis > sinceMillis {
out = append(out, s)
}
}
return out
}
func (l *nodeLiveSampler) lapsed() bool {
l.mu.Lock()
defer l.mu.Unlock()
if time.Now().Before(l.until) {
return false
}
l.running = false
l.ring = nil
return true
}
func (l *nodeLiveSampler) record(s telemetryv1.NodeLiveSample) {
l.mu.Lock()
defer l.mu.Unlock()
if len(l.ring) == telemetryv1.LiveRingSamples {
l.ring = append(l.ring[:0], l.ring[1:]...)
}
l.ring = append(l.ring, s)
}
func (l *nodeLiveSampler) run() {
ticker := time.NewTicker(telemetryv1.LiveSamplePeriodMillis * time.Millisecond)
defer ticker.Stop()
var prev *hostCounters
for range ticker.C {
if l.lapsed() {
return
}
cur := readHostCounters(l.insight.env.ProcRoot, l.insight.env.SysRoot, planeNICs(l.insight.currentFacts(context.Background())))
if prev != nil {
if s, ok := hostRates(prev, cur); ok {
l.record(s)
if data, err := json.Marshal(s); err == nil {
_ = l.nc.Publish(l.subject, data)
}
}
}
prev = cur
}
}
// planeNICs keys each set of NICs by the planes it carries, so a NIC that
// carries lan and vpc is counted once, as "lan+vpc".
func planeNICs(f clusterv1.NodeFacts) map[string][]string {
byNICs := map[string][]string{}
nics := map[string][]string{}
for _, p := range f.Planes {
key := strings.Join(p.NICs, ",")
if key == "" {
key = "bridge:" + p.Bridge
}
byNICs[key] = append(byNICs[key], p.Plane)
if _, ok := nics[key]; !ok {
if len(p.NICs) == 0 {
nics[key] = []string{p.Bridge}
} else {
nics[key] = p.NICs
}
}
}
out := map[string][]string{}
for key, planes := range byNICs {
out[strings.Join(planes, "+")] = nics[key]
}
return out
}
type nicCounters struct{ rxBytes, txBytes, rxPackets, txPackets uint64 }
type hostCounters struct {
at time.Time
busy, idle uint64
memPercent float64
load1 float64
planes map[string]nicCounters
}
func readHostCounters(procRoot, sysRoot string, planes map[string][]string) *hostCounters {
c := &hostCounters{at: time.Now(), planes: map[string]nicCounters{}}
if data, err := os.ReadFile(filepath.Join(procRoot, "stat")); err == nil {
line, _, _ := strings.Cut(string(data), "\n")
fields := strings.Fields(line)
for i, f := range fields[1:] {
v, _ := strconv.ParseUint(f, 10, 64)
// user nice system idle iowait irq softirq steal; guest time is
// already counted in user.
switch {
case i == 3 || i == 4:
c.idle += v
case i < 8:
c.busy += v
}
}
}
if data, err := os.ReadFile(filepath.Join(procRoot, "meminfo")); err == nil {
var total, avail float64
for line := range strings.SplitSeq(string(data), "\n") {
key, value, _ := strings.Cut(line, ":")
fields := strings.Fields(value)
if len(fields) == 0 {
continue
}
v, _ := strconv.ParseFloat(fields[0], 64)
switch key {
case "MemTotal":
total = v
case "MemAvailable":
avail = v
}
}
if total > 0 {
c.memPercent = (total - avail) / total * 100
}
}
if data, err := os.ReadFile(filepath.Join(procRoot, "loadavg")); err == nil {
if fields := strings.Fields(string(data)); len(fields) > 0 {
c.load1, _ = strconv.ParseFloat(fields[0], 64)
}
}
for plane, nics := range planes {
var sum nicCounters
for _, nic := range nics {
read := func(name string) uint64 {
data, err := os.ReadFile(filepath.Join(sysRoot, "class", "net", nic, "statistics", name))
if err != nil {
return 0
}
v, _ := strconv.ParseUint(strings.TrimSpace(string(data)), 10, 64)
return v
}
sum.rxBytes += read("rx_bytes")
sum.txBytes += read("tx_bytes")
sum.rxPackets += read("rx_packets")
sum.txPackets += read("tx_packets")
}
c.planes[plane] = sum
}
return c
}
func hostRates(prev, cur *hostCounters) (telemetryv1.NodeLiveSample, bool) {
elapsed := cur.at.Sub(prev.at).Seconds()
if elapsed <= 0 || cur.busy < prev.busy || cur.idle < prev.idle {
return telemetryv1.NodeLiveSample{}, false
}
s := telemetryv1.NodeLiveSample{
TimeMillis: cur.at.UnixMilli(),
MemoryUsedPercent: round2(cur.memPercent),
Load1: cur.load1,
Planes: map[string]telemetryv1.NodeLivePlane{},
}
if total := float64((cur.busy - prev.busy) + (cur.idle - prev.idle)); total > 0 {
s.CPUPercent = round2(float64(cur.busy-prev.busy) / total * 100)
}
rate := func(a, b uint64) float64 {
if b < a {
return 0
}
return round2(float64(b-a) / elapsed)
}
for plane, c := range cur.planes {
p, ok := prev.planes[plane]
if !ok {
continue
}
s.Planes[plane] = telemetryv1.NodeLivePlane{
NetworkIn: rate(p.rxBytes, c.rxBytes), NetworkOut: rate(p.txBytes, c.txBytes),
PacketsIn: rate(p.rxPackets, c.rxPackets), PacketsOut: rate(p.txPackets, c.txPackets),
}
}
return s, true
}
func round2(v float64) float64 {
return float64(int64(v*100+0.5)) / 100
}
@@ -1,229 +0,0 @@
//test:in-package — the handlers, the plane lookups and the live sampler are
//unexported, and the fixture seeds the cached facts directly so no test reads
//this host's hardware.
package daemon
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
clusterv1 "github.com/mulgadc/spinifex/contracts/cluster/v1"
telemetryv1 "github.com/mulgadc/spinifex/contracts/telemetry/v1"
testutil "github.com/mulgadc/spinifex/internal/testkit"
"github.com/nats-io/nats.go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func insightFacts() clusterv1.NodeFacts {
return clusterv1.NodeFacts{
NICs: []clusterv1.NICFacts{
{Name: "eth0", SpeedMbps: 25000},
{Name: "eth1", SpeedMbps: 10000},
{Name: "eth2", SpeedMbps: 1000},
},
Planes: []clusterv1.PlaneFacts{
{Plane: clusterv1.PlaneLAN, Bridge: "br-lan", Address: "10.10.8.1", Prefix: 24, NICs: []string{"eth0", "eth1"}, MTU: 9000},
{Plane: clusterv1.PlaneVPC, Bridge: "br-lan", Address: "10.10.8.1", Prefix: 24, NICs: []string{"eth0", "eth1"}, MTU: 9000},
{Plane: clusterv1.PlaneWAN, Bridge: "br-wan", NICs: []string{"eth2"}},
},
}
}
func insightDaemon(t *testing.T, facts clusterv1.NodeFacts) (*Daemon, *nats.Conn) {
t.Helper()
_, nc, _ := testutil.StartTestJetStream(t)
insight := newNodeInsight("node1")
insight.facts, insight.factsAt = facts, time.Now()
return &Daemon{node: "node1", natsConn: nc, insight: insight}, nc
}
func callHandler[T any](t *testing.T, nc *nats.Conn, handler func(*nats.Msg) string, req any) T {
t.Helper()
subject := nats.NewInbox()
sub, err := nc.Subscribe(subject, func(msg *nats.Msg) { handler(msg) })
require.NoError(t, err)
t.Cleanup(func() { _ = sub.Unsubscribe() })
data, err := json.Marshal(req)
require.NoError(t, err)
reply, err := nc.Request(subject, data, 5*time.Second)
require.NoError(t, err)
var out T
require.NoError(t, json.Unmarshal(reply.Data, &out))
return out
}
func validRun() clusterv1.NetTestRunRequest {
return clusterv1.NetTestRunRequest{
Plane: clusterv1.PlaneLAN, Address: "10.10.8.2", Port: clusterv1.NetTestPort,
DurationSeconds: 5, Streams: 4,
}
}
func TestHandleNetTestRun_RefusesTargetOutsidePlaneSubnet(t *testing.T) {
d, nc := insightDaemon(t, insightFacts())
for _, target := range []string{"10.10.9.2", "8.8.8.8", "127.0.0.1", "not-an-ip", ""} {
req := validRun()
req.Address = target
res := callHandler[clusterv1.NetTestRunResponse](t, nc, d.handleNetTestRun, req)
assert.Contains(t, res.Error, "outside this node's lan subnet", "target %q", target)
}
}
func TestHandleNetTestRun_RefusesInvalidRequests(t *testing.T) {
d, nc := insightDaemon(t, insightFacts())
mutations := map[string]func(*clusterv1.NetTestRunRequest){
"unknown plane": func(r *clusterv1.NetTestRunRequest) { r.Plane = "storage" },
"short duration": func(r *clusterv1.NetTestRunRequest) { r.DurationSeconds = 1 },
"long duration": func(r *clusterv1.NetTestRunRequest) { r.DurationSeconds = 600 },
"no streams": func(r *clusterv1.NetTestRunRequest) { r.Streams = 0 },
"many streams": func(r *clusterv1.NetTestRunRequest) { r.Streams = 99 },
"other port": func(r *clusterv1.NetTestRunRequest) { r.Port = 22 },
}
for name, mutate := range mutations {
req := validRun()
mutate(&req)
res := callHandler[clusterv1.NetTestRunResponse](t, nc, d.handleNetTestRun, req)
assert.Equal(t, "invalid request", res.Error, name)
}
}
func TestHandleNetTestRun_RefusesPlaneWithoutAddress(t *testing.T) {
d, nc := insightDaemon(t, insightFacts())
req := validRun()
req.Plane = clusterv1.PlaneWAN
res := callHandler[clusterv1.NetTestRunResponse](t, nc, d.handleNetTestRun, req)
assert.Equal(t, "this node has no address on the wan plane", res.Error)
}
func TestHandleNetTestRun_OneTestPerNode(t *testing.T) {
d, nc := insightDaemon(t, insightFacts())
d.insight.testing.Lock()
defer d.insight.testing.Unlock()
res := callHandler[clusterv1.NetTestRunResponse](t, nc, d.handleNetTestRun, validRun())
assert.Equal(t, "a test is already running from this node", res.Error)
}
func TestHandleNetTestServe_RefusesBadRequests(t *testing.T) {
d, nc := insightDaemon(t, insightFacts())
res := callHandler[clusterv1.NetTestServeResponse](t, nc, d.handleNetTestServe,
clusterv1.NetTestServeRequest{Plane: clusterv1.PlaneLAN, DurationSeconds: 600})
assert.Equal(t, "invalid request", res.Error)
res = callHandler[clusterv1.NetTestServeResponse](t, nc, d.handleNetTestServe,
clusterv1.NetTestServeRequest{Plane: clusterv1.PlaneWAN, DurationSeconds: 5})
assert.Equal(t, "this node has no address on the wan plane", res.Error)
assert.Zero(t, res.Port)
}
func TestHandleNodeFacts_ServesCachedFacts(t *testing.T) {
d, nc := insightDaemon(t, insightFacts())
got := callHandler[clusterv1.NodeFacts](t, nc, d.handleNodeFacts, struct{}{})
assert.Equal(t, insightFacts().Planes, got.Planes)
}
func TestPlaneLink_FastestNICAndMTU(t *testing.T) {
d, _ := insightDaemon(t, insightFacts())
speed, mtu := d.insight.planeLink(t.Context(), clusterv1.PlaneLAN)
assert.Equal(t, 25000, speed)
assert.Equal(t, 9000, mtu)
speed, mtu = d.insight.planeLink(t.Context(), "storage")
assert.Zero(t, speed)
assert.Zero(t, mtu)
}
func TestPlaneNICs_SharedNICsCountedOnce(t *testing.T) {
got := planeNICs(insightFacts())
assert.Equal(t, map[string][]string{
"lan+vpc": {"eth0", "eth1"},
"wan": {"eth2"},
}, got)
got = planeNICs(clusterv1.NodeFacts{Planes: []clusterv1.PlaneFacts{{Plane: clusterv1.PlaneWAN, Bridge: "br-wan"}}})
assert.Equal(t, map[string][]string{"wan": {"br-wan"}}, got)
}
func writeHostTree(t *testing.T, root string, stat, rx string) {
t.Helper()
proc := filepath.Join(root, "proc")
stats := filepath.Join(root, "sys", "class", "net", "eth0", "statistics")
require.NoError(t, os.MkdirAll(proc, 0o755))
require.NoError(t, os.MkdirAll(stats, 0o755))
files := map[string]string{
filepath.Join(proc, "stat"): stat,
filepath.Join(proc, "meminfo"): "MemTotal: 1000 kB\nMemFree: 100 kB\nMemAvailable: 250 kB\n",
filepath.Join(proc, "loadavg"): "1.50 1.00 0.50 1/100 1\n",
filepath.Join(stats, "rx_bytes"): rx,
filepath.Join(stats, "tx_bytes"): "0\n",
filepath.Join(stats, "rx_packets"): "0\n",
filepath.Join(stats, "tx_packets"): "0\n",
}
for path, body := range files {
require.NoError(t, os.WriteFile(path, []byte(body), 0o600))
}
}
func TestHostRates_FromProcAndSysCounters(t *testing.T) {
root := t.TempDir()
planes := map[string][]string{"lan": {"eth0", "missing0"}}
writeHostTree(t, root, "cpu 100 0 100 700 100 0 0 0 0 0\ncpu0 1 1 1 1\n", "1000\n")
prev := readHostCounters(filepath.Join(root, "proc"), filepath.Join(root, "sys"), planes)
writeHostTree(t, root, "cpu 200 0 200 1300 200 0 0 0 0 0\n", "3000\n")
cur := readHostCounters(filepath.Join(root, "proc"), filepath.Join(root, "sys"), planes)
cur.at = prev.at.Add(2 * time.Second)
s, ok := hostRates(prev, cur)
require.True(t, ok)
assert.InDelta(t, 22.22, s.CPUPercent, 0.01)
assert.InDelta(t, 75.0, s.MemoryUsedPercent, 0.01)
assert.InDelta(t, 1.5, s.Load1, 0.001)
assert.InDelta(t, 1000.0, s.Planes["lan"].NetworkIn, 0.01)
_, ok = hostRates(cur, prev)
assert.False(t, ok, "counters going backwards are a reset, not a rate")
}
func TestNodeLiveSampler_RingAndLease(t *testing.T) {
l := &nodeLiveSampler{running: true}
for i := range telemetryv1.LiveRingSamples + 5 {
l.record(telemetryv1.NodeLiveSample{TimeMillis: int64(i)})
}
require.Len(t, l.ring, telemetryv1.LiveRingSamples)
assert.Equal(t, int64(5), l.ring[0].TimeMillis)
l.until = time.Now().Add(time.Minute)
got := l.watch(int64(telemetryv1.LiveRingSamples + 2))
assert.Len(t, got, 2)
assert.False(t, l.lapsed())
l.until = time.Now().Add(-time.Second)
assert.True(t, l.lapsed())
assert.Empty(t, l.ring)
assert.False(t, l.running)
}
func TestHandleNodeLiveWatch_ReturnsRing(t *testing.T) {
d, nc := insightDaemon(t, insightFacts())
d.insight.live = &nodeLiveSampler{insight: d.insight, nc: nc, subject: nats.NewInbox(), running: true}
d.insight.live.record(telemetryv1.NodeLiveSample{TimeMillis: 10})
d.insight.live.record(telemetryv1.NodeLiveSample{TimeMillis: 20})
ring := callHandler[telemetryv1.NodeLiveRing](t, nc, d.handleNodeLiveWatch, telemetryv1.LiveWatch{SinceMillis: 10})
require.Len(t, ring.Samples, 1)
assert.Equal(t, int64(20), ring.Samples[0].TimeMillis)
}
-236
View File
@@ -1,236 +0,0 @@
// Package nettest runs one direction of a node-to-node throughput test with
// iperf2, bound to one plane's address on each end.
package nettest
import (
"context"
"errors"
"fmt"
"net"
"os"
"os/exec"
"strconv"
"strings"
"sync"
"syscall"
"time"
clusterv1 "github.com/mulgadc/spinifex/contracts/cluster/v1"
)
// Limits on one direction of a test. A request outside them is refused.
const (
MinDuration = 5
MaxDuration = 30
MaxStreams = 16
)
const (
serverGrace = 15 * time.Second
readyTimeout = 3 * time.Second
pingCount = 10
)
var (
iperfPaths = []string{"/usr/bin/iperf", "/usr/local/bin/iperf"}
setprivPaths = []string{"/usr/bin/setpriv", "/bin/setpriv"}
pingPaths = []string{"/usr/bin/ping", "/bin/ping"}
)
func findTool(paths []string) string {
for _, p := range paths {
if _, err := os.Stat(p); err == nil {
return p
}
}
return ""
}
// ErrNoIperf is returned on a node without iperf2 installed.
var ErrNoIperf = errors.New("iperf (version 2) is not installed on this node")
// unprivileged wraps a command so it runs with no capabilities. The daemon's
// ambient capabilities would otherwise pass to a network-facing listener.
func unprivileged(ctx context.Context, name string, args ...string) (*exec.Cmd, error) {
setpriv := findTool(setprivPaths)
if setpriv == "" {
return nil, errors.New("setpriv is not installed, refusing to run a listener with the daemon's capabilities")
}
full := append([]string{"--inh-caps=-all", "--ambient-caps=-all", "--no-new-privs", name}, args...)
cmd := exec.CommandContext(ctx, setpriv, full...)
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
cmd.Cancel = func() error { return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) }
return cmd, nil
}
// Server is one listener; only one runs on a node at a time.
type Server struct {
mu sync.Mutex
stop context.CancelFunc
}
// Serve starts a one-shot iperf server on bindIP that exits after the test
// window, and returns once it accepts connections.
func (s *Server) Serve(bindIP string, durationSeconds int) error {
iperf := findTool(iperfPaths)
if iperf == "" {
return ErrNoIperf
}
if net.ParseIP(bindIP) == nil {
return fmt.Errorf("invalid bind address %q", bindIP)
}
window := time.Duration(durationSeconds)*time.Second + serverGrace
s.mu.Lock()
defer s.mu.Unlock()
if s.stop != nil {
s.stop()
s.stop = nil
}
ctx, cancel := context.WithTimeout(context.Background(), window)
cmd, err := unprivileged(ctx, iperf, "-s", "-B", bindIP, "-p", strconv.Itoa(clusterv1.NetTestPort),
"-t", strconv.Itoa(int(window.Seconds())))
if err != nil {
cancel()
return err
}
if err := cmd.Start(); err != nil {
cancel()
return fmt.Errorf("start iperf server: %w", err)
}
s.stop = cancel
go func() {
_ = cmd.Wait()
cancel()
}()
addr := net.JoinHostPort(bindIP, strconv.Itoa(clusterv1.NetTestPort))
deadline := time.Now().Add(readyTimeout)
for time.Now().Before(deadline) {
conn, err := net.DialTimeout("tcp", addr, 200*time.Millisecond)
if err == nil {
_ = conn.Close()
return nil
}
select {
case <-ctx.Done():
return errors.New("iperf server exited before it was ready")
case <-time.After(100 * time.Millisecond):
}
}
return errors.New("iperf server did not start listening")
}
// Stop ends a running listener, if any.
func (s *Server) Stop() {
s.mu.Lock()
defer s.mu.Unlock()
if s.stop != nil {
s.stop()
s.stop = nil
}
}
// Run is the client side: it sends to address from bindIP for the duration
// and measures the round trip with ping beforehand, so the two never overlap.
func Run(ctx context.Context, req clusterv1.NetTestRunRequest, bindIP string) (clusterv1.NetTestRunResponse, error) {
out := clusterv1.NetTestRunResponse{SourceAddress: bindIP, Streams: req.Streams, Tool: "iperf2"}
iperf := findTool(iperfPaths)
if iperf == "" {
return out, ErrNoIperf
}
if net.ParseIP(req.Address) == nil || net.ParseIP(bindIP) == nil {
return out, errors.New("invalid address")
}
if ping := findTool(pingPaths); ping != "" {
pctx, cancel := context.WithTimeout(ctx, 5*time.Second)
cmd, err := unprivileged(pctx, ping, "-n", "-q", "-c", strconv.Itoa(pingCount), "-i", "0.2", "-W", "1", "-I", bindIP, req.Address)
if err == nil {
if data, err := cmd.Output(); err == nil || len(data) > 0 {
out.RTTMinMillis, out.RTTAvgMillis, out.RTTMaxMillis, out.PacketLoss = parsePing(string(data))
}
}
cancel()
}
cctx, cancel := context.WithTimeout(ctx, time.Duration(req.DurationSeconds)*time.Second+serverGrace)
defer cancel()
cmd, err := unprivileged(cctx, iperf, "-c", req.Address, "-B", bindIP, "-p", strconv.Itoa(req.Port),
"-t", strconv.Itoa(req.DurationSeconds), "-P", strconv.Itoa(req.Streams), "-y", "C")
if err != nil {
return out, err
}
data, err := cmd.Output()
if err != nil && len(data) == 0 {
var exit *exec.ExitError
if errors.As(err, &exit) && len(exit.Stderr) > 0 {
return out, fmt.Errorf("iperf: %s", strings.TrimSpace(string(exit.Stderr)))
}
return out, fmt.Errorf("iperf: %w", err)
}
if err := parseIperfCSV(string(data), &out); err != nil {
return out, err
}
return out, nil
}
// parseIperfCSV reads iperf2's `-y C` report. With more than one stream the
// line whose ID is -1 is the sum; with one, the only line is.
func parseIperfCSV(text string, out *clusterv1.NetTestRunResponse) error {
var sum, single []string
streams := 0
for line := range strings.SplitSeq(strings.TrimSpace(text), "\n") {
f := strings.Split(strings.TrimSpace(line), ",")
if len(f) < 9 {
continue
}
if f[5] == "-1" {
sum = f
continue
}
streams++
single = f
if bps, err := strconv.ParseFloat(f[8], 64); err == nil {
out.StreamBPS = append(out.StreamBPS, bps)
}
}
row := sum
if row == nil {
row = single
}
if row == nil {
return errors.New("iperf printed no report")
}
out.Streams = streams
out.Bytes, _ = strconv.ParseInt(row[7], 10, 64)
out.BitsPerSecond, _ = strconv.ParseFloat(row[8], 64)
if start, end, ok := strings.Cut(row[6], "-"); ok {
s, _ := strconv.ParseFloat(start, 64)
e, _ := strconv.ParseFloat(end, 64)
out.Seconds = e - s
}
return nil
}
// parsePing reads "x% packet loss" and "rtt min/avg/max/mdev = a/b/c/d ms".
func parsePing(text string) (minMs, avgMs, maxMs, loss float64) {
for line := range strings.SplitSeq(text, "\n") {
if i := strings.Index(line, "% packet loss"); i > 0 {
fields := strings.Fields(line[:i])
if len(fields) > 0 {
loss, _ = strconv.ParseFloat(fields[len(fields)-1], 64)
}
}
if _, values, ok := strings.Cut(line, "= "); ok && strings.Contains(line, "min/avg/max") {
parts := strings.Split(strings.Fields(values)[0], "/")
if len(parts) >= 3 {
minMs, _ = strconv.ParseFloat(parts[0], 64)
avgMs, _ = strconv.ParseFloat(parts[1], 64)
maxMs, _ = strconv.ParseFloat(parts[2], 64)
}
}
}
return minMs, avgMs, maxMs, loss
}
@@ -1,51 +0,0 @@
package nettest
import (
"testing"
clusterv1 "github.com/mulgadc/spinifex/contracts/cluster/v1"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Captured from iperf 2.2.1 with -P 3 -y C.
const threeStreams = `20261102023725,127.0.0.1,36493,127.0.0.1,5099,1,0.0-2.0,1544421440,6124843671
20261102023725,127.0.0.1,40019,127.0.0.1,5099,3,0.0-2.0,1603665984,6361182295
20261102023725,127.0.0.1,35431,127.0.0.1,5099,2,0.0-2.0,1424490560,5647792895
20261102023725,127.0.0.1,0,127.0.0.1,5099,-1,0.0-2.0,4572577984,18116331744
`
func TestIperfCSVTakesTheSumLine(t *testing.T) {
var out clusterv1.NetTestRunResponse
require.NoError(t, parseIperfCSV(threeStreams, &out))
assert.InDelta(t, 18116331744, out.BitsPerSecond, 1)
assert.Equal(t, int64(4572577984), out.Bytes)
assert.InDelta(t, 2.0, out.Seconds, 0.001)
assert.Equal(t, 3, out.Streams)
assert.Len(t, out.StreamBPS, 3)
}
func TestIperfCSVWithOneStreamHasNoSumLine(t *testing.T) {
var out clusterv1.NetTestRunResponse
require.NoError(t, parseIperfCSV("20261102023725,10.0.0.1,36493,10.0.0.2,5001,3,0.0-10.0,1180000000,943718400\n", &out))
assert.InDelta(t, 943718400, out.BitsPerSecond, 1)
assert.Equal(t, 1, out.Streams)
}
func TestIperfCSVRefusesAnEmptyReport(t *testing.T) {
var out clusterv1.NetTestRunResponse
assert.Error(t, parseIperfCSV("connect failed: Connection refused\n", &out))
}
func TestPingSummary(t *testing.T) {
minMs, avgMs, maxMs, loss := parsePing(`--- 10.2.0.3 ping statistics ---
10 packets transmitted, 9 received, 10% packet loss, time 1803ms
rtt min/avg/max/mdev = 0.041/0.063/0.120/0.021 ms
`)
assert.InDelta(t, 0.041, minMs, 1e-9)
assert.InDelta(t, 0.063, avgMs, 1e-9)
assert.InDelta(t, 0.120, maxMs, 1e-9)
assert.InDelta(t, 10, loss, 1e-9)
}
-401
View File
@@ -1,401 +0,0 @@
package nodefacts
import (
"bufio"
"bytes"
"cmp"
"context"
"net"
"os"
"path/filepath"
"runtime"
"slices"
"strconv"
"strings"
clusterv1 "github.com/mulgadc/spinifex/contracts/cluster/v1"
)
// Env is where Build reads from. Tests point the roots at fixtures.
type Env struct {
Dir string
SysRoot string
ProcRoot string
EtcRoot string
Node string
Version string
Commit string
// LiveLLDP reads neighbours now, so a re-cabled port shows without a
// restart. Nil or an error falls back to what Collect saved.
LiveLLDP func(context.Context) ([]byte, error)
// Addrs returns an interface's IPv4 addresses with their prefix lengths.
Addrs func(iface string) []net.IPNet
}
// DefaultEnv reads the live host.
func DefaultEnv(node, version, commit string) Env {
return Env{
Dir: DefaultDir, SysRoot: "/sys", ProcRoot: "/proc", EtcRoot: "/etc",
Node: node, Version: version, Commit: commit,
LiveLLDP: liveLLDP, Addrs: interfaceAddrs,
}
}
func liveLLDP(ctx context.Context) ([]byte, error) {
ctl := findTool(networkctlPaths)
if ctl == "" {
return nil, errNoFile
}
return run(ctx, ctl, "lldp", "--json=short")
}
func interfaceAddrs(name string) []net.IPNet {
iface, err := net.InterfaceByName(name)
if err != nil {
return nil
}
addrs, err := iface.Addrs()
if err != nil {
return nil
}
var out []net.IPNet
for _, a := range addrs {
if ipn, ok := a.(*net.IPNet); ok && ipn.IP.To4() != nil {
out = append(out, *ipn)
}
}
return out
}
// Source states reported in NodeFacts.Sources.
const (
sourceOK = "ok"
sourceUnavailable = "unavailable"
)
// Build assembles the facts from the collected probes and live sysfs.
func Build(ctx context.Context, env Env) clusterv1.NodeFacts {
f := clusterv1.NodeFacts{Node: env.Node, Sources: map[string]string{}}
if data, err := readFacts(env.Dir, fileCollected); err == nil {
f.CollectedAt = strings.TrimSpace(string(data))
}
if data, err := readFacts(env.Dir, fileIdentity); err == nil {
f.System = parseIdentity(string(data))
f.Sources["dmi"] = sourceOK
} else {
f.Sources["dmi"] = sourceUnavailable
}
if data, err := os.ReadFile(filepath.Join(env.ProcRoot, "device-tree", "model")); err == nil {
f.System.DeviceModel = strings.TrimRight(string(data), "\x00\n ")
}
if data, err := readFacts(env.Dir, fileVirt); err == nil {
f.Virt = strings.TrimSpace(string(data))
}
f.FormFactor = formFactor(f.Virt, f.System)
if data, err := readFacts(env.Dir, fileProcessor); err == nil {
f.CPU = parseProcessors(string(data))
}
addCPUInfo(&f.CPU, env.ProcRoot)
if data, err := readFacts(env.Dir, fileMemory); err == nil {
f.Memory = parseMemory(string(data))
}
addMeminfo(&f.Memory, env.ProcRoot)
f.OS = osFacts(env)
neighbours, lldpState := lldpNeighbours(ctx, env)
f.Sources["lldp"] = lldpState
f.NICs = nicFacts(env, neighbours)
f.Planes = planeFacts(env, f.NICs)
for i := range f.NICs {
for _, p := range f.Planes {
if slices.Contains(p.NICs, f.NICs[i].Name) {
f.NICs[i].Planes = append(f.NICs[i].Planes, p.Plane)
}
}
}
return f
}
// formFactor decides the picture the console draws. A hypervisor wins over
// whatever chassis the virtual firmware claims.
func formFactor(virt string, s clusterv1.SystemFacts) string {
model := strings.ToLower(s.DeviceModel)
switch {
case virt != "" && virt != "none":
return "virtual-machine"
case strings.Contains(model, "raspberry pi"):
return "sbc"
case strings.Contains(model, "jetson"):
return "jetson"
}
switch strings.ToLower(s.ChassisType) {
case "rack mount chassis", "blade", "blade enclosure", "multi-system", "main server chassis":
return "rack-server"
case "tower", "mini tower", "desktop", "low profile desktop", "space-saving", "all in one":
return "tower"
case "mini pc", "compact pci", "embedded pc", "sub chassis", "stick pc":
return "sbc"
}
if model != "" {
return "sbc"
}
return "unknown"
}
// addCPUInfo fills what DMI cannot tell a VM, and the features worth naming.
func addCPUInfo(c *clusterv1.CPUFacts, procRoot string) {
data, err := os.ReadFile(filepath.Join(procRoot, "cpuinfo"))
if err != nil {
return
}
var (
model string
flags []string
threads int
socket string
sockets = map[string]bool{}
cores = map[string]bool{}
)
sc := bufio.NewScanner(bytes.NewReader(data))
sc.Buffer(make([]byte, 64*1024), 1024*1024)
for sc.Scan() {
key, value, ok := strings.Cut(sc.Text(), ":")
if !ok {
continue
}
key, value = strings.TrimSpace(key), strings.TrimSpace(value)
switch key {
case "processor":
threads++
case "model name":
model = value
case "physical id":
socket = value
sockets[value] = true
case "core id":
cores[socket+"/"+value] = true
case "flags", "Features": // ARM names the same list Features
if flags == nil {
flags = strings.Fields(value)
}
}
}
if model != "" {
c.Model = strings.Join(strings.Fields(model), " ")
}
if c.Threads == 0 {
c.Threads = threads
}
if c.Sockets == 0 {
c.Sockets = max(len(sockets), 1)
}
if c.Cores == 0 {
c.Cores = cmp.Or(len(cores), threads)
}
switch {
case slices.Contains(flags, "svm"):
c.Virtualization = "AMD-V"
case slices.Contains(flags, "vmx"):
c.Virtualization = "VT-x"
}
c.Flags = flags
for _, want := range []string{"avx512f", "avx2", "aes", "sev", "sme"} {
if slices.Contains(flags, want) {
c.Features = append(c.Features, want)
}
}
if runtime.GOARCH == "arm64" && c.Model == "" {
c.Model = "ARM64"
}
}
func addMeminfo(m *clusterv1.MemoryFacts, procRoot string) {
data, err := os.ReadFile(filepath.Join(procRoot, "meminfo"))
if err != nil {
return
}
for line := range strings.SplitSeq(string(data), "\n") {
key, value, ok := strings.Cut(line, ":")
if !ok {
continue
}
kb, err := strconv.ParseUint(strings.Fields(value + " 0")[0], 10, 64)
if err != nil {
continue
}
switch key {
case "MemTotal":
m.OSTotalBytes = kb << 10
case "MemAvailable":
m.OSAvailBytes = kb << 10
case "Hugetlb":
m.HugePageBytes = kb << 10
}
}
}
func osFacts(env Env) clusterv1.OSFacts {
o := clusterv1.OSFacts{Arch: runtime.GOARCH, Version: env.Version, Commit: env.Commit}
if data, err := os.ReadFile(filepath.Join(env.EtcRoot, "os-release")); err == nil {
for line := range strings.SplitSeq(string(data), "\n") {
if v, ok := strings.CutPrefix(line, "PRETTY_NAME="); ok {
o.Name = strings.Trim(v, `"`)
}
}
}
if data, err := os.ReadFile(filepath.Join(env.ProcRoot, "sys", "kernel", "osrelease")); err == nil {
o.Kernel = strings.TrimSpace(string(data))
}
if data, err := os.ReadFile(filepath.Join(env.ProcRoot, "uptime")); err == nil {
if fields := strings.Fields(string(data)); len(fields) > 0 {
secs, _ := strconv.ParseFloat(fields[0], 64)
o.UptimeSeconds = int64(secs)
}
}
if data, err := os.ReadFile(filepath.Join(env.ProcRoot, "sys", "kernel", "hostname")); err == nil {
o.Hostname = strings.TrimSpace(string(data))
}
return o
}
// lldpNeighbours prefers a live read and falls back to the start-up probe.
func lldpNeighbours(ctx context.Context, env Env) (map[string]clusterv1.LLDPNeighbor, string) {
if env.LiveLLDP != nil {
if data, err := env.LiveLLDP(ctx); err == nil {
if n, err := parseLLDP(data); err == nil {
return n, sourceOK
}
}
}
data, err := readFacts(env.Dir, fileLLDP)
if err != nil {
return nil, sourceUnavailable
}
n, err := parseLLDP(data)
if err != nil {
return nil, sourceUnavailable
}
return n, sourceOK
}
func readSys(sysRoot, nic, name string) string {
data, err := os.ReadFile(filepath.Join(sysRoot, "class", "net", nic, name))
if err != nil {
return ""
}
return strings.TrimSpace(string(data))
}
func nicFacts(env Env, neighbours map[string]clusterv1.LLDPNeighbor) []clusterv1.NICFacts {
var out []clusterv1.NICFacts
for _, name := range physicalNICs(env.SysRoot) {
n := clusterv1.NICFacts{
Name: name,
MAC: readSys(env.SysRoot, name, "address"),
State: readSys(env.SysRoot, name, "operstate"),
}
n.MTU, _ = strconv.Atoi(readSys(env.SysRoot, name, "mtu"))
if speed, err := strconv.Atoi(readSys(env.SysRoot, name, "speed")); err == nil && speed > 0 {
n.SpeedMbps = speed
}
if target, err := os.Readlink(filepath.Join(env.SysRoot, "class", "net", name, "master")); err == nil {
n.Master = filepath.Base(target)
}
if data, err := readFacts(env.Dir, nicPrefix+name+".txt"); err == nil {
e := parseEthtool(string(data))
n.Driver, n.Firmware, n.BusInfo, n.Duplex, n.Port = e.driver, e.firmware, e.busInfo, e.duplex, e.port
n.MaxMbps = e.maxMbps
if n.SpeedMbps == 0 {
n.SpeedMbps = e.speedMbps
}
n.Model = parseUdevModel(string(data))
if br := ovsBridge(string(data)); br != "" && (n.Master == "" || n.Master == "ovs-system") {
n.Master = br
}
}
if n.Driver == "" {
if target, err := os.Readlink(filepath.Join(env.SysRoot, "class", "net", name, "device", "driver")); err == nil {
n.Driver = filepath.Base(target)
}
}
if nb, ok := neighbours[name]; ok {
n.Neighbor = &nb
}
out = append(out, n)
}
return out
}
func ovsBridge(text string) string {
for line := range strings.SplitSeq(text, "\n") {
if v, ok := strings.CutPrefix(strings.TrimSpace(line), "OVS_BRIDGE="); ok {
return v
}
}
return ""
}
// planeFacts finds each plane's bridge and address. A host without br-vpc
// carries the overlay on whichever bridge holds the encap address.
func planeFacts(env Env, nics []clusterv1.NICFacts) []clusterv1.PlaneFacts {
addrs := env.Addrs
if addrs == nil {
addrs = func(string) []net.IPNet { return nil }
}
var encap string
if data, err := readFacts(env.Dir, fileEncapIP); err == nil {
encap = strings.TrimSpace(string(data))
}
bridges := map[string]string{
clusterv1.PlaneWAN: "br-wan",
clusterv1.PlaneLAN: "br-lan",
clusterv1.PlaneVPC: "br-vpc",
}
if encap != "" {
if br := bridgeHolding(encap, addrs); br != "" {
bridges[clusterv1.PlaneVPC] = br
}
}
var out []clusterv1.PlaneFacts
for _, plane := range []string{clusterv1.PlaneWAN, clusterv1.PlaneLAN, clusterv1.PlaneVPC} {
br := bridges[plane]
if _, err := os.Stat(filepath.Join(env.SysRoot, "class", "net", br)); err != nil {
continue
}
p := clusterv1.PlaneFacts{Plane: plane, Bridge: br}
p.MTU, _ = strconv.Atoi(readSys(env.SysRoot, br, "mtu"))
list := addrs(br)
for _, a := range list {
if plane == clusterv1.PlaneVPC && encap != "" && a.IP.String() != encap {
continue
}
ones, _ := a.Mask.Size()
p.Address, p.Prefix = a.IP.String(), ones
break
}
for _, n := range nics {
if n.Master == br {
p.NICs = append(p.NICs, n.Name)
}
}
out = append(out, p)
}
return out
}
func bridgeHolding(ip string, addrs func(string) []net.IPNet) string {
for _, br := range []string{"br-vpc", "br-lan", "br-wan"} {
for _, a := range addrs(br) {
if a.IP.String() == ip {
return br
}
}
}
return ""
}
-208
View File
@@ -1,208 +0,0 @@
// Package nodefacts describes a node's hardware, OS and cabling for the
// operator console. The root-only probes run once at daemon start through
// Collect; Build reads their output and adds what sysfs reports live.
package nodefacts
import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"os/exec"
"os/user"
"path/filepath"
"strconv"
"strings"
"time"
)
// DefaultDir is where Collect writes and Build reads.
const DefaultDir = "/etc/spinifex/node-facts"
const probeTimeout = 10 * time.Second
// Raw probe files under the facts directory.
const (
fileIdentity = "dmi-identity.txt"
fileProcessor = "dmi-processor.txt"
fileMemory = "dmi-memory.txt"
fileVirt = "virt.txt"
fileLLDP = "lldp.json"
fileEncapIP = "ovn-encap-ip.txt"
fileCollected = "collected-at.txt"
nicPrefix = "nic-"
)
// Tools are looked up by absolute path: /usr/sbin is not on a service's PATH.
var (
dmidecodePaths = []string{"/usr/sbin/dmidecode", "/sbin/dmidecode"}
ethtoolPaths = []string{"/usr/sbin/ethtool", "/sbin/ethtool"}
networkctlPaths = []string{"/usr/bin/networkctl", "/bin/networkctl"}
ovsVsctlPaths = []string{"/usr/bin/ovs-vsctl", "/usr/sbin/ovs-vsctl"}
udevadmPaths = []string{"/usr/bin/udevadm", "/bin/udevadm"}
detectVirtPaths = []string{"/usr/bin/systemd-detect-virt", "/bin/systemd-detect-virt"}
)
func findTool(paths []string) string {
for _, p := range paths {
if info, err := os.Stat(p); err == nil && info.Mode().IsRegular() {
return p
}
}
return ""
}
// Collect runs the probes that need root and writes their raw output under
// dir, readable by the spinifex group. A probe that fails leaves no file, so
// Build reports that source as unavailable rather than stopping the daemon.
func Collect(ctx context.Context, dir, sysRoot string) error {
if err := os.MkdirAll(dir, 0o750); err != nil {
return fmt.Errorf("create %s: %w", dir, err)
}
gid := spinifexGID()
if gid >= 0 {
_ = os.Chown(dir, 0, gid)
}
write := func(name string, data []byte) {
if err := writeFile(dir, name, data, gid); err != nil {
slog.Warn("node-facts: write failed", "file", name, "err", err)
}
}
if dmi := findTool(dmidecodePaths); dmi != "" {
for name, args := range map[string][]string{
fileIdentity: {"-t", "bios", "-t", "system", "-t", "chassis", "-t", "baseboard"},
fileProcessor: {"-t", "processor"},
fileMemory: {"-t", "memory"},
} {
if out, err := run(ctx, dmi, args...); err == nil {
write(name, out)
} else {
slog.Info("node-facts: dmidecode unavailable", "type", name, "err", err)
}
}
}
if virt := findTool(detectVirtPaths); virt != "" {
// Exits 1 on bare metal while printing "none", so the output counts.
out, _ := run(ctx, virt)
write(fileVirt, out)
}
if ctl := findTool(networkctlPaths); ctl != "" {
if out, err := run(ctx, ctl, "lldp", "--json=short"); err == nil {
write(fileLLDP, out)
}
}
if vsctl := findTool(ovsVsctlPaths); vsctl != "" {
if out, err := run(ctx, vsctl, "get", "Open_vSwitch", ".", "external_ids:ovn-encap-ip"); err == nil {
write(fileEncapIP, []byte(strings.Trim(strings.TrimSpace(string(out)), `"`)))
}
}
ethtool, udevadm, vsctl := findTool(ethtoolPaths), findTool(udevadmPaths), findTool(ovsVsctlPaths)
for _, nic := range physicalNICs(sysRoot) {
var b strings.Builder
if ethtool != "" {
out, _ := run(ctx, ethtool, nic)
b.Write(out)
out, _ = run(ctx, ethtool, "-i", nic)
b.Write(out)
}
if udevadm != "" {
out, _ := run(ctx, udevadm, "info", "--query=property", "--path=/sys/class/net/"+nic)
b.Write(out)
}
if vsctl != "" {
if out, err := run(ctx, vsctl, "iface-to-br", nic); err == nil {
fmt.Fprintf(&b, "OVS_BRIDGE=%s\n", strings.TrimSpace(string(out)))
}
}
write(nicPrefix+nic+".txt", []byte(b.String()))
}
write(fileCollected, []byte(time.Now().UTC().Format(time.RFC3339)))
return nil
}
func run(ctx context.Context, name string, args ...string) ([]byte, error) {
ctx, cancel := context.WithTimeout(ctx, probeTimeout)
defer cancel()
out, err := exec.CommandContext(ctx, name, args...).Output()
if err != nil && len(out) == 0 {
return nil, err
}
return out, nil
}
// writeFile replaces dir/name atomically. The files carry serial numbers, so
// they are root-owned and readable only by the spinifex group.
func writeFile(dir, name string, data []byte, gid int) error {
tmp, err := os.CreateTemp(dir, "."+name+".*")
if err != nil {
return err
}
defer func() { _ = os.Remove(tmp.Name()) }()
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Chmod(0o640); err != nil {
_ = tmp.Close()
return err
}
if gid >= 0 {
_ = tmp.Chown(0, gid)
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmp.Name(), filepath.Join(dir, name))
}
func spinifexGID() int {
g, err := user.LookupGroup("spinifex")
if err != nil {
return -1
}
gid, err := strconv.Atoi(g.Gid)
if err != nil {
return -1
}
return gid
}
// physicalNICs lists interfaces backed by a device, which leaves out bridges,
// taps, veths and VLANs.
func physicalNICs(sysRoot string) []string {
entries, err := os.ReadDir(filepath.Join(sysRoot, "class", "net"))
if err != nil {
return nil
}
var out []string
for _, e := range entries {
base := filepath.Join(sysRoot, "class", "net", e.Name())
if _, err := os.Stat(filepath.Join(base, "device")); err != nil {
continue
}
// A VF or an SR-IOV representor still has a device; its phys_port_name
// marks it, and it is not cabled to anything.
if name, err := os.ReadFile(filepath.Join(base, "phys_port_name")); err == nil && strings.HasPrefix(strings.TrimSpace(string(name)), "pf") {
continue
}
out = append(out, e.Name())
}
return out
}
var errNoFile = errors.New("not collected")
func readFacts(dir, name string) ([]byte, error) {
data, err := os.ReadFile(filepath.Join(dir, name))
if errors.Is(err, os.ErrNotExist) {
return nil, errNoFile
}
return data, err
}
-233
View File
@@ -1,233 +0,0 @@
package nodefacts
import (
"slices"
"strconv"
"strings"
clusterv1 "github.com/mulgadc/spinifex/contracts/cluster/v1"
)
// dmiBlock is one structure from dmidecode's text output: its title line and
// its "Key: value" fields. Indented list items are folded under their key.
type dmiBlock struct {
title string
fields map[string]string
lists map[string][]string
}
func (b dmiBlock) get(key string) string {
return cleanDMI(b.fields[key])
}
// parseDMI splits dmidecode output into its structures, in order.
func parseDMI(text string) []dmiBlock {
var (
blocks []dmiBlock
cur *dmiBlock
listKey string
)
flush := func() {
if cur != nil {
blocks = append(blocks, *cur)
cur = nil
}
}
for line := range strings.SplitSeq(text, "\n") {
line = strings.TrimRight(line, " \r")
switch {
case line == "" || strings.HasPrefix(line, "Handle ") || strings.HasPrefix(line, "#"):
flush()
listKey = ""
case !strings.HasPrefix(line, "\t"):
if strings.HasPrefix(line, "SMBIOS ") || strings.HasPrefix(line, "Getting SMBIOS") {
continue
}
flush()
cur = &dmiBlock{title: line, fields: map[string]string{}, lists: map[string][]string{}}
listKey = ""
case cur == nil:
case strings.HasPrefix(line, "\t\t"):
if listKey != "" {
cur.lists[listKey] = append(cur.lists[listKey], strings.TrimSpace(line))
}
default:
key, value, _ := strings.Cut(strings.TrimSpace(line), ":")
key, value = strings.TrimSpace(key), strings.TrimSpace(value)
cur.fields[key] = value
listKey = ""
if value == "" {
listKey = key
}
}
}
flush()
return blocks
}
func blocksTitled(blocks []dmiBlock, title string) []dmiBlock {
var out []dmiBlock
for _, b := range blocks {
if b.title == title {
out = append(out, b)
}
}
return out
}
// placeholders are the values firmware writes when a field was never filled.
var placeholders = map[string]bool{
"": true, "unknown": true, "not specified": true, "not present": true, "none": true,
"to be filled by o.e.m.": true, "default string": true, "system serial number": true,
"system product name": true, "system manufacturer": true, "o.e.m.": true, "oem": true,
"0123456789": true, "123456789": true, "no asset tag": true, "chassis serial number": true,
"base board serial number": true, "not settable": true, "unknown unknown": true,
"03000200-0400-0500-0006-000700080009": true,
}
// cleanDMI trims a DMI value and drops the placeholders firmware ships with.
func cleanDMI(v string) string {
v = strings.TrimSpace(v)
lower := strings.ToLower(v)
if placeholders[lower] || strings.HasSuffix(lower, "_assettag") || strings.Trim(lower, "0") == "" {
return ""
}
return v
}
// parseIdentity reads the bios, system, chassis and baseboard structures.
func parseIdentity(text string) clusterv1.SystemFacts {
blocks := parseDMI(text)
var s clusterv1.SystemFacts
if b := blocksTitled(blocks, "BIOS Information"); len(b) > 0 {
s.BIOSVendor, s.BIOSVersion, s.BIOSDate = b[0].get("Vendor"), b[0].get("Version"), b[0].get("Release Date")
}
if b := blocksTitled(blocks, "System Information"); len(b) > 0 {
s.Vendor, s.Product, s.Family = b[0].get("Manufacturer"), b[0].get("Product Name"), b[0].get("Family")
s.Version, s.Serial, s.UUID = b[0].get("Version"), b[0].get("Serial Number"), b[0].get("UUID")
}
if b := blocksTitled(blocks, "Chassis Information"); len(b) > 0 {
s.ChassisType, s.ChassisAsset = b[0].get("Type"), b[0].get("Asset Tag")
}
if b := blocksTitled(blocks, "Base Board Information"); len(b) > 0 {
s.BoardVendor, s.BoardProduct, s.BoardSerial = b[0].get("Manufacturer"), b[0].get("Product Name"), b[0].get("Serial Number")
}
return s
}
// parseProcessors summarises the populated sockets.
func parseProcessors(text string) clusterv1.CPUFacts {
var c clusterv1.CPUFacts
for _, b := range blocksTitled(parseDMI(text), "Processor Information") {
if !strings.HasPrefix(b.fields["Status"], "Populated") {
continue
}
c.Sockets++
if c.Model == "" {
c.Model = strings.Join(strings.Fields(b.get("Version")), " ")
c.Vendor = b.get("Manufacturer")
}
cores, _ := strconv.Atoi(b.fields["Core Count"])
threads, _ := strconv.Atoi(b.fields["Thread Count"])
c.Cores += cores
c.Threads += threads
c.CoresPerSocket = max(c.CoresPerSocket, cores)
c.MaxMHz = max(c.MaxMHz, atoiPrefix(b.fields["Max Speed"]))
c.CurrentMHz = max(c.CurrentMHz, atoiPrefix(b.fields["Current Speed"]))
}
return c
}
// memoryKey is everything a DIMM is grouped by.
type memoryKey struct {
size uint64
typ, detail, form, maker, part, rank string
speed, configured int
}
// parseMemory groups the populated DIMMs and counts the slots of every array.
func parseMemory(text string) clusterv1.MemoryFacts {
var m clusterv1.MemoryFacts
blocks := parseDMI(text)
for _, b := range blocksTitled(blocks, "Physical Memory Array") {
if b.fields["Use"] != "System Memory" {
continue
}
if m.MaxCapacity == "" {
m.MaxCapacity = b.get("Maximum Capacity")
}
if m.ECC == "" {
m.ECC = b.get("Error Correction Type")
}
}
groups := map[memoryKey]*clusterv1.MemoryGroup{}
var order []memoryKey
for _, b := range blocksTitled(blocks, "Memory Device") {
m.SlotsTotal++
size := parseSize(b.fields["Size"])
if size == 0 {
continue
}
m.SlotsUsed++
m.TotalBytes += size
k := memoryKey{
size: size, typ: b.get("Type"), detail: b.get("Type Detail"), form: b.get("Form Factor"),
maker: b.get("Manufacturer"), part: b.get("Part Number"), rank: b.get("Rank"),
speed: atoiPrefix(b.fields["Speed"]), configured: atoiPrefix(b.fields["Configured Memory Speed"]),
}
g, ok := groups[k]
if !ok {
g = &clusterv1.MemoryGroup{
SizeBytes: size, Type: k.typ, TypeDetail: k.detail, FormFactor: k.form,
SpeedMTs: k.speed, ConfiguredMTs: k.configured, Manufacturer: k.maker,
PartNumber: k.part, Rank: k.rank,
}
groups[k] = g
order = append(order, k)
}
g.Count++
if serial := b.get("Serial Number"); serial != "" {
g.Serials = append(g.Serials, serial)
}
}
for _, k := range order {
m.Groups = append(m.Groups, *groups[k])
}
slices.SortStableFunc(m.Groups, func(a, b clusterv1.MemoryGroup) int { return b.Count - a.Count })
return m
}
// parseSize reads a DMI size such as "16 GB" or "4096 MB"; anything else,
// including "No Module Installed", is zero.
func parseSize(v string) uint64 {
fields := strings.Fields(v)
if len(fields) != 2 {
return 0
}
n, err := strconv.ParseUint(fields[0], 10, 64)
if err != nil {
return 0
}
switch fields[1] {
case "kB", "KB":
return n << 10
case "MB":
return n << 20
case "GB":
return n << 30
case "TB":
return n << 40
}
return 0
}
// atoiPrefix reads the leading integer of a value such as "3675 MHz".
func atoiPrefix(v string) int {
fields := strings.Fields(v)
if len(fields) == 0 {
return 0
}
n, _ := strconv.Atoi(fields[0])
return n
}
-132
View File
@@ -1,132 +0,0 @@
package nodefacts
import (
"encoding/json"
"regexp"
"strconv"
"strings"
clusterv1 "github.com/mulgadc/spinifex/contracts/cluster/v1"
)
// ethtoolFacts is what `ethtool <if>` and `ethtool -i <if>` add to sysfs.
type ethtoolFacts struct {
speedMbps int
maxMbps int
duplex string
port string
driver string
firmware string
busInfo string
}
var linkModeSpeed = regexp.MustCompile(`\b(\d+)base`)
// parseEthtool reads both outputs, concatenated in either order.
func parseEthtool(text string) ethtoolFacts {
var e ethtoolFacts
inSupported := false
for line := range strings.SplitSeq(text, "\n") {
trimmed := strings.TrimSpace(line)
key, value, hasColon := strings.Cut(trimmed, ":")
value = strings.TrimSpace(value)
if hasColon && !strings.Contains(key, "base") {
inSupported = key == "Supported link modes"
}
if inSupported {
for _, m := range linkModeSpeed.FindAllStringSubmatch(trimmed, -1) {
if n, err := strconv.Atoi(m[1]); err == nil {
e.maxMbps = max(e.maxMbps, n)
}
}
}
if !hasColon {
continue
}
switch key {
case "Speed":
e.speedMbps = atoiPrefix(strings.TrimSuffix(value, "Mb/s"))
case "Duplex":
if value != "Unknown! (255)" {
e.duplex = value
}
case "Port":
if value != "Other" {
e.port = value
}
case "driver":
e.driver = value
case "firmware-version":
e.firmware = value
case "bus-info":
e.busInfo = value
}
}
return e
}
// parseUdevModel reads the hwdb vendor and model from `udevadm info` output.
func parseUdevModel(text string) string {
var vendor, model string
for line := range strings.SplitSeq(text, "\n") {
key, value, _ := strings.Cut(strings.TrimSpace(line), "=")
switch key {
case "ID_VENDOR_FROM_DATABASE":
vendor = value
case "ID_MODEL_FROM_DATABASE":
model = value
}
}
vendor = strings.TrimSuffix(strings.TrimSuffix(vendor, " Corporation"), " Technologies")
switch {
case model == "":
return vendor
case vendor == "":
return model
}
return vendor + " " + model
}
// lldpCapabilityNames are the IEEE 802.1AB system capability bits.
var lldpCapabilityNames = []string{"other", "repeater", "bridge", "wlan-ap", "router", "telephone", "docsis", "station"}
type networkctlLLDP struct {
Neighbors []struct {
InterfaceName string `json:"InterfaceName"`
Neighbors []struct {
ChassisID string `json:"ChassisID"`
PortID string `json:"PortID"`
PortDescription string `json:"PortDescription"`
SystemName string `json:"SystemName"`
SystemDescription string `json:"SystemDescription"`
EnabledCapabilities int `json:"EnabledCapabilities"`
} `json:"Neighbors"`
} `json:"Neighbors"`
}
// parseLLDP reads `networkctl lldp --json=short` into the first neighbour of
// each interface. A NIC sees one switch port, so a second is a hub or a loop.
func parseLLDP(data []byte) (map[string]clusterv1.LLDPNeighbor, error) {
var doc networkctlLLDP
if err := json.Unmarshal(data, &doc); err != nil {
return nil, err
}
out := map[string]clusterv1.LLDPNeighbor{}
for _, iface := range doc.Neighbors {
if len(iface.Neighbors) == 0 {
continue
}
n := iface.Neighbors[0]
var caps []string
for i, name := range lldpCapabilityNames {
if n.EnabledCapabilities&(1<<i) != 0 {
caps = append(caps, name)
}
}
out[iface.InterfaceName] = clusterv1.LLDPNeighbor{
ChassisID: n.ChassisID, SystemName: n.SystemName, SystemDescription: n.SystemDescription,
PortID: n.PortID, PortDescription: n.PortDescription, Capabilities: caps,
}
}
return out, nil
}
@@ -1,280 +0,0 @@
package nodefacts
import (
"context"
"errors"
"net"
"os"
"path/filepath"
"testing"
clusterv1 "github.com/mulgadc/spinifex/contracts/cluster/v1"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// The fixtures are dmidecode, ethtool and networkctl output captured from
// prod node1, dev-prod bottlebrush and a QEMU dev box, serials replaced.
func fixture(t *testing.T, host, name string) string {
t.Helper()
data, err := os.ReadFile(filepath.Join("testdata", host, name))
require.NoError(t, err)
return string(data)
}
func TestMemoryGroupsIdenticalDIMMsAndCountsEmptySlots(t *testing.T) {
m := parseMemory(fixture(t, "bottlebrush", "dmi-memory.txt"))
assert.Equal(t, 32, m.SlotsTotal)
assert.Equal(t, 16, m.SlotsUsed)
assert.Equal(t, uint64(256)<<30, m.TotalBytes)
assert.Equal(t, "8 TB", m.MaxCapacity)
assert.Equal(t, "Multi-bit ECC", m.ECC)
require.Len(t, m.Groups, 1)
g := m.Groups[0]
assert.Equal(t, 16, g.Count)
assert.Equal(t, uint64(16)<<30, g.SizeBytes)
assert.Equal(t, "DDR4", g.Type)
assert.Equal(t, 2400, g.SpeedMTs)
assert.Equal(t, 2400, g.ConfiguredMTs)
assert.Equal(t, "SK Hynix", g.Manufacturer)
assert.Equal(t, "HMA82GR7AFR8N-UH", g.PartNumber)
assert.Len(t, g.Serials, 16)
}
func TestMemorySplitsDIMMsThatDifferInAnyShownField(t *testing.T) {
dimm := func(size, speed, part string) string {
return "Memory Device\n\tSize: " + size + "\n\tType: DDR5\n\tSpeed: " + speed +
"\n\tManufacturer: Samsung\n\tPart Number: " + part + "\n\tConfigured Memory Speed: 4800 MT/s\n\n"
}
m := parseMemory(dimm("32 GB", "5600 MT/s", "A") + dimm("32 GB", "5600 MT/s", "A") +
dimm("64 GB", "5600 MT/s", "B") + "Memory Device\n\tSize: No Module Installed\n\n")
assert.Equal(t, 4, m.SlotsTotal)
assert.Equal(t, 3, m.SlotsUsed)
require.Len(t, m.Groups, 2)
assert.Equal(t, 2, m.Groups[0].Count, "largest group first")
assert.Equal(t, 5600, m.Groups[0].SpeedMTs)
assert.Equal(t, 4800, m.Groups[0].ConfiguredMTs, "running below rated speed stays visible")
assert.Equal(t, uint64(64)<<30, m.Groups[1].SizeBytes)
}
func TestProcessorsSummariseEverySocket(t *testing.T) {
c := parseProcessors(fixture(t, "node1", "dmi-processor.txt"))
assert.Equal(t, "AMD EPYC 7513 32-Core Processor", c.Model)
assert.Equal(t, 2, c.Sockets)
assert.Equal(t, 32, c.CoresPerSocket)
assert.Equal(t, 64, c.Cores)
assert.Equal(t, 128, c.Threads)
assert.Equal(t, 3675, c.MaxMHz)
}
func TestCPUInfoKeepsEveryFlagAndNamesTheNotableOnes(t *testing.T) {
proc := t.TempDir()
cpuinfo := "processor\t: 0\nmodel name\t: AMD EPYC 7713\nphysical id\t: 0\ncore id\t\t: 0\nflags\t\t: fpu sse2 svm avx2 aes sev\n\n" +
"processor\t: 1\nphysical id\t: 0\ncore id\t\t: 0\nflags\t\t: fpu sse2 svm avx2 aes sev\n"
require.NoError(t, os.WriteFile(filepath.Join(proc, "cpuinfo"), []byte(cpuinfo), 0o644))
var c clusterv1.CPUFacts
addCPUInfo(&c, proc)
assert.Equal(t, []string{"fpu", "sse2", "svm", "avx2", "aes", "sev"}, c.Flags)
assert.Equal(t, []string{"avx2", "aes", "sev"}, c.Features)
assert.Equal(t, "AMD-V", c.Virtualization)
assert.Equal(t, 2, c.Threads)
assert.Equal(t, 1, c.Cores, "two threads of one core")
}
func TestCPUInfoReadsARMFeaturesAndCountsCoresWithoutCoreIDs(t *testing.T) {
proc := t.TempDir()
cpuinfo := "processor\t: 0\nFeatures\t: fp asimd aes pmull sha2\n\n" +
"processor\t: 1\nFeatures\t: fp asimd aes pmull sha2\n"
require.NoError(t, os.WriteFile(filepath.Join(proc, "cpuinfo"), []byte(cpuinfo), 0o644))
var c clusterv1.CPUFacts
addCPUInfo(&c, proc)
assert.Equal(t, []string{"fp", "asimd", "aes", "pmull", "sha2"}, c.Flags)
assert.Equal(t, []string{"aes"}, c.Features)
assert.Equal(t, 2, c.Cores, "with no core ids every thread is a core")
}
func TestIdentityDropsFirmwarePlaceholders(t *testing.T) {
s := parseIdentity(fixture(t, "node1", "dmi-identity.txt"))
assert.Equal(t, "GIGABYTE", s.Vendor)
assert.Equal(t, "H262-Z62-ZB", s.Product)
assert.Equal(t, "Rack Mount Chassis", s.ChassisType)
assert.Equal(t, "M30", s.BIOSVersion)
assert.Equal(t, "06/23/2026", s.BIOSDate)
vm := parseIdentity(fixture(t, "devbox", "dmi-identity.txt"))
assert.Equal(t, "SeaBIOS", vm.BIOSVendor)
assert.Equal(t, "QEMU", vm.Vendor)
assert.Empty(t, vm.Serial, "QEMU leaves the serial unset")
assert.Empty(t, cleanDMI("To Be Filled By O.E.M."))
assert.Empty(t, cleanDMI("DIMM_P0_A0_AssetTag"))
assert.Empty(t, cleanDMI("00000000"))
}
func TestFormFactor(t *testing.T) {
cases := []struct {
name string
virt string
sys clusterv1.SystemFacts
want string
}{
{"hypervisor wins over the chassis it fakes", "kvm", clusterv1.SystemFacts{ChassisType: "Rack Mount Chassis"}, "virtual-machine"},
{"rack server", "none", clusterv1.SystemFacts{ChassisType: "Rack Mount Chassis"}, "rack-server"},
{"tower", "none", clusterv1.SystemFacts{ChassisType: "Tower"}, "tower"},
{"raspberry pi from the device tree", "none", clusterv1.SystemFacts{DeviceModel: "Raspberry Pi 5 Model B Rev 1.0"}, "sbc"},
{"jetson from the device tree", "none", clusterv1.SystemFacts{DeviceModel: "NVIDIA Jetson AGX Orin Developer Kit"}, "jetson"},
{"nothing to go on", "", clusterv1.SystemFacts{}, "unknown"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
assert.Equal(t, c.want, formFactor(c.virt, c.sys))
})
}
}
func TestEthtoolReadsLinkAndDriver(t *testing.T) {
text := fixture(t, "bottlebrush", "nic-enp193s0f1np1.txt")
e := parseEthtool(text)
assert.Equal(t, 25000, e.speedMbps)
assert.Equal(t, 25000, e.maxMbps)
assert.Equal(t, "Full", e.duplex)
assert.Equal(t, "Direct Attach Copper", e.port)
assert.Equal(t, "mlx5_core", e.driver)
assert.Equal(t, "0000:c1:00.1", e.busInfo)
assert.Contains(t, parseUdevModel(text), "BlueField-2")
copper := parseEthtool(fixture(t, "bottlebrush", "nic-eno1.txt"))
assert.Equal(t, 1000, copper.maxMbps)
assert.Equal(t, "Twisted Pair", copper.port)
assert.Equal(t, "igb", copper.driver)
}
func TestLLDPNamesTheSwitchPortPerNIC(t *testing.T) {
n, err := parseLLDP([]byte(fixture(t, "node1", "lldp.json")))
require.NoError(t, err)
require.Contains(t, n, "eno1")
assert.Equal(t, "MikroTik 1gbe", n["eno1"].SystemName)
assert.Equal(t, "ether18", n["eno1"].PortID)
assert.Equal(t, []string{"bridge", "router"}, n["eno1"].Capabilities)
assert.Equal(t, n["enp33s0f0np0"].ChassisID, n["enp33s0f1np1"].ChassisID, "both 100G ports land on one switch")
assert.Equal(t, "qsfp28-7-1", n["enp33s0f0np0"].PortID)
}
// fakeHost lays out a sysfs tree with two NICs under bridges, as dev-prod has:
// no br-vpc, so the overlay rides br-lan.
func fakeHost(t *testing.T) Env {
t.Helper()
root := t.TempDir()
sys := filepath.Join(root, "sys")
nic := func(name, master, speed string) {
dir := filepath.Join(sys, "class", "net", name)
require.NoError(t, os.MkdirAll(filepath.Join(dir, "device"), 0o755))
for file, value := range map[string]string{"speed": speed, "operstate": "up", "mtu": "9000", "address": "02:00:00:00:00:01"} {
require.NoError(t, os.WriteFile(filepath.Join(dir, file), []byte(value+"\n"), 0o644))
}
require.NoError(t, os.Symlink("../"+master, filepath.Join(dir, "master")))
}
bridge := func(name string) {
dir := filepath.Join(sys, "class", "net", name)
require.NoError(t, os.MkdirAll(dir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, "mtu"), []byte("9000\n"), 0o644))
}
bridge("br-wan")
bridge("br-lan")
nic("eno1", "br-wan", "1000")
nic("enp193s0f1np1", "br-lan", "25000")
dir := filepath.Join(root, "facts")
require.NoError(t, os.MkdirAll(dir, 0o755))
for name, src := range map[string]string{
fileIdentity: "dmi-identity.txt", fileMemory: "dmi-memory.txt", fileProcessor: "dmi-processor.txt",
"nic-enp193s0f1np1.txt": "nic-enp193s0f1np1.txt", "nic-eno1.txt": "nic-eno1.txt",
} {
require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(fixture(t, "bottlebrush", src)), 0o644))
}
require.NoError(t, os.WriteFile(filepath.Join(dir, fileVirt), []byte("none\n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dir, fileEncapIP), []byte("10.10.8.4"), 0o644))
addrs := map[string][]net.IPNet{
"br-wan": {{IP: net.IPv4(192, 168, 1, 14), Mask: net.CIDRMask(23, 32)}},
"br-lan": {{IP: net.IPv4(10, 10, 8, 4), Mask: net.CIDRMask(24, 32)}},
}
return Env{
Dir: dir, SysRoot: sys, ProcRoot: filepath.Join(root, "proc"), EtcRoot: filepath.Join(root, "etc"),
Node: "bottlebrush",
LiveLLDP: func(context.Context) ([]byte, error) {
return nil, errors.New("systemd-networkd is not running")
},
Addrs: func(name string) []net.IPNet { return addrs[name] },
}
}
func TestBuildMapsPlanesToNICsWhenTheOverlayRidesTheLANBridge(t *testing.T) {
f := Build(context.Background(), fakeHost(t))
assert.Equal(t, "rack-server", f.FormFactor)
assert.Equal(t, "R182-Z92-00", f.System.Product)
assert.Equal(t, sourceUnavailable, f.Sources["lldp"], "no networkd and nothing collected")
planes := map[string]clusterv1.PlaneFacts{}
for _, p := range f.Planes {
planes[p.Plane] = p
}
assert.Equal(t, "192.168.1.14", planes["wan"].Address)
assert.Equal(t, []string{"eno1"}, planes["wan"].NICs)
assert.Equal(t, "br-lan", planes["vpc"].Bridge, "encap address is on br-lan")
assert.Equal(t, "10.10.8.4", planes["vpc"].Address)
assert.Equal(t, []string{"enp193s0f1np1"}, planes["lan"].NICs)
nics := map[string]clusterv1.NICFacts{}
for _, n := range f.NICs {
nics[n.Name] = n
}
assert.Equal(t, []string{"lan", "vpc"}, nics["enp193s0f1np1"].Planes)
assert.Equal(t, 25000, nics["enp193s0f1np1"].SpeedMbps)
assert.Equal(t, "mlx5_core", nics["enp193s0f1np1"].Driver)
assert.Nil(t, nics["eno1"].Neighbor)
}
func TestBuildFallsBackToTheCollectedLLDP(t *testing.T) {
env := fakeHost(t)
require.NoError(t, os.WriteFile(filepath.Join(env.Dir, fileLLDP),
[]byte(`{"Neighbors":[{"InterfaceName":"eno1","Neighbors":[{"ChassisID":"aa:bb","PortID":"ether3","SystemName":"core","EnabledCapabilities":4}]}]}`), 0o644))
f := Build(context.Background(), env)
assert.Equal(t, sourceOK, f.Sources["lldp"])
for _, n := range f.NICs {
if n.Name == "eno1" {
require.NotNil(t, n.Neighbor)
assert.Equal(t, "ether3", n.Neighbor.PortID)
}
}
}
func TestWriteFile_ReplacesAtomicallyGroupReadOnly(t *testing.T) {
dir := t.TempDir()
require.NoError(t, writeFile(dir, fileIdentity, []byte("first"), -1))
require.NoError(t, writeFile(dir, fileIdentity, []byte("second"), -1))
data, err := readFacts(dir, fileIdentity)
require.NoError(t, err)
assert.Equal(t, "second", string(data))
info, err := os.Stat(filepath.Join(dir, fileIdentity))
require.NoError(t, err)
assert.Equal(t, os.FileMode(0o640), info.Mode().Perm(), "the facts carry serial numbers")
entries, err := os.ReadDir(dir)
require.NoError(t, err)
assert.Len(t, entries, 1, "no temporary file is left behind")
}
@@ -1,186 +0,0 @@
# dmidecode 3.6
Getting SMBIOS data from sysfs.
SMBIOS 3.2.0 present.
Handle 0x0000, DMI type 0, 26 bytes
BIOS Information
Vendor: GIGABYTE
Version: R23
Release Date: 07/10/2021
Address: 0xF0000
Runtime Size: 64 kB
ROM Size: 16 MB
Characteristics:
PCI is supported
BIOS is upgradeable
BIOS shadowing is allowed
Boot from CD is supported
Selectable boot is supported
BIOS ROM is socketed
EDD is supported
5.25"/1.2 MB floppy services are supported (int 13h)
3.5"/720 kB floppy services are supported (int 13h)
3.5"/2.88 MB floppy services are supported (int 13h)
Print screen service is supported (int 5h)
Serial services are supported (int 14h)
Printer services are supported (int 17h)
ACPI is supported
USB legacy is supported
BIOS boot specification is supported
Targeted content distribution is supported
UEFI is supported
BIOS Revision: 5.14
Handle 0x0001, DMI type 1, 27 bytes
System Information
Manufacturer: GIGABYTE
Product Name: R182-Z92-00
Version: 0100
Serial Number: TEST-0001
UUID: TEST-0002
Wake-up Type: Power Switch
SKU Number: 01234567890123456789AB
Family: Server
Handle 0x0002, DMI type 2, 15 bytes
Base Board Information
Manufacturer: GIGABYTE
Product Name: MZ92-FS0-00
Version: 01010101
Serial Number: TEST-0003
Asset Tag: TEST-0004
Features:
Board is a hosting board
Board is removable
Board is replaceable
Location In Chassis: 01234567890123456789AB
Chassis Handle: 0x0003
Type: Motherboard
Contained Object Handles: 0
Handle 0x0003, DMI type 3, 22 bytes
Chassis Information
Manufacturer: GIGABYTE
Type: Rack Mount Chassis
Lock: Not Present
Version: 01234567
Serial Number: TEST-0005
Asset Tag: TEST-0006
Boot-up State: Safe
Power Supply State: Safe
Thermal State: Safe
Security Status: None
OEM Information: 0x00000000
Height: Unspecified
Number Of Power Cords: 1
Contained Elements: 0
SKU Number: 01234567890123456789AB
Handle 0x0030, DMI type 10, 10 bytes
On Board Device 1 Information
Type: Video
Status: Enabled
Description: AST2500
On Board Device 2 Information
Type: Ethernet
Status: Enabled
Description: Intel i350 Lan1
On Board Device 3 Information
Type: Ethernet
Status: Enabled
Description: Intel i350 Lan2
Handle 0x0031, DMI type 41, 11 bytes
Onboard Device
Reference Designation: Onboard VGA
Type: Video
Status: Enabled
Type Instance: 1
Bus Address: 0000:66:00.0
Handle 0x0032, DMI type 41, 11 bytes
Onboard Device
Reference Designation: Onboard LAN1
Type: Ethernet
Status: Enabled
Type Instance: 1
Bus Address: 0000:64:00.0
Handle 0x0033, DMI type 41, 11 bytes
Onboard Device
Reference Designation: Onboard LAN2
Type: Ethernet
Status: Enabled
Type Instance: 2
Bus Address: 0000:64:00.1
Handle 0x0043, DMI type 15, 73 bytes
System Event Log
Area Length: 0 bytes
Header Start Offset: 0x0000
Header Length: 16 bytes
Data Start Offset: 0x0010
Access Method: Memory-mapped physical 32-bit address
Access Address: 0xFF4FB000
Status: Valid, Not Full
Change Token: 0x00000053
Header Format: Type 1
Supported Log Type Descriptors: 25
Descriptor 1: Single-bit ECC memory error
Data Format 1: Multiple-event handle
Descriptor 2: Multi-bit ECC memory error
Data Format 2: Multiple-event handle
Descriptor 3: Parity memory error
Data Format 3: None
Descriptor 4: Bus timeout
Data Format 4: None
Descriptor 5: I/O channel block
Data Format 5: None
Descriptor 6: Software NMI
Data Format 6: None
Descriptor 7: POST memory resize
Data Format 7: None
Descriptor 8: POST error
Data Format 8: POST results bitmap
Descriptor 9: PCI parity error
Data Format 9: Multiple-event handle
Descriptor 10: PCI system error
Data Format 10: Multiple-event handle
Descriptor 11: CPU failure
Data Format 11: None
Descriptor 12: EISA failsafe timer timeout
Data Format 12: None
Descriptor 13: Correctable memory log disabled
Data Format 13: None
Descriptor 14: Logging disabled
Data Format 14: None
Descriptor 15: System limit exceeded
Data Format 15: None
Descriptor 16: Asynchronous hardware timer expired
Data Format 16: None
Descriptor 17: System configuration information
Data Format 17: None
Descriptor 18: Hard disk information
Data Format 18: None
Descriptor 19: System reconfigured
Data Format 19: None
Descriptor 20: Uncorrectable CPU-complex error
Data Format 20: None
Descriptor 21: Log area reset/cleared
Data Format 21: None
Descriptor 22: System boot
Data Format 22: None
Descriptor 23: End of log
Data Format 23: None
Descriptor 24: OEM-specific
Data Format 24: OEM-specific
Descriptor 25: OEM-specific
Data Format 25: OEM-specific
Handle 0x00B5, DMI type 13, 22 bytes
BIOS Language Information
Language Description Format: Long
Installable Languages: 1
en|US|iso8859-1
Currently Installed Language: en|US|iso8859-1
@@ -1,797 +0,0 @@
# dmidecode 3.6
Getting SMBIOS data from sysfs.
SMBIOS 3.2.0 present.
Handle 0x0045, DMI type 16, 23 bytes
Physical Memory Array
Location: System Board Or Motherboard
Use: System Memory
Error Correction Type: Multi-bit ECC
Maximum Capacity: 8 TB
Error Information Handle: 0x0044
Number Of Devices: 32
Handle 0x004D, DMI type 17, 84 bytes
Memory Device
Array Handle: 0x0045
Error Information Handle: 0x004C
Total Width: Unknown
Data Width: Unknown
Size: No Module Installed
Form Factor: Unknown
Set: None
Locator: DIMM_P0_A0
Bank Locator: BANK 0
Type: Unknown
Type Detail: Unknown
Handle 0x004F, DMI type 17, 84 bytes
Memory Device
Array Handle: 0x0045
Error Information Handle: 0x004E
Total Width: 72 bits
Data Width: 64 bits
Size: 16 GB
Form Factor: DIMM
Set: None
Locator: DIMM_P0_A1
Bank Locator: BANK 1
Type: DDR4
Type Detail: Synchronous Registered (Buffered)
Speed: 2400 MT/s
Manufacturer: SK Hynix
Serial Number: TEST-0001
Asset Tag: TEST-0002
Part Number: HMA82GR7AFR8N-UH
Rank: 2
Configured Memory Speed: 2400 MT/s
Minimum Voltage: 1.2 V
Maximum Voltage: 1.2 V
Configured Voltage: 1.2 V
Memory Technology: DRAM
Memory Operating Mode Capability: Volatile memory
Firmware Version: Unknown
Module Manufacturer ID: Bank 1, Hex 0xAD
Module Product ID: Unknown
Memory Subsystem Controller Manufacturer ID: Unknown
Memory Subsystem Controller Product ID: Unknown
Non-Volatile Size: None
Volatile Size: 16 GB
Cache Size: None
Logical Size: None
Handle 0x0052, DMI type 17, 84 bytes
Memory Device
Array Handle: 0x0045
Error Information Handle: 0x0051
Total Width: Unknown
Data Width: Unknown
Size: No Module Installed
Form Factor: Unknown
Set: None
Locator: DIMM_P0_B0
Bank Locator: BANK 0
Type: Unknown
Type Detail: Unknown
Handle 0x0054, DMI type 17, 84 bytes
Memory Device
Array Handle: 0x0045
Error Information Handle: 0x0053
Total Width: 72 bits
Data Width: 64 bits
Size: 16 GB
Form Factor: DIMM
Set: None
Locator: DIMM_P0_B1
Bank Locator: BANK 1
Type: DDR4
Type Detail: Synchronous Registered (Buffered)
Speed: 2400 MT/s
Manufacturer: SK Hynix
Serial Number: TEST-0003
Asset Tag: TEST-0004
Part Number: HMA82GR7AFR8N-UH
Rank: 2
Configured Memory Speed: 2400 MT/s
Minimum Voltage: 1.2 V
Maximum Voltage: 1.2 V
Configured Voltage: 1.2 V
Memory Technology: DRAM
Memory Operating Mode Capability: Volatile memory
Firmware Version: Unknown
Module Manufacturer ID: Bank 1, Hex 0xAD
Module Product ID: Unknown
Memory Subsystem Controller Manufacturer ID: Unknown
Memory Subsystem Controller Product ID: Unknown
Non-Volatile Size: None
Volatile Size: 16 GB
Cache Size: None
Logical Size: None
Handle 0x0057, DMI type 17, 84 bytes
Memory Device
Array Handle: 0x0045
Error Information Handle: 0x0056
Total Width: Unknown
Data Width: Unknown
Size: No Module Installed
Form Factor: Unknown
Set: None
Locator: DIMM_P0_C0
Bank Locator: BANK 0
Type: Unknown
Type Detail: Unknown
Handle 0x0059, DMI type 17, 84 bytes
Memory Device
Array Handle: 0x0045
Error Information Handle: 0x0058
Total Width: 72 bits
Data Width: 64 bits
Size: 16 GB
Form Factor: DIMM
Set: None
Locator: DIMM_P0_C1
Bank Locator: BANK 1
Type: DDR4
Type Detail: Synchronous Registered (Buffered)
Speed: 2400 MT/s
Manufacturer: SK Hynix
Serial Number: TEST-0005
Asset Tag: TEST-0006
Part Number: HMA82GR7AFR8N-UH
Rank: 2
Configured Memory Speed: 2400 MT/s
Minimum Voltage: 1.2 V
Maximum Voltage: 1.2 V
Configured Voltage: 1.2 V
Memory Technology: DRAM
Memory Operating Mode Capability: Volatile memory
Firmware Version: Unknown
Module Manufacturer ID: Bank 1, Hex 0xAD
Module Product ID: Unknown
Memory Subsystem Controller Manufacturer ID: Unknown
Memory Subsystem Controller Product ID: Unknown
Non-Volatile Size: None
Volatile Size: 16 GB
Cache Size: None
Logical Size: None
Handle 0x005C, DMI type 17, 84 bytes
Memory Device
Array Handle: 0x0045
Error Information Handle: 0x005B
Total Width: Unknown
Data Width: Unknown
Size: No Module Installed
Form Factor: Unknown
Set: None
Locator: DIMM_P0_D0
Bank Locator: BANK 0
Type: Unknown
Type Detail: Unknown
Handle 0x005E, DMI type 17, 84 bytes
Memory Device
Array Handle: 0x0045
Error Information Handle: 0x005D
Total Width: 72 bits
Data Width: 64 bits
Size: 16 GB
Form Factor: DIMM
Set: None
Locator: DIMM_P0_D1
Bank Locator: BANK 1
Type: DDR4
Type Detail: Synchronous Registered (Buffered)
Speed: 2400 MT/s
Manufacturer: SK Hynix
Serial Number: TEST-0007
Asset Tag: TEST-0008
Part Number: HMA82GR7AFR8N-UH
Rank: 2
Configured Memory Speed: 2400 MT/s
Minimum Voltage: 1.2 V
Maximum Voltage: 1.2 V
Configured Voltage: 1.2 V
Memory Technology: DRAM
Memory Operating Mode Capability: Volatile memory
Firmware Version: Unknown
Module Manufacturer ID: Bank 1, Hex 0xAD
Module Product ID: Unknown
Memory Subsystem Controller Manufacturer ID: Unknown
Memory Subsystem Controller Product ID: Unknown
Non-Volatile Size: None
Volatile Size: 16 GB
Cache Size: None
Logical Size: None
Handle 0x0061, DMI type 17, 84 bytes
Memory Device
Array Handle: 0x0045
Error Information Handle: 0x0060
Total Width: Unknown
Data Width: Unknown
Size: No Module Installed
Form Factor: Unknown
Set: None
Locator: DIMM_P0_E0
Bank Locator: BANK 0
Type: Unknown
Type Detail: Unknown
Handle 0x0063, DMI type 17, 84 bytes
Memory Device
Array Handle: 0x0045
Error Information Handle: 0x0062
Total Width: 72 bits
Data Width: 64 bits
Size: 16 GB
Form Factor: DIMM
Set: None
Locator: DIMM_P0_E1
Bank Locator: BANK 1
Type: DDR4
Type Detail: Synchronous Registered (Buffered)
Speed: 2400 MT/s
Manufacturer: SK Hynix
Serial Number: TEST-0009
Asset Tag: TEST-0010
Part Number: HMA82GR7AFR8N-UH
Rank: 2
Configured Memory Speed: 2400 MT/s
Minimum Voltage: 1.2 V
Maximum Voltage: 1.2 V
Configured Voltage: 1.2 V
Memory Technology: DRAM
Memory Operating Mode Capability: Volatile memory
Firmware Version: Unknown
Module Manufacturer ID: Bank 1, Hex 0xAD
Module Product ID: Unknown
Memory Subsystem Controller Manufacturer ID: Unknown
Memory Subsystem Controller Product ID: Unknown
Non-Volatile Size: None
Volatile Size: 16 GB
Cache Size: None
Logical Size: None
Handle 0x0066, DMI type 17, 84 bytes
Memory Device
Array Handle: 0x0045
Error Information Handle: 0x0065
Total Width: Unknown
Data Width: Unknown
Size: No Module Installed
Form Factor: Unknown
Set: None
Locator: DIMM_P0_F0
Bank Locator: BANK 0
Type: Unknown
Type Detail: Unknown
Handle 0x0068, DMI type 17, 84 bytes
Memory Device
Array Handle: 0x0045
Error Information Handle: 0x0067
Total Width: 72 bits
Data Width: 64 bits
Size: 16 GB
Form Factor: DIMM
Set: None
Locator: DIMM_P0_F1
Bank Locator: BANK 1
Type: DDR4
Type Detail: Synchronous Registered (Buffered)
Speed: 2400 MT/s
Manufacturer: SK Hynix
Serial Number: TEST-0011
Asset Tag: TEST-0012
Part Number: HMA82GR7AFR8N-UH
Rank: 2
Configured Memory Speed: 2400 MT/s
Minimum Voltage: 1.2 V
Maximum Voltage: 1.2 V
Configured Voltage: 1.2 V
Memory Technology: DRAM
Memory Operating Mode Capability: Volatile memory
Firmware Version: Unknown
Module Manufacturer ID: Bank 1, Hex 0xAD
Module Product ID: Unknown
Memory Subsystem Controller Manufacturer ID: Unknown
Memory Subsystem Controller Product ID: Unknown
Non-Volatile Size: None
Volatile Size: 16 GB
Cache Size: None
Logical Size: None
Handle 0x006B, DMI type 17, 84 bytes
Memory Device
Array Handle: 0x0045
Error Information Handle: 0x006A
Total Width: Unknown
Data Width: Unknown
Size: No Module Installed
Form Factor: Unknown
Set: None
Locator: DIMM_P0_G0
Bank Locator: BANK 0
Type: Unknown
Type Detail: Unknown
Handle 0x006D, DMI type 17, 84 bytes
Memory Device
Array Handle: 0x0045
Error Information Handle: 0x006C
Total Width: 72 bits
Data Width: 64 bits
Size: 16 GB
Form Factor: DIMM
Set: None
Locator: DIMM_P0_G1
Bank Locator: BANK 1
Type: DDR4
Type Detail: Synchronous Registered (Buffered)
Speed: 2400 MT/s
Manufacturer: SK Hynix
Serial Number: TEST-0013
Asset Tag: TEST-0014
Part Number: HMA82GR7AFR8N-UH
Rank: 2
Configured Memory Speed: 2400 MT/s
Minimum Voltage: 1.2 V
Maximum Voltage: 1.2 V
Configured Voltage: 1.2 V
Memory Technology: DRAM
Memory Operating Mode Capability: Volatile memory
Firmware Version: Unknown
Module Manufacturer ID: Bank 1, Hex 0xAD
Module Product ID: Unknown
Memory Subsystem Controller Manufacturer ID: Unknown
Memory Subsystem Controller Product ID: Unknown
Non-Volatile Size: None
Volatile Size: 16 GB
Cache Size: None
Logical Size: None
Handle 0x0070, DMI type 17, 84 bytes
Memory Device
Array Handle: 0x0045
Error Information Handle: 0x006F
Total Width: Unknown
Data Width: Unknown
Size: No Module Installed
Form Factor: Unknown
Set: None
Locator: DIMM_P0_H0
Bank Locator: BANK 0
Type: Unknown
Type Detail: Unknown
Handle 0x0072, DMI type 17, 84 bytes
Memory Device
Array Handle: 0x0045
Error Information Handle: 0x0071
Total Width: 72 bits
Data Width: 64 bits
Size: 16 GB
Form Factor: DIMM
Set: None
Locator: DIMM_P0_H1
Bank Locator: BANK 1
Type: DDR4
Type Detail: Synchronous Registered (Buffered)
Speed: 2400 MT/s
Manufacturer: SK Hynix
Serial Number: TEST-0015
Asset Tag: TEST-0016
Part Number: HMA82GR7AFR8N-UH
Rank: 2
Configured Memory Speed: 2400 MT/s
Minimum Voltage: 1.2 V
Maximum Voltage: 1.2 V
Configured Voltage: 1.2 V
Memory Technology: DRAM
Memory Operating Mode Capability: Volatile memory
Firmware Version: Unknown
Module Manufacturer ID: Bank 1, Hex 0xAD
Module Product ID: Unknown
Memory Subsystem Controller Manufacturer ID: Unknown
Memory Subsystem Controller Product ID: Unknown
Non-Volatile Size: None
Volatile Size: 16 GB
Cache Size: None
Logical Size: None
Handle 0x0079, DMI type 17, 84 bytes
Memory Device
Array Handle: 0x0045
Error Information Handle: 0x0078
Total Width: Unknown
Data Width: Unknown
Size: No Module Installed
Form Factor: Unknown
Set: None
Locator: DIMM_P1_I0
Bank Locator: BANK 0
Type: Unknown
Type Detail: Unknown
Handle 0x007B, DMI type 17, 84 bytes
Memory Device
Array Handle: 0x0045
Error Information Handle: 0x007A
Total Width: 72 bits
Data Width: 64 bits
Size: 16 GB
Form Factor: DIMM
Set: None
Locator: DIMM_P1_I1
Bank Locator: BANK 1
Type: DDR4
Type Detail: Synchronous Registered (Buffered)
Speed: 2400 MT/s
Manufacturer: SK Hynix
Serial Number: TEST-0017
Asset Tag: TEST-0018
Part Number: HMA82GR7AFR8N-UH
Rank: 2
Configured Memory Speed: 2400 MT/s
Minimum Voltage: 1.2 V
Maximum Voltage: 1.2 V
Configured Voltage: 1.2 V
Memory Technology: DRAM
Memory Operating Mode Capability: Volatile memory
Firmware Version: Unknown
Module Manufacturer ID: Bank 1, Hex 0xAD
Module Product ID: Unknown
Memory Subsystem Controller Manufacturer ID: Unknown
Memory Subsystem Controller Product ID: Unknown
Non-Volatile Size: None
Volatile Size: 16 GB
Cache Size: None
Logical Size: None
Handle 0x007E, DMI type 17, 84 bytes
Memory Device
Array Handle: 0x0045
Error Information Handle: 0x007D
Total Width: Unknown
Data Width: Unknown
Size: No Module Installed
Form Factor: Unknown
Set: None
Locator: DIMM_P1_J0
Bank Locator: BANK 0
Type: Unknown
Type Detail: Unknown
Handle 0x0080, DMI type 17, 84 bytes
Memory Device
Array Handle: 0x0045
Error Information Handle: 0x007F
Total Width: 72 bits
Data Width: 64 bits
Size: 16 GB
Form Factor: DIMM
Set: None
Locator: DIMM_P1_J1
Bank Locator: BANK 1
Type: DDR4
Type Detail: Synchronous Registered (Buffered)
Speed: 2400 MT/s
Manufacturer: SK Hynix
Serial Number: TEST-0019
Asset Tag: TEST-0020
Part Number: HMA82GR7AFR8N-UH
Rank: 2
Configured Memory Speed: 2400 MT/s
Minimum Voltage: 1.2 V
Maximum Voltage: 1.2 V
Configured Voltage: 1.2 V
Memory Technology: DRAM
Memory Operating Mode Capability: Volatile memory
Firmware Version: Unknown
Module Manufacturer ID: Bank 1, Hex 0xAD
Module Product ID: Unknown
Memory Subsystem Controller Manufacturer ID: Unknown
Memory Subsystem Controller Product ID: Unknown
Non-Volatile Size: None
Volatile Size: 16 GB
Cache Size: None
Logical Size: None
Handle 0x0083, DMI type 17, 84 bytes
Memory Device
Array Handle: 0x0045
Error Information Handle: 0x0082
Total Width: Unknown
Data Width: Unknown
Size: No Module Installed
Form Factor: Unknown
Set: None
Locator: DIMM_P1_K0
Bank Locator: BANK 0
Type: Unknown
Type Detail: Unknown
Handle 0x0085, DMI type 17, 84 bytes
Memory Device
Array Handle: 0x0045
Error Information Handle: 0x0084
Total Width: 72 bits
Data Width: 64 bits
Size: 16 GB
Form Factor: DIMM
Set: None
Locator: DIMM_P1_K1
Bank Locator: BANK 1
Type: DDR4
Type Detail: Synchronous Registered (Buffered)
Speed: 2400 MT/s
Manufacturer: SK Hynix
Serial Number: TEST-0021
Asset Tag: TEST-0022
Part Number: HMA82GR7AFR8N-UH
Rank: 2
Configured Memory Speed: 2400 MT/s
Minimum Voltage: 1.2 V
Maximum Voltage: 1.2 V
Configured Voltage: 1.2 V
Memory Technology: DRAM
Memory Operating Mode Capability: Volatile memory
Firmware Version: Unknown
Module Manufacturer ID: Bank 1, Hex 0xAD
Module Product ID: Unknown
Memory Subsystem Controller Manufacturer ID: Unknown
Memory Subsystem Controller Product ID: Unknown
Non-Volatile Size: None
Volatile Size: 16 GB
Cache Size: None
Logical Size: None
Handle 0x0088, DMI type 17, 84 bytes
Memory Device
Array Handle: 0x0045
Error Information Handle: 0x0087
Total Width: Unknown
Data Width: Unknown
Size: No Module Installed
Form Factor: Unknown
Set: None
Locator: DIMM_P1_L0
Bank Locator: BANK 0
Type: Unknown
Type Detail: Unknown
Handle 0x008A, DMI type 17, 84 bytes
Memory Device
Array Handle: 0x0045
Error Information Handle: 0x0089
Total Width: 72 bits
Data Width: 64 bits
Size: 16 GB
Form Factor: DIMM
Set: None
Locator: DIMM_P1_L1
Bank Locator: BANK 1
Type: DDR4
Type Detail: Synchronous Registered (Buffered)
Speed: 2400 MT/s
Manufacturer: SK Hynix
Serial Number: TEST-0023
Asset Tag: TEST-0024
Part Number: HMA82GR7AFR8N-UH
Rank: 2
Configured Memory Speed: 2400 MT/s
Minimum Voltage: 1.2 V
Maximum Voltage: 1.2 V
Configured Voltage: 1.2 V
Memory Technology: DRAM
Memory Operating Mode Capability: Volatile memory
Firmware Version: Unknown
Module Manufacturer ID: Bank 1, Hex 0xAD
Module Product ID: Unknown
Memory Subsystem Controller Manufacturer ID: Unknown
Memory Subsystem Controller Product ID: Unknown
Non-Volatile Size: None
Volatile Size: 16 GB
Cache Size: None
Logical Size: None
Handle 0x008D, DMI type 17, 84 bytes
Memory Device
Array Handle: 0x0045
Error Information Handle: 0x008C
Total Width: Unknown
Data Width: Unknown
Size: No Module Installed
Form Factor: Unknown
Set: None
Locator: DIMM_P1_M0
Bank Locator: BANK 0
Type: Unknown
Type Detail: Unknown
Handle 0x008F, DMI type 17, 84 bytes
Memory Device
Array Handle: 0x0045
Error Information Handle: 0x008E
Total Width: 72 bits
Data Width: 64 bits
Size: 16 GB
Form Factor: DIMM
Set: None
Locator: DIMM_P1_M1
Bank Locator: BANK 1
Type: DDR4
Type Detail: Synchronous Registered (Buffered)
Speed: 2400 MT/s
Manufacturer: SK Hynix
Serial Number: TEST-0025
Asset Tag: TEST-0026
Part Number: HMA82GR7AFR8N-UH
Rank: 2
Configured Memory Speed: 2400 MT/s
Minimum Voltage: 1.2 V
Maximum Voltage: 1.2 V
Configured Voltage: 1.2 V
Memory Technology: DRAM
Memory Operating Mode Capability: Volatile memory
Firmware Version: Unknown
Module Manufacturer ID: Bank 1, Hex 0xAD
Module Product ID: Unknown
Memory Subsystem Controller Manufacturer ID: Unknown
Memory Subsystem Controller Product ID: Unknown
Non-Volatile Size: None
Volatile Size: 16 GB
Cache Size: None
Logical Size: None
Handle 0x0092, DMI type 17, 84 bytes
Memory Device
Array Handle: 0x0045
Error Information Handle: 0x0091
Total Width: Unknown
Data Width: Unknown
Size: No Module Installed
Form Factor: Unknown
Set: None
Locator: DIMM_P1_N0
Bank Locator: BANK 0
Type: Unknown
Type Detail: Unknown
Handle 0x0094, DMI type 17, 84 bytes
Memory Device
Array Handle: 0x0045
Error Information Handle: 0x0093
Total Width: 72 bits
Data Width: 64 bits
Size: 16 GB
Form Factor: DIMM
Set: None
Locator: DIMM_P1_N1
Bank Locator: BANK 1
Type: DDR4
Type Detail: Synchronous Registered (Buffered)
Speed: 2400 MT/s
Manufacturer: SK Hynix
Serial Number: TEST-0027
Asset Tag: TEST-0028
Part Number: HMA82GR7AFR8N-UH
Rank: 2
Configured Memory Speed: 2400 MT/s
Minimum Voltage: 1.2 V
Maximum Voltage: 1.2 V
Configured Voltage: 1.2 V
Memory Technology: DRAM
Memory Operating Mode Capability: Volatile memory
Firmware Version: Unknown
Module Manufacturer ID: Bank 1, Hex 0xAD
Module Product ID: Unknown
Memory Subsystem Controller Manufacturer ID: Unknown
Memory Subsystem Controller Product ID: Unknown
Non-Volatile Size: None
Volatile Size: 16 GB
Cache Size: None
Logical Size: None
Handle 0x0097, DMI type 17, 84 bytes
Memory Device
Array Handle: 0x0045
Error Information Handle: 0x0096
Total Width: Unknown
Data Width: Unknown
Size: No Module Installed
Form Factor: Unknown
Set: None
Locator: DIMM_P1_O0
Bank Locator: BANK 0
Type: Unknown
Type Detail: Unknown
Handle 0x0099, DMI type 17, 84 bytes
Memory Device
Array Handle: 0x0045
Error Information Handle: 0x0098
Total Width: 72 bits
Data Width: 64 bits
Size: 16 GB
Form Factor: DIMM
Set: None
Locator: DIMM_P1_O1
Bank Locator: BANK 1
Type: DDR4
Type Detail: Synchronous Registered (Buffered)
Speed: 2400 MT/s
Manufacturer: SK Hynix
Serial Number: TEST-0029
Asset Tag: TEST-0030
Part Number: HMA82GR7AFR8N-UH
Rank: 2
Configured Memory Speed: 2400 MT/s
Minimum Voltage: 1.2 V
Maximum Voltage: 1.2 V
Configured Voltage: 1.2 V
Memory Technology: DRAM
Memory Operating Mode Capability: Volatile memory
Firmware Version: Unknown
Module Manufacturer ID: Bank 1, Hex 0xAD
Module Product ID: Unknown
Memory Subsystem Controller Manufacturer ID: Unknown
Memory Subsystem Controller Product ID: Unknown
Non-Volatile Size: None
Volatile Size: 16 GB
Cache Size: None
Logical Size: None
Handle 0x009C, DMI type 17, 84 bytes
Memory Device
Array Handle: 0x0045
Error Information Handle: 0x009B
Total Width: Unknown
Data Width: Unknown
Size: No Module Installed
Form Factor: Unknown
Set: None
Locator: DIMM_P1_P0
Bank Locator: BANK 0
Type: Unknown
Type Detail: Unknown
Handle 0x009E, DMI type 17, 84 bytes
Memory Device
Array Handle: 0x0045
Error Information Handle: 0x009D
Total Width: 72 bits
Data Width: 64 bits
Size: 16 GB
Form Factor: DIMM
Set: None
Locator: DIMM_P1_P1
Bank Locator: BANK 1
Type: DDR4
Type Detail: Synchronous Registered (Buffered)
Speed: 2400 MT/s
Manufacturer: SK Hynix
Serial Number: TEST-0031
Asset Tag: TEST-0032
Part Number: HMA82GR7AFR8N-UH
Rank: 2
Configured Memory Speed: 2400 MT/s
Minimum Voltage: 1.2 V
Maximum Voltage: 1.2 V
Configured Voltage: 1.2 V
Memory Technology: DRAM
Memory Operating Mode Capability: Volatile memory
Firmware Version: Unknown
Module Manufacturer ID: Bank 1, Hex 0xAD
Module Product ID: Unknown
Memory Subsystem Controller Manufacturer ID: Unknown
Memory Subsystem Controller Product ID: Unknown
Non-Volatile Size: None
Volatile Size: 16 GB
Cache Size: None
Logical Size: None
@@ -1,116 +0,0 @@
# dmidecode 3.6
Getting SMBIOS data from sysfs.
SMBIOS 3.2.0 present.
Handle 0x004B, DMI type 4, 48 bytes
Processor Information
Socket Designation: P0
Type: Central Processor
Family: Zen
Manufacturer: Advanced Micro Devices, Inc.
ID: TEST-0001
Signature: Family 23, Model 49, Stepping 0
Flags:
FPU (Floating-point unit on-chip)
VME (Virtual mode extension)
DE (Debugging extension)
PSE (Page size extension)
TSC (Time stamp counter)
MSR (Model specific registers)
PAE (Physical address extension)
MCE (Machine check exception)
CX8 (CMPXCHG8 instruction supported)
APIC (On-chip APIC hardware supported)
SEP (Fast system call)
MTRR (Memory type range registers)
PGE (Page global enable)
MCA (Machine check architecture)
CMOV (Conditional move instruction supported)
PAT (Page attribute table)
PSE-36 (36-bit page size extension)
CLFSH (CLFLUSH instruction supported)
MMX (MMX technology supported)
FXSR (FXSAVE and FXSTOR instructions supported)
SSE (Streaming SIMD extensions)
SSE2 (Streaming SIMD extensions 2)
HTT (Multi-threading)
Version: AMD EPYC 7662 64-Core Processor
Voltage: 1.1 V
External Clock: 100 MHz
Max Speed: 3300 MHz
Current Speed: 2000 MHz
Status: Populated, Enabled
Upgrade: Socket SP3
L1 Cache Handle: 0x0048
L2 Cache Handle: 0x0049
L3 Cache Handle: 0x004A
Serial Number: Unknown
Asset Tag: Unknown
Part Number: Unknown
Core Count: 64
Core Enabled: 64
Thread Count: 128
Characteristics:
64-bit capable
Multi-Core
Hardware Thread
Execute Protection
Enhanced Virtualization
Power/Performance Control
Handle 0x0077, DMI type 4, 48 bytes
Processor Information
Socket Designation: P1
Type: Central Processor
Family: Zen
Manufacturer: Advanced Micro Devices, Inc.
ID: TEST-0004
Signature: Family 23, Model 49, Stepping 0
Flags:
FPU (Floating-point unit on-chip)
VME (Virtual mode extension)
DE (Debugging extension)
PSE (Page size extension)
TSC (Time stamp counter)
MSR (Model specific registers)
PAE (Physical address extension)
MCE (Machine check exception)
CX8 (CMPXCHG8 instruction supported)
APIC (On-chip APIC hardware supported)
SEP (Fast system call)
MTRR (Memory type range registers)
PGE (Page global enable)
MCA (Machine check architecture)
CMOV (Conditional move instruction supported)
PAT (Page attribute table)
PSE-36 (36-bit page size extension)
CLFSH (CLFLUSH instruction supported)
MMX (MMX technology supported)
FXSR (FXSAVE and FXSTOR instructions supported)
SSE (Streaming SIMD extensions)
SSE2 (Streaming SIMD extensions 2)
HTT (Multi-threading)
Version: AMD EPYC 7662 64-Core Processor
Voltage: 1.1 V
External Clock: 100 MHz
Max Speed: 3300 MHz
Current Speed: 2000 MHz
Status: Populated, Enabled
Upgrade: Socket SP3
L1 Cache Handle: 0x0074
L2 Cache Handle: 0x0075
L3 Cache Handle: 0x0076
Serial Number: Unknown
Asset Tag: Unknown
Part Number: Unknown
Core Count: 64
Core Enabled: 64
Thread Count: 128
Characteristics:
64-bit capable
Multi-Core
Hardware Thread
Execute Protection
Enhanced Virtualization
Power/Performance Control
@@ -1,43 +0,0 @@
Settings for eno1:
Supported ports: [ TP ]
Supported link modes: 10baseT/Half 10baseT/Full
100baseT/Half 100baseT/Full
1000baseT/Full
Supported pause frame use: Symmetric
Supports auto-negotiation: Yes
Supported FEC modes: Not reported
Advertised link modes: 10baseT/Half 10baseT/Full
100baseT/Half 100baseT/Full
1000baseT/Full
Advertised pause frame use: Symmetric
Advertised auto-negotiation: Yes
Advertised FEC modes: Not reported
Speed: 1000Mb/s
Duplex: Full
Auto-negotiation: on
Port: Twisted Pair
PHYAD: 1
Transceiver: internal
MDI-X: on (auto)
Current message level: 0x00000007 (7)
drv probe link
Link detected: yes
netlink error: Operation not permitted
===
driver: igb
version: 6.12.73+deb13-amd64
firmware-version: 1.63, 0x800009fa
expansion-rom-version:
bus-info: 0000:64:00.0
supports-statistics: yes
supports-test: yes
supports-eeprom-access: yes
supports-register-dump: yes
supports-priv-flags: yes
===
ID_VENDOR_FROM_DATABASE=Intel Corporation
ID_MODEL_FROM_DATABASE=I350 Gigabit Network Connection
ID_NET_NAME_MAC=enx000000000001
ID_NET_NAME_ONBOARD=eno1
ID_NET_NAME_PATH=enp100s0f0
ID_NET_NAME=eno1
@@ -1,59 +0,0 @@
Settings for enp193s0f1np1:
Supported ports: [ Backplane ]
Supported link modes: 1000baseT/Full
10000baseT/Full
1000baseKX/Full
10000baseKR/Full
10000baseR_FEC
25000baseCR/Full
25000baseKR/Full
25000baseSR/Full
1000baseX/Full
10000baseCR/Full
10000baseSR/Full
10000baseLR/Full
10000baseER/Full
Supported pause frame use: Symmetric
Supports auto-negotiation: Yes
Supported FEC modes: None RS BASER
Advertised link modes: 1000baseT/Full
10000baseT/Full
1000baseKX/Full
10000baseKR/Full
10000baseR_FEC
25000baseCR/Full
25000baseKR/Full
25000baseSR/Full
1000baseX/Full
10000baseCR/Full
10000baseSR/Full
10000baseLR/Full
10000baseER/Full
Advertised pause frame use: Symmetric
Advertised auto-negotiation: Yes
Advertised FEC modes: None
Speed: 25000Mb/s
Duplex: Full
Auto-negotiation: on
Port: Direct Attach Copper
PHYAD: 0
Transceiver: internal
Link detected: yes
netlink error: Operation not permitted
===
driver: mlx5_core
version: 6.12.73+deb13-amd64
firmware-version: 24.47.1088 (MT_0000000541)
expansion-rom-version:
bus-info: 0000:c1:00.1
supports-statistics: yes
supports-test: yes
supports-eeprom-access: no
supports-register-dump: no
supports-priv-flags: yes
===
ID_VENDOR_FROM_DATABASE=Mellanox Technologies
ID_MODEL_FROM_DATABASE=MT42822 BlueField-2 integrated ConnectX-6 Dx network controller
ID_NET_NAME_MAC=enx000000000002
ID_NET_NAME_PATH=enp193s0f1np1
ID_NET_NAME=enp193s0f1np1
@@ -1,50 +0,0 @@
# dmidecode 3.6
Getting SMBIOS data from sysfs.
SMBIOS 2.8 present.
Handle 0x0000, DMI type 0, 24 bytes
BIOS Information
Vendor: SeaBIOS
Version: 1.16.3-debian-1.16.3-2
Release Date: 04/01/2014
Address: 0xE8000
Runtime Size: 96 kB
ROM Size: 64 kB
Characteristics:
BIOS characteristics not supported
Targeted content distribution is supported
BIOS Revision: 0.0
Handle 0x0100, DMI type 1, 27 bytes
System Information
Manufacturer: QEMU
Product Name: Standard PC (i440FX + PIIX, 1996)
Version: pc-i440fx-10.0
Serial Number: Not Specified
UUID: TEST-0002
Wake-up Type: Power Switch
SKU Number: Not Specified
Family: Not Specified
Handle 0x0300, DMI type 3, 22 bytes
Chassis Information
Manufacturer: QEMU
Type: Other
Lock: Not Present
Version: pc-i440fx-10.0
Serial Number: Not Specified
Asset Tag: Not Specified
Boot-up State: Safe
Power Supply State: Safe
Thermal State: Safe
Security Status: Unknown
OEM Information: 0x00000000
Height: Unspecified
Number Of Power Cords: Unspecified
Contained Elements: 0
SKU Number: Not Specified
Handle 0x2000, DMI type 32, 11 bytes
System Boot Information
Status: No errors detected
@@ -1,37 +0,0 @@
# dmidecode 3.6
Getting SMBIOS data from sysfs.
SMBIOS 2.8 present.
Handle 0x1000, DMI type 16, 23 bytes
Physical Memory Array
Location: Other
Use: System Memory
Error Correction Type: Multi-bit ECC
Maximum Capacity: 32 GB
Error Information Handle: Not Provided
Number Of Devices: 1
Handle 0x1100, DMI type 17, 40 bytes
Memory Device
Array Handle: 0x1000
Error Information Handle: Not Provided
Total Width: Unknown
Data Width: Unknown
Size: 32 GB
Form Factor: DIMM
Set: None
Locator: DIMM 0
Bank Locator: Not Specified
Type: RAM
Type Detail: Other
Speed: Unknown
Manufacturer: QEMU
Serial Number: Not Specified
Asset Tag: Not Specified
Part Number: Not Specified
Rank: Unknown
Configured Memory Speed: Unknown
Minimum Voltage: Unknown
Maximum Voltage: Unknown
Configured Voltage: Unknown
@@ -1,404 +0,0 @@
# dmidecode 3.6
Getting SMBIOS data from sysfs.
SMBIOS 2.8 present.
Handle 0x0400, DMI type 4, 42 bytes
Processor Information
Socket Designation: CPU 0
Type: Central Processor
Family: Other
Manufacturer: QEMU
ID: TEST-0001
Version: pc-i440fx-10.0
Voltage: Unknown
External Clock: Unknown
Max Speed: 2000 MHz
Current Speed: 2000 MHz
Status: Populated, Enabled
Upgrade: Other
L1 Cache Handle: Not Provided
L2 Cache Handle: Not Provided
L3 Cache Handle: Not Provided
Serial Number: Not Specified
Asset Tag: Not Specified
Part Number: Not Specified
Core Count: 1
Core Enabled: 1
Thread Count: 1
Characteristics: None
Handle 0x0401, DMI type 4, 42 bytes
Processor Information
Socket Designation: CPU 1
Type: Central Processor
Family: Other
Manufacturer: QEMU
ID: TEST-0004
Version: pc-i440fx-10.0
Voltage: Unknown
External Clock: Unknown
Max Speed: 2000 MHz
Current Speed: 2000 MHz
Status: Populated, Enabled
Upgrade: Other
L1 Cache Handle: Not Provided
L2 Cache Handle: Not Provided
L3 Cache Handle: Not Provided
Serial Number: Not Specified
Asset Tag: Not Specified
Part Number: Not Specified
Core Count: 1
Core Enabled: 1
Thread Count: 1
Characteristics: None
Handle 0x0402, DMI type 4, 42 bytes
Processor Information
Socket Designation: CPU 2
Type: Central Processor
Family: Other
Manufacturer: QEMU
ID: TEST-0007
Version: pc-i440fx-10.0
Voltage: Unknown
External Clock: Unknown
Max Speed: 2000 MHz
Current Speed: 2000 MHz
Status: Populated, Enabled
Upgrade: Other
L1 Cache Handle: Not Provided
L2 Cache Handle: Not Provided
L3 Cache Handle: Not Provided
Serial Number: Not Specified
Asset Tag: Not Specified
Part Number: Not Specified
Core Count: 1
Core Enabled: 1
Thread Count: 1
Characteristics: None
Handle 0x0403, DMI type 4, 42 bytes
Processor Information
Socket Designation: CPU 3
Type: Central Processor
Family: Other
Manufacturer: QEMU
ID: TEST-0010
Version: pc-i440fx-10.0
Voltage: Unknown
External Clock: Unknown
Max Speed: 2000 MHz
Current Speed: 2000 MHz
Status: Populated, Enabled
Upgrade: Other
L1 Cache Handle: Not Provided
L2 Cache Handle: Not Provided
L3 Cache Handle: Not Provided
Serial Number: Not Specified
Asset Tag: Not Specified
Part Number: Not Specified
Core Count: 1
Core Enabled: 1
Thread Count: 1
Characteristics: None
Handle 0x0404, DMI type 4, 42 bytes
Processor Information
Socket Designation: CPU 4
Type: Central Processor
Family: Other
Manufacturer: QEMU
ID: TEST-0013
Version: pc-i440fx-10.0
Voltage: Unknown
External Clock: Unknown
Max Speed: 2000 MHz
Current Speed: 2000 MHz
Status: Populated, Enabled
Upgrade: Other
L1 Cache Handle: Not Provided
L2 Cache Handle: Not Provided
L3 Cache Handle: Not Provided
Serial Number: Not Specified
Asset Tag: Not Specified
Part Number: Not Specified
Core Count: 1
Core Enabled: 1
Thread Count: 1
Characteristics: None
Handle 0x0405, DMI type 4, 42 bytes
Processor Information
Socket Designation: CPU 5
Type: Central Processor
Family: Other
Manufacturer: QEMU
ID: TEST-0016
Version: pc-i440fx-10.0
Voltage: Unknown
External Clock: Unknown
Max Speed: 2000 MHz
Current Speed: 2000 MHz
Status: Populated, Enabled
Upgrade: Other
L1 Cache Handle: Not Provided
L2 Cache Handle: Not Provided
L3 Cache Handle: Not Provided
Serial Number: Not Specified
Asset Tag: Not Specified
Part Number: Not Specified
Core Count: 1
Core Enabled: 1
Thread Count: 1
Characteristics: None
Handle 0x0406, DMI type 4, 42 bytes
Processor Information
Socket Designation: CPU 6
Type: Central Processor
Family: Other
Manufacturer: QEMU
ID: TEST-0019
Version: pc-i440fx-10.0
Voltage: Unknown
External Clock: Unknown
Max Speed: 2000 MHz
Current Speed: 2000 MHz
Status: Populated, Enabled
Upgrade: Other
L1 Cache Handle: Not Provided
L2 Cache Handle: Not Provided
L3 Cache Handle: Not Provided
Serial Number: Not Specified
Asset Tag: Not Specified
Part Number: Not Specified
Core Count: 1
Core Enabled: 1
Thread Count: 1
Characteristics: None
Handle 0x0407, DMI type 4, 42 bytes
Processor Information
Socket Designation: CPU 7
Type: Central Processor
Family: Other
Manufacturer: QEMU
ID: TEST-0022
Version: pc-i440fx-10.0
Voltage: Unknown
External Clock: Unknown
Max Speed: 2000 MHz
Current Speed: 2000 MHz
Status: Populated, Enabled
Upgrade: Other
L1 Cache Handle: Not Provided
L2 Cache Handle: Not Provided
L3 Cache Handle: Not Provided
Serial Number: Not Specified
Asset Tag: Not Specified
Part Number: Not Specified
Core Count: 1
Core Enabled: 1
Thread Count: 1
Characteristics: None
Handle 0x0408, DMI type 4, 42 bytes
Processor Information
Socket Designation: CPU 8
Type: Central Processor
Family: Other
Manufacturer: QEMU
ID: TEST-0025
Version: pc-i440fx-10.0
Voltage: Unknown
External Clock: Unknown
Max Speed: 2000 MHz
Current Speed: 2000 MHz
Status: Populated, Enabled
Upgrade: Other
L1 Cache Handle: Not Provided
L2 Cache Handle: Not Provided
L3 Cache Handle: Not Provided
Serial Number: Not Specified
Asset Tag: Not Specified
Part Number: Not Specified
Core Count: 1
Core Enabled: 1
Thread Count: 1
Characteristics: None
Handle 0x0409, DMI type 4, 42 bytes
Processor Information
Socket Designation: CPU 9
Type: Central Processor
Family: Other
Manufacturer: QEMU
ID: TEST-0028
Version: pc-i440fx-10.0
Voltage: Unknown
External Clock: Unknown
Max Speed: 2000 MHz
Current Speed: 2000 MHz
Status: Populated, Enabled
Upgrade: Other
L1 Cache Handle: Not Provided
L2 Cache Handle: Not Provided
L3 Cache Handle: Not Provided
Serial Number: Not Specified
Asset Tag: Not Specified
Part Number: Not Specified
Core Count: 1
Core Enabled: 1
Thread Count: 1
Characteristics: None
Handle 0x040A, DMI type 4, 42 bytes
Processor Information
Socket Designation: CPU a
Type: Central Processor
Family: Other
Manufacturer: QEMU
ID: TEST-0031
Version: pc-i440fx-10.0
Voltage: Unknown
External Clock: Unknown
Max Speed: 2000 MHz
Current Speed: 2000 MHz
Status: Populated, Enabled
Upgrade: Other
L1 Cache Handle: Not Provided
L2 Cache Handle: Not Provided
L3 Cache Handle: Not Provided
Serial Number: Not Specified
Asset Tag: Not Specified
Part Number: Not Specified
Core Count: 1
Core Enabled: 1
Thread Count: 1
Characteristics: None
Handle 0x040B, DMI type 4, 42 bytes
Processor Information
Socket Designation: CPU b
Type: Central Processor
Family: Other
Manufacturer: QEMU
ID: TEST-0034
Version: pc-i440fx-10.0
Voltage: Unknown
External Clock: Unknown
Max Speed: 2000 MHz
Current Speed: 2000 MHz
Status: Populated, Enabled
Upgrade: Other
L1 Cache Handle: Not Provided
L2 Cache Handle: Not Provided
L3 Cache Handle: Not Provided
Serial Number: Not Specified
Asset Tag: Not Specified
Part Number: Not Specified
Core Count: 1
Core Enabled: 1
Thread Count: 1
Characteristics: None
Handle 0x040C, DMI type 4, 42 bytes
Processor Information
Socket Designation: CPU c
Type: Central Processor
Family: Other
Manufacturer: QEMU
ID: TEST-0037
Version: pc-i440fx-10.0
Voltage: Unknown
External Clock: Unknown
Max Speed: 2000 MHz
Current Speed: 2000 MHz
Status: Populated, Enabled
Upgrade: Other
L1 Cache Handle: Not Provided
L2 Cache Handle: Not Provided
L3 Cache Handle: Not Provided
Serial Number: Not Specified
Asset Tag: Not Specified
Part Number: Not Specified
Core Count: 1
Core Enabled: 1
Thread Count: 1
Characteristics: None
Handle 0x040D, DMI type 4, 42 bytes
Processor Information
Socket Designation: CPU d
Type: Central Processor
Family: Other
Manufacturer: QEMU
ID: TEST-0040
Version: pc-i440fx-10.0
Voltage: Unknown
External Clock: Unknown
Max Speed: 2000 MHz
Current Speed: 2000 MHz
Status: Populated, Enabled
Upgrade: Other
L1 Cache Handle: Not Provided
L2 Cache Handle: Not Provided
L3 Cache Handle: Not Provided
Serial Number: Not Specified
Asset Tag: Not Specified
Part Number: Not Specified
Core Count: 1
Core Enabled: 1
Thread Count: 1
Characteristics: None
Handle 0x040E, DMI type 4, 42 bytes
Processor Information
Socket Designation: CPU e
Type: Central Processor
Family: Other
Manufacturer: QEMU
ID: TEST-0043
Version: pc-i440fx-10.0
Voltage: Unknown
External Clock: Unknown
Max Speed: 2000 MHz
Current Speed: 2000 MHz
Status: Populated, Enabled
Upgrade: Other
L1 Cache Handle: Not Provided
L2 Cache Handle: Not Provided
L3 Cache Handle: Not Provided
Serial Number: Not Specified
Asset Tag: Not Specified
Part Number: Not Specified
Core Count: 1
Core Enabled: 1
Thread Count: 1
Characteristics: None
Handle 0x040F, DMI type 4, 42 bytes
Processor Information
Socket Designation: CPU f
Type: Central Processor
Family: Other
Manufacturer: QEMU
ID: TEST-0046
Version: pc-i440fx-10.0
Voltage: Unknown
External Clock: Unknown
Max Speed: 2000 MHz
Current Speed: 2000 MHz
Status: Populated, Enabled
Upgrade: Other
L1 Cache Handle: Not Provided
L2 Cache Handle: Not Provided
L3 Cache Handle: Not Provided
Serial Number: Not Specified
Asset Tag: Not Specified
Part Number: Not Specified
Core Count: 1
Core Enabled: 1
Thread Count: 1
Characteristics: None
@@ -1,189 +0,0 @@
# dmidecode 3.6
Getting SMBIOS data from sysfs.
SMBIOS 3.3.0 present.
Handle 0x0000, DMI type 0, 26 bytes
BIOS Information
Vendor: GIGABYTE
Version: M30
Release Date: 06/23/2026
Address: 0xF0000
Runtime Size: 64 kB
ROM Size: 16 MB
Characteristics:
PCI is supported
BIOS is upgradeable
BIOS shadowing is allowed
Boot from CD is supported
Selectable boot is supported
BIOS ROM is socketed
EDD is supported
Japanese floppy for NEC 9800 1.2 MB is supported (int 13h)
Japanese floppy for Toshiba 1.2 MB is supported (int 13h)
5.25"/360 kB floppy services are supported (int 13h)
5.25"/1.2 MB floppy services are supported (int 13h)
3.5"/720 kB floppy services are supported (int 13h)
3.5"/2.88 MB floppy services are supported (int 13h)
Print screen service is supported (int 5h)
Serial services are supported (int 14h)
Printer services are supported (int 17h)
CGA/mono video services are supported (int 10h)
USB legacy is supported
BIOS boot specification is supported
Targeted content distribution is supported
UEFI is supported
BIOS Revision: 5.22
Handle 0x0001, DMI type 1, 27 bytes
System Information
Manufacturer: GIGABYTE
Product Name: H262-Z62-ZB
Version: 0100
Serial Number: TEST-0001
UUID: TEST-0002
Wake-up Type: Power Switch
SKU Number: 01234567890123456789AB
Family: Server
Handle 0x0002, DMI type 2, 15 bytes
Base Board Information
Manufacturer: GIGABYTE
Product Name: MZ62-HD0-ZB
Version: 02000200
Serial Number: TEST-0003
Asset Tag: TEST-0004
Features:
Board is a hosting board
Board is removable
Board is replaceable
Location In Chassis: 01234567890123456789AB
Chassis Handle: 0x0003
Type: Motherboard
Contained Object Handles: 0
Handle 0x0003, DMI type 3, 22 bytes
Chassis Information
Manufacturer: GIGABYTE
Type: Rack Mount Chassis
Lock: Not Present
Version: 01234567
Serial Number: TEST-0005
Asset Tag: TEST-0006
Boot-up State: Safe
Power Supply State: Safe
Thermal State: Safe
Security Status: None
OEM Information: 0x00000000
Height: Unspecified
Number Of Power Cords: 1
Contained Elements: 0
SKU Number: 01234567890123456789AB
Handle 0x0023, DMI type 10, 10 bytes
On Board Device 1 Information
Type: Video
Status: Enabled
Description: AST2500
On Board Device 2 Information
Type: Ethernet
Status: Enabled
Description: Intel i350 Lan1
On Board Device 3 Information
Type: Ethernet
Status: Enabled
Description: Intel i350 Lan2
Handle 0x0024, DMI type 41, 11 bytes
Onboard Device
Reference Designation: Onboard VGA
Type: Video
Status: Enabled
Type Instance: 1
Bus Address: 0000:63:00.0
Handle 0x0025, DMI type 41, 11 bytes
Onboard Device
Reference Designation: Onboard LAN1
Type: Ethernet
Status: Enabled
Type Instance: 1
Bus Address: 0000:61:00.0
Handle 0x0026, DMI type 41, 11 bytes
Onboard Device
Reference Designation: Onboard LAN2
Type: Ethernet
Status: Enabled
Type Instance: 2
Bus Address: 0000:61:00.1
Handle 0x0037, DMI type 15, 73 bytes
System Event Log
Area Length: 0 bytes
Header Start Offset: 0x0000
Header Length: 16 bytes
Data Start Offset: 0x0010
Access Method: Memory-mapped physical 32-bit address
Access Address: 0xFF536000
Status: Valid, Not Full
Change Token: 0x00000015
Header Format: Type 1
Supported Log Type Descriptors: 25
Descriptor 1: Single-bit ECC memory error
Data Format 1: Multiple-event handle
Descriptor 2: Multi-bit ECC memory error
Data Format 2: Multiple-event handle
Descriptor 3: Parity memory error
Data Format 3: None
Descriptor 4: Bus timeout
Data Format 4: None
Descriptor 5: I/O channel block
Data Format 5: None
Descriptor 6: Software NMI
Data Format 6: None
Descriptor 7: POST memory resize
Data Format 7: None
Descriptor 8: POST error
Data Format 8: POST results bitmap
Descriptor 9: PCI parity error
Data Format 9: Multiple-event handle
Descriptor 10: PCI system error
Data Format 10: Multiple-event handle
Descriptor 11: CPU failure
Data Format 11: None
Descriptor 12: EISA failsafe timer timeout
Data Format 12: None
Descriptor 13: Correctable memory log disabled
Data Format 13: None
Descriptor 14: Logging disabled
Data Format 14: None
Descriptor 15: System limit exceeded
Data Format 15: None
Descriptor 16: Asynchronous hardware timer expired
Data Format 16: None
Descriptor 17: System configuration information
Data Format 17: None
Descriptor 18: Hard disk information
Data Format 18: None
Descriptor 19: System reconfigured
Data Format 19: None
Descriptor 20: Uncorrectable CPU-complex error
Data Format 20: None
Descriptor 21: Log area reset/cleared
Data Format 21: None
Descriptor 22: System boot
Data Format 22: None
Descriptor 23: End of log
Data Format 23: None
Descriptor 24: OEM-specific
Data Format 24: OEM-specific
Descriptor 25: OEM-specific
Data Format 25: OEM-specific
Handle 0x0085, DMI type 13, 22 bytes
BIOS Language Information
Language Description Format: Long
Installable Languages: 1
en|US|iso8859-1
Currently Installed Language: en|US|iso8859-1
@@ -1,531 +0,0 @@
# dmidecode 3.6
Getting SMBIOS data from sysfs.
SMBIOS 3.3.0 present.
Handle 0x0039, DMI type 16, 23 bytes
Physical Memory Array
Location: System Board Or Motherboard
Use: System Memory
Error Correction Type: Multi-bit ECC
Maximum Capacity: 4 TB
Error Information Handle: 0x0038
Number Of Devices: 16
Handle 0x0041, DMI type 17, 92 bytes
Memory Device
Array Handle: 0x0039
Error Information Handle: 0x0040
Total Width: 72 bits
Data Width: 64 bits
Size: 16 GB
Form Factor: DIMM
Set: None
Locator: DIMM_P0_A0
Bank Locator: BANK 0
Type: DDR4
Type Detail: Synchronous Registered (Buffered)
Speed: 2933 MT/s
Manufacturer: SK Hynix
Serial Number: TEST-0001
Asset Tag: TEST-0002
Part Number: HMA82GR7JJR8N-WM
Rank: 2
Configured Memory Speed: 2933 MT/s
Minimum Voltage: 1.2 V
Maximum Voltage: 1.2 V
Configured Voltage: 1.2 V
Memory Technology: DRAM
Memory Operating Mode Capability: Volatile memory
Firmware Version: Unknown
Module Manufacturer ID: Bank 1, Hex 0xAD
Module Product ID: Unknown
Memory Subsystem Controller Manufacturer ID: Unknown
Memory Subsystem Controller Product ID: Unknown
Non-Volatile Size: None
Volatile Size: 16 GB
Cache Size: None
Logical Size: None
Handle 0x0044, DMI type 17, 92 bytes
Memory Device
Array Handle: 0x0039
Error Information Handle: 0x0043
Total Width: 72 bits
Data Width: 64 bits
Size: 16 GB
Form Factor: DIMM
Set: None
Locator: DIMM_P0_B0
Bank Locator: BANK 0
Type: DDR4
Type Detail: Synchronous Registered (Buffered)
Speed: 2933 MT/s
Manufacturer: SK Hynix
Serial Number: TEST-0003
Asset Tag: TEST-0004
Part Number: HMA82GR7JJR8N-WM
Rank: 2
Configured Memory Speed: 2933 MT/s
Minimum Voltage: 1.2 V
Maximum Voltage: 1.2 V
Configured Voltage: 1.2 V
Memory Technology: DRAM
Memory Operating Mode Capability: Volatile memory
Firmware Version: Unknown
Module Manufacturer ID: Bank 1, Hex 0xAD
Module Product ID: Unknown
Memory Subsystem Controller Manufacturer ID: Unknown
Memory Subsystem Controller Product ID: Unknown
Non-Volatile Size: None
Volatile Size: 16 GB
Cache Size: None
Logical Size: None
Handle 0x0047, DMI type 17, 92 bytes
Memory Device
Array Handle: 0x0039
Error Information Handle: 0x0046
Total Width: 72 bits
Data Width: 64 bits
Size: 16 GB
Form Factor: DIMM
Set: None
Locator: DIMM_P0_C0
Bank Locator: BANK 0
Type: DDR4
Type Detail: Synchronous Registered (Buffered)
Speed: 2933 MT/s
Manufacturer: SK Hynix
Serial Number: TEST-0005
Asset Tag: TEST-0006
Part Number: HMA82GR7JJR8N-WM
Rank: 2
Configured Memory Speed: 2933 MT/s
Minimum Voltage: 1.2 V
Maximum Voltage: 1.2 V
Configured Voltage: 1.2 V
Memory Technology: DRAM
Memory Operating Mode Capability: Volatile memory
Firmware Version: Unknown
Module Manufacturer ID: Bank 1, Hex 0xAD
Module Product ID: Unknown
Memory Subsystem Controller Manufacturer ID: Unknown
Memory Subsystem Controller Product ID: Unknown
Non-Volatile Size: None
Volatile Size: 16 GB
Cache Size: None
Logical Size: None
Handle 0x004A, DMI type 17, 92 bytes
Memory Device
Array Handle: 0x0039
Error Information Handle: 0x0049
Total Width: 72 bits
Data Width: 64 bits
Size: 16 GB
Form Factor: DIMM
Set: None
Locator: DIMM_P0_D0
Bank Locator: BANK 0
Type: DDR4
Type Detail: Synchronous Registered (Buffered)
Speed: 2933 MT/s
Manufacturer: SK Hynix
Serial Number: TEST-0007
Asset Tag: TEST-0008
Part Number: HMA82GR7JJR8N-WM
Rank: 2
Configured Memory Speed: 2933 MT/s
Minimum Voltage: 1.2 V
Maximum Voltage: 1.2 V
Configured Voltage: 1.2 V
Memory Technology: DRAM
Memory Operating Mode Capability: Volatile memory
Firmware Version: Unknown
Module Manufacturer ID: Bank 1, Hex 0xAD
Module Product ID: Unknown
Memory Subsystem Controller Manufacturer ID: Unknown
Memory Subsystem Controller Product ID: Unknown
Non-Volatile Size: None
Volatile Size: 16 GB
Cache Size: None
Logical Size: None
Handle 0x004D, DMI type 17, 92 bytes
Memory Device
Array Handle: 0x0039
Error Information Handle: 0x004C
Total Width: 72 bits
Data Width: 64 bits
Size: 16 GB
Form Factor: DIMM
Set: None
Locator: DIMM_P0_E0
Bank Locator: BANK 0
Type: DDR4
Type Detail: Synchronous Registered (Buffered)
Speed: 2933 MT/s
Manufacturer: SK Hynix
Serial Number: TEST-0009
Asset Tag: TEST-0010
Part Number: HMA82GR7JJR8N-WM
Rank: 2
Configured Memory Speed: 2933 MT/s
Minimum Voltage: 1.2 V
Maximum Voltage: 1.2 V
Configured Voltage: 1.2 V
Memory Technology: DRAM
Memory Operating Mode Capability: Volatile memory
Firmware Version: Unknown
Module Manufacturer ID: Bank 1, Hex 0xAD
Module Product ID: Unknown
Memory Subsystem Controller Manufacturer ID: Unknown
Memory Subsystem Controller Product ID: Unknown
Non-Volatile Size: None
Volatile Size: 16 GB
Cache Size: None
Logical Size: None
Handle 0x0050, DMI type 17, 92 bytes
Memory Device
Array Handle: 0x0039
Error Information Handle: 0x004F
Total Width: 72 bits
Data Width: 64 bits
Size: 16 GB
Form Factor: DIMM
Set: None
Locator: DIMM_P0_F0
Bank Locator: BANK 0
Type: DDR4
Type Detail: Synchronous Registered (Buffered)
Speed: 2933 MT/s
Manufacturer: SK Hynix
Serial Number: TEST-0011
Asset Tag: TEST-0012
Part Number: HMA82GR7JJR8N-WM
Rank: 2
Configured Memory Speed: 2933 MT/s
Minimum Voltage: 1.2 V
Maximum Voltage: 1.2 V
Configured Voltage: 1.2 V
Memory Technology: DRAM
Memory Operating Mode Capability: Volatile memory
Firmware Version: Unknown
Module Manufacturer ID: Bank 1, Hex 0xAD
Module Product ID: Unknown
Memory Subsystem Controller Manufacturer ID: Unknown
Memory Subsystem Controller Product ID: Unknown
Non-Volatile Size: None
Volatile Size: 16 GB
Cache Size: None
Logical Size: None
Handle 0x0053, DMI type 17, 92 bytes
Memory Device
Array Handle: 0x0039
Error Information Handle: 0x0052
Total Width: 72 bits
Data Width: 64 bits
Size: 16 GB
Form Factor: DIMM
Set: None
Locator: DIMM_P0_G0
Bank Locator: BANK 0
Type: DDR4
Type Detail: Synchronous Registered (Buffered)
Speed: 2933 MT/s
Manufacturer: SK Hynix
Serial Number: TEST-0013
Asset Tag: TEST-0014
Part Number: HMA82GR7JJR8N-WM
Rank: 2
Configured Memory Speed: 2933 MT/s
Minimum Voltage: 1.2 V
Maximum Voltage: 1.2 V
Configured Voltage: 1.2 V
Memory Technology: DRAM
Memory Operating Mode Capability: Volatile memory
Firmware Version: Unknown
Module Manufacturer ID: Bank 1, Hex 0xAD
Module Product ID: Unknown
Memory Subsystem Controller Manufacturer ID: Unknown
Memory Subsystem Controller Product ID: Unknown
Non-Volatile Size: None
Volatile Size: 16 GB
Cache Size: None
Logical Size: None
Handle 0x0056, DMI type 17, 92 bytes
Memory Device
Array Handle: 0x0039
Error Information Handle: 0x0055
Total Width: Unknown
Data Width: Unknown
Size: No Module Installed
Form Factor: Unknown
Set: None
Locator: DIMM_P0_H0
Bank Locator: BANK 0
Type: Unknown
Type Detail: Unknown
Handle 0x005C, DMI type 17, 92 bytes
Memory Device
Array Handle: 0x0039
Error Information Handle: 0x005B
Total Width: 72 bits
Data Width: 64 bits
Size: 16 GB
Form Factor: DIMM
Set: None
Locator: DIMM_P1_I0
Bank Locator: BANK 0
Type: DDR4
Type Detail: Synchronous Registered (Buffered)
Speed: 2933 MT/s
Manufacturer: SK Hynix
Serial Number: TEST-0015
Asset Tag: TEST-0016
Part Number: HMA82GR7JJR8N-WM
Rank: 2
Configured Memory Speed: 2933 MT/s
Minimum Voltage: 1.2 V
Maximum Voltage: 1.2 V
Configured Voltage: 1.2 V
Memory Technology: DRAM
Memory Operating Mode Capability: Volatile memory
Firmware Version: Unknown
Module Manufacturer ID: Bank 1, Hex 0xAD
Module Product ID: Unknown
Memory Subsystem Controller Manufacturer ID: Unknown
Memory Subsystem Controller Product ID: Unknown
Non-Volatile Size: None
Volatile Size: 16 GB
Cache Size: None
Logical Size: None
Handle 0x005F, DMI type 17, 92 bytes
Memory Device
Array Handle: 0x0039
Error Information Handle: 0x005E
Total Width: Unknown
Data Width: Unknown
Size: No Module Installed
Form Factor: Unknown
Set: None
Locator: DIMM_P1_J0
Bank Locator: BANK 0
Type: Unknown
Type Detail: Unknown
Handle 0x0061, DMI type 17, 92 bytes
Memory Device
Array Handle: 0x0039
Error Information Handle: 0x0060
Total Width: 72 bits
Data Width: 64 bits
Size: 16 GB
Form Factor: DIMM
Set: None
Locator: DIMM_P1_K0
Bank Locator: BANK 0
Type: DDR4
Type Detail: Synchronous Registered (Buffered)
Speed: 2933 MT/s
Manufacturer: SK Hynix
Serial Number: TEST-0017
Asset Tag: TEST-0018
Part Number: HMA82GR7JJR8N-WM
Rank: 2
Configured Memory Speed: 2933 MT/s
Minimum Voltage: 1.2 V
Maximum Voltage: 1.2 V
Configured Voltage: 1.2 V
Memory Technology: DRAM
Memory Operating Mode Capability: Volatile memory
Firmware Version: Unknown
Module Manufacturer ID: Bank 1, Hex 0xAD
Module Product ID: Unknown
Memory Subsystem Controller Manufacturer ID: Unknown
Memory Subsystem Controller Product ID: Unknown
Non-Volatile Size: None
Volatile Size: 16 GB
Cache Size: None
Logical Size: None
Handle 0x0064, DMI type 17, 92 bytes
Memory Device
Array Handle: 0x0039
Error Information Handle: 0x0063
Total Width: 72 bits
Data Width: 64 bits
Size: 16 GB
Form Factor: DIMM
Set: None
Locator: DIMM_P1_L0
Bank Locator: BANK 0
Type: DDR4
Type Detail: Synchronous Registered (Buffered)
Speed: 2933 MT/s
Manufacturer: SK Hynix
Serial Number: TEST-0019
Asset Tag: TEST-0020
Part Number: HMA82GR7JJR8N-WM
Rank: 2
Configured Memory Speed: 2933 MT/s
Minimum Voltage: 1.2 V
Maximum Voltage: 1.2 V
Configured Voltage: 1.2 V
Memory Technology: DRAM
Memory Operating Mode Capability: Volatile memory
Firmware Version: Unknown
Module Manufacturer ID: Bank 1, Hex 0xAD
Module Product ID: Unknown
Memory Subsystem Controller Manufacturer ID: Unknown
Memory Subsystem Controller Product ID: Unknown
Non-Volatile Size: None
Volatile Size: 16 GB
Cache Size: None
Logical Size: None
Handle 0x0067, DMI type 17, 92 bytes
Memory Device
Array Handle: 0x0039
Error Information Handle: 0x0066
Total Width: 72 bits
Data Width: 64 bits
Size: 16 GB
Form Factor: DIMM
Set: None
Locator: DIMM_P1_M0
Bank Locator: BANK 0
Type: DDR4
Type Detail: Synchronous Registered (Buffered)
Speed: 2933 MT/s
Manufacturer: SK Hynix
Serial Number: TEST-0021
Asset Tag: TEST-0022
Part Number: HMA82GR7JJR8N-WM
Rank: 2
Configured Memory Speed: 2933 MT/s
Minimum Voltage: 1.2 V
Maximum Voltage: 1.2 V
Configured Voltage: 1.2 V
Memory Technology: DRAM
Memory Operating Mode Capability: Volatile memory
Firmware Version: Unknown
Module Manufacturer ID: Bank 1, Hex 0xAD
Module Product ID: Unknown
Memory Subsystem Controller Manufacturer ID: Unknown
Memory Subsystem Controller Product ID: Unknown
Non-Volatile Size: None
Volatile Size: 16 GB
Cache Size: None
Logical Size: None
Handle 0x006A, DMI type 17, 92 bytes
Memory Device
Array Handle: 0x0039
Error Information Handle: 0x0069
Total Width: 72 bits
Data Width: 64 bits
Size: 16 GB
Form Factor: DIMM
Set: None
Locator: DIMM_P1_N0
Bank Locator: BANK 0
Type: DDR4
Type Detail: Synchronous Registered (Buffered)
Speed: 2933 MT/s
Manufacturer: SK Hynix
Serial Number: TEST-0023
Asset Tag: TEST-0024
Part Number: HMA82GR7JJR8N-WM
Rank: 2
Configured Memory Speed: 2933 MT/s
Minimum Voltage: 1.2 V
Maximum Voltage: 1.2 V
Configured Voltage: 1.2 V
Memory Technology: DRAM
Memory Operating Mode Capability: Volatile memory
Firmware Version: Unknown
Module Manufacturer ID: Bank 1, Hex 0xAD
Module Product ID: Unknown
Memory Subsystem Controller Manufacturer ID: Unknown
Memory Subsystem Controller Product ID: Unknown
Non-Volatile Size: None
Volatile Size: 16 GB
Cache Size: None
Logical Size: None
Handle 0x006D, DMI type 17, 92 bytes
Memory Device
Array Handle: 0x0039
Error Information Handle: 0x006C
Total Width: 72 bits
Data Width: 64 bits
Size: 16 GB
Form Factor: DIMM
Set: None
Locator: DIMM_P1_O0
Bank Locator: BANK 0
Type: DDR4
Type Detail: Synchronous Registered (Buffered)
Speed: 2933 MT/s
Manufacturer: SK Hynix
Serial Number: TEST-0025
Asset Tag: TEST-0026
Part Number: HMA82GR7JJR8N-WM
Rank: 2
Configured Memory Speed: 2933 MT/s
Minimum Voltage: 1.2 V
Maximum Voltage: 1.2 V
Configured Voltage: 1.2 V
Memory Technology: DRAM
Memory Operating Mode Capability: Volatile memory
Firmware Version: Unknown
Module Manufacturer ID: Bank 1, Hex 0xAD
Module Product ID: Unknown
Memory Subsystem Controller Manufacturer ID: Unknown
Memory Subsystem Controller Product ID: Unknown
Non-Volatile Size: None
Volatile Size: 16 GB
Cache Size: None
Logical Size: None
Handle 0x0070, DMI type 17, 92 bytes
Memory Device
Array Handle: 0x0039
Error Information Handle: 0x006F
Total Width: 72 bits
Data Width: 64 bits
Size: 16 GB
Form Factor: DIMM
Set: None
Locator: DIMM_P1_P0
Bank Locator: BANK 0
Type: DDR4
Type Detail: Synchronous Registered (Buffered)
Speed: 2933 MT/s
Manufacturer: SK Hynix
Serial Number: TEST-0027
Asset Tag: TEST-0028
Part Number: HMA82GR7JJR8N-WM
Rank: 2
Configured Memory Speed: 2933 MT/s
Minimum Voltage: 1.2 V
Maximum Voltage: 1.2 V
Configured Voltage: 1.2 V
Memory Technology: DRAM
Memory Operating Mode Capability: Volatile memory
Firmware Version: Unknown
Module Manufacturer ID: Bank 1, Hex 0xAD
Module Product ID: Unknown
Memory Subsystem Controller Manufacturer ID: Unknown
Memory Subsystem Controller Product ID: Unknown
Non-Volatile Size: None
Volatile Size: 16 GB
Cache Size: None
Logical Size: None
@@ -1,116 +0,0 @@
# dmidecode 3.6
Getting SMBIOS data from sysfs.
SMBIOS 3.3.0 present.
Handle 0x003F, DMI type 4, 48 bytes
Processor Information
Socket Designation: P0
Type: Central Processor
Family: Zen
Manufacturer: Advanced Micro Devices, Inc.
ID: TEST-0001
Signature: Family 25, Model 1, Stepping 1
Flags:
FPU (Floating-point unit on-chip)
VME (Virtual mode extension)
DE (Debugging extension)
PSE (Page size extension)
TSC (Time stamp counter)
MSR (Model specific registers)
PAE (Physical address extension)
MCE (Machine check exception)
CX8 (CMPXCHG8 instruction supported)
APIC (On-chip APIC hardware supported)
SEP (Fast system call)
MTRR (Memory type range registers)
PGE (Page global enable)
MCA (Machine check architecture)
CMOV (Conditional move instruction supported)
PAT (Page attribute table)
PSE-36 (36-bit page size extension)
CLFSH (CLFLUSH instruction supported)
MMX (MMX technology supported)
FXSR (FXSAVE and FXSTOR instructions supported)
SSE (Streaming SIMD extensions)
SSE2 (Streaming SIMD extensions 2)
HTT (Multi-threading)
Version: AMD EPYC 7513 32-Core Processor
Voltage: 1.1 V
External Clock: 100 MHz
Max Speed: 3675 MHz
Current Speed: 2600 MHz
Status: Populated, Enabled
Upgrade: Socket SP3
L1 Cache Handle: 0x003C
L2 Cache Handle: 0x003D
L3 Cache Handle: 0x003E
Serial Number: Unknown
Asset Tag: Unknown
Part Number: Unknown
Core Count: 32
Core Enabled: 32
Thread Count: 64
Characteristics:
64-bit capable
Multi-Core
Hardware Thread
Execute Protection
Enhanced Virtualization
Power/Performance Control
Handle 0x005A, DMI type 4, 48 bytes
Processor Information
Socket Designation: P1
Type: Central Processor
Family: Zen
Manufacturer: Advanced Micro Devices, Inc.
ID: TEST-0004
Signature: Family 25, Model 1, Stepping 1
Flags:
FPU (Floating-point unit on-chip)
VME (Virtual mode extension)
DE (Debugging extension)
PSE (Page size extension)
TSC (Time stamp counter)
MSR (Model specific registers)
PAE (Physical address extension)
MCE (Machine check exception)
CX8 (CMPXCHG8 instruction supported)
APIC (On-chip APIC hardware supported)
SEP (Fast system call)
MTRR (Memory type range registers)
PGE (Page global enable)
MCA (Machine check architecture)
CMOV (Conditional move instruction supported)
PAT (Page attribute table)
PSE-36 (36-bit page size extension)
CLFSH (CLFLUSH instruction supported)
MMX (MMX technology supported)
FXSR (FXSAVE and FXSTOR instructions supported)
SSE (Streaming SIMD extensions)
SSE2 (Streaming SIMD extensions 2)
HTT (Multi-threading)
Version: AMD EPYC 7513 32-Core Processor
Voltage: 1.1 V
External Clock: 100 MHz
Max Speed: 3675 MHz
Current Speed: 2600 MHz
Status: Populated, Enabled
Upgrade: Socket SP3
L1 Cache Handle: 0x0057
L2 Cache Handle: 0x0058
L3 Cache Handle: 0x0059
Serial Number: Unknown
Asset Tag: Unknown
Part Number: Unknown
Core Count: 32
Core Enabled: 32
Thread Count: 64
Characteristics:
64-bit capable
Multi-Core
Hardware Thread
Execute Protection
Enhanced Virtualization
Power/Performance Control
@@ -1 +0,0 @@
{"Neighbors":[{"InterfaceIndex":2,"InterfaceName":"eno1","InterfaceAlternativeNames":["enp97s0f0","enx74563c072e4f"],"Neighbors":[{"ChassisID":"04:f4:1c:ec:da:9e","RawChassisID":[4,4,244,28,236,218,158],"PortID":"ether18","RawPortID":[5,101,116,104,101,114,49,56],"PortDescription":"bridge/ether18","SystemName":"MikroTik 1gbe","SystemDescription":"MikroTik RouterOS 7.19.6 (stable) 2025-09-12 09:02:42 CRS326-24G-2S+","EnabledCapabilities":20}]},{"InterfaceIndex":19,"InterfaceName":"enp33s0f0np0","InterfaceAlternativeNames":["enxb8599fe8b142"],"Neighbors":[{"ChassisID":"d0:ea:11:a8:91:4c","RawChassisID":[4,208,234,17,168,145,76],"PortID":"qsfp28-7-1","RawPortID":[5,113,115,102,112,50,56,45,55,45,49],"PortDescription":"bridge/qsfp28-7-1","SystemName":"MikroTik 100gbe","SystemDescription":"MikroTik RouterOS 7.20.8 (long-term) 2026-01-30 09:17:54 CRS520-4XS-16XQ","EnabledCapabilities":20}]},{"InterfaceIndex":20,"InterfaceName":"enp33s0f1np1","InterfaceAlternativeNames":["enxb8599fe8b143"],"Neighbors":[{"ChassisID":"d0:ea:11:a8:91:4c","RawChassisID":[4,208,234,17,168,145,76],"PortID":"qsfp28-5-1","RawPortID":[5,113,115,102,112,50,56,45,53,45,49],"PortDescription":"bridge/qsfp28-5-1","SystemName":"MikroTik 100gbe","SystemDescription":"MikroTik RouterOS 7.20.8 (long-term) 2026-01-30 09:17:54 CRS520-4XS-16XQ","EnabledCapabilities":20}]}]}
-1
View File
@@ -677,7 +677,6 @@ func launchService(config *config.ClusterConfig) error {
Credentials: bedrockCredentials,
Access: bedrockAccess,
NetworkTests: operatoradmin.NewKVNetworkTests(js),
IAM: iamService,
Quota: quotaSvc,
BucketStore: objStore,
@@ -7,6 +7,8 @@ package qemunbdd_test
import (
"os"
"os/exec"
"strconv"
"strings"
"testing"
"time"
@@ -14,6 +16,8 @@ import (
"github.com/mulgadc/spinifex/spinifex/providers/ebs"
"github.com/mulgadc/spinifex/spinifex/providers/ebs/conformance"
"github.com/mulgadc/spinifex/spinifex/providers/ebs/natsserve"
"github.com/mulgadc/spinifex/spinifex/runtime/compute/nbd"
hostprocess "github.com/mulgadc/spinifex/spinifex/runtime/host/process"
"github.com/mulgadc/spinifex/spinifex/runtime/roles/qemunbdd"
"github.com/stretchr/testify/require"
)
@@ -58,9 +62,23 @@ func newQEMUProviderTB(t testing.TB) ebsprovider.EBSProvider {
return provider
}
// killQEMUExport stops the qemu-nbd serving an export behind the provider's
// back, through the pid file it writes beside the socket, as a crash would.
func killQEMUExport(t *testing.T, published *ebsprovider.PublishedVolume) {
t.Helper()
serverType, socketPath, _, _, err := nbd.ParseNBDURI(published.Export.NBD.NBDURI)
require.NoError(t, err)
require.Equal(t, "unix", serverType)
data, err := os.ReadFile(strings.TrimSuffix(socketPath, ".sock") + ".pid")
require.NoError(t, err)
pid, err := strconv.Atoi(strings.TrimSpace(string(data)))
require.NoError(t, err)
require.NoError(t, hostprocess.ForceKillProcess(pid, 5*time.Second))
}
func TestQEMUNBDProviderConformance(t *testing.T) {
requireQEMUTools(t)
conformance.RunSuite(t, newQEMUProvider)
conformance.RunSuiteWithConfig(t, newQEMUProvider, conformance.SuiteConfig{KillExport: killQEMUExport})
}
// TestQEMUNBDExternalNBDClient checks the half of the boundary our own Go
@@ -76,12 +94,12 @@ func TestQEMUNBDExternalNBDClient(t *testing.T) {
// in it, and a client that cannot tell the difference.
func TestQEMUNBDProviderNATSConformance(t *testing.T) {
requireQEMUTools(t)
conformance.RunSuite(t, func(t *testing.T) ebsprovider.EBSProvider {
conformance.RunSuiteWithConfig(t, func(t *testing.T) ebsprovider.EBSProvider {
t.Helper()
_, conn := testutil.StartTestNATS(t)
stop, err := natsserve.Serve(t.Context(), conn, newQEMUProvider(t), natsserve.Options{})
require.NoError(t, err)
t.Cleanup(stop)
return ebsprovider.NewNATSProvider(conn, 15*time.Second)
})
}, conformance.SuiteConfig{KillExport: killQEMUExport})
}
@@ -76,13 +76,15 @@ func TestLive_QEMUNBDLifecycle(t *testing.T) {
cleanupCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cleanupBestEffort(t, "unpublish restored volume", func() error {
return provider.UnpublishVolume(cleanupCtx, ebsprovider.UnpublishVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: restoredVolumeID, NodeID: nodeID})
_, err := provider.UnpublishVolume(cleanupCtx, ebsprovider.UnpublishVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: restoredVolumeID, NodeID: nodeID})
return err
})
cleanupBestEffort(t, "delete restored volume", func() error {
return provider.DeleteVolume(cleanupCtx, ebsprovider.DeleteVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: restoredVolumeID})
})
cleanupBestEffort(t, "unpublish volume", func() error {
return provider.UnpublishVolume(cleanupCtx, ebsprovider.UnpublishVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID, NodeID: nodeID})
_, err := provider.UnpublishVolume(cleanupCtx, ebsprovider.UnpublishVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID, NodeID: nodeID})
return err
})
cleanupBestEffort(t, "delete snapshot", func() error {
return provider.DeleteSnapshot(cleanupCtx, ebsprovider.DeleteSnapshotRequest{Versioned: ebsprovider.NewVersioned(), SnapshotID: snapshotID})
@@ -110,7 +112,8 @@ func TestLive_QEMUNBDLifecycle(t *testing.T) {
writePattern(t, sockPath, pattern, liveVolumeBytes)
readAndVerifyPattern(t, sockPath, pattern, liveVolumeBytes)
require.NoError(t, provider.UnpublishVolume(ctx, ebsprovider.UnpublishVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID, NodeID: nodeID}), "unpublish volume")
_, err = provider.UnpublishVolume(ctx, ebsprovider.UnpublishVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID, NodeID: nodeID})
require.NoError(t, err, "unpublish volume")
t.Logf("unpublished volume %s", volumeID)
snap, err := provider.CreateSnapshot(ctx, ebsprovider.CreateSnapshotRequest{Versioned: ebsprovider.NewVersioned(), SnapshotID: snapshotID, VolumeID: volumeID})
@@ -136,7 +139,8 @@ func TestLive_QEMUNBDLifecycle(t *testing.T) {
// snapshot and the restore into an independent volume.
readAndVerifyPattern(t, restoredSockPath, pattern, liveVolumeBytes)
require.NoError(t, provider.UnpublishVolume(ctx, ebsprovider.UnpublishVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: restoredVolumeID, NodeID: nodeID}), "unpublish restored volume")
_, err = provider.UnpublishVolume(ctx, ebsprovider.UnpublishVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: restoredVolumeID, NodeID: nodeID})
require.NoError(t, err, "unpublish restored volume")
t.Logf("unpublished restored volume %s", restoredVolumeID)
}
+87 -14
View File
@@ -14,9 +14,11 @@ import (
"strings"
"sync"
"syscall"
"time"
"github.com/mulgadc/spinifex/spinifex/providers/ebs"
"github.com/mulgadc/spinifex/spinifex/runtime/compute/nbd"
hostprocess "github.com/mulgadc/spinifex/spinifex/runtime/host/process"
)
// nbdSharedClients bounds how many simultaneous NBD connections qemu-nbd
@@ -69,8 +71,27 @@ type publication struct {
nodeID string
socketPath string
pid int
readOnly bool
}
// dead reports positive evidence that the export is no longer serving: its
// qemu-nbd has exited or its socket is gone. Without a PID nothing is known.
// A qemu-nbd whose socket is gone can still be writing for a connected client.
func (p *publication) dead() bool {
if p.pid <= 0 {
return false
}
if !hostprocess.ProcessAlive(p.pid) {
return true
}
_, err := os.Stat(p.socketPath)
return errors.Is(err, os.ErrNotExist)
}
// defaultStopWait bounds the wait for a qemu-nbd to exit after SIGTERM
// before a republish over its dead socket is refused.
const defaultStopWait = 10 * time.Second
// Provider is an EBSProvider backed by qcow2 files and qemu-nbd. One volume
// is one qcow2 file; a snapshot is an independent copy, not a chunk
// reference, so DeleteVolume never has to consult a snapshot the way viperblock does.
@@ -78,6 +99,9 @@ type Provider struct {
baseDir string
run runner
// stopWait overrides defaultStopWait. Zero means the default.
stopWait time.Duration
mu sync.Mutex
published map[string]*publication
volumeMeta map[string]volumeMeta
@@ -131,11 +155,11 @@ func (p *Provider) pidFilePath(volumeID string) string {
}
// checkVersion mirrors ebsprovider's supported schema window. qemunbdd's
// normal operations have the same representation in both versions; the NATS
// normal operations have the same representation in every version; the NATS
// server owns the version-specific PublishVolume wire conversion.
func checkVersion(version uint16) error {
if !ebsprovider.SupportsSchemaVersion(version) {
return fmt.Errorf("%w: got %d, supported versions are %d and %d", ebsprovider.ErrUnsupportedVersion, version, ebsprovider.LegacySchemaVersion, ebsprovider.SchemaVersion)
return fmt.Errorf("%w: got %d, %s", ebsprovider.ErrUnsupportedVersion, version, ebsprovider.SupportedVersionsText())
}
return nil
}
@@ -148,7 +172,11 @@ func (p *Provider) GetCapabilities(_ context.Context, req ebsprovider.GetCapabil
if err := checkVersion(req.SchemaVersion); err != nil {
return nil, err
}
return &ebsprovider.GetCapabilitiesResponse{Versioned: ebsprovider.NewVersioned(), Capabilities: capabilities}, nil
return &ebsprovider.GetCapabilitiesResponse{
Versioned: ebsprovider.ReplyVersioned(req.SchemaVersion),
Capabilities: capabilities,
ContractVersions: ebsprovider.SupportedSchemaVersions(),
}, nil
}
// qemuImgInfo is the subset of `qemu-img info --output=json` this provider
@@ -350,7 +378,7 @@ func (p *Provider) ListVolumes(_ context.Context, req ebsprovider.ListVolumesReq
return nil, fmt.Errorf("qemunbdd: read volumes directory: %w", err)
}
response := &ebsprovider.ListVolumesResponse{Versioned: ebsprovider.NewVersioned()}
response := &ebsprovider.ListVolumesResponse{Versioned: ebsprovider.ReplyVersioned(req.SchemaVersion)}
response.Volumes, response.NextToken = ebsprovider.Page(qcow2IDs(entries), req.StartingToken, int(req.PageSize()),
func(id string) ebsprovider.VolumeRef {
return ebsprovider.VolumeRef{ID: id, Handle: p.volumePath(id)}
@@ -527,7 +555,7 @@ func (p *Provider) ListSnapshots(_ context.Context, req ebsprovider.ListSnapshot
p.mu.Lock()
defer p.mu.Unlock()
response := &ebsprovider.ListSnapshotsResponse{Versioned: ebsprovider.NewVersioned()}
response := &ebsprovider.ListSnapshotsResponse{Versioned: ebsprovider.ReplyVersioned(req.SchemaVersion)}
response.Snapshots, response.NextToken = ebsprovider.Page(qcow2IDs(entries), req.StartingToken, int(req.PageSize()),
func(id string) ebsprovider.SnapshotRef {
return ebsprovider.SnapshotRef{
@@ -686,9 +714,21 @@ func (p *Provider) PublishVolume(ctx context.Context, req ebsprovider.PublishVol
}
if pub, ok := p.published[req.VolumeID]; ok {
if pub.nodeID != req.NodeID {
return nil, fmt.Errorf("%w: volume %s is published to %s", ebsprovider.ErrVolumeInUse, req.VolumeID, pub.nodeID)
return nil, fmt.Errorf("%w: volume %s is published to %s", ebsprovider.HeldElsewhereError(req.SchemaVersion), req.VolumeID, pub.nodeID)
}
// A dead export's entry is dropped and the volume exported again, so
// a republish after a crash never returns a socket nothing serves.
if pub.dead() {
if err := p.stopOrphanedExport(pub); err != nil {
return nil, fmt.Errorf("%w: volume %s: %w", ebsprovider.ErrUnavailable, req.VolumeID, err)
}
delete(p.published, req.VolumeID)
} else {
if pub.readOnly != req.ReadOnly {
return nil, fmt.Errorf("%w: volume %s is already published read_only=%t", ebsprovider.ErrVolumeInUse, req.VolumeID, pub.readOnly)
}
return &ebsprovider.PublishedVolume{VolumeID: req.VolumeID, NodeID: pub.nodeID, Export: ebsprovider.NewNBDExport(nbd.FormatNBDSocketURI(pub.socketPath))}, nil
}
return &ebsprovider.PublishedVolume{VolumeID: req.VolumeID, NodeID: pub.nodeID, Export: ebsprovider.NewNBDExport(nbd.FormatNBDSocketURI(pub.socketPath))}, nil
}
sockPath := p.socketPath(req.VolumeID)
@@ -717,10 +757,27 @@ func (p *Provider) PublishVolume(ctx context.Context, req ebsprovider.PublishVol
}
pid := readPID(pidPath)
p.published[req.VolumeID] = &publication{nodeID: req.NodeID, socketPath: sockPath, pid: pid}
p.published[req.VolumeID] = &publication{nodeID: req.NodeID, socketPath: sockPath, pid: pid, readOnly: req.ReadOnly}
return &ebsprovider.PublishedVolume{VolumeID: req.VolumeID, NodeID: req.NodeID, Export: ebsprovider.NewNBDExport(nbd.FormatNBDSocketURI(sockPath))}, nil
}
// stopOrphanedExport stops a dead entry's qemu-nbd if it is still running,
// since a connected client keeps writing through it and a second export would
// make two writers. It stops it as UnpublishVolume does, with SIGTERM.
func (p *Provider) stopOrphanedExport(pub *publication) error {
if !hostprocess.ProcessAlive(pub.pid) {
return nil
}
wait := p.stopWait
if wait == 0 {
wait = defaultStopWait
}
if !hostprocess.TerminateProcess(pub.pid, wait) {
return fmt.Errorf("qemu-nbd %d lost its socket and did not exit", pub.pid)
}
return nil
}
// readPID best-effort reads the PID qemu-nbd's --fork wrote to pidPath. A
// missing or unparsable file (as in tests, where no real qemu-nbd runs)
// yields 0, meaning UnpublishVolume will skip signaling a process.
@@ -736,20 +793,23 @@ func readPID(pidPath string) int {
return pid
}
func (p *Provider) UnpublishVolume(_ context.Context, req ebsprovider.UnpublishVolumeRequest) error {
if err := checkVersion(req.SchemaVersion); err != nil {
return err
// UnpublishVolume honours NoSeal trivially: qemu-nbd writes straight to the
// volume's qcow2 file, so there is no separate copy to seal or to drop.
func (p *Provider) UnpublishVolume(_ context.Context, req ebsprovider.UnpublishVolumeRequest) (*ebsprovider.UnpublishedVolume, error) {
if err := ebsprovider.CheckUnpublishVersion(req); err != nil {
return nil, err
}
if req.VolumeID == "" || req.NodeID == "" {
return fmt.Errorf("%w: volume and node IDs are required", ebsprovider.ErrInvalidArgument)
return nil, fmt.Errorf("%w: volume and node IDs are required", ebsprovider.ErrInvalidArgument)
}
p.mu.Lock()
defer p.mu.Unlock()
result := &ebsprovider.UnpublishedVolume{}
pub, ok := p.published[req.VolumeID]
if !ok || pub.nodeID != req.NodeID {
return nil
return result, nil
}
if pub.pid > 0 {
if proc, err := os.FindProcess(pub.pid); err == nil {
@@ -759,5 +819,18 @@ func (p *Provider) UnpublishVolume(_ context.Context, req ebsprovider.UnpublishV
_ = os.Remove(pub.socketPath)
_ = os.Remove(p.pidFilePath(req.VolumeID))
delete(p.published, req.VolumeID)
return nil
result.TornDown = req.SchemaVersion >= ebsprovider.NoSealSchemaVersion
return result, nil
}
// HoldsUnsealedWrites answers that none are held: the qcow2 file qemu-nbd
// writes is the backend, so no write is held anywhere it does not already have.
func (p *Provider) HoldsUnsealedWrites(_ context.Context, nodeID string, volumeIDs []string) ([]string, error) {
if nodeID == "" {
return nil, fmt.Errorf("%w: node ID is required", ebsprovider.ErrInvalidArgument)
}
if slices.Contains(volumeIDs, "") {
return nil, fmt.Errorf("%w: empty volume ID", ebsprovider.ErrInvalidArgument)
}
return nil, nil
}
@@ -3,13 +3,17 @@ package qemunbdd
import (
"context"
"os"
"os/exec"
"path/filepath"
"sync"
"testing"
"time"
"github.com/mulgadc/spinifex/spinifex/providers/ebs"
hostprocess "github.com/mulgadc/spinifex/spinifex/runtime/host/process"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/sys/unix"
)
// call records one invocation the fake runner captured, so tests can assert
@@ -621,14 +625,14 @@ func TestUnpublishVolume_Success(t *testing.T) {
_, err := p.PublishVolume(context.Background(), ebsprovider.PublishVolumeRequest{Versioned: versioned(), VolumeID: "vol-unpub", NodeID: "node-1"})
require.NoError(t, err)
require.NoError(t, p.UnpublishVolume(context.Background(), ebsprovider.UnpublishVolumeRequest{Versioned: versioned(), VolumeID: "vol-unpub", NodeID: "node-1"}))
require.NoError(t, unpublishErr(p.UnpublishVolume(context.Background(), ebsprovider.UnpublishVolumeRequest{Versioned: versioned(), VolumeID: "vol-unpub", NodeID: "node-1"})))
_, err = os.Stat(p.socketPath("vol-unpub"))
require.True(t, os.IsNotExist(err))
}
func TestUnpublishVolume_AbsentIsIdempotent(t *testing.T) {
p, _ := newTestProvider(t)
require.NoError(t, p.UnpublishVolume(context.Background(), ebsprovider.UnpublishVolumeRequest{Versioned: versioned(), VolumeID: "vol-never-existed", NodeID: "node-1"}))
require.NoError(t, unpublishErr(p.UnpublishVolume(context.Background(), ebsprovider.UnpublishVolumeRequest{Versioned: versioned(), VolumeID: "vol-never-existed", NodeID: "node-1"})))
}
func TestUnpublishVolume_RepeatIsIdempotent(t *testing.T) {
@@ -638,8 +642,8 @@ func TestUnpublishVolume_RepeatIsIdempotent(t *testing.T) {
_, err := p.PublishVolume(context.Background(), ebsprovider.PublishVolumeRequest{Versioned: versioned(), VolumeID: "vol-unpub-repeat", NodeID: "node-1"})
require.NoError(t, err)
require.NoError(t, p.UnpublishVolume(context.Background(), ebsprovider.UnpublishVolumeRequest{Versioned: versioned(), VolumeID: "vol-unpub-repeat", NodeID: "node-1"}))
require.NoError(t, p.UnpublishVolume(context.Background(), ebsprovider.UnpublishVolumeRequest{Versioned: versioned(), VolumeID: "vol-unpub-repeat", NodeID: "node-1"}))
require.NoError(t, unpublishErr(p.UnpublishVolume(context.Background(), ebsprovider.UnpublishVolumeRequest{Versioned: versioned(), VolumeID: "vol-unpub-repeat", NodeID: "node-1"})))
require.NoError(t, unpublishErr(p.UnpublishVolume(context.Background(), ebsprovider.UnpublishVolumeRequest{Versioned: versioned(), VolumeID: "vol-unpub-repeat", NodeID: "node-1"})))
// Republishing after unpublish must start a fresh qemu-nbd.
_, err = p.PublishVolume(context.Background(), ebsprovider.PublishVolumeRequest{Versioned: versioned(), VolumeID: "vol-unpub-repeat", NodeID: "node-1"})
@@ -666,3 +670,79 @@ func TestGetCapabilities_Fixed(t *testing.T) {
assert.False(t, resp.Capabilities.OnlineExpansion)
assert.False(t, resp.Capabilities.OwnerRouting)
}
func unpublishErr(_ *ebsprovider.UnpublishedVolume, err error) error { return err }
// runningExport starts a process standing in for a qemu-nbd that lost its
// socket but kept running, reaped on its own goroutine as a real fork would be.
func runningExport(t *testing.T) int {
t.Helper()
hostprocess.ShortenProcessExitPollForTest(t, 5*time.Millisecond)
cmd := exec.Command("sleep", "300")
require.NoError(t, cmd.Start())
reaped := make(chan struct{})
go func() { defer close(reaped); _ = cmd.Wait() }()
t.Cleanup(func() { _ = cmd.Process.Kill(); <-reaped })
return cmd.Process.Pid
}
// unstoppableExport returns a zombie: kill(pid,0) still reaches it and no
// signal makes it exit, so it stands in for a qemu-nbd that will not stop.
func unstoppableExport(t *testing.T) int {
t.Helper()
hostprocess.ShortenProcessExitPollForTest(t, 5*time.Millisecond)
cmd := exec.Command("sleep", "300")
require.NoError(t, cmd.Start())
require.NoError(t, cmd.Process.Kill())
var info unix.Siginfo
require.NoError(t, unix.Waitid(unix.P_PID, cmd.Process.Pid, &info, unix.WEXITED|unix.WNOWAIT, nil))
t.Cleanup(func() { _ = cmd.Wait() })
return cmd.Process.Pid
}
// aliveAtRun records, at the moment qemu-nbd is run, whether pid is alive.
type aliveAtRun struct {
*fakeRunner
pid int
aliveSeen []bool
}
func (r *aliveAtRun) Run(ctx context.Context, name string, args ...string) ([]byte, error) {
if name == "qemu-nbd" {
r.aliveSeen = append(r.aliveSeen, hostprocess.ProcessAlive(r.pid))
}
return r.fakeRunner.Run(ctx, name, args...)
}
// A qemu-nbd whose socket is gone still serves a client already connected to
// it, so re-exporting beside it makes two writers on one qcow2.
func TestPublishVolume_OrphanStoppedFirst(t *testing.T) {
p, fr := newTestProvider(t)
const volumeID = "vol-orph"
require.NoError(t, os.WriteFile(p.volumePath(volumeID), nil, 0o640))
pid := runningExport(t)
run := &aliveAtRun{fakeRunner: fr, pid: pid}
p.run = run
p.published[volumeID] = &publication{nodeID: "node-1", socketPath: p.socketPath(volumeID), pid: pid}
_, err := p.PublishVolume(context.Background(), ebsprovider.PublishVolumeRequest{Versioned: versioned(), VolumeID: volumeID, NodeID: "node-1"})
require.NoError(t, err)
assert.Equal(t, []bool{false}, run.aliveSeen, "the old qemu-nbd must be gone before the volume is exported again")
}
// A qemu-nbd that will not stop is still a writer, so a second export is
// refused as retry-later and the entry kept so the next attempt tries again.
func TestPublishVolume_OrphanUnstoppable(t *testing.T) {
p, fr := newTestProvider(t)
p.stopWait = 50 * time.Millisecond
const volumeID = "vol-orphan-stuck"
require.NoError(t, os.WriteFile(p.volumePath(volumeID), nil, 0o640))
pub := &publication{nodeID: "node-1", socketPath: p.socketPath(volumeID), pid: unstoppableExport(t)}
p.published[volumeID] = pub
_, err := p.PublishVolume(context.Background(), ebsprovider.PublishVolumeRequest{Versioned: versioned(), VolumeID: volumeID, NodeID: "node-1"})
require.ErrorIs(t, err, ebsprovider.ErrUnavailable)
assert.Same(t, pub, p.published[volumeID], "the entry must stay while its process may still write")
assert.Empty(t, fr.callsSnapshot(), "no second qemu-nbd may start beside one that would not stop")
}
@@ -0,0 +1 @@
import{Ft as e,Mt as t,Pt as n}from"./schemas-CKmbY8gV.js";import{s as r}from"./tabs-C7-u1MlW.js";function i(i){let{render:a,className:o,style:s,state:c=e,props:l=n,refs:u=n,metadata:d,stateAttributesMapping:f,tag:p=`div`,...m}=i,{compositeProps:h,compositeRef:g}=r({metadata:d});return t(p,i,{state:c,ref:[g,...u],props:[h,...l,m],stateAttributesMapping:f})}export{i as t};
@@ -1 +0,0 @@
import{Ct as e,Et as t,Tt as n}from"./schemas-DCQXG11d.js";import{r}from"./CompositeRoot-CmHoR-Tk.js";function i(i){let{render:a,className:o,style:s,state:c=t,props:l=n,refs:u=n,metadata:d,stateAttributesMapping:f,tag:p=`div`,...m}=i,{compositeProps:h,compositeRef:g}=r({metadata:d});return e(p,i,{state:c,ref:[g,...u],props:[h,...l,m],stateAttributesMapping:f})}export{i as t};
@@ -1 +0,0 @@
import{r as e}from"./rolldown-runtime-XKyaHtl9.js";import{Ct as t,Et as n,It as r,Lt as i,Q as a,Tt as o,Z as s,et as c,kt as l,tt as u}from"./schemas-DCQXG11d.js";import{$ as d,_ as f,a as p,d as m,i as h,l as g,m as _,n as v,p as y,r as b,s as x,t as S,u as C,v as w}from"./composite-DysENEse.js";import{i as T,n as E,r as D,t as O}from"./isElementDisabled-B7Ri5ug6.js";var k=e(i(),1);function A(e={}){let{highlightItemOnHover:t,highlightedIndex:n,onHighlightedIndexChange:r}=a(),{ref:i,index:o}=E(e),s=n===o,c=k.useRef(null),u=l(i,c);return{compositeProps:{tabIndex:s?0:-1,onFocus(){r(o)},onMouseMove(){let e=c.current;if(!t||!e)return;let n=e.hasAttribute(`disabled`)||e.ariaDisabled===`true`;!s&&!n&&e.focus()}},compositeRef:u,index:o}}var j=`data-composite-item-active`;function M(e){let{loopFocus:t=!0,orientation:n=`both`,grid:r,onLoop:i,direction:a,highlightedIndex:s,onHighlightedIndexChange:x,rootRef:T,enableHomeAndEndKeys:E=!1,stopEventPropagation:D,disabledIndices:A,modifierKeys:j=o}=e,[M,F]=k.useState(0),I=r!=null,L=k.useRef(null),R=l(L,T),z=k.useRef([]),B=k.useRef(!1),V=k.useRef(null),H=s??M,U=u((e,t=!1)=>{if(V.current=z.current[e]??null,(x??F)(e),t){let t=z.current[e];C(L.current,t,a,n)}}),W=u(e=>{if(e.size===0)return;if(B.current){let e=z.current,t=e.indexOf(V.current);if(t===-1){let t=e[H];!t||w(e,H,A)?U(N(e,A)):V.current=t}else t!==H&&U(t);return}B.current=!0;let t=Array.from(e.keys()),r=t.find(e=>e?.hasAttribute(`data-composite-item-active`))??null,i=r?e.get(r)?.index??-1:-1;if(i!==-1)U(i);else if(w(t,H,A)){let e=m(t,{disabledIndices:A});f(t,e)||U(e)}C(L.current,r,a,n)});c(()=>{if(A==null||s!=null||!B.current)return;let e=z.current;if(w(e,H,A)){let t=m(e,{disabledIndices:A});f(e,t)||U(t)}},[A,s,H,z,U]);let G=u((e,t,n)=>i?i(e,t,n,z):n),K=u(e=>{let o=e.key===`Home`||e.key===`End`;if(!p.has(e.key)||!E&&o||P(e,j)||!L.current)return;let s=a===`rtl`,c=s?v:b,l=s?b:v,u=n===`vertical`?S:c,x=n===`vertical`?h:l,C=d(e.nativeEvent);if(C!=null&&g(C)&&!O(C)){let t=C.selectionStart,n=C.selectionEnd,r=C.value;if(t==null||e.shiftKey||t!==n||e.key!==x&&t<r.length||e.key!==u&&t>0)return}let w=H,T=_(z,A),k=y(z,A);r!=null&&(w=r({disabledIndices:A,elementsRef:z,event:e,highlightedIndex:H,loopFocus:t,maxIndex:k,minIndex:T,onLoop:G,orientation:n,rtl:s}));let M=n!==`vertical`&&e.key===c||n!==`horizontal`&&e.key===`ArrowDown`,N=n!==`vertical`&&e.key===l||n!==`horizontal`&&e.key===`ArrowUp`;E&&(e.key===`Home`?w=T:e.key===`End`&&(w=k)),w===H&&(M||N)&&(t&&w===k&&M?(w=T,i&&(w=i(e,H,w,z))):t&&w===T&&N?(w=k,i&&(w=i(e,H,w,z))):w=m(z.current,{startingIndex:w,decrement:N,disabledIndices:A})),w!==H&&!f(z.current,w)&&(D&&e.stopPropagation(),(I||o||M||N)&&e.preventDefault(),U(w,!0),queueMicrotask(()=>{z.current[w]?.focus()}))});return{props:{ref:R,onFocus(e){let t=L.current,n=d(e.nativeEvent);t&&n!=null&&g(n)&&n.setSelectionRange(0,n.value.length)},onKeyDown:K},highlightedIndex:H,onHighlightedIndexChange:U,elementsRef:z,onMapChange:W,relayKeyboardEvent:K}}function N(e,t){let n=-1;for(let r=0;r<e.length;r+=1){let i=e[r];if(i&&!w(e,r,t)){if(i.hasAttribute(`data-composite-item-active`))return r;n===-1&&(n=r)}}return Math.max(n,0)}function P(e,t){for(let n of x)if(!t.includes(n)&&e.getModifierState(n))return!0;return!1}var F=r();function I(e){let{render:r,className:i,style:a,refs:c=o,props:l=o,state:u=n,stateAttributesMapping:d,highlightedIndex:f,onHighlightedIndexChange:p,orientation:m,grid:h,loopFocus:g,onLoop:_,enableHomeAndEndKeys:v,onMapChange:y,stopEventPropagation:b=!0,rootRef:x,disabledIndices:S,modifierKeys:C,highlightItemOnHover:w=!1,tag:E=`div`,...O}=e,{props:A,highlightedIndex:j,onHighlightedIndexChange:N,elementsRef:P,onMapChange:I,relayKeyboardEvent:L}=M({grid:h,loopFocus:g,onLoop:_,orientation:m,highlightedIndex:f,onHighlightedIndexChange:p,rootRef:x,stopEventPropagation:b,enableHomeAndEndKeys:v,direction:T(),disabledIndices:S,modifierKeys:C}),R=t(E,e,{state:u,ref:c,props:[A,...l,O],stateAttributesMapping:d}),z=k.useMemo(()=>({highlightedIndex:j,onHighlightedIndexChange:N,highlightItemOnHover:w,relayKeyboardEvent:L}),[j,N,w,L]);return(0,F.jsx)(s.Provider,{value:z,children:(0,F.jsx)(D,{elementsRef:P,onMapChange:e=>{y?.(e),I(e)},children:R})})}export{j as n,A as r,I as t};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
import{r as e}from"./rolldown-runtime-XKyaHtl9.js";import{Gt as t,Kt as n}from"./schemas-CKmbY8gV.js";import{i as r,p as i,r as a}from"./matchContext-C_cBF5rG.js";import{n as o}from"./x-BuYxxvoG.js";import{t as s}from"./useSelector-BuHlvrXV.js";var c=e(n(),1),l=t(),u=()=>!0,d=()=>!1;function f({children:e,fallback:t=null}){return(0,l.jsx)(c.Fragment,{children:p()?e:t})}function p(e=!0){return c.useSyncExternalStore(m,u,e?d:u)}function m(){return()=>{}}function h(e){let t=r(),n=`not-found-${s(t.stores.location,e=>e.pathname)}-${s(t.stores.status)}`;return(0,l.jsx)(o,{getResetKey:()=>n,onCatch:(t,n)=>{if(i(t))e.onCatch?.(t,n);else throw t},errorComponent:({error:t})=>{if(i(t))return e.fallback?.(t);throw t},children:e.children})}function g(){return(0,l.jsx)(`p`,{children:`Not Found`})}function _(e,t,n){return t.options.notFoundComponent?(0,l.jsx)(t.options.notFoundComponent,{...n}):e.options.defaultNotFoundComponent?(0,l.jsx)(e.options.defaultNotFoundComponent,{...n}):(0,l.jsx)(g,{})}function v(e,t){let n=t?.options.pendingComponent??e.options.defaultPendingComponent;return n?(0,l.jsx)(n,{}):null}var y=(e,t)=>e[0]===t[0]&&e[1]===t[1],b=(e,t,n)=>!t.isRoot||t.options.shellComponent||t.options.wrapInSuspense||n===!1||n===`data-only`||!e.ssr,x=c.memo(function({routeId:e}){let t=r(),n=t.stores.getMatchStore(e);return S(t,s(n))});function S(e,t){let n=e.routesById[t.routeId],r=v(e,n),s=n.options.errorComponent??e.options.defaultErrorComponent,u=n.options.onCatch??e.options.defaultOnCatch,d=n.isRoot?n.options.notFoundComponent??e.options.notFoundRoute?.options.component:n.options.notFoundComponent,p=t.ssr===!1||t.ssr===`data-only`,m=b(e,n,t.ssr)&&(n.options.wrapInSuspense??r??(n.options.errorComponent?.preload||p)),g=(0,l.jsx)(C,{match:t});p&&(g=(0,l.jsx)(f,{fallback:r,children:g})),d&&(g=(0,l.jsx)(h,{fallback:e=>{if(e.routeId??=t.routeId,e.routeId!==t.routeId)throw e;return c.createElement(d,e)},children:g})),s&&(g=(0,l.jsx)(o,{getResetKey:()=>t,errorComponent:s,onCatch:(e,n)=>{if(i(e))throw e.routeId??=t.routeId,e;u?.(e,n)},children:g})),m&&(g=(0,l.jsx)(c.Suspense,{fallback:r,children:g}));let _=n.isRoot&&n.options.shellComponent;return _?(0,l.jsx)(a.Provider,{value:t.routeId,children:(0,l.jsxs)(_,{children:[g,null]})}):(0,l.jsxs)(a.Provider,{value:t.routeId,children:[g,null]})}var C=c.memo(function({match:e}){let t=r(),n=e.routeId,i=t.routesById[n],a=c.useMemo(()=>{let r=(i.options.remountDeps??t.options.defaultRemountDeps)?.({routeId:n,loaderDeps:e.loaderDeps,params:e._strictParams,search:e._strictSearch});return r?JSON.stringify(r):void 0},[n,e.loaderDeps,e._strictParams,e._strictSearch,i.options.remountDeps,t.options.defaultRemountDeps]),o=c.useMemo(()=>{let e=i.options.component??t.options.defaultComponent;return e?(0,l.jsx)(e,{},a):(0,l.jsx)(w,{})},[a,i.options.component,t.options.defaultComponent]);if(e.status===`pending`){if(t.ssr&&!b(t,i,e.ssr))return o;if(t._tx)throw t._tx[5];return v(t,i)}if(e.status===`notFound`)return _(t,i,e.error);if(e.status===`error`)throw e.error;return o}),w=c.memo(function(){let e=r(),t=c.useContext(a),n,i,o;{let r=e.stores.getMatchStore(t);[n,i]=s(r,e=>[!!e._notFound,e.error],{compare:y}),o=s(e.stores.ids,e=>e[e.indexOf(t)+1])}if(n)return _(e,e.routesById[t],i);if(!o)return null;let u=(0,l.jsx)(x,{routeId:o});return t===`__root__`?(0,l.jsx)(c.Suspense,{fallback:v(e),children:u}):u});export{p as i,w as n,v as r,x as t};
@@ -1 +0,0 @@
import{r as e}from"./rolldown-runtime-XKyaHtl9.js";import{It as t,Lt as n}from"./schemas-DCQXG11d.js";import{d as r,r as i}from"./matchContext-CUAcfcYw.js";import{n as a}from"./x-mlA6pp-8.js";import{t as o}from"./useRouter-B_eSHXWd.js";import{t as s}from"./useSelector-CDb-n1FW.js";var c=e(n(),1),l=t(),u=()=>!0,d=()=>!1;function f({children:e,fallback:t=null}){return(0,l.jsx)(c.Fragment,{children:p()?e:t})}function p(e=!0){return c.useSyncExternalStore(m,u,e?d:u)}function m(){return()=>{}}function h(e){let t=o(),n=`not-found-${s(t.stores.location,e=>e.pathname)}-${s(t.stores.status)}`;return(0,l.jsx)(a,{getResetKey:()=>n,onCatch:(t,n)=>{if(r(t))e.onCatch?.(t,n);else throw t},errorComponent:({error:t})=>{if(r(t))return e.fallback?.(t);throw t},children:e.children})}function g(){return(0,l.jsx)(`p`,{children:`Not Found`})}function _(e,t,n){return t.options.notFoundComponent?(0,l.jsx)(t.options.notFoundComponent,{...n}):e.options.defaultNotFoundComponent?(0,l.jsx)(e.options.defaultNotFoundComponent,{...n}):(0,l.jsx)(g,{})}function v(e,t){let n=t?.options.pendingComponent??e.options.defaultPendingComponent;return n?(0,l.jsx)(n,{}):null}var y=(e,t)=>e[0]===t[0]&&e[1]===t[1],b=(e,t,n)=>!t.isRoot||t.options.shellComponent||t.options.wrapInSuspense||n===!1||n===`data-only`||!e.ssr,x=c.memo(function({routeId:e}){let t=o(),n=t.stores.getMatchStore(e);return S(t,s(n))});function S(e,t){let n=e.routesById[t.routeId],o=v(e,n),s=n.options.errorComponent??e.options.defaultErrorComponent,u=n.options.onCatch??e.options.defaultOnCatch,d=n.isRoot?n.options.notFoundComponent??e.options.notFoundRoute?.options.component:n.options.notFoundComponent,p=t.ssr===!1||t.ssr===`data-only`,m=b(e,n,t.ssr)&&(n.options.wrapInSuspense??o??(n.options.errorComponent?.preload||p)),g=(0,l.jsx)(C,{match:t});p&&(g=(0,l.jsx)(f,{fallback:o,children:g})),d&&(g=(0,l.jsx)(h,{fallback:e=>{if(e.routeId??=t.routeId,e.routeId!==t.routeId)throw e;return c.createElement(d,e)},children:g})),s&&(g=(0,l.jsx)(a,{getResetKey:()=>t,errorComponent:s,onCatch:(e,n)=>{if(r(e))throw e.routeId??=t.routeId,e;u?.(e,n)},children:g})),m&&(g=(0,l.jsx)(c.Suspense,{fallback:o,children:g}));let _=n.isRoot&&n.options.shellComponent;return _?(0,l.jsx)(i.Provider,{value:t.routeId,children:(0,l.jsxs)(_,{children:[g,null]})}):(0,l.jsxs)(i.Provider,{value:t.routeId,children:[g,null]})}var C=c.memo(function({match:e}){let t=o(),n=e.routeId,r=t.routesById[n],i=c.useMemo(()=>{let i=(r.options.remountDeps??t.options.defaultRemountDeps)?.({routeId:n,loaderDeps:e.loaderDeps,params:e._strictParams,search:e._strictSearch});return i?JSON.stringify(i):void 0},[n,e.loaderDeps,e._strictParams,e._strictSearch,r.options.remountDeps,t.options.defaultRemountDeps]),a=c.useMemo(()=>{let e=r.options.component??t.options.defaultComponent;return e?(0,l.jsx)(e,{},i):(0,l.jsx)(w,{})},[i,r.options.component,t.options.defaultComponent]);if(e.status===`pending`){if(t.ssr&&!b(t,r,e.ssr))return a;if(t._tx)throw t._tx[5];return v(t,r)}if(e.status===`notFound`)return _(t,r,e.error);if(e.status===`error`)throw e.error;return a}),w=c.memo(function(){let e=o(),t=c.useContext(i),n,r,a;{let i=e.stores.getMatchStore(t);[n,r]=s(i,e=>[!!e._notFound,e.error],{compare:y}),a=s(e.stores.ids,e=>e[e.indexOf(t)+1])}if(n)return _(e,e.routesById[t],r);if(!a)return null;let u=(0,l.jsx)(x,{routeId:a});return t===`__root__`?(0,l.jsx)(c.Suspense,{fallback:v(e),children:u}):u});export{p as i,w as n,v as r,x as t};
@@ -1 +1 @@
import{r as e}from"./rolldown-runtime-XKyaHtl9.js";import{It as t,Lt as n}from"./schemas-DCQXG11d.js";var r=e(n(),1),i=t(),a=r.createContext(void 0),o=e=>{let t=r.useContext(a);if(e)return e;if(!t)throw Error(`No QueryClient set, use QueryClientProvider to set one`);return t},s=({client:e,children:t})=>(r.useEffect(()=>(e.mount(),()=>{e.unmount()}),[e]),(0,i.jsx)(a.Provider,{value:e,children:t}));export{o as n,s as t};
import{r as e}from"./rolldown-runtime-XKyaHtl9.js";import{Gt as t,Kt as n}from"./schemas-CKmbY8gV.js";var r=e(n(),1),i=t(),a=r.createContext(void 0),o=e=>{let t=r.useContext(a);if(e)return e;if(!t)throw Error(`No QueryClient set, use QueryClientProvider to set one`);return t},s=({client:e,children:t})=>(r.useEffect(()=>(e.mount(),()=>{e.unmount()}),[e]),(0,i.jsx)(a.Provider,{value:e,children:t}));export{o as n,s as t};
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
import{r as e}from"./rolldown-runtime-XKyaHtl9.js";import{$ as t,Lt as n}from"./schemas-DCQXG11d.js";var r=e(n(),1),i=r.createContext(void 0);function a(e){let n=r.useContext(i);if(n===void 0&&!e)throw Error(t(69));return n}export{a as t};
@@ -0,0 +1 @@
import{Gt as e,Vt as t,Y as n}from"./schemas-CKmbY8gV.js";import{t as r}from"./object-browser-D7yr9rgb.js";import{t as i}from"./_-CFb8JFvm.js";var a=t(),o=e();function s(){let e=(0,a.c)(10),{bucket:t,_splat:s}=i.useParams(),c=s??``,l;e[0]===c?l=e[1]:(l=n(c),e[0]=c,e[1]=l);let u=l,d=i.useSearch(),f=i.useNavigate(),p;e[2]===f?p=e[3]:(p=function(e){f({search:t=>({...t,...e}),replace:!(`selected`in e)})},e[2]=f,e[3]=p);let m=p,h=`${t}/${u}`,g;return e[4]!==t||e[5]!==u||e[6]!==m||e[7]!==d||e[8]!==h?(g=(0,o.jsx)(r,{bucket:t,folder:u,onSearchChange:m,search:d},h),e[4]=t,e[5]=u,e[6]=m,e[7]=d,e[8]=h,e[9]=g):g=e[9],g}export{s as component};
@@ -0,0 +1,2 @@
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/_-7KkIsFVy.js","assets/schemas-CKmbY8gV.js","assets/rolldown-runtime-XKyaHtl9.js","assets/object-browser-D7yr9rgb.js","assets/QueryClientProvider-B3gFFx5-.js","assets/queryOptions-C3LOV3Yq.js","assets/useQuery-DgGWYOhJ.js","assets/lazyRouteComponent-BIOh5GOe.js","assets/matchContext-C_cBF5rG.js","assets/link-CoQQT1u0.js","assets/useSelector-BuHlvrXV.js","assets/data-table-D9AZRPew.js","assets/visuallyHidden-Di6ozTl7.js","assets/check-BURgRfkZ.js","assets/useRegisterFieldControl-BpMxH-yS.js","assets/useAriaLabelledBy-C1btqlkM.js","assets/useControlled-CliDS5IQ.js","assets/table-Br7RGWB8.js","assets/error-banner-6H_aBcIY.js","assets/chevron-right-BaqRZN6V.js","assets/upload-CUlCi7gE.js","assets/sliders-horizontal-B4nfPbkq.js","assets/detail-panel-BUy-SsXi.js","assets/x-BuYxxvoG.js","assets/chevron-down-CiWCIUx5.js","assets/copy-Clj54gC8.js","assets/loader-circle-BWw23_DY.js","assets/useScrollLock-tbqBjaVX.js","assets/useValueAsRef-DtjtqpoE.js","assets/ToolbarRootContext-CPQMf91l.js","assets/isElementDisabled-DwizONlP.js","assets/store-Dx1XhYcs.js","assets/collapsible-NKtgkvXc.js","assets/tabs-C7-u1MlW.js","assets/use-copy-to-clipboard-B2jqkkCP.js","assets/input-group-CdRkywl6.js","assets/field-D45t5nwl.js","assets/textarea-DjPTve3M.js","assets/trash-DU2S79Q3.js","assets/uploads-ypeWSAfE.js","assets/awsClient-Da_1PWK9.js","assets/auth-LWMosi88.js","assets/index.browser-BEm-79SP.js","assets/s3-GemFNNG4.js","assets/useMutation-0VvC4-D7.js","assets/s3-e8hHPZ7C.js","assets/page-heading-DOCwCs80.js","assets/resource-view-RaCpeI0k.js","assets/selection-bar-Bb4fd2ci.js","assets/use-row-selection-DGXO__u7.js","assets/bulk-delete-CdjUpmX4.js","assets/circle-x-YQRHw4rt.js","assets/alert-dialog-B2wM6M0p.js","assets/DialogTitle-BlGk2Olt.js","assets/confirm-phrase-HKyrGgCW.js","assets/resource-delete-dialog-DB-hXwnM.js","assets/delete-confirmation-dialog-khwGiTUy.js"])))=>i.map(i=>d[i]);
import{Y as e,s as t}from"./schemas-CKmbY8gV.js";import{n,t as r}from"./lazyRouteComponent-BIOh5GOe.js";import{t as i}from"./searchMiddleware-CN1kfGOC.js";import{f as a,p as o}from"./detail-panel-BUy-SsXi.js";import{Mo as s}from"./awsClient-Da_1PWK9.js";import{t as c}from"./s3-e8hHPZ7C.js";var l=n(`/_auth/s3/ls/$bucket/$`)({validateSearch:t(o),search:{middlewares:[i(a)]},loader:async({context:t,params:n})=>{await t.queryClient.query({...c(n.bucket,e(n._splat??``)),staleTime:`static`})},head:({params:t})=>({meta:[{title:`${t.bucket}/${e(t._splat??``)} | Object Storage | Mulga`}]}),component:r(()=>s(()=>import(`./_-7KkIsFVy.js`),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56])),`component`)});export{l as t};
@@ -1 +0,0 @@
import{B as e,It as t,Mt as n}from"./schemas-DCQXG11d.js";import{t as r}from"./object-browser-D2G7ChbT.js";import{t as i}from"./_-jsAX0S-n.js";var a=n(),o=t();function s(){let t=(0,a.c)(10),{bucket:n,_splat:s}=i.useParams(),c=s??``,l;t[0]===c?l=t[1]:(l=e(c),t[0]=c,t[1]=l);let u=l,d=i.useSearch(),f=i.useNavigate(),p;t[2]===f?p=t[3]:(p=function(e){f({search:t=>({...t,...e}),replace:!(`selected`in e)})},t[2]=f,t[3]=p);let m=p,h=`${n}/${u}`,g;return t[4]!==n||t[5]!==u||t[6]!==m||t[7]!==d||t[8]!==h?(g=(0,o.jsx)(r,{bucket:n,folder:u,onSearchChange:m,search:d},h),t[4]=n,t[5]=u,t[6]=m,t[7]=d,t[8]=h,t[9]=g):g=t[9],g}export{s as component};
@@ -1,2 +0,0 @@
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/_-EgUy8iPZ.js","assets/schemas-DCQXG11d.js","assets/rolldown-runtime-XKyaHtl9.js","assets/object-browser-D2G7ChbT.js","assets/QueryClientProvider-BuG40HBe.js","assets/queryOptions-Cu2lvOFG.js","assets/useQuery-D6TojLgI.js","assets/link-BX-1BPor.js","assets/matchContext-CUAcfcYw.js","assets/useRouter-B_eSHXWd.js","assets/data-table-9n7xOy9s.js","assets/useSelector-CDb-n1FW.js","assets/visuallyHidden-BHQz5bmH.js","assets/check-DPoQ9cBn.js","assets/useRegisterFieldControl-DkI655of.js","assets/useAriaLabelledBy-DStQJVTn.js","assets/useTransitionStatus-Dn-09Anc.js","assets/useValueChanged-wii0yYWk.js","assets/useControlled-NIWPmeG5.js","assets/table-BLvCYTga.js","assets/useNavigate-cMdr_EJ0.js","assets/chevron-right-DZULbaNT.js","assets/upload-uiiRWdV_.js","assets/eye-yi4wz0yh.js","assets/input-group-TEGNx6dm.js","assets/useValueAsRef-uzS1fuI8.js","assets/field-iLG94M00.js","assets/textarea-BG1iGvVs.js","assets/sliders-horizontal-DsGbsnBU.js","assets/trash-BajuWzqn.js","assets/detail-panel-DLM1ExsZ.js","assets/x-mlA6pp-8.js","assets/info-tip-Cxa2GEOd.js","assets/useScrollLock-iGnmnC-h.js","assets/composite-DysENEse.js","assets/useAnchoredPopupScrollLock-CoHshdlQ.js","assets/isElementDisabled-B7Ri5ug6.js","assets/store-BVhNJayb.js","assets/ToolbarRootContext-iMMgkc2r.js","assets/chevron-down-BgudX6bq.js","assets/copy-B9ICxw_u.js","assets/loader-circle-C7EgoGp_.js","assets/collapsible-DddM174D.js","assets/tabs-9tHEWpNn.js","assets/CompositeRoot-CmHoR-Tk.js","assets/panel-search-LDLf8HCe.js","assets/error-banner-Cahg3Ix2.js","assets/use-copy-to-clipboard-D-PxomFK.js","assets/uploads-BF3vgNvM.js","assets/awsClient-BvDfQdL8.js","assets/auth-BS4WytAe.js","assets/fetch-http-handler-B--F8e2N.js","assets/toUtf8.browser-RsrLknAJ.js","assets/index.browser-D7GVEViY.js","assets/s3-Dw9Wbkov.js","assets/useMutation-lRi0DDcO.js","assets/s3-C7kuNeMZ.js","assets/page-heading-ZfrnK2wu.js","assets/resource-view-CPdFr_3k.js","assets/selection-bar-WnknBhRB.js","assets/use-row-selection-DB6L9NiD.js","assets/bulk-delete-CXIUB03J.js","assets/circle-x-C6c0uvb-.js","assets/alert-dialog-D9jG-rkO.js","assets/DialogTitle-nAIOTphJ.js","assets/confirm-phrase-C4Dux-0Y.js","assets/resource-delete-dialog-D3NYuO-k.js","assets/delete-confirmation-dialog-O7cqOW0Y.js"])))=>i.map(i=>d[i]);
import{B as e,s as t}from"./schemas-DCQXG11d.js";import{n,t as r}from"./lazyRouteComponent-PMetaA3O.js";import{t as i}from"./searchMiddleware-Clk50hy1.js";import{U as a}from"./fetch-http-handler-B--F8e2N.js";import{t as o}from"./s3-C7kuNeMZ.js";import{r as s,t as c}from"./panel-search-LDLf8HCe.js";var l=n(`/_auth/s3/ls/$bucket/$`)({validateSearch:t(s),search:{middlewares:[i(c)]},loader:async({context:t,params:n})=>{await t.queryClient.query({...o(n.bucket,e(n._splat??``)),staleTime:`static`})},head:({params:t})=>({meta:[{title:`${t.bucket}/${e(t._splat??``)} | Object Storage | Mulga`}]}),component:r(()=>a(()=>import(`./_-EgUy8iPZ.js`),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67])),`component`)});export{l as t};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More