mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-11-02 17:23:32 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f54296dfd1 |
@@ -24,9 +24,6 @@ build/microvm/qboot.LICENSE
|
||||
build/microvm/sqs-worker/vmlinuz
|
||||
build/microvm/sqs-worker/initramfs.cpio.gz
|
||||
build/microvm/sqs-worker/.stage.*/
|
||||
build/microvm/lambda-runtime/vmlinuz
|
||||
build/microvm/lambda-runtime/initramfs.cpio.gz
|
||||
build/microvm/lambda-runtime/.stage.*/
|
||||
build/qboot/
|
||||
!spinifex/runtime/roles/spinifexui/frontend/dist/
|
||||
tmp/
|
||||
|
||||
@@ -169,7 +169,7 @@ go_local_srcs = $(shell GOWORK=off CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go list
|
||||
# worker as separate processes. A separate artifact from the LB image above.
|
||||
SQS_WORKER_MICROVM_OUT_DIR := $(MICROVM_OUT_DIR)/sqs-worker
|
||||
SQS_WORKER_MICROVM_ARTIFACTS := $(SQS_WORKER_MICROVM_OUT_DIR)/vmlinuz $(SQS_WORKER_MICROVM_OUT_DIR)/initramfs.cpio.gz
|
||||
SQS_WORKER_MICROVM_INPUTS := scripts/build-sqs-store-microvm-image.sh scripts/lib/microvm-image.sh scripts/lib/sqs-store-microvm-publish.sh $(SQS_WORKER_MICROVM_OUT_DIR)/init.sh $(SQS_WORKER_MICROVM_OUT_DIR)/inittab $(addprefix $(SQS_WORKER_MICROVM_OUT_DIR)/,sqs-worker-lib.sh sqs-worker-prepare sqs-worker-store sqs-worker-ready sqs-worker-stop) bin/nats-server bin/sqs-worker
|
||||
SQS_WORKER_MICROVM_INPUTS := scripts/build-sqs-store-microvm-image.sh scripts/lib/sqs-store-microvm-publish.sh $(SQS_WORKER_MICROVM_OUT_DIR)/init.sh $(SQS_WORKER_MICROVM_OUT_DIR)/inittab $(addprefix $(SQS_WORKER_MICROVM_OUT_DIR)/,sqs-worker-lib.sh sqs-worker-prepare sqs-worker-store sqs-worker-ready sqs-worker-stop) bin/nats-server bin/sqs-worker
|
||||
|
||||
$(SQS_WORKER_MICROVM_ARTIFACTS) &: $(SQS_WORKER_MICROVM_INPUTS)
|
||||
./scripts/build-sqs-store-microvm-image.sh
|
||||
@@ -180,20 +180,6 @@ bin/sqs-worker: go.mod go.sum $$(call go_local_srcs,./cmd/sqs-worker)
|
||||
|
||||
build-sqs-worker-microvm-image: $(SQS_WORKER_MICROVM_ARTIFACTS) ## Build the SQS microVM kernel and initramfs
|
||||
|
||||
# The Lambda runtime microVM image. Built on demand; not shipped or installed.
|
||||
LAMBDA_RUNTIME_MICROVM_OUT_DIR := $(MICROVM_OUT_DIR)/lambda-runtime
|
||||
LAMBDA_RUNTIME_MICROVM_ARTIFACTS := $(LAMBDA_RUNTIME_MICROVM_OUT_DIR)/vmlinuz $(LAMBDA_RUNTIME_MICROVM_OUT_DIR)/initramfs.cpio.gz
|
||||
LAMBDA_RUNTIME_MICROVM_INPUTS := scripts/build-lambda-runtime-microvm-image.sh scripts/lib/microvm-image.sh $(addprefix $(LAMBDA_RUNTIME_MICROVM_OUT_DIR)/,init.sh inittab run-agent) bin/lambda-agent
|
||||
|
||||
$(LAMBDA_RUNTIME_MICROVM_ARTIFACTS) &: $(LAMBDA_RUNTIME_MICROVM_INPUTS)
|
||||
./scripts/build-lambda-runtime-microvm-image.sh
|
||||
|
||||
bin/lambda-agent: go.mod go.sum $$(call go_local_srcs,./cmd/lambda-agent)
|
||||
@mkdir -p bin
|
||||
GOWORK=off CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags '-s -w' -o $@ ./cmd/lambda-agent
|
||||
|
||||
build-lambda-runtime-microvm-image: $(LAMBDA_RUNTIME_MICROVM_ARTIFACTS) ## Build the Lambda runtime microVM kernel and initramfs (not shipped)
|
||||
|
||||
# qboot is the microvm firmware. The source tarball ships with each release
|
||||
# because qboot is GPL-2.0.
|
||||
QBOOT_COMMIT := $(shell sed -n 's/^QBOOT_COMMIT="\(.*\)"$$/\1/p' scripts/build-qboot.sh)
|
||||
@@ -536,7 +522,7 @@ distro-arm64:
|
||||
distro-clean:
|
||||
rm -rf dist/
|
||||
|
||||
.PHONY: test-package-check build build-ui test-ui-e2e ui-e2e-container ui-baselines build-installer build-lb-agent build-ecs-agent build-system-image build-eks-node-image import-eks-node-image publish-eks-node-image build-ecs-node-image import-ecs-node-image build-rds-postgres-image import-rds-postgres-image build-rds-mariadb-image import-rds-mariadb-image build-microvm-image build-sqs-worker-microvm-image build-lambda-runtime-microvm-image install-microvm install-sqs-worker-microvm go_build preflight test test-cover test-race diff-coverage bench fuzz test-actions test-images test-build-scripts test-harness test-integration generate-aws-model-coverage aws-model-coverage test-segscan-oracle \
|
||||
.PHONY: test-package-check build build-ui test-ui-e2e ui-e2e-container ui-baselines build-installer build-lb-agent build-ecs-agent build-system-image build-eks-node-image import-eks-node-image publish-eks-node-image build-ecs-node-image import-ecs-node-image build-rds-postgres-image import-rds-postgres-image build-rds-mariadb-image import-rds-mariadb-image build-microvm-image build-sqs-worker-microvm-image install-microvm install-sqs-worker-microvm go_build preflight test test-cover test-race diff-coverage bench fuzz test-actions test-images test-build-scripts test-harness test-integration generate-aws-model-coverage aws-model-coverage test-segscan-oracle \
|
||||
deploy reinstall clean \
|
||||
install-system install-go install-aws quickinstall \
|
||||
lint fix govulncheck nilaway \
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
#!/bin/sh
|
||||
mount -t proc proc /proc
|
||||
mount -t sysfs sys /sys
|
||||
mount -t devtmpfs dev /dev
|
||||
|
||||
# modprobe needs a usable modules.dep; insmod by explicit path does not, so
|
||||
# fall back to it. Handles .ko and .ko.gz.
|
||||
load_mod() {
|
||||
modprobe "$1" 2>/dev/null && return 0
|
||||
for ko in $(find /lib/modules -name "$1.ko" -o -name "$1.ko.gz" 2>/dev/null); do
|
||||
insmod "$ko" 2>/dev/null && return 0
|
||||
case "$ko" in
|
||||
*.gz) gunzip -c "$ko" >/tmp/mod.ko 2>/dev/null \
|
||||
&& insmod /tmp/mod.ko 2>/dev/null && return 0 ;;
|
||||
esac
|
||||
done
|
||||
return 1
|
||||
}
|
||||
# lambda-agent reads its identity from fw_cfg and unloads qemu_fw_cfg before
|
||||
# anything else runs. virtio_net needs net_failover and failover, and the
|
||||
# insmod fallback resolves no dependencies, so load the chain bottom-up.
|
||||
load_mod qemu_fw_cfg || echo "[init] qemu_fw_cfg did not load" >/dev/console
|
||||
load_mod failover || true
|
||||
load_mod net_failover || true
|
||||
load_mod virtio_net || echo "[init] virtio_net did not load" >/dev/console
|
||||
|
||||
ip link set lo up
|
||||
|
||||
# The guest's addresses come from the same netcfg entry as other system VMs.
|
||||
NETCFG=/sys/firmware/qemu_fw_cfg/by_name/opt/spinifex/netcfg/raw
|
||||
i=0
|
||||
while [ "$i" -lt 25 ] && [ ! -f "$NETCFG" ]; do
|
||||
sleep 0.04
|
||||
i=$((i + 1))
|
||||
done
|
||||
if [ -f "$NETCFG" ]; then
|
||||
# shellcheck source=/dev/null
|
||||
. "$NETCFG"
|
||||
for n in 0 1 2 3 4 5; do
|
||||
eval mac=\$NIC${n}_MAC
|
||||
[ -z "$mac" ] && continue
|
||||
iface=""
|
||||
for _ in 1 2 3 4 5; do
|
||||
iface=$(ip -o link | awk -v m="$mac" 'tolower($0) ~ tolower(m) {print $2}' | tr -d :)
|
||||
[ -n "$iface" ] && break
|
||||
sleep 0.02
|
||||
done
|
||||
if [ -z "$iface" ]; then
|
||||
echo "[init] no interface for MAC $mac (NIC$n)" >/dev/console
|
||||
continue
|
||||
fi
|
||||
cidr="" gw="" default="" rdst="" rvia=""
|
||||
eval cidr=\$NIC${n}_CIDR
|
||||
eval gw=\$NIC${n}_GW
|
||||
eval default=\$NIC${n}_DEFAULT
|
||||
eval rdst=\$NIC${n}_ROUTE_DST
|
||||
eval rvia=\$NIC${n}_ROUTE_VIA
|
||||
ip link set "$iface" up
|
||||
[ -n "$cidr" ] && ip addr add "$cidr" dev "$iface"
|
||||
[ "$default" = "1" ] && [ -n "$gw" ] && ip route add default via "$gw" dev "$iface"
|
||||
[ -n "$rdst" ] && [ -n "$rvia" ] && ip route add "$rdst" via "$rvia" dev "$iface"
|
||||
done
|
||||
else
|
||||
# lambda-agent then fails to reach the host and the guest powers off.
|
||||
echo "[init] fw_cfg netcfg not available" >/dev/console
|
||||
fi
|
||||
|
||||
exec /sbin/init
|
||||
@@ -1,6 +0,0 @@
|
||||
# The power button loads here, not in /init: busybox init has its SIGUSR2
|
||||
# (poweroff) handler installed before sysinit runs, so a press can never reach
|
||||
# the pre-exec shell PID 1. A press before this line is lost; the host re-sends.
|
||||
::sysinit:/sbin/modprobe tiny_power_button power_signal=12
|
||||
::once:/usr/local/lib/lambda-runtime/run-agent
|
||||
::ctrlaltdel:/sbin/reboot
|
||||
@@ -1,6 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Runs the Lambda guest agent, then powers the guest off whatever its status.
|
||||
/usr/local/bin/lambda-agent >/dev/console 2>&1
|
||||
status=$?
|
||||
echo "[run-agent] lambda-agent exited with status $status" >/dev/console
|
||||
poweroff -f
|
||||
@@ -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
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
// lambda-agent runs inside a Lambda guest.
|
||||
//
|
||||
// A normal build reads its per-generation identity from fw_cfg, unloads
|
||||
// fw_cfg so nothing started later can read the guest key, and opens the
|
||||
// authenticated control connection to the host. It accepts no invocations and
|
||||
// never starts the function's bootstrap: the message protocol and grant
|
||||
// fencing do not exist yet. The agent never listens for the host.
|
||||
//
|
||||
// A lambdatestlink build instead relays invocations over an unauthenticated
|
||||
// test carrier for local proofs; it is not the guest protocol.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() { os.Exit(run()) }
|
||||
|
||||
func run() int {
|
||||
flag.Parse()
|
||||
return start(context.Background())
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
//go:build !lambdatestlink
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/agents/lambda/hostlink"
|
||||
)
|
||||
|
||||
// Exit statuses: the guest refused to start, the control connection failed,
|
||||
// or the host closed it.
|
||||
const (
|
||||
exitRefused = 2
|
||||
exitLink = 1
|
||||
exitClosed = 0
|
||||
)
|
||||
|
||||
// errNoProtocol is returned when the host sends anything: this build defines no
|
||||
// message protocol, so it accepts no work.
|
||||
var errNoProtocol = errors.New("host sent data, but this build accepts no invocations")
|
||||
|
||||
type startup struct {
|
||||
fwCfgDir string
|
||||
fwCfgRoot string
|
||||
unload func() error
|
||||
}
|
||||
|
||||
func start(ctx context.Context) int {
|
||||
s := startup{fwCfgDir: hostlink.FwCfgDir, fwCfgRoot: hostlink.FwCfgRoot, unload: hostlink.UnloadFwCfg}
|
||||
code, err := s.run(ctx)
|
||||
if err != nil {
|
||||
slog.Error("lambda-agent stopping", "exit", code, "err", err)
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
// run loads the identity, seals fw_cfg whether or not the load succeeded,
|
||||
// then authenticates to the host and holds the connection until it closes.
|
||||
func (s startup) run(ctx context.Context) (int, error) {
|
||||
id, loadErr := hostlink.LoadIdentity(s.fwCfgDir)
|
||||
if err := hostlink.SealFwCfg(s.fwCfgRoot, s.unload); err != nil {
|
||||
return exitRefused, errors.Join(loadErr, err)
|
||||
}
|
||||
if loadErr != nil {
|
||||
return exitRefused, loadErr
|
||||
}
|
||||
conn, err := hostlink.Dial(ctx, id)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
err = fmt.Errorf("%w: %w", err, context.Cause(ctx))
|
||||
}
|
||||
return exitLink, err
|
||||
}
|
||||
defer conn.Close()
|
||||
slog.Info("control connection open; invocations are not enabled in this build", "host", id.HostAddr)
|
||||
if err := awaitHostClose(ctx, conn); err != nil {
|
||||
return exitLink, err
|
||||
}
|
||||
return exitClosed, nil
|
||||
}
|
||||
|
||||
// awaitHostClose reads until the host closes the connection. Under TLS 1.3 a
|
||||
// host that rejects the guest certificate is only seen here, on first read.
|
||||
func awaitHostClose(ctx context.Context, conn *tls.Conn) error {
|
||||
stop := context.AfterFunc(ctx, func() { _ = conn.Close() })
|
||||
defer stop()
|
||||
n, err := conn.Read(make([]byte, 1))
|
||||
switch {
|
||||
case n > 0:
|
||||
return errNoProtocol
|
||||
case errors.Is(err, io.EOF):
|
||||
return nil
|
||||
case ctx.Err() != nil:
|
||||
return fmt.Errorf("control connection: %w", context.Cause(ctx))
|
||||
default:
|
||||
return fmt.Errorf("control connection: %w", err)
|
||||
}
|
||||
}
|
||||
@@ -1,256 +0,0 @@
|
||||
//go:build !lambdatestlink
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
lambdav1 "github.com/mulgadc/spinifex/contracts/lambda/v1"
|
||||
"github.com/mulgadc/spinifex/spinifex/runtime/compute/lambda/guestconn"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const scope lambdav1.Scope = "ap-southeast-2/000000000001/fn-1/env-1/gen-1"
|
||||
|
||||
// guest is a fake fw_cfg tree; unload removes it as unloading the module would.
|
||||
type guest struct {
|
||||
root string
|
||||
unloads int
|
||||
sealedAt chan struct{}
|
||||
}
|
||||
|
||||
func newGuest(t *testing.T, entries map[string][]byte) *guest {
|
||||
t.Helper()
|
||||
g := &guest{root: filepath.Join(t.TempDir(), "qemu_fw_cfg"), sealedAt: make(chan struct{}, 1)}
|
||||
for name, b := range entries {
|
||||
dir := filepath.Join(g.root, "by_name", lambdav1.FwCfgPrefix, name)
|
||||
require.NoError(t, os.MkdirAll(dir, 0o700))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "raw"), b, 0o600))
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
func (g *guest) startup() startup {
|
||||
return startup{
|
||||
fwCfgDir: filepath.Join(g.root, "by_name", lambdav1.FwCfgPrefix),
|
||||
fwCfgRoot: g.root,
|
||||
unload: func() error {
|
||||
g.unloads++
|
||||
g.sealedAt <- struct{}{}
|
||||
return os.RemoveAll(g.root)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type host struct {
|
||||
ln *net.TCPListener
|
||||
gen *guestconn.Generation
|
||||
conn chan accepted
|
||||
}
|
||||
|
||||
func newHost(t *testing.T) *host {
|
||||
t.Helper()
|
||||
ln, err := net.ListenTCP("tcp", &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1)})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { ln.Close() })
|
||||
gen, err := guestconn.NewGeneration(scope, time.Hour)
|
||||
require.NoError(t, err)
|
||||
return &host{ln: ln, gen: gen, conn: make(chan accepted, 1)}
|
||||
}
|
||||
|
||||
func (h *host) entries() map[string][]byte { return h.gen.GuestEntries(h.ln.Addr().String()) }
|
||||
|
||||
type accepted struct {
|
||||
conn *tls.Conn
|
||||
err error
|
||||
}
|
||||
|
||||
// accept runs the host end until ctx ends and reports what it accepted.
|
||||
func (h *host) accept(ctx context.Context) {
|
||||
go func() {
|
||||
c, err := h.gen.AcceptGuest(ctx, h.ln, 2*time.Second)
|
||||
h.conn <- accepted{c, err}
|
||||
}()
|
||||
}
|
||||
|
||||
// guest waits for the host to authenticate the guest.
|
||||
func (h *host) guest(t *testing.T) *tls.Conn {
|
||||
t.Helper()
|
||||
a := wait(t, h.conn, "host to authenticate the guest")
|
||||
require.NoError(t, a.err)
|
||||
return a.conn
|
||||
}
|
||||
|
||||
// wait bounds every test wait so a stalled host or startup fails promptly.
|
||||
func wait[T any](t *testing.T, ch <-chan T, what string) T {
|
||||
t.Helper()
|
||||
select {
|
||||
case v := <-ch:
|
||||
return v
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatalf("timed out waiting for %s", what)
|
||||
panic("unreachable")
|
||||
}
|
||||
}
|
||||
|
||||
type result struct {
|
||||
code int
|
||||
err error
|
||||
}
|
||||
|
||||
func runAsync(ctx context.Context, s startup) <-chan result {
|
||||
out := make(chan result, 1)
|
||||
go func() {
|
||||
code, err := s.run(ctx)
|
||||
out <- result{code, err}
|
||||
}()
|
||||
return out
|
||||
}
|
||||
|
||||
func TestStartupAuthenticatesAfterSealingFwCfg(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
h := newHost(t)
|
||||
h.accept(ctx)
|
||||
g := newGuest(t, h.entries())
|
||||
done := runAsync(ctx, g.startup())
|
||||
|
||||
conn := h.guest(t)
|
||||
select {
|
||||
case <-g.sealedAt:
|
||||
default:
|
||||
t.Fatal("guest dialled before fw_cfg was sealed")
|
||||
}
|
||||
_, err := os.Stat(g.root)
|
||||
assert.ErrorIs(t, err, os.ErrNotExist, "fw_cfg still readable while connected")
|
||||
|
||||
require.NoError(t, conn.Close())
|
||||
res := wait(t, done, "startup to return")
|
||||
assert.Equal(t, exitClosed, res.code)
|
||||
assert.NoError(t, res.err)
|
||||
assert.Equal(t, 1, g.unloads)
|
||||
}
|
||||
|
||||
func TestStartupRefusesHostData(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
h := newHost(t)
|
||||
h.accept(ctx)
|
||||
done := runAsync(ctx, newGuest(t, h.entries()).startup())
|
||||
|
||||
conn := h.guest(t)
|
||||
defer conn.Close()
|
||||
_, err := conn.Write([]byte{0})
|
||||
require.NoError(t, err)
|
||||
res := wait(t, done, "startup to return")
|
||||
assert.Equal(t, exitLink, res.code)
|
||||
assert.ErrorIs(t, res.err, errNoProtocol)
|
||||
}
|
||||
|
||||
// The host pins another generation's guest; the guest learns of the refusal
|
||||
// on its first read and fails with a link error, never a clean close.
|
||||
func TestStartupFailsWhenHostRefusesGuest(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
h := newHost(t)
|
||||
h.accept(ctx)
|
||||
other, err := guestconn.NewGeneration(scope, time.Hour)
|
||||
require.NoError(t, err)
|
||||
entries := h.entries()
|
||||
wrong := other.GuestEntries(h.ln.Addr().String())
|
||||
entries[lambdav1.EntryGuestKey], entries[lambdav1.EntryGuestCert] = wrong[lambdav1.EntryGuestKey], wrong[lambdav1.EntryGuestCert]
|
||||
g := newGuest(t, entries)
|
||||
|
||||
res := wait(t, runAsync(ctx, g.startup()), "startup to return")
|
||||
assert.Equal(t, exitLink, res.code)
|
||||
require.Error(t, res.err)
|
||||
assert.Contains(t, res.err.Error(), "bad certificate")
|
||||
assert.Equal(t, 1, g.unloads)
|
||||
select {
|
||||
case a := <-h.conn:
|
||||
require.Error(t, a.err, "host accepted a guest it does not pin")
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartupRefusesWithoutDialling(t *testing.T) {
|
||||
h := newHost(t)
|
||||
cases := map[string]struct {
|
||||
entries map[string][]byte
|
||||
unload func(g *guest) func() error
|
||||
want string
|
||||
}{
|
||||
"missing entry": {
|
||||
entries: func() map[string][]byte { e := h.entries(); delete(e, lambdav1.EntryHostCert); return e }(),
|
||||
want: "read host-cert",
|
||||
},
|
||||
"fw_cfg stays readable": {
|
||||
entries: h.entries(),
|
||||
unload: func(*guest) func() error { return func() error { return errors.New("module is built in") } },
|
||||
want: "fw_cfg still readable",
|
||||
},
|
||||
"unload claims success but tree remains": {
|
||||
entries: h.entries(),
|
||||
unload: func(*guest) func() error { return func() error { return nil } },
|
||||
want: "unload reported success",
|
||||
},
|
||||
}
|
||||
for name, tc := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
g := newGuest(t, tc.entries)
|
||||
s := g.startup()
|
||||
if tc.unload != nil {
|
||||
s.unload = tc.unload(g)
|
||||
}
|
||||
code, err := s.run(context.Background())
|
||||
assert.Equal(t, exitRefused, code)
|
||||
assert.ErrorContains(t, err, tc.want)
|
||||
})
|
||||
}
|
||||
|
||||
// Nothing dialled the host in any case above.
|
||||
require.NoError(t, h.ln.SetDeadline(time.Now().Add(50*time.Millisecond)))
|
||||
_, err := h.ln.Accept()
|
||||
var ne net.Error
|
||||
require.ErrorAs(t, err, &ne)
|
||||
assert.True(t, ne.Timeout())
|
||||
}
|
||||
|
||||
// A missing entry still seals fw_cfg: the key may be present even when
|
||||
// another entry is not.
|
||||
func TestStartupSealsEvenWhenIdentityIsIncomplete(t *testing.T) {
|
||||
h := newHost(t)
|
||||
e := h.entries()
|
||||
delete(e, lambdav1.EntryHostAddr)
|
||||
g := newGuest(t, e)
|
||||
code, err := g.startup().run(context.Background())
|
||||
assert.Equal(t, exitRefused, code)
|
||||
assert.ErrorContains(t, err, "read host-addr")
|
||||
assert.Equal(t, 1, g.unloads)
|
||||
_, statErr := os.Stat(g.root)
|
||||
assert.ErrorIs(t, statErr, os.ErrNotExist)
|
||||
}
|
||||
|
||||
func TestStartupStopsWithContext(t *testing.T) {
|
||||
ctx, cancel := context.WithCancelCause(context.Background())
|
||||
defer cancel(nil)
|
||||
h := newHost(t)
|
||||
h.accept(ctx)
|
||||
done := runAsync(ctx, newGuest(t, h.entries()).startup())
|
||||
conn := h.guest(t)
|
||||
defer conn.Close()
|
||||
|
||||
reason := errors.New("guest shutting down")
|
||||
cancel(reason)
|
||||
res := wait(t, done, "startup to return")
|
||||
assert.Equal(t, exitLink, res.code)
|
||||
assert.ErrorIs(t, res.err, reason)
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
//go:build lambdatestlink
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/agents/lambda/agent"
|
||||
"github.com/mulgadc/spinifex/spinifex/agents/lambda/hostlink"
|
||||
"github.com/mulgadc/spinifex/spinifex/agents/lambda/runtimeapi"
|
||||
)
|
||||
|
||||
const (
|
||||
runtimeAddr = "127.0.0.1:9001"
|
||||
taskRoot = "/var/task"
|
||||
)
|
||||
|
||||
var (
|
||||
linkPath = flag.String("link", os.Getenv("LAMBDA_AGENT_LINK"), "test carrier device path")
|
||||
handler = flag.String("handler", os.Getenv("_HANDLER"), "function handler passed to the bootstrap")
|
||||
)
|
||||
|
||||
func start(parent context.Context) int {
|
||||
if *linkPath == "" {
|
||||
slog.Error("no test carrier configured; refusing to start")
|
||||
return 2
|
||||
}
|
||||
// The bootstrap is tenant code; fw_cfg must be unreadable before it runs.
|
||||
if err := hostlink.SealFwCfg(hostlink.FwCfgRoot, hostlink.UnloadFwCfg); err != nil {
|
||||
slog.Error("seal fw_cfg; refusing to start", "err", err)
|
||||
return 2
|
||||
}
|
||||
dev, err := os.OpenFile(*linkPath, os.O_RDWR, 0)
|
||||
if err != nil {
|
||||
slog.Error("open test carrier; refusing to start", "err", err)
|
||||
return 2
|
||||
}
|
||||
defer dev.Close()
|
||||
link := hostlink.NewTestStream(dev)
|
||||
|
||||
srv := runtimeapi.New()
|
||||
ln, err := net.Listen("tcp", runtimeAddr)
|
||||
if err != nil {
|
||||
slog.Error("listen runtime api", "err", err)
|
||||
return 1
|
||||
}
|
||||
hs := &http.Server{Handler: srv.Handler(), ReadHeaderTimeout: 10 * time.Second}
|
||||
go func() { _ = hs.Serve(ln) }()
|
||||
defer hs.Close()
|
||||
|
||||
ctx, cancel := context.WithCancelCause(parent)
|
||||
defer cancel(nil)
|
||||
|
||||
cmd, err := agent.StartBootstrap(ctx, taskRoot+"/bootstrap", []string{
|
||||
"AWS_LAMBDA_RUNTIME_API=" + runtimeAddr,
|
||||
"_HANDLER=" + *handler,
|
||||
"LAMBDA_TASK_ROOT=" + taskRoot,
|
||||
"PATH=/bin:/sbin:/usr/bin",
|
||||
}, os.Stdout, os.Stderr)
|
||||
if err != nil {
|
||||
slog.Error("start bootstrap", "err", err)
|
||||
return 1
|
||||
}
|
||||
exited := make(chan struct{})
|
||||
go func() {
|
||||
defer close(exited)
|
||||
err := cmd.Wait()
|
||||
cancel(fmt.Errorf("bootstrap exited: %w", err))
|
||||
}()
|
||||
|
||||
err = agent.Run(ctx, link, srv)
|
||||
cancel(errors.New("agent stopping"))
|
||||
<-exited
|
||||
if err != nil {
|
||||
slog.Error("agent stopped", "err", err)
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
# Lambda v1 contracts
|
||||
|
||||
This package will own the Lambda process contracts declared by ADR-0009:S13.
|
||||
It holds the L-GUEST declaration and, in `guest.go`, the connection vocabulary both ends share: the scope, the URI SAN roles, the ALPN identifier and the fw_cfg entry names. The transport and authentication are decided (ADR-0009:S17); the invocation message types and framing are not chosen, so no message types are defined here.
|
||||
It is not an AWS tenant-facing API.
|
||||
|
||||
## L-GUEST: node executor to guest agent
|
||||
|
||||
**Owner:** the Lambda domain.
|
||||
|
||||
**Producers and consumers:** the node-local executor (`runtime/compute/lambda`) and the in-guest Lambda agent (`agents/lambda`), in both directions.
|
||||
|
||||
**Operations:** bootstrap with scoped material, readiness, invoke, completion, bounded logs, shutdown.
|
||||
|
||||
**Transport:** TCP over the microVM's network (ADR-0009:S17).
|
||||
The agent dials one connection to a listener the executor owns for the environment, and the guest exposes no listener for host control.
|
||||
At most one authenticated control connection is active per environment, and the agent may reconnect; how a prior connection and its in-flight work are handled is open (ADR-0009 M1).
|
||||
The network is shared and untrusted: no source address, port or bridge membership is identity or authority.
|
||||
Tenant code may be able to reach the listener, but it cannot open the agent's connection or key material, so any connection it makes fails authentication.
|
||||
|
||||
**Authentication:** mutual TLS 1.3, bound to the environment ID and realization generation, using material the user runtime cannot read (ADR-0009:S17).
|
||||
For each generation the executor creates ephemeral self-signed host and guest certificates whose single URI SAN is `spinifex-lambda://<Region>/<account>/<function ID>/<environment ID>/<generation>/<role>`, with role `host` or `guest`.
|
||||
Each end validates the peer certificate against the one pinned peer certificate and separately requires that URI SAN with the opposite role; hostname verification is not relied on.
|
||||
ALPN carries the L-GUEST major version, `spinifex-lambda-guest/1`, and an unsupported version fails the handshake.
|
||||
Under TLS 1.3 the guest's handshake can complete before the host refuses its certificate, so the guest treats the connection as accepted only after the host's first message.
|
||||
|
||||
**Bootstrap material:** the executor passes these fw_cfg entries under `opt/spinifex/lambda/`, and the agent reads them as root and unloads `qemu_fw_cfg` before user code starts.
|
||||
|
||||
| Entry | Content |
|
||||
|---|---|
|
||||
| `guest-key` | Guest private key, PEM |
|
||||
| `guest-cert` | Guest certificate, PEM |
|
||||
| `host-cert` | The one pinned host certificate, PEM |
|
||||
| `expect` | `<Region>/<account>/<function ID>/<environment ID>/<generation>` |
|
||||
| `host-addr` | Host listener as `host:port` |
|
||||
|
||||
The guest's own interface settings come from `opt/spinifex/netcfg`, the entry other system VMs use.
|
||||
The agent unloads `qemu_fw_cfg` even when an entry is missing, and refuses to start if the fw_cfg tree is still readable afterwards.
|
||||
Until the message format exists, the agent authenticates and holds the connection but accepts no host message and starts no user code.
|
||||
|
||||
**Authority:** one environment's attempt only.
|
||||
The user runtime never holds these keys, and sharing the guest with the agent gives user code no platform authority.
|
||||
|
||||
**Idempotency and fencing:** an invoke carries the attempt-grant identity and the attempt ID.
|
||||
The agent accepts a run command only under a current grant, and a completion under a superseded grant is discarded and reported.
|
||||
How a grant is enforced and proven ended is open (ADR-0009 M1).
|
||||
|
||||
**Errors and versions:** the agent and executor fail closed on an unknown protocol version or an identity mismatch.
|
||||
|
||||
**Readiness:** environment readiness needs authenticated, current-generation agent evidence; a running hypervisor process does not count.
|
||||
|
||||
**Payloads:** the contract must carry a synchronous payload and response of up to 6 MB and an asynchronous payload of up to 1 MB, plus framing, over a path whose size bound is declared.
|
||||
How payloads move is open (ADR-0009 M3).
|
||||
|
||||
### Compatibility boundary
|
||||
|
||||
The agent ships inside the runtime image pinned by each published version, so it is outside the coordinated release train.
|
||||
The executor supports every L-GUEST major carried by a still-supported runtime image.
|
||||
Retiring an image follows a published runtime-deprecation lifecycle that blocks create and update before it blocks invoke.
|
||||
|
||||
Evidence required before the first agent image ships: a function on an old image invoked by a new executor, and the reverse during rollback.
|
||||
|
||||
## L-RUNTIME
|
||||
|
||||
The guest agent serves the AWS Lambda Runtime API, version `2018-06-01`, to the user runtime at `AWS_LAMBDA_RUNTIME_API`.
|
||||
Spinifex does not version this AWS contract, and conformance is measured against AWS.
|
||||
@@ -1,54 +0,0 @@
|
||||
// Package lambdav1 holds the Lambda process-contract vocabulary shared by the
|
||||
// node executor and the guest agent. See README.md for the declarations.
|
||||
package lambdav1
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ALPNv1 names L-GUEST major version 1; a peer offering any other fails the handshake.
|
||||
const ALPNv1 = "spinifex-lambda-guest/1"
|
||||
|
||||
// FwCfgPrefix prefixes every fw_cfg entry the executor passes the guest.
|
||||
const FwCfgPrefix = "opt/spinifex/lambda/"
|
||||
|
||||
// fw_cfg entry names under FwCfgPrefix.
|
||||
const (
|
||||
EntryGuestKey = "guest-key"
|
||||
EntryGuestCert = "guest-cert"
|
||||
EntryHostCert = "host-cert"
|
||||
EntryScope = "expect"
|
||||
EntryHostAddr = "host-addr"
|
||||
)
|
||||
|
||||
// Roles named as the last URI SAN path segment.
|
||||
const (
|
||||
RoleHost = "host"
|
||||
RoleGuest = "guest"
|
||||
)
|
||||
|
||||
const uriScheme = "spinifex-lambda"
|
||||
|
||||
// Scope names one environment generation:
|
||||
// Region/account/function ID/environment ID/generation.
|
||||
type Scope string
|
||||
|
||||
// ParseScope accepts exactly five non-empty, slash-separated segments.
|
||||
func ParseScope(s string) (Scope, error) {
|
||||
parts := strings.Split(s, "/")
|
||||
if len(parts) != 5 {
|
||||
return "", fmt.Errorf("lambdav1: scope %q: want 5 segments, got %d", s, len(parts))
|
||||
}
|
||||
for _, p := range parts {
|
||||
if p == "" || strings.ContainsAny(p, " \t\r\n?#%") {
|
||||
return "", fmt.Errorf("lambdav1: scope %q: empty or invalid segment", s)
|
||||
}
|
||||
}
|
||||
return Scope(s), nil
|
||||
}
|
||||
|
||||
// URI is the single URI SAN a certificate for role in this scope carries.
|
||||
func (s Scope) URI(role string) string {
|
||||
return uriScheme + "://" + string(s) + "/" + role
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
package lambdav1
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParseScope(t *testing.T) {
|
||||
s, err := ParseScope("ap-southeast-2/000000000001/fn-1/env-1/gen-1")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "spinifex-lambda://ap-southeast-2/000000000001/fn-1/env-1/gen-1/host", s.URI(RoleHost))
|
||||
for _, bad := range []string{
|
||||
"", "a/b/c/d", "a/b/c/d/e/f", "a//c/d/e", "a/b/c/d/e/", "a/b/c/d/e?x", "a/b/c/d/e#x", "a b/b/c/d/e",
|
||||
} {
|
||||
_, err := ParseScope(bad)
|
||||
assert.Error(t, err, bad)
|
||||
}
|
||||
}
|
||||
@@ -74,65 +74,3 @@ type ListAccountsResponse struct {
|
||||
Accounts []AccountSummary `json:"accounts"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// IAMQuiescence is the coordinator's recorded view of IAM quiescence for one
|
||||
// fence. Recording only: it admits nothing, and QuiescedScope stays empty until
|
||||
// a final inventory re-read exists to catch a late write.
|
||||
type IAMQuiescence struct {
|
||||
OperationID string `json:"operationId"`
|
||||
Fence uint64 `json:"fence"`
|
||||
Generation uint64 `json:"generation"`
|
||||
QuiescedScope []string `json:"quiescedScope"`
|
||||
Unresolved []UnresolvedScope `json:"unresolved"`
|
||||
Consumers []ConsumerBasis `json:"consumers"`
|
||||
Writers []AccountedWriter `json:"writers"`
|
||||
Inventory []InventoryStatus `json:"inventory"`
|
||||
EvaluatedAt string `json:"evaluatedAt"`
|
||||
}
|
||||
|
||||
// UnresolvedScope names a scope or writer that blocks quiescence, with a fixed
|
||||
// reason. It is never a timeout.
|
||||
type UnresolvedScope struct {
|
||||
Scope string `json:"scope"`
|
||||
Class string `json:"class"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// ConsumerBasis cites an enforcement consumer and the accepted contract that
|
||||
// lets it count as having applied the generation.
|
||||
type ConsumerBasis struct {
|
||||
Consumer string `json:"consumer"`
|
||||
Basis string `json:"basis"`
|
||||
}
|
||||
|
||||
// AccountedWriter is a writer that cannot be fenced and is covered by the
|
||||
// owner's inventory instead. Ref names the tracked work that closes the gap.
|
||||
type AccountedWriter struct {
|
||||
Writer string `json:"writer"`
|
||||
Basis string `json:"basis"`
|
||||
Ref string `json:"ref"`
|
||||
}
|
||||
|
||||
// InventoryStatus is the owner inventory's status for one kind at evaluation.
|
||||
type InventoryStatus struct {
|
||||
Kind string `json:"kind"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// Classes of an UnresolvedScope.
|
||||
const (
|
||||
UnresolvedClassFence = "fence"
|
||||
UnresolvedClassConsumer = "consumer"
|
||||
UnresolvedClassWriter = "writer"
|
||||
)
|
||||
|
||||
// Fixed reasons of an UnresolvedScope.
|
||||
const (
|
||||
ReasonFenceMismatch = "fence-mismatch"
|
||||
ReasonIAMReadbackFailed = "iam-readback-failed"
|
||||
ReasonNoAcceptedEnforcement = "no-accepted-enforcement-contract"
|
||||
ReasonNoCompleteOwnerInventory = "no-complete-owner-inventory"
|
||||
ReasonParticipantNotDeclared = "participant-not-declared"
|
||||
ReasonOwnerInventoryIncomplete = "owner-inventory-incomplete"
|
||||
ReasonFinalInventoryRereadNotWired = "final-inventory-reread-not-wired"
|
||||
)
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
#!/bin/bash
|
||||
# build-lambda-runtime-microvm-image.sh — build the Lambda runtime microVM kernel
|
||||
# + initramfs: a guest running the Lambda guest agent, with virtio-net for its
|
||||
# host control connection. It is not shipped.
|
||||
#
|
||||
# Inputs:
|
||||
# $REPO_ROOT/build/microvm/lambda-runtime/{init.sh,inittab,run-agent}
|
||||
# $REPO_ROOT/bin/lambda-agent (make bin/lambda-agent)
|
||||
#
|
||||
# Outputs:
|
||||
# $REPO_ROOT/build/microvm/lambda-runtime/vmlinuz
|
||||
# $REPO_ROOT/build/microvm/lambda-runtime/initramfs.cpio.gz
|
||||
#
|
||||
# Rootfs strategy and module stripping: scripts/lib/microvm-image.sh.
|
||||
# shellcheck disable=SC2034 # variables are consumed by the sourced library
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
BUILD_DIR="$REPO_ROOT/build/microvm/lambda-runtime"
|
||||
INIT_SH="$BUILD_DIR/init.sh"
|
||||
INITTAB="$BUILD_DIR/inittab"
|
||||
RUN_AGENT="$BUILD_DIR/run-agent"
|
||||
LAMBDA_AGENT_BIN="$REPO_ROOT/bin/lambda-agent"
|
||||
TAG="[build-lambda-runtime-microvm-image]"
|
||||
|
||||
ALPINE_VERSION="${ALPINE_VERSION:-3.24}"
|
||||
ALPINE_MIRROR="${ALPINE_MIRROR:-https://dl-cdn.alpinelinux.org/alpine}"
|
||||
# kmod: the image's own depmod/modprobe rebuild module metadata. busybox ships ip.
|
||||
ALPINE_PACKAGES="busybox linux-virt kmod ca-certificates"
|
||||
# tiny_power_button turns the ACPI power button into a signal busybox init
|
||||
# handles as poweroff.
|
||||
MICROVM_EXTRA_MODULES="${MICROVM_EXTRA_MODULES:-tiny_power_button}"
|
||||
# virtio_net needs failover and net_failover, which the virtio* glob misses.
|
||||
MICROVM_EXTRA_KEEP_GLOBS=("*failover*.ko*")
|
||||
MICROVM_GATE_MODULES="qemu_fw_cfg virtio_net"
|
||||
|
||||
# shellcheck source=lib/microvm-image.sh
|
||||
source "$REPO_ROOT/scripts/lib/microvm-image.sh"
|
||||
|
||||
echo "$TAG repo root: $REPO_ROOT"
|
||||
echo "$TAG build dir: $BUILD_DIR"
|
||||
|
||||
microvm_check_tools
|
||||
|
||||
for f in "$INIT_SH" "$INITTAB" "$RUN_AGENT"; do
|
||||
if [ ! -f "$f" ]; then
|
||||
echo "ERROR: $f not found — cannot build initramfs" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
if [ ! -x "$LAMBDA_AGENT_BIN" ]; then
|
||||
echo "ERROR: $LAMBDA_AGENT_BIN not found — run 'make bin/lambda-agent'" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! file -b "$LAMBDA_AGENT_BIN" | grep -q "statically linked"; then
|
||||
echo "ERROR: $LAMBDA_AGENT_BIN is not statically linked — it cannot run on the musl guest" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
microvm_init_dirs
|
||||
microvm_make_rootfs
|
||||
microvm_empty_dev
|
||||
|
||||
echo "$TAG installing init, inittab, run-agent and lambda-agent..."
|
||||
install -m 0755 "$INIT_SH" "$CHROOT_DIR/init"
|
||||
install -m 0644 "$INITTAB" "$CHROOT_DIR/etc/inittab"
|
||||
mkdir -p "$CHROOT_DIR/usr/local/lib/lambda-runtime" "$CHROOT_DIR/usr/local/bin"
|
||||
install -m 0755 "$RUN_AGENT" "$CHROOT_DIR/usr/local/lib/lambda-runtime/run-agent"
|
||||
install -m 0755 "$LAMBDA_AGENT_BIN" "$CHROOT_DIR/usr/local/bin/lambda-agent"
|
||||
# Function-code mountpoint and scratch space ship empty.
|
||||
mkdir -p "$CHROOT_DIR/var/task" "$CHROOT_DIR/tmp" \
|
||||
"$CHROOT_DIR/proc" "$CHROOT_DIR/sys" "$CHROOT_DIR/dev" "$CHROOT_DIR/etc/ssl/certs"
|
||||
# The bootstrap runs unprivileged, so / must be traversable (mktemp -d makes it
|
||||
# 0700) and nothing it runs from may be group-writable.
|
||||
chmod 0755 "$CHROOT_DIR" "$CHROOT_DIR/var/task" "$CHROOT_DIR/usr/local/lib/lambda-runtime"
|
||||
|
||||
microvm_locate_kernel
|
||||
microvm_strip_modules
|
||||
microvm_strip_rootfs
|
||||
microvm_stage_kernel
|
||||
microvm_pack_initramfs
|
||||
|
||||
# Nothing reaches $BUILD_DIR until the staged pair passes these checks.
|
||||
for out in vmlinuz initramfs.cpio.gz; do
|
||||
if [ ! -s "$STAGE_DIR/$out" ]; then
|
||||
echo "ERROR: staged $out is missing or empty" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
if ! gzip -t "$STAGE_DIR/initramfs.cpio.gz"; then
|
||||
echo "ERROR: staged initramfs.cpio.gz is not valid gzip" >&2
|
||||
exit 1
|
||||
fi
|
||||
mv -f "$STAGE_DIR/vmlinuz" "$BUILD_DIR/vmlinuz"
|
||||
mv -f "$STAGE_DIR/initramfs.cpio.gz" "$BUILD_DIR/initramfs.cpio.gz"
|
||||
echo ""
|
||||
echo "$TAG done."
|
||||
@@ -18,7 +18,6 @@
|
||||
# 1. apk available on host → install directly into a temp chroot (Alpine CI/CD)
|
||||
# 2. docker available → run alpine container, export filesystem
|
||||
# 3. podman available → same via podman
|
||||
# shellcheck disable=SC2034 # variables are consumed by the sourced library
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
@@ -45,19 +44,21 @@ ALPINE_PACKAGES="busybox linux-virt iproute2 ca-certificates e2fsprogs kmod"
|
||||
# button (QMP system_powerdown) into a signal busybox init handles as poweroff.
|
||||
MICROVM_EXTRA_MODULES="${MICROVM_EXTRA_MODULES:-ext4 tiny_power_button}"
|
||||
|
||||
# Module file globs kept on top of virtio*/*fw_cfg*: virtio_net deps for live migration.
|
||||
MICROVM_EXTRA_KEEP_GLOBS=("*failover*.ko*")
|
||||
# Modules the image modprobe must resolve, beyond the extras.
|
||||
MICROVM_GATE_MODULES="qemu_fw_cfg virtio_net"
|
||||
|
||||
# shellcheck source=lib/microvm-image.sh
|
||||
source "$REPO_ROOT/scripts/lib/microvm-image.sh"
|
||||
|
||||
echo "$TAG repo root: $REPO_ROOT"
|
||||
echo "$TAG build dir: $BUILD_DIR"
|
||||
mkdir -p "$BUILD_DIR"
|
||||
|
||||
microvm_check_tools
|
||||
# --- Verify non-negotiable tools ---
|
||||
# fakeroot is mandatory: device nodes are written into the cpio archive inside a
|
||||
# faked-root environment so no real device node is ever created on the host
|
||||
# filesystem (which previously required sudo mknod and risked clobbering host
|
||||
# /dev/null on cleanup).
|
||||
for tool in cpio gzip find fakeroot; do
|
||||
if ! command -v "$tool" >/dev/null 2>&1; then
|
||||
echo "ERROR: required tool not found: $tool" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
if [ ! -x "$SQS_WORKER_BIN" ]; then
|
||||
echo "ERROR: $SQS_WORKER_BIN not found — run 'make bin/sqs-worker'" >&2
|
||||
@@ -89,11 +90,100 @@ if [ "$nats_server_version" != "$NATS_SERVER_PIN" ]; then
|
||||
fi
|
||||
echo "$TAG nats-server: $nats_server_version (matches go.mod)"
|
||||
|
||||
microvm_init_dirs
|
||||
# --- Create chroot ---
|
||||
CHROOT_DIR=$(mktemp -d)
|
||||
# Guard: every destructive op below targets "$CHROOT_DIR/...". An empty or
|
||||
# unexpected value would aim rm -rf at the host (e.g. /dev). Refuse anything that
|
||||
# is not a freshly-created temp dir.
|
||||
case "${CHROOT_DIR:?mktemp -d returned empty}" in
|
||||
/tmp/*|/var/tmp/*|"${TMPDIR:-/nonexistent}"/*) ;;
|
||||
*) echo "ERROR: refusing to use unexpected CHROOT_DIR: $CHROOT_DIR" >&2; exit 1 ;;
|
||||
esac
|
||||
# Outputs are written here first, beside $BUILD_DIR so publishing is a rename.
|
||||
STAGE_DIR=$(mktemp -d "$BUILD_DIR/.stage.XXXXXX")
|
||||
CONTAINER_TOOL=""
|
||||
CONTAINER_CID=""
|
||||
cleanup() {
|
||||
echo "$TAG cleaning up chroot: $CHROOT_DIR"
|
||||
rm -rf "$CHROOT_DIR"
|
||||
rm -rf "${STAGE_DIR:?}"
|
||||
if [ -n "$CONTAINER_CID" ] && [ -n "$CONTAINER_TOOL" ]; then
|
||||
echo "$TAG cleaning up container: $CONTAINER_CID"
|
||||
$CONTAINER_TOOL rm -f "$CONTAINER_CID" >/dev/null 2>&1 || true
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
microvm_make_rootfs
|
||||
# --- Populate rootfs ---
|
||||
# Strategy 1: native apk (Alpine hosts, CI)
|
||||
if command -v apk >/dev/null 2>&1; then
|
||||
echo "$TAG rootfs: native apk"
|
||||
mkdir -p \
|
||||
"$CHROOT_DIR/etc/apk" \
|
||||
"$CHROOT_DIR/lib/apk/db" \
|
||||
"$CHROOT_DIR/var/cache/apk"
|
||||
cat > "$CHROOT_DIR/etc/apk/repositories" <<EOF
|
||||
${ALPINE_MIRROR}/v${ALPINE_VERSION}/main
|
||||
${ALPINE_MIRROR}/v${ALPINE_VERSION}/community
|
||||
EOF
|
||||
apk add \
|
||||
--root "$CHROOT_DIR" \
|
||||
--arch x86_64 \
|
||||
--no-cache \
|
||||
--allow-untrusted \
|
||||
--initdb \
|
||||
$ALPINE_PACKAGES
|
||||
|
||||
microvm_empty_dev
|
||||
# Strategy 2: docker -> BuildKit local export.
|
||||
# Do NOT use `docker export` here. On Docker's overlay2 driver it leaks the
|
||||
# container's overlay mount (the mount survives `docker rm`), orphaning layers
|
||||
# under /var/lib/docker that only a daemon restart reclaims; on a busy CI host
|
||||
# this grows without bound. `buildx --output type=local` writes the image
|
||||
# filesystem straight to a directory and releases every mount, so nothing leaks.
|
||||
elif command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then
|
||||
echo "$TAG rootfs: docker buildx --output type=local (alpine:${ALPINE_VERSION})"
|
||||
BUILD_CTX=$(mktemp -d)
|
||||
cat > "$BUILD_CTX/Dockerfile" <<DOCKERFILE
|
||||
FROM alpine:${ALPINE_VERSION}
|
||||
RUN apk add --no-cache ${ALPINE_PACKAGES}
|
||||
DOCKERFILE
|
||||
docker buildx build \
|
||||
--platform linux/amd64 \
|
||||
--output "type=local,dest=${CHROOT_DIR}" \
|
||||
"$BUILD_CTX"
|
||||
rm -rf "$BUILD_CTX"
|
||||
|
||||
# Strategy 3: podman -> run + export. Podman's store does not exhibit the docker
|
||||
# overlay2 export-mount leak, and podman has no buildx.
|
||||
elif command -v podman >/dev/null 2>&1; then
|
||||
echo "$TAG rootfs: podman export (alpine:${ALPINE_VERSION})"
|
||||
CONTAINER_TOOL=podman
|
||||
CONTAINER_CID=$(podman run -d \
|
||||
"alpine:${ALPINE_VERSION}" \
|
||||
sh -c "apk add --no-cache ${ALPINE_PACKAGES}")
|
||||
|
||||
exit_code=$(podman wait "$CONTAINER_CID")
|
||||
if [ "$exit_code" != "0" ]; then
|
||||
echo "ERROR: package installation failed in container (exit $exit_code)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
podman export "$CONTAINER_CID" | tar -x -C "$CHROOT_DIR"
|
||||
|
||||
else
|
||||
echo "ERROR: no rootfs build tool available." >&2
|
||||
echo " Install one of: apk (Alpine), docker, podman" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- /dev: empty mountpoint only ---
|
||||
# tar extraction without root silently creates 0-byte regular files for device
|
||||
# nodes. Drop them and leave an empty /dev mountpoint. The two static nodes the
|
||||
# kernel needs (/dev/console to wire init's stdio, /dev/null) are written into
|
||||
# the cpio archive under fakeroot at pack time — NOT created on the host. init
|
||||
# mounts devtmpfs over /dev as its first action for everything else at runtime.
|
||||
rm -rf "${CHROOT_DIR:?}/dev"
|
||||
mkdir "$CHROOT_DIR/dev"
|
||||
|
||||
# --- Place init script ---
|
||||
echo "$TAG installing init.sh as /init..."
|
||||
@@ -137,8 +227,30 @@ mkdir -p \
|
||||
"$CHROOT_DIR/dev" \
|
||||
"$CHROOT_DIR/etc/ssl/certs"
|
||||
|
||||
# --- Locate kernel ---
|
||||
KERNEL_IMG=$(find "$CHROOT_DIR/boot" -name "vmlinuz*" | sort | tail -1)
|
||||
if [ -z "$KERNEL_IMG" ]; then
|
||||
echo "ERROR: no vmlinuz found in $CHROOT_DIR/boot" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "$TAG kernel image: $KERNEL_IMG"
|
||||
|
||||
microvm_locate_kernel
|
||||
KERNEL_VER=$(basename "$KERNEL_IMG" | sed 's/vmlinuz-//')
|
||||
KERNEL_CONFIG="$CHROOT_DIR/boot/config-${KERNEL_VER}"
|
||||
|
||||
# --- Assert fw_cfg module ---
|
||||
if ! find "$CHROOT_DIR/lib/modules" \
|
||||
-name "qemu_fw_cfg.ko*" \
|
||||
-o -name "fw_cfg_sysfs.ko*" \
|
||||
| grep -q .; then
|
||||
if ! grep -q "CONFIG_FW_CFG_SYSFS=y" "$KERNEL_CONFIG" 2>/dev/null; then
|
||||
echo "ERROR: qemu_fw_cfg module missing from initramfs — fw_cfg reads will fail at boot" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "$TAG fw_cfg: built-in (CONFIG_FW_CFG_SYSFS=y)"
|
||||
else
|
||||
echo "$TAG fw_cfg: module found in lib/modules"
|
||||
fi
|
||||
|
||||
# --- Assert ext4 userspace tools (data volume) ---
|
||||
for fs_tool in mkfs.ext4 e2fsck; do
|
||||
@@ -149,10 +261,227 @@ for fs_tool in mkfs.ext4 e2fsck; do
|
||||
done
|
||||
echo "$TAG e2fsprogs: mkfs.ext4 + e2fsck present"
|
||||
|
||||
microvm_strip_modules
|
||||
microvm_strip_rootfs
|
||||
microvm_stage_kernel
|
||||
microvm_pack_initramfs
|
||||
# --- Strip kernel modules (Phase 2 keep-list approach) ---
|
||||
# Only modules needed by a virtio-mmio microvm guest are kept:
|
||||
# virtio*.ko* — virtio bus, net, blk, rng, console, and helpers
|
||||
# *fw_cfg*.ko* — QEMU fw_cfg sysfs driver (delivers boot blobs)
|
||||
# *failover*.ko* — net_failover + failover (virtio_net deps for live migration)
|
||||
# $MICROVM_EXTRA_MODULES — ext4 by default, plus dependency closure
|
||||
# All other loadable modules are removed. Drivers compiled into the kernel
|
||||
# (CONFIG_VIRTIO_MMIO=y etc.) are unaffected — they live in vmlinuz, not here.
|
||||
|
||||
# module_path prints a module's kernel/ path from the stock modules.dep, by
|
||||
# name or modules.alias; it fails when the module is not loadable.
|
||||
module_path() {
|
||||
local kdir="$1" name="${2//-/_}" path alias_target
|
||||
path=$(awk -F: -v n="$name" '{ f = $1; sub(/.*\//, "", f); sub(/\.ko(\.gz|\.xz|\.zst)?$/, "", f);
|
||||
gsub(/-/, "_", f); if (f == n) { print $1; exit } }' "$kdir/modules.dep")
|
||||
if [ -z "$path" ] && [ -f "$kdir/modules.alias" ]; then
|
||||
alias_target=$(awk -v n="$2" '$1 == "alias" && $2 == n { print $3; exit }' "$kdir/modules.alias")
|
||||
[ -n "$alias_target" ] && [ "$alias_target" != "$2" ] && path=$(module_path "$kdir" "$alias_target")
|
||||
fi
|
||||
[ -n "$path" ] && printf '%s\n' "$path"
|
||||
}
|
||||
|
||||
# module_builtin reports whether a module name is compiled into the kernel.
|
||||
module_builtin() {
|
||||
local kdir="$1" name="${2//-/_}"
|
||||
[ -f "$kdir/modules.builtin" ] || return 1
|
||||
awk -v n="$name" '{ f = $0; sub(/.*\//, "", f); sub(/\.ko$/, "", f); gsub(/-/, "_", f);
|
||||
if (f == n) { found = 1; exit } } END { exit !found }' "$kdir/modules.builtin"
|
||||
}
|
||||
|
||||
# image_kmod runs the image's own kmod (depmod, modprobe) through its musl
|
||||
# loader, so module metadata matches the guest's modprobe without a host kmod.
|
||||
image_kmod() {
|
||||
"$CHROOT_DIR/lib/ld-musl-x86_64.so.1" --library-path "$CHROOT_DIR/lib:$CHROOT_DIR/usr/lib" \
|
||||
--argv0 "$1" "$CHROOT_DIR/bin/kmod" "${@:2}"
|
||||
}
|
||||
|
||||
echo "$TAG stripping kernel modules to virtio+fw_cfg+${MICROVM_EXTRA_MODULES} only..."
|
||||
for kver_dir in "$CHROOT_DIR/lib/modules"/*/; do
|
||||
kver="$(basename "$kver_dir")"
|
||||
kernel_dir="$kver_dir/kernel"
|
||||
[ -d "$kernel_dir" ] || continue
|
||||
|
||||
if [ ! -s "$kver_dir/modules.dep" ]; then
|
||||
echo "ERROR: stock ${kver_dir}modules.dep missing — cannot resolve module dependencies" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Extra modules start the closure; the glob families are kept as found.
|
||||
keep_list=$(mktemp)
|
||||
: > "$keep_list"
|
||||
for name in $MICROVM_EXTRA_MODULES; do
|
||||
if ! path=$(module_path "$kver_dir" "$name"); then
|
||||
if module_builtin "$kver_dir" "$name"; then continue; fi
|
||||
echo "ERROR: module $name is neither loadable nor built into $kver" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "$path" >> "$keep_list"
|
||||
done
|
||||
|
||||
# Close over modules.dep hard dependencies and modules.softdep pre:/post:
|
||||
# names until the set stops growing. A softdep must be loadable or built in.
|
||||
while :; do
|
||||
before=$(sort -u "$keep_list" | wc -l)
|
||||
for path in $(sort -u "$keep_list"); do
|
||||
awk -F': ' -v p="$path" '$1 == p { n = split($2, d, " "); for (i = 1; i <= n; i++) print d[i] }' \
|
||||
"$kver_dir/modules.dep" >> "$keep_list"
|
||||
mod=$(basename "$path"); mod="${mod%%.ko*}"; mod="${mod//-/_}"
|
||||
[ -f "$kver_dir/modules.softdep" ] || continue
|
||||
for soft in $(awk -v n="$mod" '{ m = $2; gsub(/-/, "_", m) }
|
||||
$1 == "softdep" && m == n { for (i = 3; i <= NF; i++) if ($i != "pre:" && $i != "post:") print $i }' \
|
||||
"$kver_dir/modules.softdep"); do
|
||||
if soft_path=$(module_path "$kver_dir" "$soft"); then
|
||||
echo "$soft_path" >> "$keep_list"
|
||||
elif ! module_builtin "$kver_dir" "$soft"; then
|
||||
echo "ERROR: softdep $soft of $mod is neither loadable nor built into $kver" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
done
|
||||
after=$(sort -u "$keep_list" | wc -l)
|
||||
[ "$before" -eq "$after" ] && break
|
||||
done
|
||||
|
||||
extra_names=$(sort -u "$keep_list" | while read -r p; do m=$(basename "$p"); m="${m%%.ko*}"; echo "${m//-/_}"; done)
|
||||
(cd "$kver_dir" && find kernel -name "virtio*.ko*" -o -name "*fw_cfg*.ko*" \
|
||||
-o -name "*failover*.ko*") >> "$keep_list"
|
||||
|
||||
# Copy the keeper set out, preserving each kernel/ relative path.
|
||||
tmp_keep=$(mktemp -d)
|
||||
while read -r rel; do
|
||||
rel="${rel#kernel/}"
|
||||
mkdir -p "$tmp_keep/$(dirname "$rel")"
|
||||
cp "$kernel_dir/$rel" "$tmp_keep/$rel"
|
||||
done < <(sort -u "$keep_list")
|
||||
rm -f "$keep_list"
|
||||
|
||||
# Replace kernel module tree with the keeper set.
|
||||
rm -rf "$kernel_dir"
|
||||
mkdir -p "$kernel_dir"
|
||||
# Restore each kept file into the same relative path.
|
||||
(cd "$tmp_keep" && find . -name "*.ko*" | while read -r f; do
|
||||
dest_dir="$kernel_dir/$(dirname "${f#./}")"
|
||||
mkdir -p "$dest_dir"
|
||||
cp "$f" "$dest_dir/"
|
||||
done)
|
||||
rm -rf "$tmp_keep"
|
||||
|
||||
# Decompress kept modules to plain .ko. Alpine ships .ko.gz, but a host
|
||||
# kmod that lacks gzip support (e.g. Debian trixie's kmod 34) silently
|
||||
# produces an EMPTY modules.dep from compressed modules — the guest's
|
||||
# modprobe then no-ops and qemu_fw_cfg never loads. Plain .ko sidesteps the
|
||||
# host-kmod dependency entirely and the guest needs no decompressor either.
|
||||
find "$kernel_dir" -name "*.ko.gz" -exec gunzip -f {} +
|
||||
|
||||
# Independent closure gate: each extra-closure module's own .modinfo depends=
|
||||
# must name a kept module or a built-in, so a resolver bug fails here.
|
||||
kept_names=$(find "$kernel_dir" -name "*.ko" -exec basename {} .ko \; | tr '-' '_' | sort -u)
|
||||
while read -r ko; do
|
||||
ko_name=$(basename "$ko" .ko); ko_name="${ko_name//-/_}"
|
||||
grep -qx "$ko_name" <<< "$extra_names" || continue
|
||||
deps=$(tr '\0' '\n' < "$ko" | grep -a -m1 '^depends=' | cut -d= -f2 | tr ',' ' ' || true)
|
||||
for dep in $deps; do
|
||||
dep="${dep//-/_}"
|
||||
if ! grep -qx "$dep" <<< "$kept_names" && ! module_builtin "$kver_dir" "$dep"; then
|
||||
echo "ERROR: $(basename "$ko") depends on $dep, which is neither kept nor built in" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
done < <(find "$kernel_dir" -name "*.ko")
|
||||
for name in $MICROVM_EXTRA_MODULES; do
|
||||
if ! grep -qx "${name//-/_}" <<< "$kept_names" && ! module_builtin "$kver_dir" "$name"; then
|
||||
echo "ERROR: required module $name missing from the stripped module tree" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo "$TAG extra module closure: $(echo "$extra_names" | tr '\n' ' ')"
|
||||
echo "$TAG modules kept: $(echo "$kept_names" | tr '\n' ' ')"
|
||||
|
||||
# Rebuild module metadata with the image's own kmod, the binary the guest's
|
||||
# modprobe runs, so no host depmod is needed. Stock indexes still name .ko.gz
|
||||
# files, so they are removed first and a failed rebuild cannot ship them.
|
||||
for f in dep dep.bin alias alias.bin softdep weakdep symbols symbols.bin \
|
||||
builtin.bin builtin.alias.bin devname; do
|
||||
rm -f "$kver_dir/modules.$f"
|
||||
done
|
||||
if ! image_kmod depmod -b "$CHROOT_DIR" "$kver"; then
|
||||
echo "ERROR: image depmod failed for $kver" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -s "$kver_dir/modules.dep" ] || [ ! -s "$kver_dir/modules.dep.bin" ]; then
|
||||
echo "ERROR: ${kver_dir}modules.dep or modules.dep.bin missing or empty after depmod" >&2
|
||||
exit 1
|
||||
fi
|
||||
# Resolution gate: the image's modprobe must resolve every required module
|
||||
# to plain .ko files present in the stripped tree.
|
||||
for name in $MICROVM_EXTRA_MODULES qemu_fw_cfg virtio_net; do
|
||||
module_builtin "$kver_dir" "$name" && continue
|
||||
if ! plan=$(image_kmod modprobe -d "$CHROOT_DIR" -S "$kver" --show-depends "$name"); then
|
||||
echo "ERROR: image modprobe cannot resolve $name for $kver" >&2
|
||||
exit 1
|
||||
fi
|
||||
while read -r verb path _; do
|
||||
[ "$verb" = "insmod" ] || continue
|
||||
case "$path" in
|
||||
"$CHROOT_DIR/lib/modules/$kver/kernel/"*.ko) [ -f "$path" ] && continue ;;
|
||||
esac
|
||||
echo "ERROR: modprobe plan for $name names $path, not a kept plain .ko" >&2
|
||||
exit 1
|
||||
done <<< "$plan"
|
||||
echo "$TAG modprobe $name: $(awk '{ n = split($2, a, "/"); printf "%s ", a[n] }' <<< "$plan")"
|
||||
done
|
||||
done
|
||||
|
||||
# --- Strip package-manager artifacts (not needed at runtime) ---
|
||||
echo "$TAG stripping package manager artifacts..."
|
||||
rm -rf \
|
||||
"$CHROOT_DIR/lib/apk" \
|
||||
"$CHROOT_DIR/var/cache/apk" \
|
||||
"$CHROOT_DIR/etc/apk" \
|
||||
"$CHROOT_DIR/usr/share/man" \
|
||||
"$CHROOT_DIR/usr/share/doc" \
|
||||
"$CHROOT_DIR/usr/share/apk" \
|
||||
"$CHROOT_DIR/usr/include" \
|
||||
"$CHROOT_DIR/usr/lib/pkgconfig"
|
||||
|
||||
# --- Strip debug symbols from binaries ---
|
||||
# nats-server in /usr/local/bin is left as built (-s -w), so the shipped bytes
|
||||
# are the ones whose version was checked above.
|
||||
echo "$TAG stripping debug symbols from binaries..."
|
||||
find "$CHROOT_DIR/usr/sbin" "$CHROOT_DIR/usr/bin" \
|
||||
"$CHROOT_DIR/sbin" "$CHROOT_DIR/bin" \
|
||||
-type f 2>/dev/null | while read -r bin; do
|
||||
strip --strip-all "$bin" 2>/dev/null || true
|
||||
done
|
||||
|
||||
# --- Copy vmlinuz out before stripping /boot from chroot ---
|
||||
VMLINUZ_OUT="$STAGE_DIR/vmlinuz"
|
||||
echo "$TAG copying kernel: $VMLINUZ_OUT"
|
||||
cp "$KERNEL_IMG" "$VMLINUZ_OUT"
|
||||
|
||||
# Drop /boot from the chroot — vmlinuz, Alpine's stock initramfs-virt, System.map
|
||||
# and kernel config all live here (~25 MiB). None are needed at runtime: the
|
||||
# guest already has the kernel loaded by QEMU's -kernel, and Alpine's initramfs
|
||||
# is unused because we replace it with our own /init. Leaving /boot in the cpio
|
||||
# nearly doubles uncompressed initramfs size and forces a 512 MiB guest just to
|
||||
# survive decompression.
|
||||
rm -rf "${CHROOT_DIR:?}/boot"
|
||||
|
||||
# --- Build initramfs ---
|
||||
INITRAMFS_OUT="$STAGE_DIR/initramfs.cpio.gz"
|
||||
echo "$TAG building initramfs: $INITRAMFS_OUT"
|
||||
# Create the static device nodes and pack the archive inside a single fakeroot
|
||||
# session: mknod/cpio see faked device entries and write real char-device
|
||||
# records into the cpio, while the host filesystem never gets an actual node.
|
||||
fakeroot sh -c '
|
||||
cd "$1" || exit 1
|
||||
mknod -m 600 dev/console c 5 1
|
||||
mknod -m 666 dev/null c 1 3
|
||||
find . | cpio --quiet -o -H newc | gzip -9 > "$2"
|
||||
' _ "$CHROOT_DIR" "$INITRAMFS_OUT"
|
||||
|
||||
# --- Gate the staged pair, then publish it ---
|
||||
# Nothing reaches $BUILD_DIR until every gate passes, so a failed build cannot
|
||||
|
||||
@@ -1,391 +0,0 @@
|
||||
#!/bin/bash
|
||||
# microvm-image.sh — shared steps for the microVM image builders, sourced by
|
||||
# build-sqs-store-microvm-image.sh and build-lambda-runtime-microvm-image.sh.
|
||||
#
|
||||
# The caller sets before use:
|
||||
# TAG log prefix
|
||||
# BUILD_DIR output directory; STAGE_DIR is created beside it
|
||||
# ALPINE_VERSION, ALPINE_MIRROR, ALPINE_PACKAGES
|
||||
# MICROVM_EXTRA_MODULES modules kept by name, with their dependency closure
|
||||
# MICROVM_GATE_MODULES modules the image modprobe must resolve, beyond the extras
|
||||
# MICROVM_EXTRA_KEEP_GLOBS bash array of module file globs kept on top of virtio*/*fw_cfg*
|
||||
# Functions set CHROOT_DIR, STAGE_DIR, KERNEL_IMG and KERNEL_VER for the caller.
|
||||
|
||||
|
||||
microvm_check_tools() {
|
||||
# --- Verify non-negotiable tools ---
|
||||
# fakeroot is mandatory: device nodes are written into the cpio archive inside a
|
||||
# faked-root environment so no real device node is ever created on the host
|
||||
# filesystem (which previously required sudo mknod and risked clobbering host
|
||||
# /dev/null on cleanup).
|
||||
for tool in cpio gzip find fakeroot; do
|
||||
if ! command -v "$tool" >/dev/null 2>&1; then
|
||||
echo "ERROR: required tool not found: $tool" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
microvm_init_dirs() {
|
||||
# --- Create chroot ---
|
||||
CHROOT_DIR=$(mktemp -d)
|
||||
# Guard: every destructive op below targets "$CHROOT_DIR/...". An empty or
|
||||
# unexpected value would aim rm -rf at the host (e.g. /dev). Refuse anything that
|
||||
# is not a freshly-created temp dir.
|
||||
case "${CHROOT_DIR:?mktemp -d returned empty}" in
|
||||
/tmp/*|/var/tmp/*|"${TMPDIR:-/nonexistent}"/*) ;;
|
||||
*) echo "ERROR: refusing to use unexpected CHROOT_DIR: $CHROOT_DIR" >&2; exit 1 ;;
|
||||
esac
|
||||
# Outputs are written here first, beside $BUILD_DIR so publishing is a rename.
|
||||
STAGE_DIR=$(mktemp -d "$BUILD_DIR/.stage.XXXXXX")
|
||||
CONTAINER_TOOL=""
|
||||
CONTAINER_CID=""
|
||||
trap microvm_cleanup EXIT
|
||||
}
|
||||
|
||||
microvm_cleanup() {
|
||||
echo "$TAG cleaning up chroot: $CHROOT_DIR"
|
||||
rm -rf "$CHROOT_DIR"
|
||||
rm -rf "${STAGE_DIR:?}"
|
||||
if [ -n "$CONTAINER_CID" ] && [ -n "$CONTAINER_TOOL" ]; then
|
||||
echo "$TAG cleaning up container: $CONTAINER_CID"
|
||||
$CONTAINER_TOOL rm -f "$CONTAINER_CID" >/dev/null 2>&1 || true
|
||||
fi
|
||||
}
|
||||
|
||||
# microvm_make_rootfs populates $CHROOT_DIR from ALPINE_PACKAGES.
|
||||
microvm_make_rootfs() {
|
||||
# --- Populate rootfs ---
|
||||
# Strategy 1: native apk (Alpine hosts, CI)
|
||||
if command -v apk >/dev/null 2>&1; then
|
||||
echo "$TAG rootfs: native apk"
|
||||
mkdir -p \
|
||||
"$CHROOT_DIR/etc/apk" \
|
||||
"$CHROOT_DIR/lib/apk/db" \
|
||||
"$CHROOT_DIR/var/cache/apk"
|
||||
cat > "$CHROOT_DIR/etc/apk/repositories" <<EOF
|
||||
${ALPINE_MIRROR}/v${ALPINE_VERSION}/main
|
||||
${ALPINE_MIRROR}/v${ALPINE_VERSION}/community
|
||||
EOF
|
||||
apk add \
|
||||
--root "$CHROOT_DIR" \
|
||||
--arch x86_64 \
|
||||
--no-cache \
|
||||
--allow-untrusted \
|
||||
--initdb \
|
||||
$ALPINE_PACKAGES
|
||||
|
||||
# Strategy 2: docker -> BuildKit local export.
|
||||
# Do NOT use `docker export` here. On Docker's overlay2 driver it leaks the
|
||||
# container's overlay mount (the mount survives `docker rm`), orphaning layers
|
||||
# under /var/lib/docker that only a daemon restart reclaims; on a busy CI host
|
||||
# this grows without bound. `buildx --output type=local` writes the image
|
||||
# filesystem straight to a directory and releases every mount, so nothing leaks.
|
||||
elif command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then
|
||||
echo "$TAG rootfs: docker buildx --output type=local (alpine:${ALPINE_VERSION})"
|
||||
BUILD_CTX=$(mktemp -d)
|
||||
cat > "$BUILD_CTX/Dockerfile" <<DOCKERFILE
|
||||
FROM alpine:${ALPINE_VERSION}
|
||||
RUN apk add --no-cache ${ALPINE_PACKAGES}
|
||||
DOCKERFILE
|
||||
docker buildx build \
|
||||
--platform linux/amd64 \
|
||||
--output "type=local,dest=${CHROOT_DIR}" \
|
||||
"$BUILD_CTX"
|
||||
rm -rf "$BUILD_CTX"
|
||||
|
||||
# Strategy 3: podman -> run + export. Podman's store does not exhibit the docker
|
||||
# overlay2 export-mount leak, and podman has no buildx.
|
||||
elif command -v podman >/dev/null 2>&1; then
|
||||
echo "$TAG rootfs: podman export (alpine:${ALPINE_VERSION})"
|
||||
CONTAINER_TOOL=podman
|
||||
CONTAINER_CID=$(podman run -d \
|
||||
"alpine:${ALPINE_VERSION}" \
|
||||
sh -c "apk add --no-cache ${ALPINE_PACKAGES}")
|
||||
|
||||
exit_code=$(podman wait "$CONTAINER_CID")
|
||||
if [ "$exit_code" != "0" ]; then
|
||||
echo "ERROR: package installation failed in container (exit $exit_code)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
podman export "$CONTAINER_CID" | tar -x -C "$CHROOT_DIR"
|
||||
|
||||
else
|
||||
echo "ERROR: no rootfs build tool available." >&2
|
||||
echo " Install one of: apk (Alpine), docker, podman" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
microvm_empty_dev() {
|
||||
# --- /dev: empty mountpoint only ---
|
||||
# tar extraction without root silently creates 0-byte regular files for device
|
||||
# nodes. Drop them and leave an empty /dev mountpoint. The two static nodes the
|
||||
# kernel needs (/dev/console to wire init's stdio, /dev/null) are written into
|
||||
# the cpio archive under fakeroot at pack time — NOT created on the host. init
|
||||
# mounts devtmpfs over /dev as its first action for everything else at runtime.
|
||||
rm -rf "${CHROOT_DIR:?}/dev"
|
||||
mkdir "$CHROOT_DIR/dev"
|
||||
}
|
||||
|
||||
# microvm_locate_kernel finds the kernel and asserts fw_cfg is loadable or built in.
|
||||
microvm_locate_kernel() {
|
||||
# --- Locate kernel ---
|
||||
KERNEL_IMG=$(find "$CHROOT_DIR/boot" -name "vmlinuz*" | sort | tail -1)
|
||||
if [ -z "$KERNEL_IMG" ]; then
|
||||
echo "ERROR: no vmlinuz found in $CHROOT_DIR/boot" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "$TAG kernel image: $KERNEL_IMG"
|
||||
|
||||
KERNEL_VER=$(basename "$KERNEL_IMG" | sed 's/vmlinuz-//')
|
||||
KERNEL_CONFIG="$CHROOT_DIR/boot/config-${KERNEL_VER}"
|
||||
|
||||
# --- Assert fw_cfg module ---
|
||||
if ! find "$CHROOT_DIR/lib/modules" \
|
||||
-name "qemu_fw_cfg.ko*" \
|
||||
-o -name "fw_cfg_sysfs.ko*" \
|
||||
| grep -q .; then
|
||||
if ! grep -q "CONFIG_FW_CFG_SYSFS=y" "$KERNEL_CONFIG" 2>/dev/null; then
|
||||
echo "ERROR: qemu_fw_cfg module missing from initramfs — fw_cfg reads will fail at boot" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "$TAG fw_cfg: built-in (CONFIG_FW_CFG_SYSFS=y)"
|
||||
else
|
||||
echo "$TAG fw_cfg: module found in lib/modules"
|
||||
fi
|
||||
}
|
||||
|
||||
# --- Strip kernel modules (Phase 2 keep-list approach) ---
|
||||
# Only modules needed by a virtio-mmio microvm guest are kept:
|
||||
# virtio*.ko* — virtio bus, net, blk, rng, console, and helpers
|
||||
# *fw_cfg*.ko* — QEMU fw_cfg sysfs driver (delivers boot blobs)
|
||||
# $MICROVM_EXTRA_KEEP_GLOBS — caller-named module file globs
|
||||
# $MICROVM_EXTRA_MODULES — caller-named modules, plus dependency closure
|
||||
# All other loadable modules are removed. Drivers compiled into the kernel
|
||||
# (CONFIG_VIRTIO_MMIO=y etc.) are unaffected — they live in vmlinuz, not here.
|
||||
|
||||
# module_path prints a module's kernel/ path from the stock modules.dep, by
|
||||
# name or modules.alias; it fails when the module is not loadable.
|
||||
module_path() {
|
||||
local kdir="$1" name="${2//-/_}" path alias_target
|
||||
path=$(awk -F: -v n="$name" '{ f = $1; sub(/.*\//, "", f); sub(/\.ko(\.gz|\.xz|\.zst)?$/, "", f);
|
||||
gsub(/-/, "_", f); if (f == n) { print $1; exit } }' "$kdir/modules.dep")
|
||||
if [ -z "$path" ] && [ -f "$kdir/modules.alias" ]; then
|
||||
alias_target=$(awk -v n="$2" '$1 == "alias" && $2 == n { print $3; exit }' "$kdir/modules.alias")
|
||||
[ -n "$alias_target" ] && [ "$alias_target" != "$2" ] && path=$(module_path "$kdir" "$alias_target")
|
||||
fi
|
||||
[ -n "$path" ] && printf '%s\n' "$path"
|
||||
}
|
||||
|
||||
# module_builtin reports whether a module name is compiled into the kernel.
|
||||
module_builtin() {
|
||||
local kdir="$1" name="${2//-/_}"
|
||||
[ -f "$kdir/modules.builtin" ] || return 1
|
||||
awk -v n="$name" '{ f = $0; sub(/.*\//, "", f); sub(/\.ko$/, "", f); gsub(/-/, "_", f);
|
||||
if (f == n) { found = 1; exit } } END { exit !found }' "$kdir/modules.builtin"
|
||||
}
|
||||
|
||||
# image_kmod runs the image's own kmod (depmod, modprobe) through its musl
|
||||
# loader, so module metadata matches the guest's modprobe without a host kmod.
|
||||
image_kmod() {
|
||||
"$CHROOT_DIR/lib/ld-musl-x86_64.so.1" --library-path "$CHROOT_DIR/lib:$CHROOT_DIR/usr/lib" \
|
||||
--argv0 "$1" "$CHROOT_DIR/bin/kmod" "${@:2}"
|
||||
}
|
||||
|
||||
microvm_strip_modules() {
|
||||
echo "$TAG stripping kernel modules to virtio+fw_cfg+${MICROVM_EXTRA_MODULES} only..."
|
||||
for kver_dir in "$CHROOT_DIR/lib/modules"/*/; do
|
||||
kver="$(basename "$kver_dir")"
|
||||
kernel_dir="$kver_dir/kernel"
|
||||
[ -d "$kernel_dir" ] || continue
|
||||
|
||||
if [ ! -s "$kver_dir/modules.dep" ]; then
|
||||
echo "ERROR: stock ${kver_dir}modules.dep missing — cannot resolve module dependencies" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Extra modules start the closure; the glob families are kept as found.
|
||||
keep_list=$(mktemp)
|
||||
: > "$keep_list"
|
||||
for name in $MICROVM_EXTRA_MODULES; do
|
||||
if ! path=$(module_path "$kver_dir" "$name"); then
|
||||
if module_builtin "$kver_dir" "$name"; then continue; fi
|
||||
echo "ERROR: module $name is neither loadable nor built into $kver" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "$path" >> "$keep_list"
|
||||
done
|
||||
|
||||
# Close over modules.dep hard dependencies and modules.softdep pre:/post:
|
||||
# names until the set stops growing. A softdep must be loadable or built in.
|
||||
while :; do
|
||||
before=$(sort -u "$keep_list" | wc -l)
|
||||
for path in $(sort -u "$keep_list"); do
|
||||
awk -F': ' -v p="$path" '$1 == p { n = split($2, d, " "); for (i = 1; i <= n; i++) print d[i] }' \
|
||||
"$kver_dir/modules.dep" >> "$keep_list"
|
||||
mod=$(basename "$path"); mod="${mod%%.ko*}"; mod="${mod//-/_}"
|
||||
[ -f "$kver_dir/modules.softdep" ] || continue
|
||||
for soft in $(awk -v n="$mod" '{ m = $2; gsub(/-/, "_", m) }
|
||||
$1 == "softdep" && m == n { for (i = 3; i <= NF; i++) if ($i != "pre:" && $i != "post:") print $i }' \
|
||||
"$kver_dir/modules.softdep"); do
|
||||
if soft_path=$(module_path "$kver_dir" "$soft"); then
|
||||
echo "$soft_path" >> "$keep_list"
|
||||
elif ! module_builtin "$kver_dir" "$soft"; then
|
||||
echo "ERROR: softdep $soft of $mod is neither loadable nor built into $kver" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
done
|
||||
after=$(sort -u "$keep_list" | wc -l)
|
||||
[ "$before" -eq "$after" ] && break
|
||||
done
|
||||
|
||||
extra_names=$(sort -u "$keep_list" | while read -r p; do m=$(basename "$p"); m="${m%%.ko*}"; echo "${m//-/_}"; done)
|
||||
find_args=(-name "virtio*.ko*" -o -name "*fw_cfg*.ko*")
|
||||
for glob in ${MICROVM_EXTRA_KEEP_GLOBS[@]+"${MICROVM_EXTRA_KEEP_GLOBS[@]}"}; do
|
||||
find_args+=(-o -name "$glob")
|
||||
done
|
||||
(cd "$kver_dir" && find kernel "${find_args[@]}") >> "$keep_list"
|
||||
|
||||
# Copy the keeper set out, preserving each kernel/ relative path.
|
||||
tmp_keep=$(mktemp -d)
|
||||
while read -r rel; do
|
||||
rel="${rel#kernel/}"
|
||||
mkdir -p "$tmp_keep/$(dirname "$rel")"
|
||||
cp "$kernel_dir/$rel" "$tmp_keep/$rel"
|
||||
done < <(sort -u "$keep_list")
|
||||
rm -f "$keep_list"
|
||||
|
||||
# Replace kernel module tree with the keeper set.
|
||||
rm -rf "$kernel_dir"
|
||||
mkdir -p "$kernel_dir"
|
||||
# Restore each kept file into the same relative path.
|
||||
(cd "$tmp_keep" && find . -name "*.ko*" | while read -r f; do
|
||||
dest_dir="$kernel_dir/$(dirname "${f#./}")"
|
||||
mkdir -p "$dest_dir"
|
||||
cp "$f" "$dest_dir/"
|
||||
done)
|
||||
rm -rf "$tmp_keep"
|
||||
|
||||
# Decompress kept modules to plain .ko. Alpine ships .ko.gz, but a host
|
||||
# kmod that lacks gzip support (e.g. Debian trixie's kmod 34) silently
|
||||
# produces an EMPTY modules.dep from compressed modules — the guest's
|
||||
# modprobe then no-ops and qemu_fw_cfg never loads. Plain .ko sidesteps the
|
||||
# host-kmod dependency entirely and the guest needs no decompressor either.
|
||||
find "$kernel_dir" -name "*.ko.gz" -exec gunzip -f {} +
|
||||
|
||||
# Independent closure gate: each extra-closure module's own .modinfo depends=
|
||||
# must name a kept module or a built-in, so a resolver bug fails here.
|
||||
kept_names=$(find "$kernel_dir" -name "*.ko" -exec basename {} .ko \; | tr '-' '_' | sort -u)
|
||||
while read -r ko; do
|
||||
ko_name=$(basename "$ko" .ko); ko_name="${ko_name//-/_}"
|
||||
grep -qx "$ko_name" <<< "$extra_names" || continue
|
||||
deps=$(tr '\0' '\n' < "$ko" | grep -a -m1 '^depends=' | cut -d= -f2 | tr ',' ' ' || true)
|
||||
for dep in $deps; do
|
||||
dep="${dep//-/_}"
|
||||
if ! grep -qx "$dep" <<< "$kept_names" && ! module_builtin "$kver_dir" "$dep"; then
|
||||
echo "ERROR: $(basename "$ko") depends on $dep, which is neither kept nor built in" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
done < <(find "$kernel_dir" -name "*.ko")
|
||||
for name in $MICROVM_EXTRA_MODULES; do
|
||||
if ! grep -qx "${name//-/_}" <<< "$kept_names" && ! module_builtin "$kver_dir" "$name"; then
|
||||
echo "ERROR: required module $name missing from the stripped module tree" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo "$TAG extra module closure: $(echo "$extra_names" | tr '\n' ' ')"
|
||||
echo "$TAG modules kept: $(echo "$kept_names" | tr '\n' ' ')"
|
||||
|
||||
# Rebuild module metadata with the image's own kmod, the binary the guest's
|
||||
# modprobe runs, so no host depmod is needed. Stock indexes still name .ko.gz
|
||||
# files, so they are removed first and a failed rebuild cannot ship them.
|
||||
for f in dep dep.bin alias alias.bin softdep weakdep symbols symbols.bin \
|
||||
builtin.bin builtin.alias.bin devname; do
|
||||
rm -f "$kver_dir/modules.$f"
|
||||
done
|
||||
if ! image_kmod depmod -b "$CHROOT_DIR" "$kver"; then
|
||||
echo "ERROR: image depmod failed for $kver" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -s "$kver_dir/modules.dep" ] || [ ! -s "$kver_dir/modules.dep.bin" ]; then
|
||||
echo "ERROR: ${kver_dir}modules.dep or modules.dep.bin missing or empty after depmod" >&2
|
||||
exit 1
|
||||
fi
|
||||
# Resolution gate: the image's modprobe must resolve every required module
|
||||
# to plain .ko files present in the stripped tree.
|
||||
for name in $MICROVM_EXTRA_MODULES $MICROVM_GATE_MODULES; do
|
||||
module_builtin "$kver_dir" "$name" && continue
|
||||
if ! plan=$(image_kmod modprobe -d "$CHROOT_DIR" -S "$kver" --show-depends "$name"); then
|
||||
echo "ERROR: image modprobe cannot resolve $name for $kver" >&2
|
||||
exit 1
|
||||
fi
|
||||
while read -r verb path _; do
|
||||
[ "$verb" = "insmod" ] || continue
|
||||
case "$path" in
|
||||
"$CHROOT_DIR/lib/modules/$kver/kernel/"*.ko) [ -f "$path" ] && continue ;;
|
||||
esac
|
||||
echo "ERROR: modprobe plan for $name names $path, not a kept plain .ko" >&2
|
||||
exit 1
|
||||
done <<< "$plan"
|
||||
echo "$TAG modprobe $name: $(awk '{ n = split($2, a, "/"); printf "%s ", a[n] }' <<< "$plan")"
|
||||
done
|
||||
done
|
||||
}
|
||||
|
||||
# microvm_strip_rootfs drops package-manager artifacts and debug symbols.
|
||||
microvm_strip_rootfs() {
|
||||
# --- Strip package-manager artifacts (not needed at runtime) ---
|
||||
echo "$TAG stripping package manager artifacts..."
|
||||
rm -rf \
|
||||
"$CHROOT_DIR/lib/apk" \
|
||||
"$CHROOT_DIR/var/cache/apk" \
|
||||
"$CHROOT_DIR/etc/apk" \
|
||||
"$CHROOT_DIR/usr/share/man" \
|
||||
"$CHROOT_DIR/usr/share/doc" \
|
||||
"$CHROOT_DIR/usr/share/apk" \
|
||||
"$CHROOT_DIR/usr/include" \
|
||||
"$CHROOT_DIR/usr/lib/pkgconfig"
|
||||
|
||||
# --- Strip debug symbols from binaries ---
|
||||
echo "$TAG stripping debug symbols from binaries..."
|
||||
find "$CHROOT_DIR/usr/sbin" "$CHROOT_DIR/usr/bin" \
|
||||
"$CHROOT_DIR/sbin" "$CHROOT_DIR/bin" \
|
||||
-type f 2>/dev/null | while read -r bin; do
|
||||
strip --strip-all "$bin" 2>/dev/null || true
|
||||
done
|
||||
}
|
||||
|
||||
# microvm_stage_kernel copies vmlinuz to $STAGE_DIR, then drops /boot from the chroot.
|
||||
microvm_stage_kernel() {
|
||||
# --- Copy vmlinuz out before stripping /boot from chroot ---
|
||||
VMLINUZ_OUT="$STAGE_DIR/vmlinuz"
|
||||
echo "$TAG copying kernel: $VMLINUZ_OUT"
|
||||
cp "$KERNEL_IMG" "$VMLINUZ_OUT"
|
||||
|
||||
# Drop /boot from the chroot — vmlinuz, Alpine's stock initramfs-virt, System.map
|
||||
# and kernel config all live here (~25 MiB). None are needed at runtime: the
|
||||
# guest already has the kernel loaded by QEMU's -kernel, and Alpine's initramfs
|
||||
# is unused because we replace it with our own /init. Leaving /boot in the cpio
|
||||
# nearly doubles uncompressed initramfs size and forces a 512 MiB guest just to
|
||||
# survive decompression.
|
||||
rm -rf "${CHROOT_DIR:?}/boot"
|
||||
}
|
||||
|
||||
microvm_pack_initramfs() {
|
||||
# --- Build initramfs ---
|
||||
INITRAMFS_OUT="$STAGE_DIR/initramfs.cpio.gz"
|
||||
echo "$TAG building initramfs: $INITRAMFS_OUT"
|
||||
# Create the static device nodes and pack the archive inside a single fakeroot
|
||||
# session: mknod/cpio see faked device entries and write real char-device
|
||||
# records into the cpio, while the host filesystem never gets an actual node.
|
||||
fakeroot sh -c '
|
||||
cd "$1" || exit 1
|
||||
mknod -m 600 dev/console c 5 1
|
||||
mknod -m 666 dev/null c 1 3
|
||||
find . | cpio --quiet -o -H newc | gzip -9 > "$2"
|
||||
' _ "$CHROOT_DIR" "$INITRAMFS_OUT"
|
||||
}
|
||||
+2
-4
@@ -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"
|
||||
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
// Package agent runs the guest loop: take a request from the host link, hand
|
||||
// it to the function runtime, and report the outcome back.
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/agents/lambda/hostlink"
|
||||
"github.com/mulgadc/spinifex/spinifex/agents/lambda/runtimeapi"
|
||||
)
|
||||
|
||||
const (
|
||||
errTypeTimedOut = "Sandbox.Timedout"
|
||||
errTypeUnknown = "Runtime.Unknown"
|
||||
)
|
||||
|
||||
// ErrInvocationTimedOut stops the agent after it reports a timed-out
|
||||
// invocation, because the runtime may still be running it.
|
||||
var ErrInvocationTimedOut = errors.New("invocation timed out; environment must not serve another request")
|
||||
|
||||
// Run serves requests from link until the host asks for shutdown (nil), the
|
||||
// runtime reports an init error, an invocation times out, ctx ends, or the
|
||||
// link fails. GrantID and AttemptID are echoed to the host unchanged.
|
||||
func Run(ctx context.Context, link hostlink.Link, srv *runtimeapi.Server) error {
|
||||
ctx, cancel := context.WithCancelCause(ctx)
|
||||
defer cancel(nil)
|
||||
go func() {
|
||||
select {
|
||||
case <-srv.InitFailed():
|
||||
ie, _ := srv.InitError()
|
||||
cancel(fmt.Errorf("runtime init error %q: %s", ie.Type, ie.Body))
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
req, err := link.Next(ctx)
|
||||
if errors.Is(err, hostlink.ErrShutdown) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return cause(ctx, err)
|
||||
}
|
||||
|
||||
res, err := srv.Invoke(ctx, runtimeapi.Invocation{
|
||||
RequestID: req.RequestID,
|
||||
FunctionARN: req.FunctionARN,
|
||||
Deadline: time.UnixMilli(req.DeadlineMs),
|
||||
TraceID: req.TraceID,
|
||||
Payload: req.Payload,
|
||||
})
|
||||
timedOut := errors.Is(err, runtimeapi.ErrDeadlineExceeded)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return cause(ctx, err)
|
||||
}
|
||||
res = failure(err)
|
||||
}
|
||||
resp := hostlink.Response{
|
||||
GrantID: req.GrantID,
|
||||
AttemptID: req.AttemptID,
|
||||
RequestID: req.RequestID,
|
||||
Payload: res.Payload,
|
||||
FunctionError: res.FunctionError,
|
||||
}
|
||||
if err := link.Complete(ctx, resp); err != nil {
|
||||
return cause(ctx, err)
|
||||
}
|
||||
if timedOut {
|
||||
return ErrInvocationTimedOut
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func cause(ctx context.Context, err error) error {
|
||||
if ctx.Err() != nil {
|
||||
return context.Cause(ctx)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func failure(err error) runtimeapi.Result {
|
||||
errType := errTypeUnknown
|
||||
if errors.Is(err, runtimeapi.ErrDeadlineExceeded) {
|
||||
errType = errTypeTimedOut
|
||||
}
|
||||
return runtimeapi.Result{Payload: runtimeapi.ErrorBody(err.Error(), errType), FunctionError: errType}
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/agents/lambda/hostlink"
|
||||
"github.com/mulgadc/spinifex/spinifex/agents/lambda/runtimeapi"
|
||||
)
|
||||
|
||||
const api = "/2018-06-01/runtime/"
|
||||
|
||||
// fakeLink feeds scripted requests and records completions.
|
||||
type fakeLink struct {
|
||||
reqs []hostlink.Request
|
||||
got chan hostlink.Response
|
||||
}
|
||||
|
||||
var _ hostlink.Link = (*fakeLink)(nil)
|
||||
|
||||
func (l *fakeLink) Next(ctx context.Context) (hostlink.Request, error) {
|
||||
if len(l.reqs) == 0 {
|
||||
return hostlink.Request{}, hostlink.ErrShutdown
|
||||
}
|
||||
r := l.reqs[0]
|
||||
l.reqs = l.reqs[1:]
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (l *fakeLink) Complete(ctx context.Context, r hostlink.Response) error {
|
||||
l.got <- r
|
||||
return nil
|
||||
}
|
||||
|
||||
// runtime polls next and answers using reply.
|
||||
func runtime(t *testing.T, base string, reply func(id string, body []byte) (path string, out string)) {
|
||||
t.Helper()
|
||||
go func() {
|
||||
for {
|
||||
resp, err := http.Get(base + api + "invocation/next")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
id := resp.Header.Get("Lambda-Runtime-Aws-Request-Id")
|
||||
path, out := reply(id, body)
|
||||
r, err := http.Post(base+api+"invocation/"+id+path, "application/json", strings.NewReader(out))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
r.Body.Close()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func deadline(d time.Duration) int64 { return time.Now().Add(d).UnixMilli() }
|
||||
|
||||
func TestRunServesRequestsAndEchoesIDs(t *testing.T) {
|
||||
srv := runtimeapi.New()
|
||||
ts := httptest.NewServer(srv.Handler())
|
||||
defer ts.Close()
|
||||
runtime(t, ts.URL, func(id string, body []byte) (string, string) {
|
||||
if string(body) == "bad" {
|
||||
return "/error", `{"errorMessage":"no"}`
|
||||
}
|
||||
return "/response", "echo:" + string(body)
|
||||
})
|
||||
|
||||
link := &fakeLink{
|
||||
got: make(chan hostlink.Response, 4),
|
||||
reqs: []hostlink.Request{
|
||||
{GrantID: "g1", AttemptID: "a1", RequestID: "r1", FunctionARN: "arn", DeadlineMs: deadline(2 * time.Second), Payload: []byte("one")},
|
||||
{GrantID: "g2", AttemptID: "a2", RequestID: "r2", FunctionARN: "arn", DeadlineMs: deadline(2 * time.Second), Payload: []byte("bad")},
|
||||
},
|
||||
}
|
||||
require.NoError(t, Run(context.Background(), link, srv))
|
||||
|
||||
r1, r2 := <-link.got, <-link.got
|
||||
assert.Equal(t, hostlink.Response{GrantID: "g1", AttemptID: "a1", RequestID: "r1", Payload: []byte("echo:one")}, r1)
|
||||
assert.Equal(t, "g2", r2.GrantID)
|
||||
assert.Equal(t, "a2", r2.AttemptID)
|
||||
assert.Equal(t, "Unhandled", r2.FunctionError)
|
||||
}
|
||||
|
||||
func TestRunReportsTimeoutAndStops(t *testing.T) {
|
||||
srv := runtimeapi.New() // no runtime polling: the invocation expires
|
||||
link := &fakeLink{
|
||||
got: make(chan hostlink.Response, 2),
|
||||
reqs: []hostlink.Request{
|
||||
{GrantID: "g", AttemptID: "a", RequestID: "r", DeadlineMs: deadline(30 * time.Millisecond)},
|
||||
{GrantID: "g2", AttemptID: "a2", RequestID: "r2", DeadlineMs: deadline(2 * time.Second)},
|
||||
},
|
||||
}
|
||||
require.ErrorIs(t, Run(context.Background(), link, srv), ErrInvocationTimedOut)
|
||||
r := <-link.got
|
||||
assert.Equal(t, "Sandbox.Timedout", r.FunctionError)
|
||||
assert.Equal(t, "g", r.GrantID)
|
||||
assert.Len(t, link.reqs, 1, "the next request must not be taken")
|
||||
assert.Empty(t, link.got, "nothing else is reported")
|
||||
}
|
||||
|
||||
// blockLink blocks in Next until ctx ends.
|
||||
type blockLink struct{ fakeLink }
|
||||
|
||||
func (blockLink) Next(ctx context.Context) (hostlink.Request, error) {
|
||||
<-ctx.Done()
|
||||
return hostlink.Request{}, ctx.Err()
|
||||
}
|
||||
|
||||
func TestRunStopsOnInitError(t *testing.T) {
|
||||
srv := runtimeapi.New()
|
||||
ts := httptest.NewServer(srv.Handler())
|
||||
defer ts.Close()
|
||||
r, err := http.Post(ts.URL+api+"init/error", "application/json", strings.NewReader(`{"errorMessage":"x"}`))
|
||||
require.NoError(t, err)
|
||||
r.Body.Close()
|
||||
|
||||
err = Run(context.Background(), &blockLink{}, srv)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "init error")
|
||||
}
|
||||
|
||||
type errLink struct{ fakeLink }
|
||||
|
||||
func (errLink) Next(context.Context) (hostlink.Request, error) {
|
||||
return hostlink.Request{}, errors.New("link down")
|
||||
}
|
||||
|
||||
func TestRunReturnsLinkError(t *testing.T) {
|
||||
err := Run(context.Background(), &errLink{}, runtimeapi.New())
|
||||
require.EqualError(t, err, "link down")
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
//go:build linux
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os/exec"
|
||||
goruntime "runtime"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// sandboxID is the uid and gid the function's bootstrap runs as.
|
||||
const sandboxID = 993
|
||||
|
||||
// StartBootstrap starts the function's bootstrap at path as the sandbox uid and
|
||||
// gid with no supplementary groups, with no_new_privs, and with exactly env.
|
||||
// The caller waits on the returned command.
|
||||
func StartBootstrap(ctx context.Context, path string, env []string, stdout, stderr io.Writer) (*exec.Cmd, error) {
|
||||
cred := &syscall.Credential{Uid: sandboxID, Gid: sandboxID, Groups: []uint32{}}
|
||||
return startBootstrap(ctx, path, env, stdout, stderr, cred)
|
||||
}
|
||||
|
||||
func startBootstrap(ctx context.Context, path string, env []string, stdout, stderr io.Writer, cred *syscall.Credential) (*exec.Cmd, error) {
|
||||
cmd := exec.CommandContext(ctx, path)
|
||||
// A nil Env inherits the agent's environment; the bootstrap gets only env.
|
||||
cmd.Env = append([]string{}, env...)
|
||||
cmd.Stdout, cmd.Stderr = stdout, stderr
|
||||
if cred != nil {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Credential: cred}
|
||||
}
|
||||
cmd.Cancel = func() error { return cmd.Process.Signal(syscall.SIGTERM) }
|
||||
cmd.WaitDelay = 5 * time.Second
|
||||
|
||||
started := make(chan error, 1)
|
||||
go func() {
|
||||
// no_new_privs is per thread and the child inherits it from this one. The
|
||||
// thread is never unlocked, so Go ends it when this goroutine returns.
|
||||
goruntime.LockOSThread()
|
||||
if err := unix.Prctl(unix.PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); err != nil {
|
||||
started <- fmt.Errorf("set no_new_privs: %w", err)
|
||||
return
|
||||
}
|
||||
started <- cmd.Start()
|
||||
}()
|
||||
if err := <-started; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cmd, nil
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
//go:build linux
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
goruntime "runtime"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// statusProbe writes an executable that prints its own credential lines.
|
||||
func statusProbe(t *testing.T) string {
|
||||
t.Helper()
|
||||
// The sandbox uid must be able to reach the probe through the test's dirs.
|
||||
dir := t.TempDir()
|
||||
require.NoError(t, os.Chmod(filepath.Dir(dir), 0o755))
|
||||
require.NoError(t, os.Chmod(dir, 0o755))
|
||||
path := filepath.Join(dir, "bootstrap")
|
||||
script := "#!/bin/sh\ngrep -E '^(Uid|Gid|Groups|NoNewPrivs):' /proc/self/status\n"
|
||||
require.NoError(t, os.WriteFile(path, []byte(script), 0o755))
|
||||
return path
|
||||
}
|
||||
|
||||
func statusLines(t *testing.T, out []byte) map[string]string {
|
||||
t.Helper()
|
||||
lines := map[string]string{}
|
||||
for l := range strings.SplitSeq(strings.TrimSpace(string(out)), "\n") {
|
||||
k, v, ok := strings.Cut(l, ":")
|
||||
require.True(t, ok, "status line %q", l)
|
||||
lines[k] = strings.Join(strings.Fields(v), " ")
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
// run starts a bootstrap with the given credential and returns its stdout.
|
||||
func run(t *testing.T, path string, env []string, sandbox bool) []byte {
|
||||
t.Helper()
|
||||
var out bytes.Buffer
|
||||
start := startBootstrap
|
||||
if sandbox {
|
||||
start = func(ctx context.Context, p string, e []string, o, s io.Writer, _ *syscall.Credential) (*exec.Cmd, error) {
|
||||
return StartBootstrap(ctx, p, e, o, s)
|
||||
}
|
||||
}
|
||||
cmd, err := start(context.Background(), path, env, &out, os.Stderr, nil)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, cmd.Wait())
|
||||
return out.Bytes()
|
||||
}
|
||||
|
||||
// threadNoNewPrivs reads no_new_privs for the calling OS thread.
|
||||
func threadNoNewPrivs(t *testing.T) string {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile("/proc/thread-self/status")
|
||||
require.NoError(t, err)
|
||||
return statusLines(t, grepLines(b, "NoNewPrivs:"))["NoNewPrivs"]
|
||||
}
|
||||
|
||||
func grepLines(b []byte, prefix string) []byte {
|
||||
var keep []string
|
||||
for l := range strings.SplitSeq(string(b), "\n") {
|
||||
if strings.HasPrefix(l, prefix) {
|
||||
keep = append(keep, l)
|
||||
}
|
||||
}
|
||||
return []byte(strings.Join(keep, "\n"))
|
||||
}
|
||||
|
||||
func TestStartBootstrapSetsNoNewPrivsWithoutChangingTheCaller(t *testing.T) {
|
||||
goruntime.LockOSThread()
|
||||
defer goruntime.UnlockOSThread()
|
||||
if threadNoNewPrivs(t) != "0" {
|
||||
t.Skip("test thread already has no_new_privs; the child would inherit it")
|
||||
}
|
||||
|
||||
out := run(t, statusProbe(t), nil, false)
|
||||
assert.Equal(t, "1", statusLines(t, out)["NoNewPrivs"])
|
||||
assert.Equal(t, "0", threadNoNewPrivs(t), "the calling thread must keep no_new_privs unset")
|
||||
}
|
||||
|
||||
func TestStartBootstrapDropsToSandboxIdentity(t *testing.T) {
|
||||
if os.Geteuid() != 0 {
|
||||
t.Skip("dropping to the sandbox uid needs root")
|
||||
}
|
||||
lines := statusLines(t, run(t, statusProbe(t), nil, true))
|
||||
assert.Equal(t, "993 993 993 993", lines["Uid"])
|
||||
assert.Equal(t, "993 993 993 993", lines["Gid"])
|
||||
assert.Empty(t, lines["Groups"])
|
||||
assert.Equal(t, "1", lines["NoNewPrivs"])
|
||||
}
|
||||
|
||||
// A tenant LD_PRELOAD constructor runs before the bootstrap's main; it must
|
||||
// already see no_new_privs, so no loader runs before the flag is set.
|
||||
func TestStartBootstrapPreloadConstructorSeesNoNewPrivs(t *testing.T) {
|
||||
goruntime.LockOSThread()
|
||||
defer goruntime.UnlockOSThread()
|
||||
if threadNoNewPrivs(t) != "0" {
|
||||
t.Skip("test thread already has no_new_privs; the check would pass vacuously")
|
||||
}
|
||||
cc, err := exec.LookPath("cc")
|
||||
if err != nil {
|
||||
t.Skip("no C compiler for the preload library")
|
||||
}
|
||||
bootstrap, err := exec.LookPath("true")
|
||||
require.NoError(t, err)
|
||||
dir := t.TempDir()
|
||||
src := filepath.Join(dir, "preload.c")
|
||||
lib := filepath.Join(dir, "preload.so")
|
||||
require.NoError(t, os.WriteFile(src, []byte(preloadSource), 0o644))
|
||||
build := exec.Command(cc, "-shared", "-fPIC", "-o", lib, src)
|
||||
build.Stderr = os.Stderr
|
||||
require.NoError(t, build.Run())
|
||||
|
||||
out := run(t, bootstrap, []string{"LD_PRELOAD=" + lib}, false)
|
||||
assert.Equal(t, "preload NoNewPrivs: 1", strings.Join(strings.Fields(string(out)), " "))
|
||||
}
|
||||
|
||||
const preloadSource = `#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
__attribute__((constructor)) static void report(void) {
|
||||
char line[256];
|
||||
FILE *f = fopen("/proc/self/status", "r");
|
||||
if (!f)
|
||||
return;
|
||||
while (fgets(line, sizeof line, f))
|
||||
if (strncmp(line, "NoNewPrivs:", 11) == 0)
|
||||
printf("preload %s", line);
|
||||
fclose(f);
|
||||
fflush(stdout);
|
||||
}
|
||||
`
|
||||
|
||||
func TestStartBootstrapDoesNotInheritAgentEnvironment(t *testing.T) {
|
||||
t.Setenv("ZZ_AGENT_SECRET", "must-not-leak")
|
||||
probe, err := exec.LookPath("env")
|
||||
require.NoError(t, err)
|
||||
|
||||
for name, env := range map[string][]string{"nil": nil, "empty": {}, "given": {"ONLY=this"}} {
|
||||
out := run(t, probe, env, false)
|
||||
// Never print the captured environment: an inherited one may hold secrets.
|
||||
if strings.TrimSuffix(string(out), "\n") != strings.Join(env, "\n") {
|
||||
t.Errorf("%s: bootstrap environment is not exactly the one given", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Without root the sandbox credential cannot be applied, so the exported entry
|
||||
// point must refuse rather than run the bootstrap as the caller.
|
||||
func TestStartBootstrapAsNonRootRefusesInsteadOfKeepingCallerIdentity(t *testing.T) {
|
||||
if os.Geteuid() == 0 {
|
||||
t.Skip("root can apply the sandbox credential")
|
||||
}
|
||||
_, err := StartBootstrap(context.Background(), statusProbe(t), nil, nil, nil)
|
||||
assert.ErrorIs(t, err, syscall.EPERM)
|
||||
}
|
||||
|
||||
func TestStartBootstrapMissingExecutableFails(t *testing.T) {
|
||||
_, err := startBootstrap(context.Background(), filepath.Join(t.TempDir(), "bootstrap"), nil, nil, nil, nil)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
// Package hostlink is the guest's boundary to host control: an authenticated
|
||||
// TCP connection under per-generation mutual TLS. The message protocol over it
|
||||
// is not yet chosen; implementations of Link are carriers.
|
||||
package hostlink
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
// ErrShutdown is returned by Link.Next when the host asks the agent to stop.
|
||||
var ErrShutdown = errors.New("hostlink: host requested shutdown")
|
||||
|
||||
// Request is one invocation from the host. GrantID and AttemptID are opaque
|
||||
// to the agent and are echoed unchanged in the Response.
|
||||
type Request struct {
|
||||
GrantID string `json:"grant_id"`
|
||||
AttemptID string `json:"attempt_id"`
|
||||
RequestID string `json:"request_id"`
|
||||
FunctionARN string `json:"function_arn"`
|
||||
TraceID string `json:"trace_id,omitempty"`
|
||||
DeadlineMs int64 `json:"deadline_ms"`
|
||||
Payload []byte `json:"payload"`
|
||||
}
|
||||
|
||||
// Response is the outcome of a Request. FunctionError is empty on success.
|
||||
type Response struct {
|
||||
GrantID string `json:"grant_id"`
|
||||
AttemptID string `json:"attempt_id"`
|
||||
RequestID string `json:"request_id"`
|
||||
Payload []byte `json:"payload"`
|
||||
FunctionError string `json:"function_error,omitempty"`
|
||||
}
|
||||
|
||||
// Link carries invocations from the host to the guest and results back.
|
||||
type Link interface {
|
||||
// Next blocks for the next request, or returns ErrShutdown.
|
||||
Next(ctx context.Context) (Request, error)
|
||||
// Complete reports the outcome of a request.
|
||||
Complete(ctx context.Context, resp Response) error
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
package hostlink
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// FwCfgRoot is the sysfs tree the qemu_fw_cfg module exposes; removing the
|
||||
// module removes every entry under it, including the guest key.
|
||||
const FwCfgRoot = "/sys/firmware/qemu_fw_cfg"
|
||||
|
||||
// UnloadFwCfg removes the qemu_fw_cfg module from the running kernel.
|
||||
func UnloadFwCfg() error {
|
||||
if err := unix.DeleteModule("qemu_fw_cfg", 0); err != nil {
|
||||
return fmt.Errorf("hostlink: unload qemu_fw_cfg: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SealFwCfg makes fw_cfg unreadable for the rest of the boot. It succeeds only
|
||||
// once root is gone, so a built-in or busy module fails closed rather than
|
||||
// leaving the guest key readable by code started later.
|
||||
func SealFwCfg(root string, unload func() error) error {
|
||||
if _, err := os.Stat(root); errors.Is(err, fs.ErrNotExist) {
|
||||
return nil
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("hostlink: fw_cfg: %w", err)
|
||||
}
|
||||
unloadErr := unload()
|
||||
_, err := os.Stat(root)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
if unloadErr == nil {
|
||||
unloadErr = errors.New("unload reported success")
|
||||
}
|
||||
return fmt.Errorf("hostlink: fw_cfg still readable at %s: %w", root, errors.Join(unloadErr, err))
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
package hostlink
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func fwCfgTree(t *testing.T) string {
|
||||
t.Helper()
|
||||
root := filepath.Join(t.TempDir(), "qemu_fw_cfg")
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(root, "by_name"), 0o700))
|
||||
return root
|
||||
}
|
||||
|
||||
func TestSealFwCfg(t *testing.T) {
|
||||
t.Run("already absent needs no unload", func(t *testing.T) {
|
||||
called := false
|
||||
require.NoError(t, SealFwCfg(filepath.Join(t.TempDir(), "absent"), func() error { called = true; return nil }))
|
||||
assert.False(t, called)
|
||||
})
|
||||
t.Run("unload removes the tree", func(t *testing.T) {
|
||||
root := fwCfgTree(t)
|
||||
require.NoError(t, SealFwCfg(root, func() error { return os.RemoveAll(root) }))
|
||||
})
|
||||
t.Run("unload fails and the tree remains", func(t *testing.T) {
|
||||
builtIn := errors.New("module is built in")
|
||||
err := SealFwCfg(fwCfgTree(t), func() error { return builtIn })
|
||||
assert.ErrorIs(t, err, builtIn)
|
||||
assert.ErrorContains(t, err, "still readable")
|
||||
})
|
||||
t.Run("unload succeeds but the tree remains", func(t *testing.T) {
|
||||
assert.ErrorContains(t, SealFwCfg(fwCfgTree(t), func() error { return nil }), "unload reported success")
|
||||
})
|
||||
t.Run("unload errors after removing the tree", func(t *testing.T) {
|
||||
root := fwCfgTree(t)
|
||||
require.NoError(t, SealFwCfg(root, func() error { _ = os.RemoveAll(root); return errors.New("late") }))
|
||||
})
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
//go:build lambdatestlink
|
||||
|
||||
package hostlink
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// maxFrame bounds one frame: a 6 MiB payload as base64 plus headroom.
|
||||
const maxFrame = (6<<20+2)/3*4 + 64<<10
|
||||
|
||||
const (
|
||||
typeInvoke = "invoke"
|
||||
typeShutdown = "shutdown"
|
||||
typeResult = "result"
|
||||
)
|
||||
|
||||
// ErrFrameTooLarge is returned for a frame beyond the size bound.
|
||||
var ErrFrameTooLarge = errors.New("hostlink: frame too large")
|
||||
|
||||
type frame struct {
|
||||
Type string `json:"type"`
|
||||
Request *Request `json:"request,omitempty"`
|
||||
Response *Response `json:"response,omitempty"`
|
||||
}
|
||||
|
||||
// TestStream is a test-only carrier over an io.ReadWriter, framing JSON with
|
||||
// a 4-byte big-endian length. It is not the guest protocol.
|
||||
type TestStream struct {
|
||||
rw io.ReadWriter
|
||||
in chan frame
|
||||
wmu sync.Mutex
|
||||
|
||||
err error // set before in is closed
|
||||
}
|
||||
|
||||
var _ Link = (*TestStream)(nil)
|
||||
|
||||
// NewTestStream starts reading frames from rw.
|
||||
func NewTestStream(rw io.ReadWriter) *TestStream {
|
||||
s := &TestStream{rw: rw, in: make(chan frame)}
|
||||
go s.readLoop()
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *TestStream) readLoop() {
|
||||
defer close(s.in)
|
||||
for {
|
||||
f, err := readFrame(s.rw)
|
||||
if err != nil {
|
||||
s.err = err
|
||||
return
|
||||
}
|
||||
s.in <- f
|
||||
}
|
||||
}
|
||||
|
||||
func (s *TestStream) recv(ctx context.Context) (frame, error) {
|
||||
select {
|
||||
case f, ok := <-s.in:
|
||||
if !ok {
|
||||
return frame{}, s.err
|
||||
}
|
||||
return f, nil
|
||||
case <-ctx.Done():
|
||||
return frame{}, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// Next implements Link.
|
||||
func (s *TestStream) Next(ctx context.Context) (Request, error) {
|
||||
f, err := s.recv(ctx)
|
||||
if err != nil {
|
||||
return Request{}, err
|
||||
}
|
||||
switch f.Type {
|
||||
case typeShutdown:
|
||||
return Request{}, ErrShutdown
|
||||
case typeInvoke:
|
||||
if f.Request == nil {
|
||||
return Request{}, errors.New("hostlink: invoke frame without request")
|
||||
}
|
||||
return *f.Request, nil
|
||||
default:
|
||||
return Request{}, fmt.Errorf("hostlink: unexpected frame type %q", f.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// Complete implements Link.
|
||||
func (s *TestStream) Complete(ctx context.Context, resp Response) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.write(frame{Type: typeResult, Response: &resp})
|
||||
}
|
||||
|
||||
// HostSend writes an invoke frame, as the host side of the carrier.
|
||||
func (s *TestStream) HostSend(req Request) error {
|
||||
return s.write(frame{Type: typeInvoke, Request: &req})
|
||||
}
|
||||
|
||||
// HostShutdown writes a shutdown frame, as the host side of the carrier.
|
||||
func (s *TestStream) HostShutdown() error {
|
||||
return s.write(frame{Type: typeShutdown})
|
||||
}
|
||||
|
||||
// HostResult reads a result frame, as the host side of the carrier.
|
||||
func (s *TestStream) HostResult(ctx context.Context) (Response, error) {
|
||||
f, err := s.recv(ctx)
|
||||
if err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
if f.Type != typeResult || f.Response == nil {
|
||||
return Response{}, fmt.Errorf("hostlink: unexpected frame type %q", f.Type)
|
||||
}
|
||||
return *f.Response, nil
|
||||
}
|
||||
|
||||
func (s *TestStream) write(f frame) error {
|
||||
b, err := json.Marshal(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(b) > maxFrame {
|
||||
return ErrFrameTooLarge
|
||||
}
|
||||
buf := make([]byte, 4+len(b))
|
||||
binary.BigEndian.PutUint32(buf, uint32(len(b)))
|
||||
copy(buf[4:], b)
|
||||
s.wmu.Lock()
|
||||
defer s.wmu.Unlock()
|
||||
_, err = s.rw.Write(buf)
|
||||
return err
|
||||
}
|
||||
|
||||
func readFrame(r io.Reader) (frame, error) {
|
||||
var hdr [4]byte
|
||||
if _, err := io.ReadFull(r, hdr[:]); err != nil {
|
||||
return frame{}, err
|
||||
}
|
||||
n := binary.BigEndian.Uint32(hdr[:])
|
||||
if n > maxFrame {
|
||||
return frame{}, ErrFrameTooLarge
|
||||
}
|
||||
body := make([]byte, n)
|
||||
if _, err := io.ReadFull(r, body); err != nil {
|
||||
if err == io.EOF {
|
||||
err = io.ErrUnexpectedEOF
|
||||
}
|
||||
return frame{}, err
|
||||
}
|
||||
var f frame
|
||||
if err := json.Unmarshal(body, &f); err != nil {
|
||||
return frame{}, fmt.Errorf("hostlink: bad frame: %w", err)
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
//go:build lambdatestlink
|
||||
|
||||
package hostlink
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// pair returns the guest carrier and the raw host end of a pipe.
|
||||
func pair(t *testing.T) (*TestStream, net.Conn) {
|
||||
t.Helper()
|
||||
g, h := net.Pipe()
|
||||
t.Cleanup(func() { g.Close(); h.Close() })
|
||||
return NewTestStream(g), h
|
||||
}
|
||||
|
||||
func TestRoundTrip(t *testing.T) {
|
||||
guest, host := pair(t)
|
||||
hostEnd := NewTestStream(host)
|
||||
req := Request{GrantID: "g", AttemptID: "a", RequestID: "r", FunctionARN: "arn", TraceID: "t", DeadlineMs: 42, Payload: []byte{0, 1, 2, 255}}
|
||||
go func() { _ = hostEnd.HostSend(req) }()
|
||||
|
||||
got, err := guest.Next(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, req, got)
|
||||
|
||||
resp := Response{GrantID: "g", AttemptID: "a", RequestID: "r", Payload: []byte("out"), FunctionError: "Unhandled"}
|
||||
go func() { _ = guest.Complete(context.Background(), resp) }()
|
||||
gotResp, err := hostEnd.HostResult(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, resp, gotResp)
|
||||
}
|
||||
|
||||
func TestOversizeFrameRefused(t *testing.T) {
|
||||
guest, host := pair(t)
|
||||
go func() {
|
||||
var hdr [4]byte
|
||||
binary.BigEndian.PutUint32(hdr[:], maxFrame+1)
|
||||
_, _ = host.Write(hdr[:])
|
||||
}()
|
||||
_, err := guest.Next(context.Background())
|
||||
require.ErrorIs(t, err, ErrFrameTooLarge)
|
||||
|
||||
err = guest.Complete(context.Background(), Response{Payload: make([]byte, maxFrame)})
|
||||
require.ErrorIs(t, err, ErrFrameTooLarge)
|
||||
}
|
||||
|
||||
func TestTruncatedFrameErrors(t *testing.T) {
|
||||
guest, host := pair(t)
|
||||
go func() {
|
||||
var hdr [4]byte
|
||||
binary.BigEndian.PutUint32(hdr[:], 100)
|
||||
_, _ = host.Write(append(hdr[:], []byte(`{"type"`)...))
|
||||
host.Close()
|
||||
}()
|
||||
_, err := guest.Next(context.Background())
|
||||
require.Error(t, err)
|
||||
assert.NotErrorIs(t, err, ErrShutdown)
|
||||
}
|
||||
|
||||
func TestShutdownSurfaced(t *testing.T) {
|
||||
guest, host := pair(t)
|
||||
hostEnd := NewTestStream(host)
|
||||
go func() { _ = hostEnd.HostShutdown() }()
|
||||
_, err := guest.Next(context.Background())
|
||||
require.ErrorIs(t, err, ErrShutdown)
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
package hostlink
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
lambdav1 "github.com/mulgadc/spinifex/contracts/lambda/v1"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/pki"
|
||||
)
|
||||
|
||||
// FwCfgDir is where the guest kernel exposes the executor's fw_cfg entries.
|
||||
const FwCfgDir = FwCfgRoot + "/by_name/" + lambdav1.FwCfgPrefix
|
||||
|
||||
// Identity is the guest's material for one environment generation.
|
||||
type Identity struct {
|
||||
Cert tls.Certificate
|
||||
HostCert *x509.Certificate
|
||||
Scope lambdav1.Scope
|
||||
HostAddr string
|
||||
}
|
||||
|
||||
// LoadIdentity reads the executor's fw_cfg entries from dir.
|
||||
func LoadIdentity(dir string) (Identity, error) {
|
||||
read := func(name string) ([]byte, error) {
|
||||
b, err := os.ReadFile(filepath.Join(dir, name, "raw"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hostlink: read %s: %w", name, err)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
var id Identity
|
||||
keyPEM, err := read(lambdav1.EntryGuestKey)
|
||||
if err != nil {
|
||||
return id, err
|
||||
}
|
||||
certPEM, err := read(lambdav1.EntryGuestCert)
|
||||
if err != nil {
|
||||
return id, err
|
||||
}
|
||||
hostPEM, err := read(lambdav1.EntryHostCert)
|
||||
if err != nil {
|
||||
return id, err
|
||||
}
|
||||
scope, err := read(lambdav1.EntryScope)
|
||||
if err != nil {
|
||||
return id, err
|
||||
}
|
||||
addr, err := read(lambdav1.EntryHostAddr)
|
||||
if err != nil {
|
||||
return id, err
|
||||
}
|
||||
|
||||
if id.Cert, err = tls.X509KeyPair(certPEM, keyPEM); err != nil {
|
||||
return Identity{}, fmt.Errorf("hostlink: guest key pair: %w", err)
|
||||
}
|
||||
blk, rest := pem.Decode(hostPEM)
|
||||
if blk == nil || blk.Type != "CERTIFICATE" || len(strings.TrimSpace(string(rest))) != 0 {
|
||||
return Identity{}, errors.New("hostlink: host certificate: want exactly one PEM certificate")
|
||||
}
|
||||
if id.HostCert, err = x509.ParseCertificate(blk.Bytes); err != nil {
|
||||
return Identity{}, fmt.Errorf("hostlink: host certificate: %w", err)
|
||||
}
|
||||
if id.Scope, err = lambdav1.ParseScope(strings.TrimSpace(string(scope))); err != nil {
|
||||
return Identity{}, err
|
||||
}
|
||||
id.HostAddr = strings.TrimSpace(string(addr))
|
||||
if _, _, err := net.SplitHostPort(id.HostAddr); err != nil {
|
||||
return Identity{}, fmt.Errorf("hostlink: host address: %w", err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// ClientConfig is the guest's TLS 1.3 configuration. The host has no DNS name,
|
||||
// so VerifyConnection checks the pinned host certificate and its URI SAN.
|
||||
func (id Identity) ClientConfig() *tls.Config {
|
||||
return &tls.Config{
|
||||
MinVersion: tls.VersionTLS13,
|
||||
MaxVersion: tls.VersionTLS13,
|
||||
Certificates: []tls.Certificate{id.Cert},
|
||||
NextProtos: []string{lambdav1.ALPNv1},
|
||||
InsecureSkipVerify: true, //nolint:gosec // VerifyConnection performs full validation against the pinned host certificate.
|
||||
VerifyConnection: func(cs tls.ConnectionState) error {
|
||||
if cs.NegotiatedProtocol != lambdav1.ALPNv1 {
|
||||
return fmt.Errorf("hostlink: application protocol %q, want %q", cs.NegotiatedProtocol, lambdav1.ALPNv1)
|
||||
}
|
||||
return pki.VerifyPinned(cs.PeerCertificates, id.HostCert, x509.ExtKeyUsageServerAuth, id.Scope.URI(lambdav1.RoleHost))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Dial opens the authenticated control connection to the host. Under TLS 1.3
|
||||
// the host may still refuse the guest's certificate after Dial returns; that
|
||||
// surfaces on the first read, so the caller must not treat Dial as acceptance.
|
||||
func Dial(ctx context.Context, id Identity) (*tls.Conn, error) {
|
||||
d := tls.Dialer{NetDialer: &net.Dialer{Timeout: 10 * time.Second}, Config: id.ClientConfig()}
|
||||
c, err := d.DialContext(ctx, "tcp", id.HostAddr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hostlink: dial host %s: %w", id.HostAddr, err)
|
||||
}
|
||||
tc, ok := c.(*tls.Conn)
|
||||
if !ok {
|
||||
_ = c.Close()
|
||||
return nil, fmt.Errorf("hostlink: dial host %s: unexpected connection type %T", id.HostAddr, c)
|
||||
}
|
||||
return tc, nil
|
||||
}
|
||||
@@ -1,291 +0,0 @@
|
||||
package hostlink
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
lambdav1 "github.com/mulgadc/spinifex/contracts/lambda/v1"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/pki"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const (
|
||||
gen1 lambdav1.Scope = "ap-southeast-2/000000000001/fn-1/env-1/gen-1"
|
||||
gen2 lambdav1.Scope = "ap-southeast-2/000000000001/fn-1/env-1/gen-2"
|
||||
)
|
||||
|
||||
type testIdent struct {
|
||||
cert tls.Certificate
|
||||
leaf *x509.Certificate
|
||||
certPEM []byte
|
||||
keyPEM []byte
|
||||
}
|
||||
|
||||
func newTestIdent(t *testing.T, scope lambdav1.Scope, role string) testIdent {
|
||||
t.Helper()
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
require.NoError(t, err)
|
||||
u, err := url.Parse(scope.URI(role))
|
||||
require.NoError(t, err)
|
||||
usage := x509.ExtKeyUsageServerAuth
|
||||
if role == lambdav1.RoleGuest {
|
||||
usage = x509.ExtKeyUsageClientAuth
|
||||
}
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(time.Now().UnixNano()), Subject: pkix.Name{CommonName: role},
|
||||
NotBefore: time.Now().Add(-time.Minute), NotAfter: time.Now().Add(time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{usage},
|
||||
BasicConstraintsValid: true, URIs: []*url.URL{u},
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||
require.NoError(t, err)
|
||||
kder, err := x509.MarshalECPrivateKey(key)
|
||||
require.NoError(t, err)
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: kder})
|
||||
c, err := tls.X509KeyPair(certPEM, keyPEM)
|
||||
require.NoError(t, err)
|
||||
leaf, err := x509.ParseCertificate(der)
|
||||
require.NoError(t, err)
|
||||
return testIdent{cert: c, leaf: leaf, certPEM: certPEM, keyPEM: keyPEM}
|
||||
}
|
||||
|
||||
// hostConfig is the host end as the executor will run it: it pins one guest
|
||||
// certificate and checks its URI SAN with the shared pki.VerifyPinned.
|
||||
func hostConfig(host testIdent, pinGuest *x509.Certificate, scope lambdav1.Scope) *tls.Config {
|
||||
return &tls.Config{
|
||||
MinVersion: tls.VersionTLS13, Certificates: []tls.Certificate{host.cert},
|
||||
ClientAuth: tls.RequireAnyClientCert, NextProtos: []string{lambdav1.ALPNv1},
|
||||
VerifyConnection: func(cs tls.ConnectionState) error {
|
||||
return pki.VerifyPinned(cs.PeerCertificates, pinGuest, x509.ExtKeyUsageClientAuth, scope.URI(lambdav1.RoleGuest))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// serve accepts one connection with cfg, writes "ok" if the handshake
|
||||
// succeeds, and returns the address and the host's handshake result.
|
||||
func serve(t *testing.T, cfg *tls.Config) (string, <-chan error) {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { ln.Close() })
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
raw, err := ln.Accept()
|
||||
if err != nil {
|
||||
done <- err
|
||||
return
|
||||
}
|
||||
defer raw.Close()
|
||||
tc := tls.Server(raw, cfg)
|
||||
_ = tc.SetDeadline(time.Now().Add(5 * time.Second))
|
||||
err = tc.Handshake()
|
||||
if err == nil {
|
||||
_, err = tc.Write([]byte("ok"))
|
||||
}
|
||||
done <- err
|
||||
}()
|
||||
return ln.Addr().String(), done
|
||||
}
|
||||
|
||||
func guestIdentity(guest, pinHost testIdent, scope lambdav1.Scope, addr string) Identity {
|
||||
return Identity{Cert: guest.cert, HostCert: pinHost.leaf, Scope: scope, HostAddr: addr}
|
||||
}
|
||||
|
||||
func dialAndRead(t *testing.T, id Identity) (string, error) {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
c, err := Dial(ctx, id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer c.Close()
|
||||
_ = c.SetReadDeadline(time.Now().Add(5 * time.Second))
|
||||
buf := make([]byte, 2)
|
||||
n, err := c.Read(buf)
|
||||
return string(buf[:n]), err
|
||||
}
|
||||
|
||||
func TestDialMutualAuthentication(t *testing.T) {
|
||||
host, guest := newTestIdent(t, gen1, lambdav1.RoleHost), newTestIdent(t, gen1, lambdav1.RoleGuest)
|
||||
addr, hostErr := serve(t, hostConfig(host, guest.leaf, gen1))
|
||||
|
||||
got, err := dialAndRead(t, guestIdentity(guest, host, gen1, addr))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "ok", got)
|
||||
require.NoError(t, <-hostErr)
|
||||
}
|
||||
|
||||
func TestDialRefusesHost(t *testing.T) {
|
||||
guest := newTestIdent(t, gen1, lambdav1.RoleGuest)
|
||||
pinned := newTestIdent(t, gen1, lambdav1.RoleHost)
|
||||
cases := []struct {
|
||||
name string
|
||||
host testIdent
|
||||
pin testIdent
|
||||
mutate func(*tls.Config)
|
||||
wantErr string
|
||||
}{
|
||||
{"unpinned host certificate", newTestIdent(t, gen1, lambdav1.RoleHost), pinned, nil, "peer certificate"},
|
||||
{"pinned certificate of a later generation", newTestIdent(t, gen2, lambdav1.RoleHost), testIdent{}, nil, "peer identity mismatch"},
|
||||
{"guest role presented as host", newTestIdent(t, gen1, lambdav1.RoleGuest), testIdent{}, nil, "peer certificate"},
|
||||
{"no application protocol", pinned, pinned, func(c *tls.Config) { c.NextProtos = nil }, "application protocol"},
|
||||
{"TLS 1.2 only", pinned, pinned, func(c *tls.Config) { c.MinVersion, c.MaxVersion = tls.VersionTLS12, tls.VersionTLS12 }, "protocol version"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
pin := tc.pin
|
||||
if pin.leaf == nil {
|
||||
pin = tc.host // the guest pins exactly what is presented
|
||||
}
|
||||
cfg := hostConfig(tc.host, guest.leaf, gen1)
|
||||
if tc.mutate != nil {
|
||||
tc.mutate(cfg)
|
||||
}
|
||||
addr, _ := serve(t, cfg)
|
||||
_, err := dialAndRead(t, guestIdentity(guest, pin, gen1, addr))
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tc.wantErr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// newCASignedHost returns a CA and a host leaf it signed carrying the same URI
|
||||
// SAN as a legitimate host certificate.
|
||||
func newCASignedHost(t *testing.T, scope lambdav1.Scope) (ca testIdent, leaf tls.Certificate) {
|
||||
t.Helper()
|
||||
caKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
require.NoError(t, err)
|
||||
u, err := url.Parse(scope.URI(lambdav1.RoleHost))
|
||||
require.NoError(t, err)
|
||||
caTmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "ca"},
|
||||
NotBefore: time.Now().Add(-time.Minute), NotAfter: time.Now().Add(time.Hour),
|
||||
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
BasicConstraintsValid: true, IsCA: true, URIs: []*url.URL{u},
|
||||
}
|
||||
caDER, err := x509.CreateCertificate(rand.Reader, caTmpl, caTmpl, &caKey.PublicKey, caKey)
|
||||
require.NoError(t, err)
|
||||
caCert, err := x509.ParseCertificate(caDER)
|
||||
require.NoError(t, err)
|
||||
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
require.NoError(t, err)
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(2), Subject: pkix.Name{CommonName: "host"},
|
||||
NotBefore: time.Now().Add(-time.Minute), NotAfter: time.Now().Add(time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
URIs: []*url.URL{u},
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, caCert, &key.PublicKey, caKey)
|
||||
require.NoError(t, err)
|
||||
return testIdent{leaf: caCert}, tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key}
|
||||
}
|
||||
|
||||
// Trusting the pin as a root is not enough: a different leaf it signed, with
|
||||
// the right URI SAN, must still be refused.
|
||||
func TestDialRefusesLeafSignedByPin(t *testing.T) {
|
||||
guest := newTestIdent(t, gen1, lambdav1.RoleGuest)
|
||||
ca, leaf := newCASignedHost(t, gen1)
|
||||
for name, chain := range map[string][][]byte{
|
||||
"leaf only": leaf.Certificate,
|
||||
"leaf with CA": {leaf.Certificate[0], ca.leaf.Raw},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
cfg := hostConfig(testIdent{}, guest.leaf, gen1)
|
||||
cfg.Certificates = []tls.Certificate{{Certificate: chain, PrivateKey: leaf.PrivateKey}}
|
||||
addr, _ := serve(t, cfg)
|
||||
_, err := dialAndRead(t, guestIdentity(guest, ca, gen1, addr))
|
||||
require.Error(t, err)
|
||||
assert.Regexp(t, "not the pinned certificate|want exactly the pinned one", err.Error())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostRefusalSurfacesOnFirstRead(t *testing.T) {
|
||||
host, guest := newTestIdent(t, gen1, lambdav1.RoleHost), newTestIdent(t, gen1, lambdav1.RoleGuest)
|
||||
other := newTestIdent(t, gen1, lambdav1.RoleGuest)
|
||||
addr, hostErr := serve(t, hostConfig(host, other.leaf, gen1))
|
||||
|
||||
_, err := dialAndRead(t, guestIdentity(guest, host, gen1, addr))
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "bad certificate")
|
||||
assert.ErrorContains(t, <-hostErr, "peer certificate")
|
||||
}
|
||||
|
||||
func TestHostRefusesGuestOfAnotherGeneration(t *testing.T) {
|
||||
host, guest := newTestIdent(t, gen1, lambdav1.RoleHost), newTestIdent(t, gen2, lambdav1.RoleGuest)
|
||||
addr, hostErr := serve(t, hostConfig(host, guest.leaf, gen1))
|
||||
|
||||
_, err := dialAndRead(t, guestIdentity(guest, host, gen1, addr))
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, <-hostErr, pki.ErrPeerIdentity)
|
||||
}
|
||||
|
||||
func writeEntries(t *testing.T, entries map[string][]byte) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
for name, b := range entries {
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(dir, name), 0o700))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, name, "raw"), b, 0o600))
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
func validEntries(t *testing.T) map[string][]byte {
|
||||
host, guest := newTestIdent(t, gen1, lambdav1.RoleHost), newTestIdent(t, gen1, lambdav1.RoleGuest)
|
||||
return map[string][]byte{
|
||||
lambdav1.EntryGuestKey: guest.keyPEM, lambdav1.EntryGuestCert: guest.certPEM, lambdav1.EntryHostCert: host.certPEM,
|
||||
lambdav1.EntryScope: []byte(string(gen1) + "\n"), lambdav1.EntryHostAddr: []byte("10.15.8.1:18443\n"),
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadIdentity(t *testing.T) {
|
||||
id, err := LoadIdentity(writeEntries(t, validEntries(t)))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, gen1, id.Scope)
|
||||
assert.Equal(t, "10.15.8.1:18443", id.HostAddr)
|
||||
require.Len(t, id.HostCert.URIs, 1)
|
||||
assert.Equal(t, gen1.URI(lambdav1.RoleHost), id.HostCert.URIs[0].String())
|
||||
}
|
||||
|
||||
func TestLoadIdentityRejects(t *testing.T) {
|
||||
other := newTestIdent(t, gen1, lambdav1.RoleGuest)
|
||||
cases := []struct {
|
||||
name string
|
||||
edit func(map[string][]byte)
|
||||
wantErr string
|
||||
}{
|
||||
{"missing entry", func(e map[string][]byte) { delete(e, lambdav1.EntryHostCert) }, "read host-cert"},
|
||||
{"key does not match certificate", func(e map[string][]byte) { e[lambdav1.EntryGuestKey] = other.keyPEM }, "guest key pair"},
|
||||
{"two host certificates", func(e map[string][]byte) {
|
||||
e[lambdav1.EntryHostCert] = append(e[lambdav1.EntryHostCert], e[lambdav1.EntryHostCert]...)
|
||||
}, "exactly one PEM"},
|
||||
{"host certificate not PEM", func(e map[string][]byte) { e[lambdav1.EntryHostCert] = []byte("nope") }, "exactly one PEM"},
|
||||
{"short scope", func(e map[string][]byte) { e[lambdav1.EntryScope] = []byte("ap-southeast-2/000000000001/fn-1") }, "want 5 segments"},
|
||||
{"address without port", func(e map[string][]byte) { e[lambdav1.EntryHostAddr] = []byte("10.15.8.1") }, "host address"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
e := validEntries(t)
|
||||
tc.edit(e)
|
||||
_, err := LoadIdentity(writeEntries(t, e))
|
||||
assert.ErrorContains(t, err, tc.wantErr)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,285 +0,0 @@
|
||||
// Package runtimeapi serves the AWS Lambda Runtime API (version 2018-06-01)
|
||||
// inside the guest, handing one invocation at a time to the function runtime.
|
||||
package runtimeapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MaxResponseBytes is the largest response payload a runtime may post.
|
||||
const MaxResponseBytes = 6291456
|
||||
|
||||
const (
|
||||
// ErrTypeResponseTooLarge is the function error for an oversize response.
|
||||
ErrTypeResponseTooLarge = "Function.ResponseSizeTooLarge"
|
||||
// ErrTypeUnhandled is the function error when the runtime names no type.
|
||||
ErrTypeUnhandled = "Unhandled"
|
||||
|
||||
errTypeInvalidRequestID = "InvalidRequestID"
|
||||
errTypeTooLarge = "RequestEntityTooLarge"
|
||||
errTypeInvalidRequest = "InvalidRequest"
|
||||
|
||||
headerRequestID = "Lambda-Runtime-Aws-Request-Id"
|
||||
headerDeadline = "Lambda-Runtime-Deadline-Ms"
|
||||
headerARN = "Lambda-Runtime-Invoked-Function-Arn"
|
||||
headerTraceID = "Lambda-Runtime-Trace-Id"
|
||||
headerErrorType = "Lambda-Runtime-Function-Error-Type"
|
||||
|
||||
maxInitErrorBytes = 1 << 20
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrBusy is returned by Invoke while another invocation is in flight.
|
||||
ErrBusy = errors.New("runtimeapi: an invocation is already in flight")
|
||||
// ErrDeadlineExceeded is returned by Invoke when the deadline passes.
|
||||
ErrDeadlineExceeded = errors.New("runtimeapi: invocation deadline exceeded")
|
||||
// ErrInvalidInvocation is returned by Invoke for an invocation with no request ID.
|
||||
ErrInvalidInvocation = errors.New("runtimeapi: invocation has no request id")
|
||||
)
|
||||
|
||||
// Invocation is one event handed to the runtime.
|
||||
type Invocation struct {
|
||||
RequestID string
|
||||
FunctionARN string
|
||||
Deadline time.Time
|
||||
TraceID string
|
||||
Payload []byte
|
||||
}
|
||||
|
||||
// Result is what the runtime answered. FunctionError is empty on success and
|
||||
// otherwise the error type.
|
||||
type Result struct {
|
||||
Payload []byte
|
||||
FunctionError string
|
||||
}
|
||||
|
||||
// InitError is an initialisation failure reported by the runtime.
|
||||
type InitError struct {
|
||||
Type string
|
||||
Body []byte
|
||||
}
|
||||
|
||||
type flight struct {
|
||||
inv Invocation
|
||||
taken bool
|
||||
done chan Result
|
||||
}
|
||||
|
||||
// Server implements the Runtime API for a single in-flight invocation.
|
||||
type Server struct {
|
||||
mu sync.Mutex
|
||||
cur *flight
|
||||
wake chan struct{}
|
||||
|
||||
initOnce sync.Once
|
||||
initDone chan struct{}
|
||||
initErr InitError
|
||||
}
|
||||
|
||||
// New returns a Server with no invocation in flight.
|
||||
func New() *Server {
|
||||
return &Server{wake: make(chan struct{}, 1), initDone: make(chan struct{})}
|
||||
}
|
||||
|
||||
// InitFailed is closed once the runtime reports an init error.
|
||||
func (s *Server) InitFailed() <-chan struct{} { return s.initDone }
|
||||
|
||||
// InitError returns the reported init error, if any.
|
||||
func (s *Server) InitError() (InitError, bool) {
|
||||
select {
|
||||
case <-s.initDone:
|
||||
return s.initErr, true
|
||||
default:
|
||||
return InitError{}, false
|
||||
}
|
||||
}
|
||||
|
||||
// Invoke hands inv to the runtime and blocks until the runtime posts a
|
||||
// response or error, the deadline passes, or ctx ends.
|
||||
func (s *Server) Invoke(ctx context.Context, inv Invocation) (Result, error) {
|
||||
if inv.RequestID == "" {
|
||||
return Result{}, ErrInvalidInvocation
|
||||
}
|
||||
f := &flight{inv: inv, done: make(chan Result, 1)}
|
||||
s.mu.Lock()
|
||||
if s.cur != nil {
|
||||
s.mu.Unlock()
|
||||
return Result{}, ErrBusy
|
||||
}
|
||||
s.cur = f
|
||||
s.mu.Unlock()
|
||||
select {
|
||||
case s.wake <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
|
||||
timer := time.NewTimer(time.Until(inv.Deadline))
|
||||
defer timer.Stop()
|
||||
var cause error
|
||||
select {
|
||||
case res := <-f.done:
|
||||
return res, nil
|
||||
case <-timer.C:
|
||||
cause = ErrDeadlineExceeded
|
||||
case <-ctx.Done():
|
||||
cause = ctx.Err()
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
if s.cur == f {
|
||||
s.cur = nil
|
||||
s.mu.Unlock()
|
||||
return Result{}, cause
|
||||
}
|
||||
s.mu.Unlock()
|
||||
// Completed while we were giving up; the result is already queued.
|
||||
return <-f.done, nil
|
||||
}
|
||||
|
||||
// Handler returns the Runtime API routes.
|
||||
func (s *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /2018-06-01/runtime/invocation/next", s.next)
|
||||
mux.HandleFunc("POST /2018-06-01/runtime/invocation/{id}/response", s.response)
|
||||
mux.HandleFunc("POST /2018-06-01/runtime/invocation/{id}/error", s.invocationError)
|
||||
mux.HandleFunc("POST /2018-06-01/runtime/init/error", s.initError)
|
||||
return mux
|
||||
}
|
||||
|
||||
func (s *Server) next(w http.ResponseWriter, r *http.Request) {
|
||||
for {
|
||||
s.mu.Lock()
|
||||
f := s.cur
|
||||
if f != nil && !f.taken {
|
||||
f.taken = true
|
||||
s.mu.Unlock()
|
||||
h := w.Header()
|
||||
h.Set(headerRequestID, f.inv.RequestID)
|
||||
h.Set(headerDeadline, strconv.FormatInt(f.inv.Deadline.UnixMilli(), 10))
|
||||
h.Set(headerARN, f.inv.FunctionARN)
|
||||
if f.inv.TraceID != "" {
|
||||
h.Set(headerTraceID, f.inv.TraceID)
|
||||
}
|
||||
_, _ = w.Write(f.inv.Payload)
|
||||
return
|
||||
}
|
||||
s.mu.Unlock()
|
||||
select {
|
||||
case <-s.wake:
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// claimable reports whether id names the delivered, uncompleted invocation.
|
||||
func (s *Server) claimable(id string) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.cur != nil && s.cur.taken && s.cur.inv.RequestID == id
|
||||
}
|
||||
|
||||
// complete finishes the invocation named id, reporting whether it was live.
|
||||
func (s *Server) complete(id string, res Result) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
f := s.cur
|
||||
if f == nil || !f.taken || f.inv.RequestID != id {
|
||||
return false
|
||||
}
|
||||
s.cur = nil
|
||||
f.done <- res
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Server) response(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if !s.claimable(id) {
|
||||
invalidID(w, id)
|
||||
return
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, MaxResponseBytes+1))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, errTypeInvalidRequest, "read body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if len(body) > MaxResponseBytes {
|
||||
msg := "Response payload size exceeded maximum allowed payload size (" + strconv.Itoa(MaxResponseBytes) + " bytes)."
|
||||
s.complete(id, Result{Payload: ErrorBody(msg, ErrTypeResponseTooLarge), FunctionError: ErrTypeResponseTooLarge})
|
||||
writeError(w, http.StatusRequestEntityTooLarge, errTypeTooLarge, msg)
|
||||
return
|
||||
}
|
||||
if !s.complete(id, Result{Payload: body}) {
|
||||
invalidID(w, id)
|
||||
return
|
||||
}
|
||||
writeStatus(w)
|
||||
}
|
||||
|
||||
func (s *Server) invocationError(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if !s.claimable(id) {
|
||||
invalidID(w, id)
|
||||
return
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, MaxResponseBytes))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, errTypeInvalidRequest, "read body: "+err.Error())
|
||||
return
|
||||
}
|
||||
errType := r.Header.Get(headerErrorType)
|
||||
if errType == "" {
|
||||
errType = ErrTypeUnhandled
|
||||
}
|
||||
if !s.complete(id, Result{Payload: body, FunctionError: errType}) {
|
||||
invalidID(w, id)
|
||||
return
|
||||
}
|
||||
writeStatus(w)
|
||||
}
|
||||
|
||||
func (s *Server) initError(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(io.LimitReader(r.Body, maxInitErrorBytes))
|
||||
s.initOnce.Do(func() {
|
||||
s.initErr = InitError{Type: r.Header.Get(headerErrorType), Body: body}
|
||||
close(s.initDone)
|
||||
})
|
||||
writeStatus(w)
|
||||
}
|
||||
|
||||
func invalidID(w http.ResponseWriter, id string) {
|
||||
writeError(w, http.StatusBadRequest, errTypeInvalidRequestID, "Invalid request ID: "+id)
|
||||
}
|
||||
|
||||
func writeStatus(w http.ResponseWriter) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
_, _ = w.Write([]byte(`{"status":"OK"}`))
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, code int, errType, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
_, _ = w.Write(ErrorBody(msg, errType))
|
||||
}
|
||||
|
||||
type errorPayload struct {
|
||||
ErrorMessage string `json:"errorMessage"`
|
||||
ErrorType string `json:"errorType"`
|
||||
}
|
||||
|
||||
// ErrorBody renders the Lambda error document for msg and errType.
|
||||
func ErrorBody(msg, errType string) []byte {
|
||||
b, err := json.Marshal(errorPayload{ErrorMessage: msg, ErrorType: errType})
|
||||
if err != nil {
|
||||
return []byte(`{"errorType":"Runtime.Unknown"}`)
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -1,292 +0,0 @@
|
||||
package runtimeapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const base = "/2018-06-01/runtime/"
|
||||
|
||||
type invokeOut struct {
|
||||
res Result
|
||||
err error
|
||||
}
|
||||
|
||||
func start(t *testing.T) (*Server, *httptest.Server) {
|
||||
t.Helper()
|
||||
s := New()
|
||||
ts := httptest.NewServer(s.Handler())
|
||||
t.Cleanup(ts.Close)
|
||||
return s, ts
|
||||
}
|
||||
|
||||
func invokeAsync(s *Server, inv Invocation) <-chan invokeOut {
|
||||
ch := make(chan invokeOut, 1)
|
||||
go func() {
|
||||
res, err := s.Invoke(context.Background(), inv)
|
||||
ch <- invokeOut{res, err}
|
||||
}()
|
||||
return ch
|
||||
}
|
||||
|
||||
func post(t *testing.T, url string, body []byte, hdr map[string]string) (*http.Response, []byte) {
|
||||
t.Helper()
|
||||
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
|
||||
require.NoError(t, err)
|
||||
for k, v := range hdr {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return resp, b
|
||||
}
|
||||
|
||||
func get(t *testing.T, url string) (*http.Response, []byte) {
|
||||
t.Helper()
|
||||
resp, err := http.Get(url)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return resp, b
|
||||
}
|
||||
|
||||
func inv(id string, d time.Duration) Invocation {
|
||||
return Invocation{
|
||||
RequestID: id,
|
||||
FunctionARN: "arn:aws:lambda:ap-southeast-2:000000000001:function:f",
|
||||
Deadline: time.Now().Add(d),
|
||||
Payload: []byte(`{"a":1}`),
|
||||
}
|
||||
}
|
||||
|
||||
func errType(t *testing.T, body []byte) string {
|
||||
t.Helper()
|
||||
var e map[string]string
|
||||
require.NoError(t, json.Unmarshal(body, &e))
|
||||
assert.NotEmpty(t, e["errorMessage"])
|
||||
return e["errorType"]
|
||||
}
|
||||
|
||||
func TestNextResponseRoundTrip(t *testing.T) {
|
||||
s, ts := start(t)
|
||||
in := inv("req-1", 2*time.Second)
|
||||
in.TraceID = "Root=1-abc"
|
||||
out := invokeAsync(s, in)
|
||||
|
||||
resp, body := get(t, ts.URL+base+"invocation/next")
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
assert.Equal(t, `{"a":1}`, string(body))
|
||||
assert.Equal(t, "req-1", resp.Header.Get("Lambda-Runtime-Aws-Request-Id"))
|
||||
assert.Equal(t, strconv.FormatInt(in.Deadline.UnixMilli(), 10), resp.Header.Get("Lambda-Runtime-Deadline-Ms"))
|
||||
assert.Equal(t, in.FunctionARN, resp.Header.Get("Lambda-Runtime-Invoked-Function-Arn"))
|
||||
assert.Equal(t, "Root=1-abc", resp.Header.Get("Lambda-Runtime-Trace-Id"))
|
||||
|
||||
r, _ := post(t, ts.URL+base+"invocation/req-1/response", []byte(`{"sum":3}`), nil)
|
||||
assert.Equal(t, http.StatusAccepted, r.StatusCode)
|
||||
|
||||
got := <-out
|
||||
require.NoError(t, got.err)
|
||||
assert.Equal(t, `{"sum":3}`, string(got.res.Payload))
|
||||
assert.Empty(t, got.res.FunctionError)
|
||||
}
|
||||
|
||||
func TestTraceHeaderOmittedWhenUnset(t *testing.T) {
|
||||
s, ts := start(t)
|
||||
invokeAsync(s, inv("req-1", time.Second))
|
||||
resp, _ := get(t, ts.URL+base+"invocation/next")
|
||||
_, present := resp.Header["Lambda-Runtime-Trace-Id"]
|
||||
assert.False(t, present)
|
||||
}
|
||||
|
||||
func TestErrorPostKeepsBodyAndType(t *testing.T) {
|
||||
s, ts := start(t)
|
||||
out := invokeAsync(s, inv("req-1", 2*time.Second))
|
||||
get(t, ts.URL+base+"invocation/next")
|
||||
|
||||
body := []byte(`{"errorMessage":"boom","errorType":"Custom"}`)
|
||||
r, _ := post(t, ts.URL+base+"invocation/req-1/error", body, map[string]string{"Lambda-Runtime-Function-Error-Type": "Custom"})
|
||||
assert.Equal(t, http.StatusAccepted, r.StatusCode)
|
||||
|
||||
got := <-out
|
||||
require.NoError(t, got.err)
|
||||
assert.Equal(t, "Custom", got.res.FunctionError)
|
||||
assert.JSONEq(t, string(body), string(got.res.Payload))
|
||||
}
|
||||
|
||||
func TestErrorPostWithoutTypeIsUnhandled(t *testing.T) {
|
||||
s, ts := start(t)
|
||||
out := invokeAsync(s, inv("req-1", 2*time.Second))
|
||||
get(t, ts.URL+base+"invocation/next")
|
||||
post(t, ts.URL+base+"invocation/req-1/error", []byte(`{}`), nil)
|
||||
assert.Equal(t, "Unhandled", (<-out).res.FunctionError)
|
||||
}
|
||||
|
||||
func TestUnknownAndRepeatedIDRefused(t *testing.T) {
|
||||
s, ts := start(t)
|
||||
|
||||
r, b := post(t, ts.URL+base+"invocation/nope/response", []byte("x"), nil)
|
||||
assert.Equal(t, http.StatusBadRequest, r.StatusCode)
|
||||
assert.Equal(t, "InvalidRequestID", errType(t, b))
|
||||
|
||||
out := invokeAsync(s, inv("req-1", 2*time.Second))
|
||||
get(t, ts.URL+base+"invocation/next")
|
||||
post(t, ts.URL+base+"invocation/req-1/response", []byte("ok"), nil)
|
||||
<-out
|
||||
|
||||
r, b = post(t, ts.URL+base+"invocation/req-1/response", []byte("again"), nil)
|
||||
assert.Equal(t, http.StatusBadRequest, r.StatusCode)
|
||||
assert.Equal(t, "InvalidRequestID", errType(t, b))
|
||||
r, _ = post(t, ts.URL+base+"invocation/req-1/error", []byte("{}"), nil)
|
||||
assert.Equal(t, http.StatusBadRequest, r.StatusCode)
|
||||
}
|
||||
|
||||
func TestResponseNotAcceptedBeforeDelivery(t *testing.T) {
|
||||
s, ts := start(t)
|
||||
invokeAsync(s, inv("req-1", time.Second))
|
||||
r, _ := post(t, ts.URL+base+"invocation/req-1/response", []byte("early"), nil)
|
||||
assert.Equal(t, http.StatusBadRequest, r.StatusCode)
|
||||
}
|
||||
|
||||
func TestOversizeResponse(t *testing.T) {
|
||||
s, ts := start(t)
|
||||
out := invokeAsync(s, inv("req-1", 5*time.Second))
|
||||
get(t, ts.URL+base+"invocation/next")
|
||||
|
||||
r, b := post(t, ts.URL+base+"invocation/req-1/response", make([]byte, MaxResponseBytes+1), nil)
|
||||
assert.Equal(t, http.StatusRequestEntityTooLarge, r.StatusCode)
|
||||
assert.Equal(t, "RequestEntityTooLarge", errType(t, b))
|
||||
|
||||
got := <-out
|
||||
require.NoError(t, got.err)
|
||||
assert.Equal(t, "Function.ResponseSizeTooLarge", got.res.FunctionError)
|
||||
assert.Equal(t, "Function.ResponseSizeTooLarge", errType(t, got.res.Payload))
|
||||
}
|
||||
|
||||
func TestResponseAtLimitAccepted(t *testing.T) {
|
||||
s, ts := start(t)
|
||||
out := invokeAsync(s, inv("req-1", 5*time.Second))
|
||||
get(t, ts.URL+base+"invocation/next")
|
||||
r, _ := post(t, ts.URL+base+"invocation/req-1/response", make([]byte, MaxResponseBytes), nil)
|
||||
assert.Equal(t, http.StatusAccepted, r.StatusCode)
|
||||
got := <-out
|
||||
require.NoError(t, got.err)
|
||||
assert.Len(t, got.res.Payload, MaxResponseBytes)
|
||||
}
|
||||
|
||||
func TestDeadlineExpiryRefusesLateResponse(t *testing.T) {
|
||||
s, ts := start(t)
|
||||
out := invokeAsync(s, inv("req-1", 40*time.Millisecond))
|
||||
get(t, ts.URL+base+"invocation/next")
|
||||
|
||||
got := <-out
|
||||
require.ErrorIs(t, got.err, ErrDeadlineExceeded)
|
||||
|
||||
r, b := post(t, ts.URL+base+"invocation/req-1/response", []byte("late"), nil)
|
||||
assert.Equal(t, http.StatusBadRequest, r.StatusCode)
|
||||
assert.Equal(t, "InvalidRequestID", errType(t, b))
|
||||
|
||||
// The slot is free again for the next invocation.
|
||||
out = invokeAsync(s, inv("req-2", 2*time.Second))
|
||||
resp, _ := get(t, ts.URL+base+"invocation/next")
|
||||
assert.Equal(t, "req-2", resp.Header.Get("Lambda-Runtime-Aws-Request-Id"))
|
||||
post(t, ts.URL+base+"invocation/req-2/response", []byte("ok"), nil)
|
||||
require.NoError(t, (<-out).err)
|
||||
}
|
||||
|
||||
func TestConcurrentInvokeRefused(t *testing.T) {
|
||||
s, ts := start(t)
|
||||
out := invokeAsync(s, inv("req-1", 2*time.Second))
|
||||
require.Eventually(t, func() bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.cur != nil
|
||||
}, time.Second, time.Millisecond)
|
||||
|
||||
_, err := s.Invoke(context.Background(), inv("req-2", time.Second))
|
||||
require.ErrorIs(t, err, ErrBusy)
|
||||
|
||||
get(t, ts.URL+base+"invocation/next")
|
||||
post(t, ts.URL+base+"invocation/req-1/response", []byte("ok"), nil)
|
||||
require.NoError(t, (<-out).err)
|
||||
}
|
||||
|
||||
func TestInvokeContextCancel(t *testing.T) {
|
||||
s, _ := start(t)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := s.Invoke(ctx, inv("req-1", time.Minute))
|
||||
done <- err
|
||||
}()
|
||||
cancel()
|
||||
require.ErrorIs(t, <-done, context.Canceled)
|
||||
}
|
||||
|
||||
func TestNextBlocksUntilInvoke(t *testing.T) {
|
||||
s, ts := start(t)
|
||||
type got struct {
|
||||
resp *http.Response
|
||||
body []byte
|
||||
}
|
||||
ch := make(chan got, 1)
|
||||
go func() {
|
||||
resp, err := http.Get(ts.URL + base + "invocation/next") //nolint:noctx // test client
|
||||
if err != nil {
|
||||
ch <- got{}
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
ch <- got{resp, body}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ch:
|
||||
t.Fatal("next returned before any invocation")
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
|
||||
out := invokeAsync(s, inv("req-1", 2*time.Second))
|
||||
g := <-ch
|
||||
require.NotNil(t, g.resp, "next request failed")
|
||||
assert.Equal(t, "req-1", g.resp.Header.Get("Lambda-Runtime-Aws-Request-Id"))
|
||||
post(t, ts.URL+base+"invocation/req-1/response", []byte("ok"), nil)
|
||||
require.NoError(t, (<-out).err)
|
||||
}
|
||||
|
||||
func TestInitErrorRecorded(t *testing.T) {
|
||||
s, ts := start(t)
|
||||
_, ok := s.InitError()
|
||||
require.False(t, ok)
|
||||
|
||||
r, _ := post(t, ts.URL+base+"init/error", []byte(`{"errorMessage":"bad"}`), map[string]string{"Lambda-Runtime-Function-Error-Type": "Runtime.InitError"})
|
||||
assert.Equal(t, http.StatusAccepted, r.StatusCode)
|
||||
|
||||
select {
|
||||
case <-s.InitFailed():
|
||||
default:
|
||||
t.Fatal("InitFailed not closed")
|
||||
}
|
||||
ie, ok := s.InitError()
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "Runtime.InitError", ie.Type)
|
||||
assert.JSONEq(t, `{"errorMessage":"bad"}`, string(ie.Body))
|
||||
}
|
||||
|
||||
func TestInvokeRequiresRequestID(t *testing.T) {
|
||||
s := New()
|
||||
_, err := s.Invoke(context.Background(), Invocation{Deadline: time.Now().Add(time.Second)})
|
||||
require.ErrorIs(t, err, ErrInvalidInvocation)
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
package pki
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// ErrPeerIdentity is returned when a pinned peer certificate does not carry
|
||||
// the expected URI SAN.
|
||||
var ErrPeerIdentity = errors.New("pki: peer identity mismatch")
|
||||
|
||||
// VerifyPinned requires chain to be exactly the pinned certificate, valid now
|
||||
// for usage, whose only URI SAN is wantURI. Trusting the pin as a root alone
|
||||
// would also accept other leaves it signed, so the DER is compared first.
|
||||
func VerifyPinned(chain []*x509.Certificate, pinned *x509.Certificate, usage x509.ExtKeyUsage, wantURI string) error {
|
||||
if pinned == nil {
|
||||
return errors.New("pki: no pinned peer certificate")
|
||||
}
|
||||
if len(chain) != 1 {
|
||||
return fmt.Errorf("pki: peer sent %d certificates, want exactly the pinned one", len(chain))
|
||||
}
|
||||
leaf := chain[0]
|
||||
if !bytes.Equal(leaf.Raw, pinned.Raw) {
|
||||
return errors.New("pki: peer certificate is not the pinned certificate")
|
||||
}
|
||||
roots := x509.NewCertPool()
|
||||
roots.AddCert(pinned)
|
||||
if _, err := leaf.Verify(x509.VerifyOptions{Roots: roots, KeyUsages: []x509.ExtKeyUsage{usage}}); err != nil {
|
||||
return fmt.Errorf("pki: peer certificate: %w", err)
|
||||
}
|
||||
if len(leaf.URIs) != 1 || leaf.URIs[0].String() != wantURI {
|
||||
return fmt.Errorf("%w: got %v, want %s", ErrPeerIdentity, leaf.URIs, wantURI)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
package pki
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"math/big"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func pinnedCert(t *testing.T, uris ...string) *x509.Certificate {
|
||||
t.Helper()
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
require.NoError(t, err)
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1), NotBefore: time.Now().Add(-time.Minute), NotAfter: time.Now().Add(time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
|
||||
BasicConstraintsValid: true,
|
||||
}
|
||||
for _, s := range uris {
|
||||
u, err := url.Parse(s)
|
||||
require.NoError(t, err)
|
||||
tmpl.URIs = append(tmpl.URIs, u)
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||
require.NoError(t, err)
|
||||
c, err := x509.ParseCertificate(der)
|
||||
require.NoError(t, err)
|
||||
return c
|
||||
}
|
||||
|
||||
func TestVerifyPinned(t *testing.T) {
|
||||
const want = "spinifex-lambda://r/a/f/e/g/guest"
|
||||
pin := pinnedCert(t, want)
|
||||
other := pinnedCert(t, want)
|
||||
twoURIs := pinnedCert(t, want, "spinifex-lambda://r/a/f/e/g/host")
|
||||
|
||||
require.NoError(t, VerifyPinned([]*x509.Certificate{pin}, pin, x509.ExtKeyUsageClientAuth, want))
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
chain []*x509.Certificate
|
||||
pin *x509.Certificate
|
||||
usage x509.ExtKeyUsage
|
||||
uri string
|
||||
msg string
|
||||
}{
|
||||
{"no pin", []*x509.Certificate{pin}, nil, x509.ExtKeyUsageClientAuth, want, "no pinned peer certificate"},
|
||||
{"empty chain", nil, pin, x509.ExtKeyUsageClientAuth, want, "sent 0 certificates"},
|
||||
{"extra chain certificate", []*x509.Certificate{pin, other}, pin, x509.ExtKeyUsageClientAuth, want, "sent 2 certificates"},
|
||||
{"same identity, other key", []*x509.Certificate{other}, pin, x509.ExtKeyUsageClientAuth, want, "not the pinned certificate"},
|
||||
{"wrong usage", []*x509.Certificate{pin}, pin, x509.ExtKeyUsageServerAuth, want, "incompatible key usage"},
|
||||
{"wrong URI", []*x509.Certificate{pin}, pin, x509.ExtKeyUsageClientAuth, "spinifex-lambda://r/a/f/e/g2/guest", "identity mismatch"},
|
||||
{"second URI", []*x509.Certificate{twoURIs}, twoURIs, x509.ExtKeyUsageClientAuth, want, "identity mismatch"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
err := VerifyPinned(tc.chain, tc.pin, tc.usage, tc.uri)
|
||||
assert.ErrorContains(t, err, tc.msg, tc.name)
|
||||
}
|
||||
assert.ErrorIs(t, VerifyPinned([]*x509.Certificate{pin}, pin, x509.ExtKeyUsageClientAuth, "x://y"), ErrPeerIdentity)
|
||||
}
|
||||
@@ -8,8 +8,6 @@ import (
|
||||
"slices"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
operatorv1 "github.com/mulgadc/spinifex/contracts/operator/v1"
|
||||
)
|
||||
|
||||
// AccountStatusTerminating mirrors iamdomain.AccountStatusTerminating.
|
||||
@@ -31,10 +29,6 @@ type Engine struct {
|
||||
// and this is the only view into it while it runs.
|
||||
OnStage func(ctx context.Context, stage StageResult)
|
||||
|
||||
// OnQuiesced receives the recorded quiescence evaluation once the account is
|
||||
// TERMINATING and before the first stage. It records evidence and gates nothing.
|
||||
OnQuiesced func(ctx context.Context, quiescence operatorv1.IAMQuiescence)
|
||||
|
||||
// ec2Inventory is EC2's read-only platform inventory. Only DryRun consults
|
||||
// it, and only to log a comparison; no teardown or Inventory call does.
|
||||
ec2Inventory ec2PlatformInventory
|
||||
@@ -124,13 +118,9 @@ func (e *Engine) Teardown(ctx context.Context, req Request) (*Result, error) {
|
||||
return e.Inventory(ctx, req.AccountID)
|
||||
}
|
||||
|
||||
generation, err := e.Accounts.QuiesceAccount(req.AccountID, req.OperationID, req.Fence)
|
||||
if err != nil {
|
||||
if _, err := e.Accounts.QuiesceAccount(req.AccountID, req.OperationID, req.Fence); err != nil {
|
||||
return nil, fmt.Errorf("quiesce account: %w", err)
|
||||
}
|
||||
if e.OnQuiesced != nil {
|
||||
e.OnQuiesced(ctx, e.evaluateQuiescence(ctx, req, generation))
|
||||
}
|
||||
e.logger().InfoContext(ctx, "Account teardown started",
|
||||
"accountID", account.AccountID, "accountName", account.AccountName, "force", req.Force)
|
||||
|
||||
@@ -335,23 +325,3 @@ func containsResource(resources []Resource, want Resource) bool {
|
||||
func SortReapers(reapers []Reaper) {
|
||||
sort.SliceStable(reapers, func(i, j int) bool { return reapers[i].Stage() < reapers[j].Stage() })
|
||||
}
|
||||
|
||||
// evaluateQuiescence reads back what the quiesce wrote and, only if the fence
|
||||
// is confirmed, asks EC2 for its platform inventory within a short budget.
|
||||
func (e *Engine) evaluateQuiescence(ctx context.Context, req Request, generation uint64) operatorv1.IAMQuiescence {
|
||||
ev := QuiesceEvidence{
|
||||
OperationID: req.OperationID, Fence: req.Fence, Generation: generation, EvaluatedAt: time.Now(),
|
||||
}
|
||||
ev.Account, ev.ReadbackErr = e.Accounts.GetAccount(req.AccountID)
|
||||
if _, confirmed := fenceReadback(ev); confirmed && e.ec2Inventory != nil {
|
||||
budget := ec2PlatformShadowBudget
|
||||
if e.ec2PlatformShadowBudget > 0 {
|
||||
budget = e.ec2PlatformShadowBudget
|
||||
}
|
||||
inventoryCtx, cancel := context.WithTimeout(ctx, budget)
|
||||
defer cancel()
|
||||
reply := e.ec2Inventory.PlatformInventory(inventoryCtx, req.AccountID, req.OperationID)
|
||||
ev.Inventory = &reply
|
||||
}
|
||||
return EvaluateQuiescence(ev)
|
||||
}
|
||||
|
||||
@@ -61,8 +61,6 @@ func (f *fakeAccounts) QuiesceAccount(_, operationID string, fence uint64) (uint
|
||||
f.statuses = append(f.statuses, AccountStatusTerminating)
|
||||
f.quiesces = append(f.quiesces, quiesceCall{operationID, fence})
|
||||
f.account.Status = AccountStatusTerminating
|
||||
f.account.Generation = 1
|
||||
f.account.TerminationOp = &TerminationOp{OperationID: operationID, Fence: fence}
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
package accountlifecycle
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
ec2v1 "github.com/mulgadc/spinifex/contracts/ec2/v1"
|
||||
operatorv1 "github.com/mulgadc/spinifex/contracts/operator/v1"
|
||||
)
|
||||
|
||||
// finalInventoryRereadWired says whether anything re-reads the owner inventory
|
||||
// before the account record goes. Until it does, a write landing after the
|
||||
// reaper stage is orphaned, so no kind may be recorded as quiesced.
|
||||
var finalInventoryRereadWired = false
|
||||
|
||||
// QuiesceEvidence is what the engine read after QuiesceAccount returned.
|
||||
type QuiesceEvidence struct {
|
||||
OperationID string
|
||||
Fence uint64
|
||||
Generation uint64
|
||||
|
||||
// Account is the IAM record read back after the quiesce.
|
||||
Account *Account
|
||||
ReadbackErr error
|
||||
Inventory *ec2v1.PlatformInventoryReply
|
||||
EvaluatedAt time.Time
|
||||
}
|
||||
|
||||
// enforcementConsumers are the consumers with an accepted contract for the
|
||||
// account admission fact. Predastore is deliberately absent: it has none.
|
||||
var enforcementConsumers = []operatorv1.ConsumerBasis{
|
||||
{Consumer: "awsgw-admission", Basis: "ADR-0005:S7"},
|
||||
{Consumer: "ecr-registry", Basis: "ADR-0005:S7"},
|
||||
{Consumer: "sts-mint-gate", Basis: "ADR-0008:S9 (IAM-owned)"},
|
||||
}
|
||||
|
||||
// accountedWriters cannot be fenced and are covered by the owner inventory.
|
||||
var accountedWriters = []operatorv1.AccountedWriter{
|
||||
{Writer: "daemon-nats-handlers", Basis: "owner-inventory", Ref: "mulga-tpoz4"},
|
||||
}
|
||||
|
||||
// ec2PlatformKinds are the kinds the first scope covers. The inventory's own
|
||||
// tag-index kind belongs to a different register row and is not part of it.
|
||||
var ec2PlatformKinds = []string{
|
||||
ec2v1.PlatformKindKeyPair,
|
||||
ec2v1.PlatformKindPlacementGroup,
|
||||
ec2v1.PlatformKindLaunchTemplate,
|
||||
}
|
||||
|
||||
type unresolvedEntry struct {
|
||||
scope, class, reason string
|
||||
}
|
||||
|
||||
// otherScopes lists every scope the first exercise does not cover, by name.
|
||||
// A register row family missing from here would read as quiesced by omission.
|
||||
var otherScopes = []unresolvedEntry{
|
||||
{"ec2-instances", operatorv1.UnresolvedClassWriter, operatorv1.ReasonNoCompleteOwnerInventory},
|
||||
{"ec2-spot-requests", operatorv1.UnresolvedClassWriter, operatorv1.ReasonNoCompleteOwnerInventory},
|
||||
{"ebs-volumes", operatorv1.UnresolvedClassWriter, operatorv1.ReasonNoCompleteOwnerInventory},
|
||||
{"ec2-snapshots-images", operatorv1.UnresolvedClassWriter, operatorv1.ReasonNoCompleteOwnerInventory},
|
||||
{"ec2-vpc", operatorv1.UnresolvedClassWriter, operatorv1.ReasonNoCompleteOwnerInventory},
|
||||
{"host-network", operatorv1.UnresolvedClassWriter, operatorv1.ReasonNoCompleteOwnerInventory},
|
||||
{"ec2-account-settings", operatorv1.UnresolvedClassWriter, operatorv1.ReasonNoCompleteOwnerInventory},
|
||||
{"ec2-tag-index", operatorv1.UnresolvedClassWriter, operatorv1.ReasonNoCompleteOwnerInventory},
|
||||
{"iam", operatorv1.UnresolvedClassWriter, operatorv1.ReasonNoCompleteOwnerInventory},
|
||||
{"elbv2", operatorv1.UnresolvedClassWriter, operatorv1.ReasonNoCompleteOwnerInventory},
|
||||
{"acm", operatorv1.UnresolvedClassWriter, operatorv1.ReasonNoCompleteOwnerInventory},
|
||||
{"eks", operatorv1.UnresolvedClassWriter, operatorv1.ReasonNoCompleteOwnerInventory},
|
||||
{"ecs", operatorv1.UnresolvedClassWriter, operatorv1.ReasonNoCompleteOwnerInventory},
|
||||
{"rds", operatorv1.UnresolvedClassWriter, operatorv1.ReasonNoCompleteOwnerInventory},
|
||||
{"dns", operatorv1.UnresolvedClassWriter, operatorv1.ReasonNoCompleteOwnerInventory},
|
||||
{"ochre", operatorv1.UnresolvedClassWriter, operatorv1.ReasonNoCompleteOwnerInventory},
|
||||
{"quota", operatorv1.UnresolvedClassWriter, operatorv1.ReasonNoCompleteOwnerInventory},
|
||||
{"idempotency", operatorv1.UnresolvedClassWriter, operatorv1.ReasonNoCompleteOwnerInventory},
|
||||
{"s3", operatorv1.UnresolvedClassConsumer, operatorv1.ReasonNoAcceptedEnforcement},
|
||||
{"ecr", operatorv1.UnresolvedClassConsumer, operatorv1.ReasonNoAcceptedEnforcement},
|
||||
{"lambda", operatorv1.UnresolvedClassWriter, operatorv1.ReasonParticipantNotDeclared},
|
||||
{"sqs", operatorv1.UnresolvedClassWriter, operatorv1.ReasonParticipantNotDeclared},
|
||||
}
|
||||
|
||||
// EvaluateQuiescence applies ADR-0008:S9's three conditions to the evidence and
|
||||
// records what holds. A fence the read-back does not confirm stops everything
|
||||
// else; otherwise every scope is listed, and none is reported quiesced yet.
|
||||
func EvaluateQuiescence(ev QuiesceEvidence) operatorv1.IAMQuiescence {
|
||||
out := operatorv1.IAMQuiescence{
|
||||
OperationID: ev.OperationID,
|
||||
Fence: ev.Fence,
|
||||
Generation: ev.Generation,
|
||||
QuiescedScope: []string{},
|
||||
Unresolved: []operatorv1.UnresolvedScope{},
|
||||
Consumers: []operatorv1.ConsumerBasis{},
|
||||
Writers: []operatorv1.AccountedWriter{},
|
||||
Inventory: []operatorv1.InventoryStatus{},
|
||||
EvaluatedAt: ev.EvaluatedAt.UTC().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
if reason, ok := fenceReadback(ev); !ok {
|
||||
out.Unresolved = append(out.Unresolved, operatorv1.UnresolvedScope{
|
||||
Scope: "all", Class: operatorv1.UnresolvedClassFence, Reason: reason,
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
out.Consumers = append(out.Consumers, enforcementConsumers...)
|
||||
out.Writers = append(out.Writers, accountedWriters...)
|
||||
|
||||
for _, kind := range ec2PlatformKinds {
|
||||
status, complete := kindInventory(ev.Inventory, kind)
|
||||
out.Inventory = append(out.Inventory, operatorv1.InventoryStatus{Kind: kind, Status: status})
|
||||
switch {
|
||||
case !complete:
|
||||
out.Unresolved = append(out.Unresolved, writerEntry("ec2-"+kind, operatorv1.ReasonOwnerInventoryIncomplete))
|
||||
case !finalInventoryRereadWired:
|
||||
out.Unresolved = append(out.Unresolved, writerEntry("ec2-"+kind, operatorv1.ReasonFinalInventoryRereadNotWired))
|
||||
default:
|
||||
out.QuiescedScope = append(out.QuiescedScope, kind)
|
||||
}
|
||||
}
|
||||
for _, entry := range otherScopes {
|
||||
out.Unresolved = append(out.Unresolved, operatorv1.UnresolvedScope{
|
||||
Scope: entry.scope, Class: entry.class, Reason: entry.reason,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func writerEntry(scope, reason string) operatorv1.UnresolvedScope {
|
||||
return operatorv1.UnresolvedScope{Scope: scope, Class: operatorv1.UnresolvedClassWriter, Reason: reason}
|
||||
}
|
||||
|
||||
// fenceReadback is condition 1: the operation record and the IAM account record
|
||||
// hold the same operation, fence and generation.
|
||||
func fenceReadback(ev QuiesceEvidence) (reason string, ok bool) {
|
||||
if ev.ReadbackErr != nil || ev.Account == nil {
|
||||
return operatorv1.ReasonIAMReadbackFailed, false
|
||||
}
|
||||
op := ev.Account.TerminationOp
|
||||
if ev.Account.Status != AccountStatusTerminating || op == nil ||
|
||||
op.OperationID != ev.OperationID || op.Fence != ev.Fence || ev.Account.Generation != ev.Generation {
|
||||
return operatorv1.ReasonFenceMismatch, false
|
||||
}
|
||||
return "", true
|
||||
}
|
||||
|
||||
// kindInventory reports one kind's status and whether the owner scan was
|
||||
// complete. A missing or repeated kind, or no reply at all, is incomplete.
|
||||
func kindInventory(reply *ec2v1.PlatformInventoryReply, kind string) (status string, complete bool) {
|
||||
if reply == nil {
|
||||
return ec2v1.PlatformStatusScanIncomplete, false
|
||||
}
|
||||
var found *ec2v1.PlatformKindReport
|
||||
for i := range reply.Kinds {
|
||||
if reply.Kinds[i].Kind != kind {
|
||||
continue
|
||||
}
|
||||
if found != nil {
|
||||
return ec2v1.PlatformStatusScanIncomplete, false
|
||||
}
|
||||
found = &reply.Kinds[i]
|
||||
}
|
||||
if found == nil || found.Status == ec2v1.PlatformStatusScanIncomplete {
|
||||
return ec2v1.PlatformStatusScanIncomplete, false
|
||||
}
|
||||
return found.Status, true
|
||||
}
|
||||
@@ -1,281 +0,0 @@
|
||||
package accountlifecycle
|
||||
|
||||
//test:in-package — the evaluator, its scope table and the engine hook are
|
||||
// unexported, and the table is what keeps a scope from reading as quiesced by omission.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
ec2v1 "github.com/mulgadc/spinifex/contracts/ec2/v1"
|
||||
operatorv1 "github.com/mulgadc/spinifex/contracts/operator/v1"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func confirmedEvidence() QuiesceEvidence {
|
||||
return QuiesceEvidence{
|
||||
OperationID: "op-1", Fence: 2, Generation: 5,
|
||||
Account: &Account{
|
||||
AccountID: testAccountID, Status: AccountStatusTerminating, Generation: 5,
|
||||
TerminationOp: &TerminationOp{OperationID: "op-1", Fence: 2},
|
||||
},
|
||||
Inventory: completeInventory(),
|
||||
EvaluatedAt: time.Date(2026, 11, 2, 0, 0, 0, 0, time.UTC),
|
||||
}
|
||||
}
|
||||
|
||||
func completeInventory() *ec2v1.PlatformInventoryReply {
|
||||
reply := &ec2v1.PlatformInventoryReply{AccountID: testAccountID}
|
||||
for _, kind := range ec2v1.PlatformKinds() {
|
||||
reply.Kinds = append(reply.Kinds, ec2v1.PlatformKindReport{Kind: kind, Status: ec2v1.PlatformStatusObservedClean})
|
||||
}
|
||||
return reply
|
||||
}
|
||||
|
||||
func unresolvedFor(q operatorv1.IAMQuiescence, scope string) []operatorv1.UnresolvedScope {
|
||||
var out []operatorv1.UnresolvedScope
|
||||
for _, entry := range q.Unresolved {
|
||||
if entry.Scope == scope {
|
||||
out = append(out, entry)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// With the fence confirmed and every L09 kind scanned complete, the evidence is
|
||||
// recorded but no kind is quiesced: nothing re-reads the inventory before the
|
||||
// account record goes, so a late write would be orphaned (ADR-0008:S9 condition 3).
|
||||
func TestEvaluateQuiescenceRecordsEvidenceButQuiescesNothingWithoutAFinalReread(t *testing.T) {
|
||||
q := EvaluateQuiescence(confirmedEvidence())
|
||||
|
||||
assert.Equal(t, "op-1", q.OperationID)
|
||||
assert.Equal(t, uint64(2), q.Fence)
|
||||
assert.Equal(t, uint64(5), q.Generation)
|
||||
assert.Empty(t, q.QuiescedScope)
|
||||
assert.NotNil(t, q.QuiescedScope, "evaluated-and-empty must serialise as [] not null")
|
||||
for _, kind := range ec2PlatformKinds {
|
||||
assert.Equal(t, []operatorv1.UnresolvedScope{{
|
||||
Scope: "ec2-" + kind, Class: operatorv1.UnresolvedClassWriter,
|
||||
Reason: operatorv1.ReasonFinalInventoryRereadNotWired,
|
||||
}}, unresolvedFor(q, "ec2-"+kind))
|
||||
}
|
||||
assert.ElementsMatch(t, []operatorv1.InventoryStatus{
|
||||
{Kind: ec2v1.PlatformKindKeyPair, Status: ec2v1.PlatformStatusObservedClean},
|
||||
{Kind: ec2v1.PlatformKindPlacementGroup, Status: ec2v1.PlatformStatusObservedClean},
|
||||
{Kind: ec2v1.PlatformKindLaunchTemplate, Status: ec2v1.PlatformStatusObservedClean},
|
||||
}, q.Inventory)
|
||||
assert.Equal(t, []operatorv1.AccountedWriter{{Writer: "daemon-nats-handlers", Basis: "owner-inventory", Ref: "mulga-tpoz4"}}, q.Writers)
|
||||
assert.Equal(t, "2026-11-02T00:00:00Z", q.EvaluatedAt)
|
||||
}
|
||||
|
||||
// The wiring step flips this switch; the complete kinds are then, and only then, named.
|
||||
func TestEvaluateQuiescenceNamesCompleteKindsOnceTheRereadIsWired(t *testing.T) {
|
||||
finalInventoryRereadWired = true
|
||||
t.Cleanup(func() { finalInventoryRereadWired = false })
|
||||
|
||||
ev := confirmedEvidence()
|
||||
ev.Inventory.Kinds[1].Status = ec2v1.PlatformStatusScanIncomplete
|
||||
q := EvaluateQuiescence(ev)
|
||||
|
||||
assert.Equal(t, []string{ec2v1.PlatformKindKeyPair, ec2v1.PlatformKindLaunchTemplate}, q.QuiescedScope)
|
||||
assert.Equal(t, operatorv1.ReasonOwnerInventoryIncomplete, unresolvedFor(q, "ec2-placement-group")[0].Reason)
|
||||
}
|
||||
|
||||
func TestEvaluateQuiescenceRecordsAnIncompleteInventoryPerKind(t *testing.T) {
|
||||
cases := map[string]func(*QuiesceEvidence){
|
||||
"one kind scan-incomplete": func(ev *QuiesceEvidence) { ev.Inventory.Kinds[0].Status = ec2v1.PlatformStatusScanIncomplete },
|
||||
"kind missing": func(ev *QuiesceEvidence) { ev.Inventory.Kinds = ev.Inventory.Kinds[1:] },
|
||||
"kind repeated": func(ev *QuiesceEvidence) {
|
||||
ev.Inventory.Kinds = append(ev.Inventory.Kinds, ev.Inventory.Kinds[0])
|
||||
},
|
||||
}
|
||||
for name, mutate := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
ev := confirmedEvidence()
|
||||
mutate(&ev)
|
||||
q := EvaluateQuiescence(ev)
|
||||
|
||||
got := unresolvedFor(q, "ec2-key-pair")
|
||||
require.Len(t, got, 1)
|
||||
assert.Equal(t, operatorv1.ReasonOwnerInventoryIncomplete, got[0].Reason)
|
||||
assert.Equal(t, operatorv1.ReasonFinalInventoryRereadNotWired, unresolvedFor(q, "ec2-launch-template")[0].Reason,
|
||||
"the other kinds keep their own result")
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("no reply at all", func(t *testing.T) {
|
||||
ev := confirmedEvidence()
|
||||
ev.Inventory = nil
|
||||
q := EvaluateQuiescence(ev)
|
||||
for _, kind := range ec2PlatformKinds {
|
||||
assert.Equal(t, operatorv1.ReasonOwnerInventoryIncomplete, unresolvedFor(q, "ec2-"+kind)[0].Reason)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Condition 1: a read-back that does not confirm the operation, fence and
|
||||
// generation records nothing as quiesced and evaluates nothing else.
|
||||
func TestEvaluateQuiescenceRecordsNothingWhenTheFenceIsNotConfirmed(t *testing.T) {
|
||||
cases := map[string]struct {
|
||||
mutate func(*QuiesceEvidence)
|
||||
reason string
|
||||
}{
|
||||
"not terminating": {func(ev *QuiesceEvidence) { ev.Account.Status = "ACTIVE" }, operatorv1.ReasonFenceMismatch},
|
||||
"no operation": {func(ev *QuiesceEvidence) { ev.Account.TerminationOp = nil }, operatorv1.ReasonFenceMismatch},
|
||||
"other operation": {func(ev *QuiesceEvidence) { ev.Account.TerminationOp.OperationID = "op-2" }, operatorv1.ReasonFenceMismatch},
|
||||
"higher fence in IAM": {func(ev *QuiesceEvidence) { ev.Account.TerminationOp.Fence = 3 }, operatorv1.ReasonFenceMismatch},
|
||||
"other generation": {func(ev *QuiesceEvidence) { ev.Account.Generation = 6 }, operatorv1.ReasonFenceMismatch},
|
||||
"read-back failed": {func(ev *QuiesceEvidence) { ev.Account, ev.ReadbackErr = nil, errors.New("kv down") }, operatorv1.ReasonIAMReadbackFailed},
|
||||
}
|
||||
for name, tc := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
finalInventoryRereadWired = true
|
||||
t.Cleanup(func() { finalInventoryRereadWired = false })
|
||||
ev := confirmedEvidence()
|
||||
tc.mutate(&ev)
|
||||
q := EvaluateQuiescence(ev)
|
||||
|
||||
assert.Empty(t, q.QuiescedScope)
|
||||
assert.Equal(t, []operatorv1.UnresolvedScope{{Scope: "all", Class: operatorv1.UnresolvedClassFence, Reason: tc.reason}}, q.Unresolved)
|
||||
assert.Empty(t, q.Consumers)
|
||||
assert.Empty(t, q.Writers)
|
||||
assert.Empty(t, q.Inventory)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Every scope outside the first exercise is named. The expected list is the
|
||||
// lifecycle register's rows other than L01, L02 and L09, so a new row has to be
|
||||
// added here and in the table rather than reading as quiesced by omission.
|
||||
func TestEveryOtherScopeIsRecordedUnresolvedByName(t *testing.T) {
|
||||
want := []string{
|
||||
"ec2-instances", "ec2-spot-requests", "ebs-volumes", "ec2-snapshots-images", "ec2-vpc",
|
||||
"host-network", "ec2-account-settings", "ec2-tag-index", "iam", "elbv2", "acm", "eks", "ecs",
|
||||
"rds", "dns", "ochre", "quota", "idempotency", "s3", "ecr", "lambda", "sqs",
|
||||
}
|
||||
q := EvaluateQuiescence(confirmedEvidence())
|
||||
|
||||
for _, scope := range want {
|
||||
got := unresolvedFor(q, scope)
|
||||
require.Len(t, got, 1, scope)
|
||||
assert.NotEmpty(t, got[0].Reason, scope)
|
||||
}
|
||||
assert.Len(t, q.Unresolved, len(want)+len(ec2PlatformKinds), "no scope beyond the register and the three kinds")
|
||||
for _, entry := range otherScopes {
|
||||
assert.False(t, slices.Contains(ec2PlatformKinds, entry.scope), entry.scope)
|
||||
}
|
||||
}
|
||||
|
||||
// Predastore has no accepted enforcement contract, so the scopes it admits writes
|
||||
// for stay unresolved and it is never cited as a consumer, even once the
|
||||
// re-read is wired and every L09 kind is complete.
|
||||
func TestPredastoreIsNeverCitedAndItsScopesNeverQuiesce(t *testing.T) {
|
||||
finalInventoryRereadWired = true
|
||||
t.Cleanup(func() { finalInventoryRereadWired = false })
|
||||
q := EvaluateQuiescence(confirmedEvidence())
|
||||
|
||||
for _, scope := range []string{"s3", "ecr"} {
|
||||
got := unresolvedFor(q, scope)
|
||||
require.Len(t, got, 1)
|
||||
assert.Equal(t, operatorv1.UnresolvedClassConsumer, got[0].Class)
|
||||
assert.Equal(t, operatorv1.ReasonNoAcceptedEnforcement, got[0].Reason)
|
||||
assert.NotContains(t, q.QuiescedScope, scope)
|
||||
}
|
||||
for _, consumer := range q.Consumers {
|
||||
assert.NotContains(t, consumer.Consumer, "predastore")
|
||||
want := "ADR-0005:S7"
|
||||
if consumer.Consumer == "sts-mint-gate" {
|
||||
want = "ADR-0008:S9 (IAM-owned)"
|
||||
}
|
||||
assert.Equal(t, want, consumer.Basis)
|
||||
}
|
||||
assert.Equal(t, []string{"awsgw-admission", "ecr-registry", "sts-mint-gate"},
|
||||
[]string{q.Consumers[0].Consumer, q.Consumers[1].Consumer, q.Consumers[2].Consumer})
|
||||
}
|
||||
|
||||
type stubInventory struct {
|
||||
reply ec2v1.PlatformInventoryReply
|
||||
calls int
|
||||
}
|
||||
|
||||
func (s *stubInventory) PlatformInventory(context.Context, string, string) ec2v1.PlatformInventoryReply {
|
||||
s.calls++
|
||||
return s.reply
|
||||
}
|
||||
|
||||
// The hook fires once, after the quiesce and before the first stage, carrying the
|
||||
// operation, fence and generation the quiesce returned.
|
||||
func TestTeardownReportsQuiescenceAfterTheQuiesceAndBeforeTheFirstStage(t *testing.T) {
|
||||
accounts := newFakeAccounts()
|
||||
var events []string
|
||||
reaper := &fakeReaper{kind: "volume", stage: StageStorage, resources: []Resource{{Kind: "volume", ID: "vol-1"}}}
|
||||
engine := quietEngine(accounts, reaper)
|
||||
inventory := &stubInventory{reply: *completeInventory()}
|
||||
engine.ec2Inventory = inventory
|
||||
var got operatorv1.IAMQuiescence
|
||||
engine.OnQuiesced = func(_ context.Context, q operatorv1.IAMQuiescence) {
|
||||
events = append(events, "quiesced")
|
||||
assert.Equal(t, 0, reaper.deletesAttempted, "no stage may have run yet")
|
||||
assert.Equal(t, AccountStatusTerminating, accounts.account.Status)
|
||||
got = q
|
||||
}
|
||||
engine.OnStage = func(context.Context, StageResult) {
|
||||
if len(events) == 0 || events[len(events)-1] != "stage" {
|
||||
events = append(events, "stage")
|
||||
}
|
||||
}
|
||||
|
||||
req := request()
|
||||
req.OperationID, req.Fence = "op-7", 3
|
||||
_, err := engine.Teardown(context.Background(), req)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"quiesced", "stage"}, events)
|
||||
assert.Equal(t, "op-7", got.OperationID)
|
||||
assert.Equal(t, uint64(3), got.Fence)
|
||||
assert.Equal(t, uint64(1), got.Generation)
|
||||
assert.Equal(t, 1, inventory.calls)
|
||||
assert.True(t, accounts.deleted, "recording must not change the legacy teardown")
|
||||
}
|
||||
|
||||
// A fence the read-back does not confirm must not spend an inventory call, and a
|
||||
// failed read-back must not stop the teardown: this step records and gates nothing.
|
||||
func TestTeardownRecordsAFailedReadbackAndStillRunsEveryStage(t *testing.T) {
|
||||
accounts := newFakeAccounts()
|
||||
engine := quietEngine(accounts)
|
||||
inventory := &stubInventory{reply: *completeInventory()}
|
||||
engine.ec2Inventory = inventory
|
||||
var got operatorv1.IAMQuiescence
|
||||
engine.OnQuiesced = func(_ context.Context, q operatorv1.IAMQuiescence) { got = q }
|
||||
accounts.getErr = nil
|
||||
|
||||
engine.Accounts = &readbackFails{fakeAccounts: accounts}
|
||||
req := request()
|
||||
req.OperationID, req.Fence = "op-1", 1
|
||||
_, err := engine.Teardown(context.Background(), req)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, got.Unresolved, 1)
|
||||
assert.Equal(t, operatorv1.ReasonIAMReadbackFailed, got.Unresolved[0].Reason)
|
||||
assert.Zero(t, inventory.calls)
|
||||
assert.True(t, accounts.deleted)
|
||||
}
|
||||
|
||||
// readbackFails serves the pre-quiesce read and fails the one after it.
|
||||
type readbackFails struct {
|
||||
*fakeAccounts
|
||||
|
||||
reads int
|
||||
}
|
||||
|
||||
func (r *readbackFails) GetAccount(id string) (*Account, error) {
|
||||
r.reads++
|
||||
if r.reads > 1 {
|
||||
return nil, errors.New("kv down")
|
||||
}
|
||||
return r.fakeAccounts.GetAccount(id)
|
||||
}
|
||||
@@ -106,14 +106,9 @@ func (a *IAMAccounts) GetAccount(accountID string) (*Account, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := &Account{
|
||||
return &Account{
|
||||
AccountID: account.AccountID, AccountName: account.AccountName, Status: account.Status,
|
||||
Generation: account.Generation,
|
||||
}
|
||||
if op := account.TerminationOp; op != nil {
|
||||
out.TerminationOp = &TerminationOp{OperationID: op.OperationID, Fence: op.Fence}
|
||||
}
|
||||
return out, nil
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *IAMAccounts) QuiesceAccount(accountID, operationID string, fence uint64) (uint64, error) {
|
||||
|
||||
@@ -136,16 +136,6 @@ type Account struct {
|
||||
AccountID string
|
||||
AccountName string
|
||||
Status string
|
||||
|
||||
// Generation and TerminationOp are what a quiesce read-back compares.
|
||||
Generation uint64
|
||||
TerminationOp *TerminationOp
|
||||
}
|
||||
|
||||
// TerminationOp mirrors the operation and fence IAM holds on a terminating account.
|
||||
type TerminationOp struct {
|
||||
OperationID string
|
||||
Fence uint64
|
||||
}
|
||||
|
||||
// Request is one teardown.
|
||||
|
||||
@@ -85,9 +85,6 @@ type accountDeletionJob struct {
|
||||
Holder string `json:"holder,omitempty"`
|
||||
LeaseUntil string `json:"leaseUntil,omitempty"`
|
||||
Takeovers []deletionTakeover `json:"takeovers,omitempty"`
|
||||
// IAM is the quiescence evaluation made under the current fence. A takeover
|
||||
// starts without one and the new holder records its own.
|
||||
IAM *operatorv1.IAMQuiescence `json:"iam,omitempty"`
|
||||
}
|
||||
|
||||
// deletionTakeover records one change of holder.
|
||||
@@ -261,7 +258,6 @@ func (d Deps) runAccountDeletion(
|
||||
ctx, cancel := context.WithTimeout(context.Background(), accountDeletionTimeout)
|
||||
tracker := &deletionTracker{jobs: jobs, job: job, revision: revision, cancel: cancel}
|
||||
engine.OnStage = tracker.stageFinished
|
||||
engine.OnQuiesced = tracker.quiesced
|
||||
|
||||
go func() {
|
||||
defer cancel()
|
||||
@@ -301,13 +297,6 @@ func (t *deletionTracker) stageFinished(ctx context.Context, stage accountteardo
|
||||
t.persist(ctx)
|
||||
}
|
||||
|
||||
func (t *deletionTracker) quiesced(ctx context.Context, quiescence operatorv1.IAMQuiescence) {
|
||||
t.mu.Lock()
|
||||
t.job.IAM = &quiescence
|
||||
t.mu.Unlock()
|
||||
t.persist(ctx)
|
||||
}
|
||||
|
||||
func (t *deletionTracker) finished(ctx context.Context, result *accountteardown.Result, err error) {
|
||||
t.mu.Lock()
|
||||
if result != nil {
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
package admin
|
||||
|
||||
//test:in-package — the job record, the tracker and the takeover that clears the
|
||||
// evaluation are unexported.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
operatorv1 "github.com/mulgadc/spinifex/contracts/operator/v1"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func sampleQuiescence(fence uint64) operatorv1.IAMQuiescence {
|
||||
return operatorv1.IAMQuiescence{
|
||||
OperationID: "op-1", Fence: fence, Generation: 4,
|
||||
QuiescedScope: []string{},
|
||||
Unresolved: []operatorv1.UnresolvedScope{{
|
||||
Scope: "ec2-key-pair", Class: operatorv1.UnresolvedClassWriter,
|
||||
Reason: operatorv1.ReasonFinalInventoryRereadNotWired,
|
||||
}},
|
||||
Consumers: []operatorv1.ConsumerBasis{{Consumer: "awsgw-admission", Basis: "ADR-0005:S7"}},
|
||||
Writers: []operatorv1.AccountedWriter{{Writer: "daemon-nats-handlers", Basis: "owner-inventory", Ref: "mulga-tpoz4"}},
|
||||
Inventory: []operatorv1.InventoryStatus{{Kind: "key-pair", Status: "observed-clean-at-scan-time"}},
|
||||
EvaluatedAt: "2026-11-02T00:00:00Z",
|
||||
}
|
||||
}
|
||||
|
||||
// A normal deletion leaves the evaluation on the record, read back from the real
|
||||
// IAM account record, and DescribeAccountDeletion returns it.
|
||||
func TestDeletionRecordCarriesTheIAMQuiescence(t *testing.T) {
|
||||
gw := newDeleteAccountGateway(t)
|
||||
account := newTenantAccount(t, gw, "tenant@example.com")
|
||||
|
||||
_, err := gw.deleteAccount(t.Context(),
|
||||
deleteAccountBody(t, account.AccountID, "tenant@example.com", deleteClientToken))
|
||||
require.NoError(t, err)
|
||||
require.Eventually(t, func() bool {
|
||||
return describeDeletion(t, gw, account.AccountID).State == operatorv1.DeletionStateCompleted
|
||||
}, 30*time.Second, 100*time.Millisecond)
|
||||
|
||||
stored := describeDeletion(t, gw, account.AccountID)
|
||||
require.NotNil(t, stored.IAM)
|
||||
assert.Equal(t, stored.OperationID, stored.IAM.OperationID)
|
||||
assert.Equal(t, uint64(1), stored.IAM.Fence)
|
||||
assert.Positive(t, stored.IAM.Generation)
|
||||
assert.Empty(t, stored.IAM.QuiescedScope)
|
||||
assert.NotEmpty(t, stored.IAM.Unresolved)
|
||||
assert.Len(t, stored.IAM.Consumers, 3)
|
||||
// The stubbed cluster answers the inventory subject with an empty body, which
|
||||
// is an incomplete inventory, and recording that must not fail the teardown.
|
||||
for _, kind := range []string{"ec2-key-pair", "ec2-placement-group", "ec2-launch-template"} {
|
||||
var reasons []string
|
||||
for _, entry := range stored.IAM.Unresolved {
|
||||
if entry.Scope == kind {
|
||||
reasons = append(reasons, entry.Reason)
|
||||
}
|
||||
}
|
||||
assert.Equal(t, []string{operatorv1.ReasonOwnerInventoryIncomplete}, reasons, kind)
|
||||
}
|
||||
}
|
||||
|
||||
// The evaluation belongs to a fence: a holder that has been taken over cannot
|
||||
// write it into the new holder's record.
|
||||
func TestSupersededHolderCannotWriteTheQuiescence(t *testing.T) {
|
||||
gw := newDeleteAccountGateway(t)
|
||||
jobs, err := gw.accountDeletionStore(t.Context())
|
||||
require.NoError(t, err)
|
||||
const accountID = "000000000042"
|
||||
|
||||
holderA, revisionA, err := claimAs(t, jobs, accountID, strings.Repeat("a", 32), "node-a")
|
||||
require.NoError(t, err)
|
||||
var cancelled atomic.Bool
|
||||
trackerA := &deletionTracker{jobs: jobs, job: holderA, revision: revisionA, cancel: func() { cancelled.Store(true) }}
|
||||
|
||||
expireLease(t, jobs, accountID)
|
||||
_, _, err = claimAs(t, jobs, accountID, strings.Repeat("b", 32), "node-b")
|
||||
require.NoError(t, err)
|
||||
|
||||
trackerA.quiesced(t.Context(), sampleQuiescence(1))
|
||||
|
||||
assert.True(t, cancelled.Load())
|
||||
stored := describeDeletion(t, gw, accountID)
|
||||
assert.Equal(t, uint64(2), stored.Fence)
|
||||
assert.Nil(t, stored.IAM, "the old holder's evaluation must not land under the new fence")
|
||||
}
|
||||
|
||||
// A takeover starts without the previous fence's evaluation, and the new holder
|
||||
// records its own under the new fence.
|
||||
func TestTakeoverClearsTheQuiescenceAndTheNewHolderRecordsItsOwn(t *testing.T) {
|
||||
gw := newDeleteAccountGateway(t)
|
||||
jobs, err := gw.accountDeletionStore(t.Context())
|
||||
require.NoError(t, err)
|
||||
const accountID = "000000000042"
|
||||
|
||||
holderA, revisionA, err := claimAs(t, jobs, accountID, strings.Repeat("a", 32), "node-a")
|
||||
require.NoError(t, err)
|
||||
trackerA := &deletionTracker{jobs: jobs, job: holderA, revision: revisionA}
|
||||
trackerA.quiesced(t.Context(), sampleQuiescence(1))
|
||||
require.NotNil(t, describeDeletion(t, gw, accountID).IAM)
|
||||
|
||||
expireLease(t, jobs, accountID)
|
||||
holderB, revisionB, err := claimAs(t, jobs, accountID, strings.Repeat("b", 32), "node-b")
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, holderB.IAM)
|
||||
assert.Nil(t, describeDeletion(t, gw, accountID).IAM, "the record shows no evaluation until the new holder makes one")
|
||||
|
||||
trackerB := &deletionTracker{jobs: jobs, job: holderB, revision: revisionB}
|
||||
trackerB.quiesced(t.Context(), sampleQuiescence(2))
|
||||
stored := describeDeletion(t, gw, accountID)
|
||||
require.NotNil(t, stored.IAM)
|
||||
assert.Equal(t, uint64(2), stored.IAM.Fence)
|
||||
}
|
||||
|
||||
// The field is optional on the wire: a record from before it decodes without it
|
||||
// and prints without it, and one with it keeps the lists as arrays.
|
||||
func TestAdminWire_DeletionJobIAMIsOptional(t *testing.T) {
|
||||
var old accountDeletionJob
|
||||
require.NoError(t, json.Unmarshal([]byte(`{"deletionId":"d","accountId":"000000000042","state":"RUNNING"}`), &old))
|
||||
assert.Nil(t, old.IAM)
|
||||
encoded, err := json.Marshal(old)
|
||||
require.NoError(t, err)
|
||||
assert.NotContains(t, string(encoded), `"iam"`)
|
||||
|
||||
quiescence := sampleQuiescence(1)
|
||||
data, err := json.Marshal(accountDeletionJob{DeletionID: "d", AccountID: "000000000042", State: "RUNNING", IAM: &quiescence})
|
||||
require.NoError(t, err)
|
||||
var raw map[string]json.RawMessage
|
||||
require.NoError(t, json.Unmarshal(data, &raw))
|
||||
goldenJSON(t, raw["iam"], `{
|
||||
"operationId":"op-1","fence":1,"generation":4,"quiescedScope":[],
|
||||
"unresolved":[{"scope":"ec2-key-pair","class":"writer","reason":"final-inventory-reread-not-wired"}],
|
||||
"consumers":[{"consumer":"awsgw-admission","basis":"ADR-0005:S7"}],
|
||||
"writers":[{"writer":"daemon-nats-handlers","basis":"owner-inventory","ref":"mulga-tpoz4"}],
|
||||
"inventory":[{"kind":"key-pair","status":"observed-clean-at-scan-time"}],
|
||||
"evaluatedAt":"2026-11-02T00:00:00Z"}`)
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,441 +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}
|
||||
var held *networkTestClaim
|
||||
var rev uint64
|
||||
// A holder releasing between the create and the read leaves nothing to
|
||||
// read, so the create is tried again rather than failing the request.
|
||||
for attempt := 0; ; attempt++ {
|
||||
_, 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 {
|
||||
break
|
||||
}
|
||||
if !errors.Is(err, kvstore.ErrNotFound) || attempt == 2 {
|
||||
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,225 +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. A run waits for gate.
|
||||
func fakeNode(t *testing.T, nc *nats.Conn, node, addr string, bps float64, broken bool, gate <-chan struct{}) {
|
||||
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) {
|
||||
if gate != nil {
|
||||
<-gate
|
||||
}
|
||||
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"}}
|
||||
gate := make(chan struct{})
|
||||
fakeNode(t, nc, "a", "10.10.8.4", 23e9, false, gate)
|
||||
fakeNode(t, nc, "b", "10.10.8.5", 22e9, false, gate)
|
||||
fakeNode(t, nc, "c", "10.10.8.6", 0, true, gate)
|
||||
|
||||
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")
|
||||
close(gate)
|
||||
|
||||
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, nil)
|
||||
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)
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -69,28 +69,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.
|
||||
@@ -100,7 +94,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
|
||||
}
|
||||
@@ -179,16 +173,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
|
||||
@@ -218,15 +207,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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,183 +0,0 @@
|
||||
// Package guestconn is the host end of the Lambda guest control connection:
|
||||
// per-generation identities and an authenticating TCP accept loop.
|
||||
package guestconn
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
lambdav1 "github.com/mulgadc/spinifex/contracts/lambda/v1"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/pki"
|
||||
)
|
||||
|
||||
// Generation is the host's material for one environment generation: its own
|
||||
// identity and the one guest certificate it accepts. Nothing is reused.
|
||||
type Generation struct {
|
||||
Scope lambdav1.Scope
|
||||
host tls.Certificate
|
||||
hostPEM []byte
|
||||
guestCert *x509.Certificate
|
||||
guestPEM []byte
|
||||
guestKey []byte
|
||||
}
|
||||
|
||||
// NewGeneration creates fresh self-signed host and guest identities for
|
||||
// scope, valid from a minute ago for ttl.
|
||||
func NewGeneration(scope lambdav1.Scope, ttl time.Duration) (*Generation, error) {
|
||||
if _, err := lambdav1.ParseScope(string(scope)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ttl <= 0 {
|
||||
return nil, errors.New("guestconn: generation ttl must be positive")
|
||||
}
|
||||
hostPEM, hostKey, _, err := selfSigned(scope, lambdav1.RoleHost, ttl)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
host, err := tls.X509KeyPair(hostPEM, hostKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("guestconn: host key pair: %w", err)
|
||||
}
|
||||
guestPEM, guestKey, guestCert, err := selfSigned(scope, lambdav1.RoleGuest, ttl)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Generation{Scope: scope, host: host, hostPEM: hostPEM, guestCert: guestCert, guestPEM: guestPEM, guestKey: guestKey}, nil
|
||||
}
|
||||
|
||||
func selfSigned(scope lambdav1.Scope, role string, ttl time.Duration) (certPEM, keyPEM []byte, cert *x509.Certificate, err error) {
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("guestconn: %s key: %w", role, err)
|
||||
}
|
||||
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 127))
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("guestconn: %s serial: %w", role, err)
|
||||
}
|
||||
u, err := url.Parse(scope.URI(role))
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("guestconn: %s URI: %w", role, err)
|
||||
}
|
||||
usage := x509.ExtKeyUsageServerAuth
|
||||
if role == lambdav1.RoleGuest {
|
||||
usage = x509.ExtKeyUsageClientAuth
|
||||
}
|
||||
now := time.Now()
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: serial, Subject: pkix.Name{CommonName: "spinifex-lambda " + role},
|
||||
NotBefore: now.Add(-time.Minute), NotAfter: now.Add(ttl),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{usage},
|
||||
BasicConstraintsValid: true, URIs: []*url.URL{u},
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("guestconn: %s certificate: %w", role, err)
|
||||
}
|
||||
kder, err := x509.MarshalECPrivateKey(key)
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("guestconn: %s key encoding: %w", role, err)
|
||||
}
|
||||
if cert, err = x509.ParseCertificate(der); err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("guestconn: %s certificate: %w", role, err)
|
||||
}
|
||||
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}),
|
||||
pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: kder}), cert, nil
|
||||
}
|
||||
|
||||
// GuestEntries returns the fw_cfg entries the guest needs, by entry name. The
|
||||
// guest key is secret: the caller writes these 0600 and deletes them once
|
||||
// QEMU has read them.
|
||||
func (g *Generation) GuestEntries(hostAddr string) map[string][]byte {
|
||||
return map[string][]byte{
|
||||
lambdav1.EntryGuestKey: append([]byte(nil), g.guestKey...),
|
||||
lambdav1.EntryGuestCert: append([]byte(nil), g.guestPEM...),
|
||||
lambdav1.EntryHostCert: append([]byte(nil), g.hostPEM...),
|
||||
lambdav1.EntryScope: []byte(g.Scope),
|
||||
lambdav1.EntryHostAddr: []byte(hostAddr),
|
||||
}
|
||||
}
|
||||
|
||||
// ServerConfig is the host's TLS 1.3 configuration: it requires a client
|
||||
// certificate and accepts only this generation's guest certificate.
|
||||
func (g *Generation) ServerConfig() *tls.Config {
|
||||
return &tls.Config{
|
||||
MinVersion: tls.VersionTLS13,
|
||||
MaxVersion: tls.VersionTLS13,
|
||||
Certificates: []tls.Certificate{g.host},
|
||||
NextProtos: []string{lambdav1.ALPNv1},
|
||||
ClientAuth: tls.RequireAnyClientCert,
|
||||
VerifyConnection: func(cs tls.ConnectionState) error {
|
||||
if cs.NegotiatedProtocol != lambdav1.ALPNv1 {
|
||||
return fmt.Errorf("guestconn: application protocol %q, want %q", cs.NegotiatedProtocol, lambdav1.ALPNv1)
|
||||
}
|
||||
return pki.VerifyPinned(cs.PeerCertificates, g.guestCert, x509.ExtKeyUsageClientAuth, g.Scope.URI(lambdav1.RoleGuest))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// DeadlineListener is a listener whose Accept can be interrupted.
|
||||
type DeadlineListener interface {
|
||||
net.Listener
|
||||
SetDeadline(t time.Time) error
|
||||
}
|
||||
|
||||
// AcceptGuest returns the first connection that authenticates as this
|
||||
// generation's guest. Peers on the untrusted network may connect first; each
|
||||
// gets handshakeTimeout and is closed on failure. It stops when ctx ends.
|
||||
func (g *Generation) AcceptGuest(ctx context.Context, ln DeadlineListener, handshakeTimeout time.Duration) (*tls.Conn, error) {
|
||||
if handshakeTimeout <= 0 {
|
||||
return nil, errors.New("guestconn: handshake timeout must be positive")
|
||||
}
|
||||
_ = ln.SetDeadline(time.Time{})
|
||||
fired := make(chan struct{})
|
||||
stop := context.AfterFunc(ctx, func() {
|
||||
_ = ln.SetDeadline(time.Now())
|
||||
close(fired)
|
||||
})
|
||||
// If the callback started, wait for it so its expired deadline cannot
|
||||
// land after the clear and leave the listener unusable.
|
||||
defer func() {
|
||||
if !stop() {
|
||||
<-fired
|
||||
_ = ln.SetDeadline(time.Time{})
|
||||
}
|
||||
}()
|
||||
cfg := g.ServerConfig()
|
||||
for {
|
||||
raw, err := ln.Accept()
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return nil, fmt.Errorf("guestconn: no authenticated guest: %w", context.Cause(ctx))
|
||||
}
|
||||
return nil, fmt.Errorf("guestconn: accept: %w", err)
|
||||
}
|
||||
if c := g.handshake(ctx, raw, cfg, handshakeTimeout); c != nil {
|
||||
return c, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Generation) handshake(ctx context.Context, raw net.Conn, cfg *tls.Config, timeout time.Duration) *tls.Conn {
|
||||
hctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
tc := tls.Server(raw, cfg)
|
||||
if err := tc.HandshakeContext(hctx); err != nil {
|
||||
slog.Warn("guestconn: refused control connection", "remote", raw.RemoteAddr().String(), "err", err)
|
||||
_ = raw.Close()
|
||||
return nil
|
||||
}
|
||||
return tc
|
||||
}
|
||||
@@ -1,233 +0,0 @@
|
||||
package guestconn
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
lambdav1 "github.com/mulgadc/spinifex/contracts/lambda/v1"
|
||||
"github.com/mulgadc/spinifex/spinifex/agents/lambda/hostlink"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const (
|
||||
gen1 lambdav1.Scope = "ap-southeast-2/000000000001/fn-1/env-1/gen-1"
|
||||
gen2 lambdav1.Scope = "ap-southeast-2/000000000001/fn-1/env-1/gen-2"
|
||||
)
|
||||
|
||||
func writeEntries(t *testing.T, entries map[string][]byte) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
for name, b := range entries {
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(dir, name), 0o700))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, name, "raw"), b, 0o600))
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
func listen(t *testing.T) *net.TCPListener {
|
||||
t.Helper()
|
||||
ln, err := net.ListenTCP("tcp", &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1)})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { ln.Close() })
|
||||
return ln
|
||||
}
|
||||
|
||||
func newGeneration(t *testing.T, scope lambdav1.Scope) *Generation {
|
||||
t.Helper()
|
||||
g, err := NewGeneration(scope, time.Hour)
|
||||
require.NoError(t, err)
|
||||
return g
|
||||
}
|
||||
|
||||
// guestFor loads the guest identity from g's fw_cfg entries, as the guest
|
||||
// agent would.
|
||||
func guestFor(t *testing.T, g *Generation, addr string) hostlink.Identity {
|
||||
t.Helper()
|
||||
id, err := hostlink.LoadIdentity(writeEntries(t, g.GuestEntries(addr)))
|
||||
require.NoError(t, err)
|
||||
return id
|
||||
}
|
||||
|
||||
type accepted struct {
|
||||
conn net.Conn
|
||||
err error
|
||||
}
|
||||
|
||||
func acceptAsync(g *Generation, ln DeadlineListener, ctx context.Context, timeout time.Duration) <-chan accepted {
|
||||
out := make(chan accepted, 1)
|
||||
go func() {
|
||||
c, err := g.AcceptGuest(ctx, ln, timeout)
|
||||
out <- accepted{c, err}
|
||||
}()
|
||||
return out
|
||||
}
|
||||
|
||||
func TestHostGuestExchange(t *testing.T) {
|
||||
ln := listen(t)
|
||||
g := newGeneration(t, gen1)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
hostSide := acceptAsync(g, ln, ctx, 5*time.Second)
|
||||
|
||||
guest, err := hostlink.Dial(ctx, guestFor(t, g, ln.Addr().String()))
|
||||
require.NoError(t, err)
|
||||
defer guest.Close()
|
||||
host := <-hostSide
|
||||
require.NoError(t, host.err)
|
||||
defer host.conn.Close()
|
||||
|
||||
_, err = host.conn.Write([]byte("host->guest"))
|
||||
require.NoError(t, err)
|
||||
buf := make([]byte, 11)
|
||||
_, err = io.ReadFull(guest, buf)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "host->guest", string(buf))
|
||||
|
||||
_, err = guest.Write([]byte("guest->host"))
|
||||
require.NoError(t, err)
|
||||
_, err = io.ReadFull(host.conn, buf)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "guest->host", string(buf))
|
||||
}
|
||||
|
||||
func TestGuestEntriesUseTheNamesTheGuestReads(t *testing.T) {
|
||||
g := newGeneration(t, gen1)
|
||||
id := guestFor(t, g, "10.0.0.1:1")
|
||||
assert.Equal(t, gen1, id.Scope)
|
||||
assert.Equal(t, gen1.URI(lambdav1.RoleHost), id.HostCert.URIs[0].String())
|
||||
assert.Equal(t, gen1.URI(lambdav1.RoleGuest), id.Cert.Leaf.URIs[0].String())
|
||||
}
|
||||
|
||||
// A wrong guest is refused and the host keeps listening for the right one;
|
||||
// so is a peer that connects and never handshakes.
|
||||
func TestHostRefusesWrongGuestThenAcceptsTheRightOne(t *testing.T) {
|
||||
ln := listen(t)
|
||||
g := newGeneration(t, gen1)
|
||||
addr := ln.Addr().String()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
hostSide := acceptAsync(g, ln, ctx, 300*time.Millisecond)
|
||||
|
||||
silent, err := net.Dial("tcp", addr)
|
||||
require.NoError(t, err)
|
||||
defer silent.Close()
|
||||
|
||||
for name, wrong := range map[string]hostlink.Identity{
|
||||
"same scope, other generation's keys": guestFor(t, newGeneration(t, gen1), addr),
|
||||
"later generation": guestFor(t, newGeneration(t, gen2), addr),
|
||||
} {
|
||||
good := guestFor(t, g, addr)
|
||||
wrong.HostCert, wrong.Scope = good.HostCert, good.Scope // trust the real host, present the wrong guest
|
||||
c, err := hostlink.Dial(ctx, wrong)
|
||||
if err == nil {
|
||||
_ = c.SetReadDeadline(time.Now().Add(5 * time.Second))
|
||||
_, err = c.Read(make([]byte, 1))
|
||||
c.Close()
|
||||
}
|
||||
require.Error(t, err, name)
|
||||
assert.Contains(t, err.Error(), "bad certificate", name)
|
||||
}
|
||||
|
||||
right, err := hostlink.Dial(ctx, guestFor(t, g, addr))
|
||||
require.NoError(t, err)
|
||||
defer right.Close()
|
||||
host := <-hostSide
|
||||
require.NoError(t, host.err)
|
||||
host.conn.Close()
|
||||
}
|
||||
|
||||
// gatedListener holds the cancel callback's expiring SetDeadline until the
|
||||
// test releases it, so a clear that does not wait would land first.
|
||||
type gatedListener struct {
|
||||
entered, gate, cleared chan struct{}
|
||||
mu sync.Mutex
|
||||
deadline time.Time
|
||||
calls int
|
||||
}
|
||||
|
||||
func (l *gatedListener) Accept() (net.Conn, error) {
|
||||
<-l.entered
|
||||
return nil, errors.New("listener deadline expired")
|
||||
}
|
||||
func (l *gatedListener) Close() error { return nil }
|
||||
func (l *gatedListener) Addr() net.Addr { return &net.TCPAddr{} }
|
||||
func (l *gatedListener) SetDeadline(d time.Time) error {
|
||||
if !d.IsZero() {
|
||||
close(l.entered)
|
||||
<-l.gate
|
||||
}
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
l.calls++
|
||||
l.deadline = d
|
||||
if d.IsZero() && l.calls > 1 {
|
||||
close(l.cleared)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestAcceptGuestClearsDeadlineAfterCancelCallback(t *testing.T) {
|
||||
ln := &gatedListener{entered: make(chan struct{}), gate: make(chan struct{}), cleared: make(chan struct{})}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
hostSide := acceptAsync(newGeneration(t, gen1), ln, ctx, time.Second)
|
||||
cancel()
|
||||
<-ln.entered
|
||||
|
||||
// A clear that does not wait for the callback arrives now; one that does
|
||||
// cannot, so the bounded wait only delays the passing case.
|
||||
select {
|
||||
case <-ln.cleared:
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
}
|
||||
close(ln.gate)
|
||||
require.ErrorIs(t, (<-hostSide).err, context.Canceled)
|
||||
|
||||
ln.mu.Lock()
|
||||
defer ln.mu.Unlock()
|
||||
assert.True(t, ln.deadline.IsZero(), "listener left with expired deadline %v", ln.deadline)
|
||||
assert.Equal(t, 3, ln.calls)
|
||||
}
|
||||
|
||||
func TestAcceptGuestStopsWithContext(t *testing.T) {
|
||||
ln := listen(t)
|
||||
g := newGeneration(t, gen1)
|
||||
ctx, cancel := context.WithCancelCause(context.Background())
|
||||
hostSide := acceptAsync(g, ln, ctx, time.Second)
|
||||
reason := errors.New("environment superseded")
|
||||
cancel(reason)
|
||||
|
||||
select {
|
||||
case host := <-hostSide:
|
||||
assert.ErrorIs(t, host.err, reason)
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("AcceptGuest did not return after cancellation")
|
||||
}
|
||||
|
||||
// The listener is usable again by a later call.
|
||||
ctx2, cancel2 := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel2()
|
||||
hostSide = acceptAsync(g, ln, ctx2, time.Second)
|
||||
c, err := hostlink.Dial(ctx2, guestFor(t, g, ln.Addr().String()))
|
||||
require.NoError(t, err)
|
||||
defer c.Close()
|
||||
host := <-hostSide
|
||||
require.NoError(t, host.err)
|
||||
host.conn.Close()
|
||||
}
|
||||
|
||||
func TestNewGenerationRejects(t *testing.T) {
|
||||
_, err := NewGeneration("a/b/c", time.Hour)
|
||||
assert.ErrorContains(t, err, "want 5 segments")
|
||||
_, err = NewGeneration(gen1, 0)
|
||||
assert.ErrorContains(t, err, "ttl must be positive")
|
||||
_, err = newGeneration(t, gen1).AcceptGuest(context.Background(), listen(t), 0)
|
||||
assert.ErrorContains(t, err, "handshake timeout must be positive")
|
||||
}
|
||||
@@ -36,6 +36,7 @@ type MicroVMSpec struct {
|
||||
FwCfg []FwCfgEntry
|
||||
NetDevs []NetDev
|
||||
Devices []Device
|
||||
SerialPorts []SerialPort
|
||||
}
|
||||
|
||||
// NewMicroVMConfig builds the Config for a direct-boot microvm launch: qboot,
|
||||
@@ -58,6 +59,7 @@ func NewMicroVMConfig(s MicroVMSpec) Config {
|
||||
FwCfg: s.FwCfg,
|
||||
NetDevs: s.NetDevs,
|
||||
Devices: s.Devices,
|
||||
SerialPorts: s.SerialPorts,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -468,6 +468,17 @@ type Config struct {
|
||||
Initrd string `json:"initrd,omitempty"` // path to initramfs; emits -initrd when set
|
||||
KernelCmdline string `json:"kernel_cmdline,omitempty"` // emits -append when set
|
||||
FwCfg []FwCfgEntry `json:"fw_cfg,omitempty"` // each emits -fw_cfg name=<name>,file=<path>
|
||||
|
||||
// SerialPorts are named virtio-serial ports, each backed by a host unix
|
||||
// socket that QEMU listens on. They carry no network traffic.
|
||||
SerialPorts []SerialPort `json:"serial_ports,omitempty"`
|
||||
}
|
||||
|
||||
// SerialPort is one virtserialport named Name in the guest, whose host end is
|
||||
// a unix socket at Socket that QEMU creates and listens on.
|
||||
type SerialPort struct {
|
||||
Name string `json:"name"`
|
||||
Socket string `json:"socket"`
|
||||
}
|
||||
|
||||
// FwCfgEntry describes a single QEMU firmware configuration file entry.
|
||||
@@ -672,6 +683,12 @@ func (cfg *Config) Execute() (*exec.Cmd, error) {
|
||||
args = append(args, "-fw_cfg", fmt.Sprintf("name=%s,file=%s", fw.Name, fw.File))
|
||||
}
|
||||
|
||||
portArgs, err := serialPortArgs(cfg.SerialPorts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
args = append(args, portArgs...)
|
||||
|
||||
if cfg.Architecture == "" {
|
||||
return nil, fmt.Errorf("architecture missing")
|
||||
}
|
||||
@@ -700,6 +717,29 @@ func (cfg *Config) Execute() (*exec.Cmd, error) {
|
||||
return cmd, nil
|
||||
}
|
||||
|
||||
// serialPortArgs renders one virtio-serial controller and, per port, a
|
||||
// listening socket chardev and its virtserialport. Commas are refused because
|
||||
// QEMU would read them as further options.
|
||||
func serialPortArgs(ports []SerialPort) ([]string, error) {
|
||||
if len(ports) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
args := []string{"-device", "virtio-serial-pci,id=vserial0"}
|
||||
for i, p := range ports {
|
||||
if p.Name == "" || p.Socket == "" {
|
||||
return nil, fmt.Errorf("serial port %d: name and socket are required", i)
|
||||
}
|
||||
if strings.ContainsRune(p.Name, ',') || strings.ContainsRune(p.Socket, ',') {
|
||||
return nil, fmt.Errorf("serial port %q: name and socket must not contain a comma", p.Name)
|
||||
}
|
||||
id := fmt.Sprintf("vport%d", i)
|
||||
args = append(args,
|
||||
"-chardev", fmt.Sprintf("socket,id=%s,path=%s,server=on,wait=off", id, p.Socket),
|
||||
"-device", fmt.Sprintf("virtserialport,bus=vserial0.0,chardev=%s,name=%s", id, p.Name))
|
||||
}
|
||||
return args, nil
|
||||
}
|
||||
|
||||
// appendOpt renders a chardev append option, or nothing when off, so a
|
||||
// fresh launch keeps the command line it always had.
|
||||
func appendOpt(key string, on bool) string {
|
||||
|
||||
@@ -694,3 +694,55 @@ func TestExecute_MicrovmWithPCIEFileChardev(t *testing.T) {
|
||||
assert.Equal(t, "file,id=console0,path=/var/log/console.log", argValue(args, "-chardev"))
|
||||
assert.Equal(t, "chardev:console0", argValue(args, "-serial"))
|
||||
}
|
||||
|
||||
func TestExecute_SerialPortsRenderListeningSocketsAndPorts(t *testing.T) {
|
||||
cfg := NewMicroVMConfig(MicroVMSpec{
|
||||
Name: "lambda", Architecture: "x86_64", VCPUs: 1, MemoryMiB: 256,
|
||||
ImageDir: "/img",
|
||||
SerialPorts: []SerialPort{
|
||||
{Name: "lambda.ctl", Socket: "/run/env/ctl.sock"},
|
||||
{Name: "second", Socket: "/run/env/second.sock"},
|
||||
},
|
||||
})
|
||||
cmd, err := cfg.Execute()
|
||||
require.NoError(t, err)
|
||||
args := cmd.Args[1:]
|
||||
|
||||
assert.Equal(t, []string{
|
||||
"socket,id=vport0,path=/run/env/ctl.sock,server=on,wait=off",
|
||||
"socket,id=vport1,path=/run/env/second.sock,server=on,wait=off",
|
||||
}, allArgValues(args, "-chardev"))
|
||||
devs := allArgValues(args, "-device")
|
||||
assert.Equal(t, 1, strings.Count(strings.Join(devs, " "), "virtio-serial-pci"))
|
||||
assert.Contains(t, devs, "virtio-serial-pci,id=vserial0")
|
||||
assert.Contains(t, devs, "virtserialport,bus=vserial0.0,chardev=vport0,name=lambda.ctl")
|
||||
assert.Contains(t, devs, "virtserialport,bus=vserial0.0,chardev=vport1,name=second")
|
||||
}
|
||||
|
||||
func TestExecute_NoSerialPortsAddsNoVirtioSerial(t *testing.T) {
|
||||
cfg := NewMicroVMConfig(MicroVMSpec{Name: "lb", Architecture: "x86_64", VCPUs: 1, MemoryMiB: 256, ImageDir: "/img"})
|
||||
cmd, err := cfg.Execute()
|
||||
require.NoError(t, err)
|
||||
assert.NotContains(t, strings.Join(cmd.Args, " "), "virtio-serial")
|
||||
assert.NotContains(t, strings.Join(cmd.Args, " "), "virtserialport")
|
||||
}
|
||||
|
||||
func TestExecute_SerialPortRefusesOptionInjection(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
port SerialPort
|
||||
want string
|
||||
}{
|
||||
{SerialPort{Name: "ctl,id=x", Socket: "/run/ctl.sock"}, "must not contain a comma"},
|
||||
{SerialPort{Name: "ctl", Socket: "/run/ctl.sock,server=off"}, "must not contain a comma"},
|
||||
{SerialPort{Name: "", Socket: "/run/ctl.sock"}, "name and socket are required"},
|
||||
{SerialPort{Name: "ctl", Socket: ""}, "name and socket are required"},
|
||||
} {
|
||||
cfg := NewMicroVMConfig(MicroVMSpec{
|
||||
Name: "lambda", Architecture: "x86_64", VCPUs: 1, MemoryMiB: 256,
|
||||
ImageDir: "/img", SerialPorts: []SerialPort{tc.port},
|
||||
})
|
||||
_, err := cfg.Execute()
|
||||
require.Error(t, err, "port %+v", tc.port)
|
||||
assert.Contains(t, err.Error(), tc.want, "port %+v", tc.port)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -171,7 +170,6 @@ type Daemon struct {
|
||||
spotInstanceService *ec2spotinstance.SpotInstanceServiceImpl
|
||||
vpcService *ec2vpc.VPCServiceImpl
|
||||
sqsWorker *sqsWorkerEnsurer
|
||||
insight *nodeInsight
|
||||
eipService ec2eip.EIPService
|
||||
elbv2Service *elbv2domain.ELBv2ServiceImpl
|
||||
eksService *ekscluster.EKSServiceImpl
|
||||
@@ -909,10 +907,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()
|
||||
@@ -1052,7 +1046,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"},
|
||||
@@ -1066,13 +1059,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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}]}]}
|
||||
@@ -717,7 +717,6 @@ func launchService(config *config.ClusterConfig) error {
|
||||
Credentials: bedrockCredentials,
|
||||
Access: bedrockAccess,
|
||||
|
||||
NetworkTests: operatoradmin.NewKVNetworkTests(js),
|
||||
Quota: quotaSvc,
|
||||
BucketStore: objStore,
|
||||
SignupMaxAccounts: signupMaxAccounts,
|
||||
|
||||
+1
@@ -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
@@ -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
@@ -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};
|
||||
+1
File diff suppressed because one or more lines are too long
-1
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
@@ -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};
|
||||
+1
File diff suppressed because one or more lines are too long
-1
@@ -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
+1
-1
@@ -1 +1 @@
|
||||
import{It as e,Mt as t}from"./schemas-DCQXG11d.js";import{t as n}from"./object-browser-D2G7ChbT.js";import{t as r}from"./_bucket-aGUJujUa.js";var i=t(),a=e();function o(){let e=(0,i.c)(6),{bucket:t}=r.useParams(),o=r.useSearch(),s=r.useNavigate(),c;e[0]===s?c=e[1]:(c=function(e){s({search:t=>({...t,...e}),replace:!(`selected`in e)})},e[0]=s,e[1]=c);let l=c,u;return e[2]!==t||e[3]!==l||e[4]!==o?(u=(0,a.jsx)(n,{bucket:t,folder:``,onSearchChange:l,search:o},t),e[2]=t,e[3]=l,e[4]=o,e[5]=u):u=e[5],u}export{o as component};
|
||||
import{Gt as e,Vt as t}from"./schemas-CKmbY8gV.js";import{t as n}from"./object-browser-D7yr9rgb.js";import{t as r}from"./_bucket-DJ9W9e9C.js";var i=t(),a=e();function o(){let e=(0,i.c)(6),{bucket:t}=r.useParams(),o=r.useSearch(),s=r.useNavigate(),c;e[0]===s?c=e[1]:(c=function(e){s({search:t=>({...t,...e}),replace:!(`selected`in e)})},e[0]=s,e[1]=c);let l=c,u;return e[2]!==t||e[3]!==l||e[4]!==o?(u=(0,a.jsx)(n,{bucket:t,folder:``,onSearchChange:l,search:o},t),e[2]=t,e[3]=l,e[4]=o,e[5]=u):u=e[5],u}export{o as component};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user